diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 8fde2fd727..efecf7f740 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -38,6 +38,9 @@ "!apps/cli/src/commands/vanity-subdomains/**", "!apps/cli/src/commands/whoami/**", "!apps/cli/src/shared/compute/**", + "!apps/cli/src/shared/functions/deploy.ts", + "!apps/cli/src/shared/functions/serve.ts", + "!apps/cli/src/shared/functions/serve-main-bundler.ts", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 427701f663..4344799aab 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -44,7 +44,8 @@ regardless of automatic agent output detection; this is dotenv data, not a shell are quoted so that sourcing the file performs no shell expansion. Only this explicit export reveals credentials. Ordinary status remains free of secrets. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects -unknown variables, invalid names, and collisions. API credentials are omitted when Auth is disabled. +unknown variables, invalid names, and collisions. API credentials belong to the stack and remain +available when Auth is disabled; database credentials require a registered default database. The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pretty`, `json`, `toml`, `yaml`, `table`, and `csv`. `--output-format text`, `json`, or `stream-json` replace them. @@ -88,14 +89,17 @@ precedence over `experimental.stack`; an unset or empty value falls back to the Other values are rejected. The override is applied before reading the project configuration. When the flag is on, `--local` targets of the `db`, `migration`, `test db`, `gen types`, and -`inspect` families use the project stack and provision throwaway shadow Postgres through -`@supabase/stack` (`EphemeralPostgres`). Top-level `supabase pull` uses the same stack shadow +`inspect` families use the project stack and register independent shadow PostgreSQL instances +through `@supabase/stack`. Top-level `supabase pull` uses the same stack shadow as `db pull`. Linked and `--db-url` targets stay on the Management API for engine selection. A `--db-url` that matches `config.toml` host and port is still rewritten like a published stack target for dump's tool container. Compose names (`supabase_db_*`, `supabase_network_*`, `db:5432`) are not used. The stack backend requires the in-process pg-delta engine; `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, and `db pull --diff-engine migra` are -rejected. The flag does not switch the `functions` command family. +rejected. Functions serve uses the shared Functions instance and can start without PostgreSQL or +Auth. It restarts that instance for changed configuration or watched source files and leaves it +running when the CLI exits. Storage commands and bucket seeding use the same stack selection when +the flag is enabled; see [Storage and bucket seeding](#storage-and-bucket-seeding) below. `storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including bucket seeding inside `db reset --local`) also consult `experimental.stack`, with the same `SUPABASE_EXPERIMENTAL_STACK` @@ -103,12 +107,18 @@ env-precedence rule as `start`/`stop`/`status`. See [Storage and bucket seeding](#storage-and-bucket-seeding) below. Explicit `--linked`/ `--project-ref` remote targeting for these commands is unaffected by the flag either way. +Functions paths declared in `config.toml` retain their `supabase/`-relative base, including +configured entrypoints, import maps, and static files outside `supabase/functions`. The stack +resolves their dependencies and mounts source files read-only for container runtimes. These inputs +remain available after the serving CLI exits. + `db start` brings up a postgres-only project stack on first create. An existing stack resumes its persisted services (webhooks setup only; no second overlay or migrate-and-seed). -`supabase start` while that postgres-only stack is running stops it and starts the full -configured stack, keeping data. `--from-backup` is not supported on the stack path. `db reset ---local` and declarative `--apply` wipe Postgres through `resetDatabase` and then migrate or -seed on stack credentials. +Whole-stack startup uses registered instances and preserves their IDs and planned endpoints. +`--from-backup` is not supported on the stack path. `db reset --local` and declarative `--apply` +rebuild the primary database through CLI SQL orchestration using a fresh registered baseline, +then migrate or seed through managed stack credentials. The primary instance keeps its identity; +unrelated service data is retained. `gen types --local` and `inspect db … --local` resolve the project stack through the same `--local` database target as `db dump`. They do not start a stack. diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 0a6f25df37..c8183c7e61 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,4 +1,6 @@ -import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { Effect } from "effect"; +import { bundleServeMainTemplate as bundleCliServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { bundleServeMainTemplate as bundleStackServeMainTemplate } from "../../../packages/stack/src/functions/serve-main-bundler.ts"; import { OXFMT_OPTIONAL_PLUGIN_EXTERNALS } from "./bundle-externals.ts"; /** @@ -22,7 +24,12 @@ const result = await Bun.build({ external: [...OXFMT_OPTIONAL_PLUGIN_EXTERNALS], define: { SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), - SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( + await Effect.runPromise(bundleCliServeMainTemplate), + ), + SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( + await Effect.runPromise(bundleStackServeMainTemplate), + ), }, }); for (const log of result.logs) { diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 87a9e3e3b4..0380ba7796 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -4,7 +4,9 @@ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { Effect } from "effect"; +import { bundleServeMainTemplate as bundleCliServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { bundleServeMainTemplate as bundleStackServeMainTemplate } from "../../../packages/stack/src/functions/serve-main-bundler.ts"; import { OXFMT_OPTIONAL_PLUGIN_EXTERNALS } from "./bundle-externals.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -88,7 +90,12 @@ const entrypoint = path.join(root, "apps/cli/src/main.ts"); const distDir = path.join(root, "dist"); const goSource = path.resolve(root, "apps/cli-go"); const buildDefines = { - SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( + await Effect.runPromise(bundleCliServeMainTemplate), + ), + SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( + await Effect.runPromise(bundleStackServeMainTemplate), + ), "process.env.SUPABASE_CLI_POSTHOG_KEY": JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), "process.env.SUPABASE_CLI_POSTHOG_HOST": JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), }; diff --git a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts index 3a68a3fe22..f3caa8804d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts @@ -30,6 +30,7 @@ import { containerArchiveBytes, isUserDefinedDockerNetwork, } from "../../shared/functions/functions-docker.ts"; +import { FunctionsDockerError } from "../../shared/functions/functions-docker.errors.ts"; import { buildStartContainerCreateArgs, applyBitbucketStartContainerFilter, @@ -610,21 +611,18 @@ function copyStartSecretFilesIntoContainer( ): Effect.Effect { if (secretFiles.length === 0) return Effect.void; - return Effect.tryPromise({ - try: () => - containerArchiveBytes( - Object.fromEntries( - secretFiles.map((secretFile) => [secretFile.containerPath, secretFile.content]), - ), - ), - catch: (cause) => - new ContainerCreateError({ - message: `failed to create docker container: failed to prepare container secret files: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - reason: "internal", - }), - }).pipe( + return containerArchiveBytes( + Object.fromEntries( + secretFiles.map((secretFile) => [secretFile.containerPath, secretFile.content]), + ), + ).pipe( + Effect.mapError( + (cause: FunctionsDockerError) => + new ContainerCreateError({ + message: `failed to create docker container: failed to prepare container secret files: ${cause.message}`, + reason: "internal", + }), + ), Effect.flatMap((archive) => dockerCopyArchiveIntoContainer(spawner, archive, `${containerId}:/`, secretCopyFailure), ), diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts index 4cf3f85a21..90db0f626d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts @@ -12,6 +12,7 @@ * can't afford downtime. */ +import { randomUUID } from "node:crypto"; import { Effect, Option, Stream, type FileSystem } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -137,8 +138,7 @@ export const stampPgDataBaselineMarker = ( * Streams `docker cp :${PGDATA_PATH} -` to a temp file next to `tarPath` and * `rename`s it into place, so a partially written tar is never visible under the final name; * any failure removes the temp file. The container must already be stopped (callers own the - * stop/start), and the temp name is scoped by pid alone, so concurrent exports to the same - * `tarPath` must be externally serialized (`shadow-cache.ts` holds `shadowExportMutex`). + * stop/start), and each invocation gets its own UUID-scoped temp name. */ export const exportPgDataTar = ( spawner: Spawner, @@ -146,11 +146,10 @@ export const exportPgDataTar = ( fs: FileSystem.FileSystem, tarPath: string, ): Effect.Effect => { - const tempPath = `${tarPath}.${process.pid}.partial`; + const tempPath = `${tarPath}.${randomUUID()}.partial`; return Effect.gen(function* () { - // Clears a leftover temp file (crashed predecessor or pre-created by another process) so the - // exclusive-create below starts from a fresh inode. - yield* fs.remove(tempPath).pipe(Effect.orElseSucceed(() => undefined)); + // The UUID path is absent by construction; O_EXCL below also protects against the vanishingly + // unlikely collision without removing another export's in-flight file. yield* Effect.scoped( Effect.gen(function* () { const child = yield* spawnContainerCli( diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts index 2be34aba41..694ded1fdc 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts @@ -221,7 +221,8 @@ const RESET_RECREATE_DATABASES_STATEMENTS = [ * statements. Roles are not dropped here since they are cluster-level entities — use stop then * start instead. */ -const resetRecreateDatabases = Effect.fnUntraced(function* (session: DbSession) { +/** Recreates the two project databases in an existing managed Postgres instance. */ +export const resetRecreateDatabases = Effect.fnUntraced(function* (session: DbSession) { yield* resetDisconnectClients(session); for (const [index, statement] of RESET_RECREATE_DATABASES_STATEMENTS.entries()) { yield* session.exec(statement).pipe( diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index aab5577a98..eb5404aaeb 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -9,7 +9,7 @@ * invocation only, emitted by its own handler after calling this function. */ -import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { Cause, Data, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { detectGitBranch } from "../../shared/git/git-branch.ts"; @@ -28,22 +28,22 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { aqua, yellow } from "../colors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; -import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts"; -import { DbConnection } from "../db-connection.service.ts"; +import { + checkDbToml, + loadProjectEnv, + readDbToml, + type DbTomlValues, +} from "../db-config.toml-read.ts"; +import { DbConnection, type PgConnInput } from "../db-connection.service.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; import { migrateAndSeed } from "../migrate-and-seed.ts"; import { hasConfiguredBuckets, seedBucketsRun } from "../seed-buckets.ts"; import { awaitStorageReady } from "./await-storage-ready.ts"; import { resolveResetSeedConfig } from "./db-setup.ts"; -import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; -import { recreateLocalDatabase } from "./recreate-local-database.ts"; +import { recreateLocalDatabase, resetRecreateDatabases } from "./recreate-local-database.ts"; import { currentStackBackend } from "../stack-backend.ts"; -import { - optionalCatalogConfigFromStatus, - stackLocalDatabaseConn, - stackOpenReadyProject, -} from "../stack-local-database.ts"; +import { optionalCatalogConfigFromStatus, stackOpenReadyProject } from "../stack-local-database.ts"; import { classifyStorageCapability, describeStorageCapability, @@ -53,6 +53,129 @@ import { } from "../stack-storage.ts"; import { loadStackConfig } from "../stack-config.ts"; import { StackCatalogSetup } from "../stack-catalog-setup.ts"; +import { + buildLocalDbContainerInputs, + type LocalDbContainerInputs, +} from "./local-container-inputs.ts"; +import { shadowRunInputFromLocalContainerInputs } from "./shadow-database.ts"; +import { stackWithShadowDatabase } from "../stack-shadow.ts"; +import type { EffectStack, ServiceInstanceId, StackRuntime } from "@supabase/stack/effect"; +import { rewriteDumpHostForToolContainer } from "../postgres-client.run.ts"; +import { BundledPostgresClient, bundledPostgresClientRuntime } from "../bundled-postgres-client.ts"; +import { RESERVED_ROLES, toDumpEnv } from "../pg-dump.env.ts"; +import { parseConnectionString } from "../db-config.parse.ts"; +import { splitAndTrim } from "../sql-split.ts"; + +const stackCredential = (value: string | Redacted.Redacted): string => + typeof value === "string" ? value : Redacted.value(value); + +const CREATE_ROLE_STATEMENT = + /^\s*(?:(?:--[^\r\n]*(?:\r\n|\r|\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*CREATE\s+(?:ROLE|USER)\s+(?:"((?:[^"]|"")*)"|([A-Za-z_][A-Za-z0-9_$]*))/i; +const managedRolePatterns = RESERVED_ROLES.map((pattern) => new RegExp(`^${pattern}$`)); + +const roleNamesToReset = (sql: string): ReadonlyArray => { + const names: Array = []; + for (const statement of splitAndTrim(sql)) { + const match = CREATE_ROLE_STATEMENT.exec(statement); + const quoted = match?.[1]; + const unquoted = match?.[2]; + const name = quoted === undefined ? unquoted?.toLowerCase() : quoted.replaceAll('""', '"'); + if (name !== undefined && !managedRolePatterns.some((pattern) => pattern.test(name))) + names.push(name); + } + return [...new Set(names)]; +}; + +const readRoleNamesToReset = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +) { + const rolesPath = path.join(workdir, "supabase", "roles.sql"); + const exists = yield* fs + .exists(rolesPath) + .pipe(Effect.mapError((cause) => resetFailed(`failed to check roles.sql: ${cause.message}`))); + if (!exists) return []; + const sql = yield* fs + .readFileString(rolesPath) + .pipe(Effect.mapError((cause) => resetFailed(`failed to read roles.sql: ${cause.message}`))); + return roleNamesToReset(sql); +}); + +const dropRole = (name: string): string => `DROP ROLE IF EXISTS "${name.replaceAll('"', '""')}"`; + +/** Shell script that streams a plain logical dump into the target database. */ +export const restoreStackLogicalBaselineScript = (): string => + [ + "set -euo pipefail", + 'pg_dump --format=plain | PGPASSWORD="$TARGET_PASSWORD" PGHOST="$TARGET_HOST" PGPORT="$TARGET_PORT" PGUSER="$TARGET_USER" PGDATABASE="$TARGET_DATABASE" psql --no-password --no-psqlrc -v ON_ERROR_STOP=1', + ].join("\n"); + +/** Runs a plain logical dump from the baseline instance directly into the primary endpoint. */ +const restoreStackLogicalBaseline = Effect.fnUntraced(function* (input: { + readonly source: PgConnInput; + readonly target: PgConnInput; + readonly version: string; + readonly runtime: StackRuntime; + readonly platform: string; + readonly extraHosts: ReadonlyArray; + readonly arch?: string; +}) { + const script = restoreStackLogicalBaselineScript(); + const clientRuntime = + bundledPostgresClientRuntime(input.runtime, input.platform, input.arch) ?? input.runtime; + const source = + clientRuntime.kind === "container" + ? { + ...input.source, + host: rewriteDumpHostForToolContainer(input.source.host, { + platform: input.platform, + usesHostNetwork: true, + }), + } + : input.source; + const target = + clientRuntime.kind === "container" + ? { + ...input.target, + host: rewriteDumpHostForToolContainer(input.target.host, { + platform: input.platform, + usesHostNetwork: true, + }), + } + : input.target; + const clientEnv = { + ...toDumpEnv(source), + TARGET_HOST: target.host, + TARGET_PORT: String(target.port), + TARGET_USER: target.user, + TARGET_PASSWORD: target.password, + TARGET_DATABASE: target.database, + }; + const bundled = yield* BundledPostgresClient; + const result = yield* bundled + .run({ + version: input.version, + runtime: clientRuntime, + argv: ["bash", "-c", script, "--"], + env: clientEnv, + network: "host", + extraHosts: input.extraHosts, + onStdout: () => Effect.void, + teeStderr: true, + }) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to restore stack database baseline: ${cause.message}`), + ), + ); + if (result.exitCode !== 0) + return yield* Effect.fail( + new ResetLocalDbFailedError({ + message: `failed to restore stack database baseline: exit ${result.exitCode}${result.stderr.trim().length > 0 ? `: ${result.stderr.trim()}` : ""}`, + }), + ); +}); /** The local database container is not running. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ @@ -100,6 +223,239 @@ const suggestionOf = (error: unknown): string | undefined => ? error.suggestion : undefined; +const resumeDependents = (stack: EffectStack, ids: ReadonlyArray) => + stack.start({ services: ids }).pipe( + Effect.asVoid, + Effect.mapError((cause) => + resetFailed(`failed to resume dependent instances [${ids.join(", ")}]: ${cause.message}`), + ), + ); + +const resetStackDatabase = Effect.fnUntraced(function* (input: { + readonly stack: EffectStack; + readonly localInputs: LocalDbContainerInputs; + readonly image: string; + readonly toml: DbTomlValues; + readonly platform: string; + readonly arch?: string; +}) { + const dbConn = yield* DbConnection; + const primary = yield* input.stack.services + .get({ name: "database" }) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to resolve primary database: ${cause.message}`), + ), + ); + if (primary.service !== "database") + return yield* resetFailed("the designated primary service is not a database"); + const primaryDescriptor = yield* primary.describe.pipe( + Effect.mapError((cause) => resetFailed(`failed to inspect primary database: ${cause.message}`)), + ); + const primaryMajor = Number.parseInt(primaryDescriptor.config.version.split(".")[0] ?? "", 10); + if (!Number.isInteger(primaryMajor)) + return yield* resetFailed("primary database has no valid resolved Postgres version"); + + const descriptors = yield* input.stack.services.list.pipe( + Effect.mapError((cause) => resetFailed(`failed to inspect stack services: ${cause.message}`)), + ); + const byId = new Map(); + for (const descriptor of descriptors) byId.set(descriptor.id, descriptor); + const dependentIds = new Set(); + const dependsOnPrimary = (id: string, visiting: Set): boolean => { + if (visiting.has(id)) return false; + visiting.add(id); + const descriptor = byId.get(id); + if (descriptor === undefined) return false; + return Object.values(descriptor.dependencies).some( + (dependencyId) => + dependencyId === primary.id || dependsOnPrimary(dependencyId, new Set(visiting)), + ); + }; + for (const descriptor of descriptors) { + if (descriptor.id !== primary.id && dependsOnPrimary(descriptor.id, new Set())) + dependentIds.add(descriptor.id); + } + const status = yield* input.stack.status.pipe( + Effect.mapError((cause) => resetFailed(`failed to inspect stack: ${cause.message}`)), + ); + const resumeIds = status.instances + .filter((instance) => dependentIds.has(instance.id) && instance.intent === "started") + .map((instance) => instance.id); + const primaryCredentials = yield* primary.credentials.pipe( + Effect.mapError((cause) => resetFailed(`failed to read primary credentials: ${cause.message}`)), + ); + if (primaryCredentials === undefined) + return yield* resetFailed("primary database credentials are unavailable"); + const primaryConn = parseConnectionString(stackCredential(primaryCredentials.url)); + if (primaryConn === undefined) return yield* resetFailed("failed to parse primary database URL"); + const primaryConfig = { + version: primaryDescriptor.config.version, + activation: primaryDescriptor.config.activation, + settings: primaryDescriptor.config.settings, + }; + // Disable background workers while DROP DATABASE tears down extensions such as pg_net. + const maintenanceConfig = { + ...primaryConfig, + settings: { + ...primaryConfig.settings, + settings: { + ...primaryConfig.settings.settings, + max_worker_processes: 0, + }, + }, + }; + const baselineLocalInputs: LocalDbContainerInputs = { + ...input.localInputs, + postgresSpecBase: { + ...input.localInputs.postgresSpecBase, + db: { + ...input.localInputs.postgresSpecBase.db, + major_version: primaryMajor, + settings: primaryDescriptor.config.settings.settings, + }, + }, + setup: { + ...input.localInputs.setup, + majorVersion: primaryMajor, + }, + }; + const baselineInput = shadowRunInputFromLocalContainerInputs( + baselineLocalInputs, + input.image, + { + shadowPort: input.toml.shadowPort, + password: stackCredential(primaryCredentials.password), + webhooksEnabled: input.toml.webhooksEnabled, + baseline: input.toml.baseline, + vault: input.toml.vault, + }, + yield* FileSystem.FileSystem, + yield* Path.Path, + ); + + const reset = stackWithShadowDatabase( + baselineInput, + (shadow) => { + const destructive = Effect.gen(function* () { + const baselineDescriptor = yield* shadow.service.describe.pipe( + Effect.mapError((cause) => + resetFailed(`failed to inspect database baseline: ${cause.message}`), + ), + ); + if ( + baselineDescriptor.initializationProfileId !== primaryDescriptor.initializationProfileId + ) + return yield* resetFailed( + "database baseline initialization does not match the primary instance", + ); + const sourceCredentials = yield* shadow.service.credentials.pipe( + Effect.mapError((cause) => + resetFailed(`failed to read baseline credentials: ${cause.message}`), + ), + ); + if (sourceCredentials === undefined) + return yield* resetFailed("baseline database credentials are unavailable"); + const sourceConn = parseConnectionString(stackCredential(sourceCredentials.url)); + if (sourceConn === undefined) + return yield* resetFailed("failed to parse baseline database URL"); + const rolesToReset = yield* readRoleNamesToReset( + yield* FileSystem.FileSystem, + yield* Path.Path, + input.localInputs.containerOpts.workdir, + ); + + if (resumeIds.length > 0) { + yield* input.stack + .stop({ services: resumeIds }) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to stop database dependents: ${cause.message}`), + ), + ); + } + const restorePrimaryConfig = primary.restart({ config: primaryConfig }).pipe( + Effect.asVoid, + Effect.mapError((cause) => + resetFailed(`failed to restore primary database settings: ${cause.message}`), + ), + ); + const resetWithWorkersDisabled = Effect.gen(function* () { + yield* primary.restart({ config: maintenanceConfig }).pipe( + Effect.asVoid, + Effect.mapError((cause) => + resetFailed(`failed to prepare primary database reset: ${cause.message}`), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const maintenance = yield* dbConn.connect( + { ...primaryConn, user: "supabase_admin", database: "template1" }, + { isLocal: true, dnsResolver: "native" }, + ); + yield* resetRecreateDatabases(maintenance).pipe( + Effect.mapError((cause) => + resetFailed(`failed to recreate primary databases: ${cause.message}`), + ), + ); + for (const role of rolesToReset) { + yield* maintenance + .exec(dropRole(role)) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to reset role ${role}: ${cause.message}`), + ), + ); + } + }), + ); + for (const database of ["postgres", "_supabase"] as const) { + yield* restoreStackLogicalBaseline({ + source: { ...sourceConn, database }, + // The logical dump preserves managed object owners and ACLs. The database role + // cannot SET ROLE to supabase_admin, so restore through the managed superuser + // while retaining the primary database password. + target: { ...primaryConn, user: "supabase_admin", database }, + version: baselineDescriptor.config.version, + runtime: shadow.runtime, + platform: input.platform, + extraHosts: input.localInputs.containerOpts.extraHosts, + arch: input.arch, + }); + } + }).pipe(Effect.onExit(() => restorePrimaryConfig)); + yield* resetWithWorkersDisabled; + }); + return destructive; + }, + { + bypassCache: true, + applyOverlay: false, + database: { + version: primaryDescriptor.config.version, + settings: primaryDescriptor.config.settings, + initialization: { from: primary.id }, + }, + }, + ); + yield* reset.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + resumeIds.length === 0 + ? Effect.failCause(cause) + : Effect.exit(resumeDependents(input.stack, resumeIds)).pipe( + Effect.flatMap((resumed) => + Exit.isSuccess(resumed) + ? Effect.failCause(cause) + : Effect.failCause(Cause.combine(cause, resumed.cause)), + ), + ), + onSuccess: Effect.succeed, + }), + ); + return resumeIds; +}); + /** Resets the local database in-process. See this module's own header for the full design rationale. */ export const resetLocalDatabase = Effect.fnUntraced(function* ( input: ResetLocalDatabaseInput = PLAIN_FULL_RESET, @@ -137,63 +493,107 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( ); const optionalConfig = optionalCatalogConfigFromStatus(stackConfig, runningStatus); yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); - yield* opened.value.stack.resetDatabase.pipe( - Effect.catchTag("StackNotRunningError", () => - Effect.fail( - new ResetLocalDbNotRunningError({ message: "The local stack is not running." }), - ), - ), - Effect.mapError((cause) => resetFailed(`failed to reset local database: ${cause.message}`)), + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* NetworkIdFlag; + const debug = yield* DebugFlag; + const localInputs = yield* buildLocalDbContainerInputs( + spawner, + workdir, + networkIdFlag, + runtimeInfo.platform, + debug, ); - yield* catalog.value - .apply({ - target: { - kind: "live", - stack: opened.value.stack, - projectRoot: workdir, - config: stackConfig, - }, - optionalConfig, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir, - }, - }) - .pipe(Effect.mapError((cause) => resetFailed(cause.message))); + const resolvedImage = yield* localInputs.resolvePostgresImage; + const resumeIds = yield* resetStackDatabase({ + stack: opened.value.stack, + localInputs, + image: resolvedImage, + toml, + platform: runtimeInfo.platform, + arch: runtimeInfo.arch, + }); + const primary = yield* opened.value.stack.services + .get({ name: "database" }) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to resolve primary database: ${cause.message}`), + ), + ); + if (primary.service !== "database") + return yield* resetFailed("the designated primary service is not a database"); const dbConn = yield* DbConnection; - const conn = yield* stackLocalDatabaseConn.pipe( - Effect.mapError((cause) => new ResetLocalDbNotRunningError({ message: cause.message })), + const credentials = yield* primary.credentials.pipe( + Effect.mapError((cause) => + resetFailed(`failed to read primary credentials: ${cause.message}`), + ), ); - yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* dbConn - .connect(conn, { isLocal: true, dnsResolver: "native" }) - .pipe( - Effect.mapError((cause) => - resetFailed(`failed to connect after reset: ${cause.message}`), - ), - ); - yield* migrateAndSeed(session, fs, path, workdir, input.version, { - migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), - experimental, - pgDeltaEnabled: toml.pgDelta.enabled, - schemaPaths: toml.schemaPaths, - localDatabaseWebhooksEnabled: toml.webhooksEnabled, - }).pipe(Effect.mapError((cause) => resetFailed(cause.message))); + if (credentials === undefined) + return yield* resetFailed("primary database credentials are unavailable"); + const conn = parseConnectionString(stackCredential(credentials.url)); + if (conn === undefined) return yield* resetFailed("failed to parse primary database URL"); + const overlayAndMigrate = Effect.gen(function* () { + yield* catalog.value + .apply({ + target: { + kind: "service", + stack: opened.value.stack, + service: primary, + projectRoot: workdir, + config: stackConfig, + }, + optionalConfig, + overlay: { + webhooks: "config", + webhooksEnabled: toml.webhooksEnabled, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + workdir, + }, + }) + .pipe(Effect.mapError((cause) => resetFailed(cause.message))); + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn + .connect(conn, { isLocal: true, dnsResolver: "native" }) + .pipe( + Effect.mapError((cause) => + resetFailed(`failed to connect after reset: ${cause.message}`), + ), + ); + yield* migrateAndSeed(session, fs, path, workdir, input.version, { + migrationsEnabled: toml.migrationsEnabled, + seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), + experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + schemaPaths: toml.schemaPaths, + localDatabaseWebhooksEnabled: toml.webhooksEnabled, + }).pipe(Effect.mapError((cause) => resetFailed(cause.message))); + }), + ); + }); + yield* overlayAndMigrate.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + resumeIds.length === 0 + ? Effect.failCause(cause) + : Effect.exit(resumeDependents(opened.value.stack, resumeIds)).pipe( + Effect.flatMap((resumed) => + Exit.isSuccess(resumed) + ? Effect.failCause(cause) + : Effect.failCause(Cause.combine(cause, resumed.cause)), + ), + ), + onSuccess: Effect.succeed, }), ); - const status = yield* opened.value.stack.status.pipe( + if (resumeIds.length > 0) yield* resumeDependents(opened.value.stack, resumeIds); + const inspectStatus = opened.value.stack.status.pipe( Effect.mapError((cause) => resetFailed(`failed to inspect stack after reset: ${cause.message}`), ), ); - // Bucket creation and object seeding are owned by the CLI, not the stack runtime; the - // gateway's lazy activation serves requests through `dormant`/`starting`, so seeding never - // waits for `ready`. See docs/stack-commands.md#storage-and-bucket-seeding. + const status = yield* inspectStatus; const skipSeeding = ( reason: string, nextStep = "Run supabase seed buckets --local once Storage is available.", @@ -203,8 +603,6 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( "stderr", ); const capability = status.capabilities.find((entry) => entry.name === "storage"); - // The database is already rebuilt, so any typed seeding failure only warns; defects and - // interruption still propagate. yield* Effect.gen(function* () { const context = yield* loadLocalProjectContext(workdir, (message) => resetFailed(message)); if (!hasConfiguredBuckets(context.config)) return; @@ -303,7 +701,7 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( }); // Seed objects from supabase/buckets when storage is up; summary is suppressed since reset - // emits its own result. See docs/stack-commands.md#storage-and-bucket-seeding. + // emits its own result. const storageReady = yield* awaitStorageReady(spawner, projectId); if (storageReady) { // Non-interactive: overwrite/prune confirmations never open a TTY prompt. In text mode diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.unit.test.ts new file mode 100644 index 0000000000..282ebddb5f --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.unit.test.ts @@ -0,0 +1,50 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; + +import { restoreStackLogicalBaselineScript } from "./reset-local-database.ts"; + +describe("restoreStackLogicalBaselineScript", () => { + it("streams the baseline dump into psql with the target credentials", () => { + const directory = mkdtempSync(join(tmpdir(), "stack-reset-script-")); + const capture = join(directory, "captured.sql"); + try { + const dump = join(directory, "pg_dump"); + const psql = join(directory, "psql"); + writeFileSync( + dump, + '#!/usr/bin/env bash\nprintf "CREATE TABLE baseline_marker(id int);\\n"\n', + ); + writeFileSync( + psql, + '#!/usr/bin/env bash\n[[ "$PGUSER" == "supabase_admin" ]] || exit 4\ncat > "$CAPTURE"\n', + ); + chmodSync(dump, 0o755); + chmodSync(psql, 0o755); + + execFileSync("bash", ["-c", restoreStackLogicalBaselineScript(), "--"], { + env: { + PATH: `${directory}:${process.env.PATH ?? ""}`, + CAPTURE: capture, + PGHOST: "source-host", + PGPORT: "5432", + PGUSER: "postgres", + PGPASSWORD: "source-password", + PGDATABASE: "postgres", + TARGET_HOST: "target-host", + TARGET_PORT: "5433", + TARGET_USER: "supabase_admin", + TARGET_PASSWORD: "target-password", + TARGET_DATABASE: "postgres", + }, + }); + + expect(readFileSync(capture, "utf8")).toBe("CREATE TABLE baseline_marker(id int);\n"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 098313470a..a7c15e2bc7 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -267,7 +267,7 @@ describe("acquireShadowDatabase", () => { ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); }); - it.live("a warm hit also sweeps abandoned partials left by a killed concurrent writer", () => { + it.live("a warm hit retains partials left by concurrent writers", () => { const docker = mockDockerDaemonCliSpawner(); const cluster = fakeCluster(); const out = mockOutput(); @@ -278,18 +278,25 @@ describe("acquireShadowDatabase", () => { const path = yield* Path.Path; const input = shadowInput(fs, path); yield* coldRun(docker, input); - // A concurrent writer SIGKILLed mid-export: its partial is older than 5 minutes. - const abandoned = path.join( + // A stale foreign partial and a fresh in-flight partial are both retained: their mtime + // cannot prove that the writer has stopped. + const stale = path.join( shadowCacheDir(path), - "shadow-baseline-0011223344556677.tar.4242.partial", + "shadow-baseline-0011223344556677.tar.01234567-89ab-cdef-0123-456789abcdef.partial", ); - yield* fs.writeFileString(abandoned, "stale"); + const fresh = path.join( + shadowCacheDir(path), + "shadow-baseline-fedcba9876543210.tar.fedcba98-7654-3210-fedc-ba9876543210.partial", + ); + yield* fs.writeFileString(stale, "stale"); + yield* fs.writeFileString(fresh, "in-flight"); const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); - yield* fs.utimes(abandoned, sixMinutesAgo, sixMinutesAgo); + yield* fs.utimes(stale, sixMinutesAgo, sixMinutesAgo); const warm = yield* acquireShadowDatabase(docker.spawner, input); expect(warm.baselinePresent).toBe(true); - expect(yield* fs.exists(abandoned)).toBe(false); + expect(yield* fs.exists(stale)).toBe(true); + expect(yield* fs.exists(fresh)).toBe(true); }), ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); }); @@ -578,7 +585,7 @@ describe("acquireShadowDatabase", () => { ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); }); - it.live("a cold export sweeps abandoned partial temp files but never fresh ones", () => { + it.live("a cold export retains foreign partial temp files", () => { const docker = mockDockerDaemonCliSpawner(); const cluster = fakeCluster(); const out = mockOutput(); @@ -589,18 +596,25 @@ describe("acquireShadowDatabase", () => { const path = yield* Path.Path; const tempDir = shadowCacheDir(path); yield* fs.makeDirectory(tempDir, { recursive: true }); - // A SIGKILLed export leftover (older than 5 minutes) and a live writer's fresh temp file. - const abandoned = path.join(tempDir, "shadow-baseline-0123456789abcdef.tar.99999.partial"); - const live = path.join(tempDir, "shadow-baseline-fedcba9876543210.tar.88888.partial"); - yield* fs.writeFileString(abandoned, "stale"); - yield* fs.writeFileString(live, "in-flight"); + // A stale foreign partial and a fresh in-flight partial are both retained: their mtime + // cannot prove that the writer has stopped. + const stale = path.join( + tempDir, + "shadow-baseline-0123456789abcdef.tar.01234567-89ab-cdef-0123-456789abcdef.partial", + ); + const fresh = path.join( + tempDir, + "shadow-baseline-fedcba9876543210.tar.fedcba98-7654-3210-fedc-ba9876543210.partial", + ); + yield* fs.writeFileString(stale, "stale"); + yield* fs.writeFileString(fresh, "in-flight"); const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); - yield* fs.utimes(abandoned, sixMinutesAgo, sixMinutesAgo); + yield* fs.utimes(stale, sixMinutesAgo, sixMinutesAgo); yield* coldRun(docker, shadowInput(fs, path)); - expect(yield* fs.exists(abandoned)).toBe(false); - expect(yield* fs.exists(live)).toBe(true); + expect(yield* fs.exists(stale)).toBe(true); + expect(yield* fs.exists(fresh)).toBe(true); expect(yield* soleTarName(fs, path)).toHaveLength(1); }), ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 45042bb087..9fd7800b74 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -148,7 +148,7 @@ export interface ShadowCacheKeyInputs { * PG<=14 setup SQL is excluded because that major is cache-ineligible. */ let shadowBaselineEmbeddedDigestMemo: string | undefined; -export const shadowBaselineEmbeddedDigest = (): string => +const shadowBaselineEmbeddedDigest = (): string => (shadowBaselineEmbeddedDigestMemo ??= createHash("sha256") .update( [ @@ -355,7 +355,7 @@ export function shadowBaselineTarFileName(key: string): string { /** * Whether `fileName` is a published baseline snapshot (`shadow-baseline-.tar`). Checks the - * exact prefix and suffix, so partials (`…tar..partial`) are never eviction candidates. + * exact prefix and suffix, so partials (`…tar..partial`) are never eviction candidates. */ export function isShadowBaselineTar(fileName: string): boolean { return ( @@ -416,43 +416,13 @@ const forgetShadowBaselineTar = ( /** * Whether `fileName` is one of {@link exportPgDataTar}'s in-flight temp files - * (`shadow-baseline-.tar..partial`). A name-only check; whether it's abandoned is an - * mtime question the sweep answers separately, so a live writer's temp file is never a candidate. + * (`shadow-baseline-.tar..partial`). A name-only check for diagnostics; active and + * abandoned partials are retained because mtime cannot prove that a long export has settled. */ export function isShadowBaselinePartial(fileName: string): boolean { - return /^shadow-baseline-[0-9a-f]{16}\.tar\.\d+\.partial$/u.test(fileName); + return /^shadow-baseline-[0-9a-f]{16}\.tar\.[0-9a-f-]+\.partial$/u.test(fileName); } -/** A partial older than 5 minutes is abandoned; a live export finishes in seconds. */ -const SHADOW_PARTIAL_ABANDON_MS = 5 * 60 * 1000; - -/** - * Removes abandoned `.partial` temp files (see {@link isShadowBaselinePartial}) left behind by a - * crashed cold export — later runs use their own pid, and the retention sweep ignores `.partial` - * names, so nothing else ever cleans these up. Best-effort throughout. - */ -const sweepAbandonedShadowBaselinePartials = (input: ShadowSetupInput): Effect.Effect => - Effect.gen(function* () { - const cacheDir = shadowBaselineCacheDir(input.path); - const entries = yield* input.fs - .readDirectory(cacheDir) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - const now = yield* Clock.currentTimeMillis; - yield* Effect.forEach( - entries.filter(isShadowBaselinePartial), - (entry) => - Effect.gen(function* () { - const filePath = input.path.join(cacheDir, entry); - const info = yield* input.fs.stat(filePath); - const mtime = Option.getOrUndefined(info.mtime); - if (mtime !== undefined && now - mtime.getTime() > SHADOW_PARTIAL_ABANDON_MS) { - yield* forgetShadowBaselineTar(input.fs, filePath); - } - }).pipe(Effect.orElseSucceed(() => undefined)), - { discard: true }, - ); - }); - /** * Applies the global-cache LRU + TTL retention rule (see {@link shadowBaselineTarsToEvict}). * Best-effort throughout — a snapshot that cannot be swept costs ~90MB of disk, so it must never @@ -540,8 +510,8 @@ const awaitShadowReady = ( /** * Serializes same-process cold exports. Two shadows provisioned concurrently in one process with - * an equal key would race on the same `..partial` temp path, since `exportPgDataTar` - * scopes that name by pid alone; cross-process writers are unaffected (distinct pids). + * an equal key would otherwise race on a shared temp path; `exportPgDataTar` gives every invocation + * its own UUID-scoped path while this mutex still serializes publication of equal keys. */ const shadowExportMutex = Semaphore.makeUnsafe(1); @@ -574,7 +544,6 @@ const writeShadowBaselineTar = ( shadowCacheUnavailable(`failed to create ${cacheDir}: ${cause.message}`), ), ); - yield* sweepAbandonedShadowBaselinePartials(input); yield* exportPgDataTar(spawner, containerId, input.fs, tarPath).pipe( Effect.mapError((cause: PgDataSnapshotUnavailable) => shadowCacheUnavailable(cause.reason)), ); @@ -901,9 +870,8 @@ export const acquireShadowDatabase = ( if (!cached) return yield* coldCachedShadow(spawner, input, key, tarPath, keyInputs.value.rolesSql, true); - // Warm hits refresh mtime and sweep leftovers the cold path would otherwise never see again. + // Warm hits refresh mtime before applying published-tar retention. yield* touchShadowBaselineTar(input.fs, tarPath); - yield* sweepAbandonedShadowBaselinePartials(input); yield* sweepShadowBaselineRetention(input, input.path.basename(tarPath)); return yield* warmShadow(spawner, input, key, tarPath).pipe( diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts index af693f1bd3..e01ef012f0 100644 --- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts @@ -16,7 +16,6 @@ import { declarativeSeamLayer } from "../commands/db/shared/pgdelta.seam.layer.t import { localDockerEngineLayer } from "./db-bootstrap/local-db-running.ts"; import { stackApiLayer } from "./stack-api.ts"; import { bundledPostgresClientLayer } from "./bundled-postgres-client.ts"; -import { ephemeralPostgresLayer } from "./stack-shadow.ts"; import { stackCatalogSetupLayer } from "./stack-catalog-setup.ts"; /** The in-process pg-delta engine — the only implementation. */ @@ -84,6 +83,5 @@ export const pgDeltaCommandRuntimeLayer = Layer.mergeAll( localDockerEngine, stackApiLayer, bundledPostgresClientLayer, - ephemeralPostgresLayer, stackCatalogSetupLayer, ); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.ts b/apps/cli/src/command-internal/stack-catalog-setup.ts index 8c1234079d..6678dde0c6 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.ts @@ -1,12 +1,9 @@ import { Context, Crypto, Data, Effect, FileSystem, Layer, Path, Redacted } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - schemaInit, + type EffectServiceInstance, type EffectStack, - type SchemaInitCapabilityName, - type SchemaInitTarget, type StackConfig, - type StackRuntime, } from "@supabase/stack/effect"; import { Output } from "../shared/output/output.service.ts"; import { @@ -24,16 +21,6 @@ import { } from "./db-bootstrap/db-setup.ts"; import type { VaultSecret } from "./vault.ts"; -const PLATFORM_TRIO = [ - "auth", - "storage", - "realtime", -] as const satisfies ReadonlyArray; -const OPTIONAL_CAPS = [ - "analytics", - "pooler", -] as const satisfies ReadonlyArray; - export class StackCatalogSetupError extends Data.TaggedError("StackCatalogSetupError")<{ readonly message: string; readonly cause?: unknown; @@ -56,116 +43,54 @@ interface LiveStackCatalogInput { readonly kind: "live"; readonly stack: EffectStack; readonly projectRoot: string; - readonly config: StackConfig; + readonly config?: StackConfig; } -interface EphemeralStackCatalogInput { - readonly kind: "ephemeral"; +interface ServiceStackCatalogInput { + readonly kind: "service"; + readonly stack: EffectStack; + readonly service: EffectServiceInstance<"database">; readonly projectRoot: string; - readonly runtime: StackRuntime; - readonly config: StackConfig; - readonly databaseUrl: string; - readonly databasePassword: Redacted.Redacted; - readonly jwtSecret?: Redacted.Redacted; - readonly networkId?: string; + readonly config?: StackConfig; } export interface StackCatalogSetupInput { - readonly target: LiveStackCatalogInput | EphemeralStackCatalogInput; + readonly target: LiveStackCatalogInput | ServiceStackCatalogInput; readonly overlay: StackCatalogOverlay; - /** Used only for analytics/pooler one-shots. Platform trio stays on `target.config`. */ + readonly config?: StackConfig; readonly optionalConfig?: StackConfig; } -const capabilityEnabled = (config: StackConfig, name: SchemaInitCapabilityName): boolean => { - const cap = config.capabilities?.[name]; - return cap === undefined || cap.enabled !== false; -}; - -const jwtSecretFromConfig = (config: StackConfig): Redacted.Redacted | undefined => { - const signing = config.security?.jwt?.signing; - return signing?.kind === "symmetric" ? signing.secret : undefined; -}; - const catalogError = (error: { readonly message: string }): StackCatalogSetupError => new StackCatalogSetupError({ message: error.message, cause: error }); -const runSchemaInit = (names: ReadonlyArray, target: SchemaInitTarget) => - names.length === 0 ? Effect.void : schemaInit(names, target).pipe(Effect.mapError(catalogError)); +const credentialValue = (value: string | Redacted.Redacted): string => + typeof value === "string" ? value : Redacted.value(value); -const targetConnection = (target: LiveStackCatalogInput | EphemeralStackCatalogInput) => - target.kind === "ephemeral" - ? Effect.succeed({ - databaseUrl: target.databaseUrl, - databasePassword: target.databasePassword, - jwtSecret: target.jwtSecret, - runtime: target.runtime, - }) - : Effect.gen(function* () { - const credentials = yield* target.stack.credentials; - const status = yield* target.stack.status; - return { - databaseUrl: Redacted.value(credentials.database.url), - databasePassword: credentials.database.password, - jwtSecret: jwtSecretFromConfig(target.config), - runtime: status.runtime, - }; - }).pipe(Effect.mapError(catalogError)); +const targetConnection = (target: LiveStackCatalogInput | ServiceStackCatalogInput) => + Effect.gen(function* () { + if (target.kind === "service") { + const credentials = yield* target.service.credentials; + if (credentials === undefined) + return yield* new StackCatalogSetupError({ + message: "stack database credentials are unavailable", + }); + return { databaseUrl: credentialValue(credentials.url) }; + } + const credentials = yield* target.stack.credentials; + if (credentials.database === undefined) + return yield* new StackCatalogSetupError({ + message: "stack database credentials are unavailable", + }); + return { databaseUrl: Redacted.value(credentials.database.url) }; + }).pipe(Effect.mapError(catalogError)); const applyCatalog = (input: StackCatalogSetupInput) => Effect.gen(function* () { - const output = yield* Output; const dbConn = yield* DbConnection; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const connection = yield* targetConnection(input.target); - const schemaTarget: SchemaInitTarget = - input.target.kind === "live" - ? { - kind: "live", - stackId: input.target.stack.id, - projectRoot: input.target.projectRoot, - runtime: connection.runtime, - config: input.target.config, - databaseUrl: connection.databaseUrl, - secrets: { - databasePassword: connection.databasePassword, - ...(connection.jwtSecret === undefined ? {} : { jwtSecret: connection.jwtSecret }), - }, - } - : { - kind: "ephemeral", - projectRoot: input.target.projectRoot, - runtime: connection.runtime, - config: input.target.config, - databaseUrl: connection.databaseUrl, - secrets: { - databasePassword: connection.databasePassword, - ...(connection.jwtSecret === undefined ? {} : { jwtSecret: connection.jwtSecret }), - }, - ...(input.target.networkId === undefined ? {} : { networkId: input.target.networkId }), - }; - const config = input.target.config; - const failClosed = PLATFORM_TRIO.filter((name) => capabilityEnabled(config, name)); - yield* runSchemaInit(failClosed, schemaTarget); - if (input.target.kind === "live") { - const optionalSource = input.optionalConfig ?? config; - const optional = OPTIONAL_CAPS.filter((name) => capabilityEnabled(optionalSource, name)); - yield* Effect.forEach( - optional, - (name) => - schemaInit([name], schemaTarget).pipe( - Effect.catchTag("RequiresActivatedProcessError", (error) => - output.raw( - `WARNING: skipped ${error.capability} schema init: ${error.message}\n`, - "stderr", - ), - ), - Effect.mapError(catalogError), - ), - { discard: true }, - ); - } const conn = parseConnectionString(connection.databaseUrl); if (conn === undefined) { return yield* new StackCatalogSetupError({ diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 4c5b36a596..8916e7be88 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -272,26 +272,6 @@ const functionRelativePath = ( return path.relative(functionRoot, target).replaceAll(path.sep, "/"); }; -const functionPathError = ( - path: Path.Path, - projectRoot: string, - name: string, - field: string, - value: string, -): string | undefined => { - if (value.length === 0) return undefined; - const functionsRoot = path.join(projectRoot, "supabase", "functions"); - const target = path.isAbsolute(value) - ? path.normalize(value) - : path.normalize( - path.join(projectRoot, "supabase", value.startsWith("./") ? value.slice(2) : value), - ); - const relative = path.relative(functionsRoot, target); - if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) - return `functions.${name}.${field} path must be inside supabase/functions`; - return undefined; -}; - const apiListener = ( document: Readonly> | undefined, config: CliConfig, @@ -1280,6 +1260,17 @@ const configInput = ( max_client_conn: poolerResolved.max_client_conn, }), }, + // The default database receives platform catalog setup only when the stack is first + // registered. Subsequent opens retain the resolved profile in the registry. + initialization: { + database: { + catalog: { + auth: {}, + storage: {}, + realtime: {}, + }, + }, + }, listeners: { api: apiListener( document, @@ -1362,8 +1353,6 @@ const decryptConsumedSecrets = ( }); const configValidationError = ( - path: Path.Path, - projectRoot: string, config: CliConfig, projectEnvValues: Readonly>, effectiveEdgeEnabled: boolean, @@ -1374,15 +1363,6 @@ const configValidationError = ( if (!effectiveEdgeEnabled) return undefined; for (const [name, functionConfig] of Object.entries(config.functions)) { if (functionConfig.enabled === false) continue; - for (const [field, value] of [ - ["import_map", functionConfig.import_map], - ["entrypoint", functionConfig.entrypoint], - ...functionConfig.static_files.map((path) => ["static_files", path] as const), - ] as const) { - if (typeof value !== "string") continue; - const pathError = functionPathError(path, projectRoot, name, field, value); - if (pathError !== undefined) return pathError; - } for (const value of Object.values(functionConfig.env)) { if (typeof value !== "string") continue; const match = /^env\(([A-Za-z_][A-Za-z0-9_]*)\)$/.exec(value); @@ -1435,8 +1415,6 @@ export const loadStackConfig = ( const validationError = yield* Effect.try({ try: () => configValidationError( - path, - projectRoot, validatedConfig, context.projectEnvValues, validatedConfig.edge_runtime.enabled, diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index df84baadf7..0fc9b18842 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -29,6 +29,8 @@ import { migrateAndSeed } from "./migrate-and-seed.ts"; const stackDatabaseConn = (stack: EffectStack) => Effect.gen(function* () { const credentials = yield* stack.credentials; + if (credentials.database === undefined) + return yield* Effect.fail({ message: "stack database credentials are unavailable" }); const conn = parseConnectionString(Redacted.value(credentials.database.url)); if (conn === undefined) return yield* Effect.fail({ message: "failed to parse stack database URL" }); @@ -178,20 +180,6 @@ export const stackOpenReadyProject = stackOpenProjectBy((cause) => notRunning(ca ), ); -export const stackProjectRuntime: Effect.Effect = - Effect.gen(function* () { - const api = yield* Effect.serviceOption(StackApi); - if (Option.isNone(api)) return undefined; - const cliSettings = yield* CommandSettings; - const descriptor = yield* api.value - .findStack({ projectRoot: cliSettings.workdir }) - .pipe(Effect.orElseSucceed(() => Option.none())); - return Option.match(descriptor, { - onNone: () => undefined, - onSome: (value) => value.runtime, - }); - }); - export class StackRuntimeUnavailableError extends Data.TaggedError("StackRuntimeUnavailableError")<{ readonly message: string; readonly suggestion?: string; @@ -279,6 +267,7 @@ const stackLocalDatabaseUrl: Effect.Effect notRunning(cause.message)), ); + if (credentials.database === undefined) return yield* notRunning(); return Redacted.value(credentials.database.url); }); @@ -336,14 +325,15 @@ export const stackEnsurePostgresOnlyStarted = Effect.gen(function* () { if (Option.isNone(existing) || existing.value.desiredLifecycle === "unconfigured") { const stack = Option.isNone(existing) ? yield* api.value - .createStack({ projectRoot: cliSettings.workdir }) + .createStack({ + projectRoot: cliSettings.workdir, + initialConfig: postgresOnlyStackStartConfig(config), + }) .pipe(Effect.mapError(startFailed)) : yield* api.value.openStack(existing.value.id).pipe(Effect.mapError(startFailed)); if (stack.dockerFallbackNotice !== undefined) yield* output.raw(`${stack.dockerFallbackNotice}\n`, "stderr"); - yield* stack - .start({ config: postgresOnlyStackStartConfig(config) }) - .pipe(Effect.mapError(startFailed)); + yield* stack.start().pipe(Effect.mapError(startFailed)); yield* applyCatalog(stack); yield* applyStackMigrateAndSeed(stack, cliSettings.workdir, toml, experimental).pipe( Effect.mapError(startFailedAfterEngine), diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index 6cf98d8816..5cc380d114 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -1,94 +1,52 @@ -import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; import { - ContainerEngineResolver, - EphemeralPostgresError, - databaseBootstrapIdentity, - schemaInitArtifactIdentity, - type CreateEphemeralPostgresOptions, - type EffectEphemeralPostgres, - type StackConfig, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Option, + Path, + Redacted, + Stream, +} from "effect"; +import { CliConfigSchema, type CliConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { StackRuntimeError, UncertainOperationError } from "@supabase/stack/effect"; +import type { + AnyEffectCreateServiceOptions, + EffectCreateServiceOptions, + EffectDatabaseInitialization, + EffectServiceInstance, + EffectServiceCollection, + EffectStack, + ServiceKind, + ServiceCredentials, + ServiceDescriptor, + SnapshotDescriptor, + StackStatus, } from "@supabase/stack/effect"; +import { ServiceInstanceIdSchema, StackIdSchema } from "@supabase/stack/effect"; +import type { ServiceStatus } from "@supabase/stack/effect"; +import { StackApi } from "./stack-api.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; -import { - mockCommandSettings, - useTempWorkdir, - withEnvVar, -} from "../../tests/helpers/command-mocks.ts"; +import { useTempWorkdir, withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { SHADOW_CACHE_ENV } from "./db-bootstrap/shadow-cache.ts"; -import { DbConnection } from "./db-connection.service.ts"; -import { loadLocalProjectContext } from "./local-project-context.ts"; -import { stackBackendLayer } from "./stack-backend.ts"; import { - StackEphemeralPostgres, stackAcquireShadowDatabase, - stackShadowBaselineTarFileName, - stackShadowCacheKey, + stackReleaseShadowDatabase, + stackWithShadowDatabase, } from "./stack-shadow.ts"; import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; -import { - noopStackCatalogSetupLayer, - recordingStackCatalogSetup, - StackCatalogSetup, -} from "./stack-catalog-setup.ts"; +import { recordingStackCatalogSetup } from "./stack-catalog-setup.ts"; const tmp = useTempWorkdir("stack-shadow-"); +type StackServiceError = Effect.Error>; const defaultConfig: CliConfig = Schema.decodeSync(CliConfigSchema)({}); const nativeRuntime = { kind: "native" as const }; -const nativeAcquire = { runtime: nativeRuntime }; - -const mockEphemeral = () => { - const restores: Array = []; - const exports: Array = []; - const runtimes: Array = []; - const create = ( - options: CreateEphemeralPostgresOptions, - ): Effect.Effect => - Effect.sync(() => { - restores.push(options.restoreFrom); - runtimes.push(options.runtime); - return { - host: "127.0.0.1", - port: 59999, - version: "17.6.1", - runtime: { kind: "native" as const }, - artifactIdentity: "native:17.6.1", - url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: Effect.void, - stop: Effect.void, - exportPgData: (tarPath: string) => - Effect.gen(function* () { - exports.push(tarPath); - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(tarPath, "pgdata").pipe(Effect.ignore); - }), - }; - }); - return { - restores, - exports, - runtimes, - layer: Layer.succeed(StackEphemeralPostgres, { - create, - resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), - }), - }; -}; - -const db = Layer.succeed(DbConnection, { - connect: () => - Effect.succeed({ - exec: () => Effect.void, - query: () => Effect.succeed([]), - execBatch: () => Effect.void, - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), -}); const input = (fs: FileSystem.FileSystem, path: Path.Path): ShadowSetupInput => ({ db: { major_version: 17, settings: {} }, @@ -96,8 +54,8 @@ const input = (fs: FileSystem.FileSystem, path: Path.Path): ShadowSetupInput( - home: string, - value: string, - body: Effect.Effect, -): Effect.Effect => - withEnvVar("SUPABASE_HOME", home, withEnvVar(SHADOW_CACHE_ENV, value, body)); +const serviceId = (id: string) => Schema.decodeSync(ServiceInstanceIdSchema)(id); +const stackId = Schema.decodeSync(StackIdSchema)("a".repeat(64)); -const expectedCacheKey = ( - overrides: Partial[0]> = {}, -): string => - stackShadowCacheKey({ - artifactIdentity: "native:17.6.1", - majorVersion: 17, - runtimeKind: "native", - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - jwtExpiry: 3600, - dbPassword: "postgres", - dbSettings: {}, - rolesSql: "", - bootstrapIdentity: databaseBootstrapIdentity, - webhooksEnabled: false, - apiGrantsKept: true, - vault: [], - jwks: "", - storageTargetMigration: "", - authEnabled: false, - storageEnabled: false, - realtimeEnabled: false, - authArtifact: "", - storageArtifact: "", - realtimeArtifact: "", - ...overrides, - }); +const serviceStatus = ( + id: ReturnType, + name: string, + phase: ServiceStatus["phase"] = "stopped", +): ServiceStatus => ({ + id, + service: "database", + name, + enabled: true, + intent: phase === "stopped" ? "stopped" : "started", + phase, + activation: "eager", + endpoints: [], +}); -const catalogAuthEnabled = (config: StackConfig): boolean => { - const cap = config.capabilities?.auth; - return cap === undefined || cap.enabled !== false; -}; +const descriptor = ( + id: string, + name = `shadow-${id}`, + profile = "profile:shadow", + initializationProfile = profile, +): ServiceDescriptor<"database"> => ({ + id: serviceId(id), + service: "database", + name, + enabled: true, + config: { + enabled: true, + activation: "eager", + idleTimeoutSeconds: false, + version: "17.6.1", + settings: {}, + }, + dependencies: {}, + snapshotSupport: "supported", + endpoints: { sql: { enabled: true, address: "127.0.0.1", port: 55432 } }, + artifactIdentity: "native:17.6.1", + runtimeIdentity: "native:database:17.6.1", + bootstrapRecipeId: "database-bootstrap-v1", + bootstrapInputsId: "inputs:shadow", + creationInputsId: "creation:shadow", + effectiveConfigFingerprint: "config:shadow", + initializationProfileId: profile, + initialization: { + profileId: initializationProfile, + recipes: [ + { + service: "analytics", + recipeId: "analytics", + artifactIdentity: "analytics", + completed: false, + }, + { service: "pooler", recipeId: "pooler", artifactIdentity: "pooler", completed: false }, + ], + }, + data: { origin: "absent" }, +}); -const engineResolver = (installed: boolean) => - Layer.succeed(ContainerEngineResolver, { - isInstalled: () => Effect.succeed(installed), - resolve: () => Effect.die("unused"), - }); +const snapshotDescriptor = (id: ReturnType): SnapshotDescriptor => ({ + lineageId: "lineage:shadow", + initializationProfileId: "profile:shadow", + artifactIdentity: "native:17.6.1", + runtimeIdentity: "native:database:17.6.1", + dataFormat: { provider: "postgres", format: "pgdata", majorVersion: 17 }, + provenance: { sourceInstanceId: id, exportOperationId: "export" }, +}); -describe("stackAcquireShadowDatabase", () => { - it.live( - "exports a stack-shadow-baseline tar on a cold miss and restores it on a warm hit", - () => { - const ephemeral = mockEphemeral(); - const out = mockOutput(); - const catalog = recordingStackCatalogSetup((input) => input.target.kind); - return Effect.scoped( +const fakeStack = (events: { + readonly creates: string[]; + readonly initializations?: EffectDatabaseInitialization[]; + readonly restores: string[]; + readonly destroys?: string[]; + readonly pendingSnapshot?: boolean; + readonly retainedSnapshotRecovery?: boolean; + readonly uncertainCreate?: boolean; + readonly mismatchedCandidate?: boolean; + readonly profileMismatch?: boolean; + readonly blockStart?: boolean; + readonly startEntered?: Deferred.Deferred; + starts?: number; + restoreFailures?: number; +}) => { + let serviceNumber = 0; + let uncertainCandidate: EffectServiceInstance<"database"> | undefined; + const makeService = (name: string): EffectServiceInstance<"database"> => { + const id = `service-${String(++serviceNumber)}`; + const serviceDescriptor = descriptor( + id, + name, + "profile:shadow", + events.profileMismatch ? "profile:other" : "profile:shadow", + ); + const credentials: ServiceCredentials<"database"> = { + url: `postgresql://postgres:postgres@127.0.0.1:${String(55431 + serviceNumber)}/postgres`, + password: "postgres", + }; + const stopped = serviceStatus(serviceDescriptor.id, name); + const ready = serviceStatus(serviceDescriptor.id, name, "ready"); + const retainedSnapshot = { + ...stopped, + phase: "recovery" as const, + pendingOperation: { id: "restore", kind: "restoreSnapshot" as const }, + recovery: { operation: "destroy" as const, message: "snapshot cleanup failed" }, + }; + return { + id: serviceDescriptor.id, + service: "database", + name, + describe: Effect.succeed(serviceDescriptor).pipe( + Effect.mapError(() => new StackRuntimeError({ message: "unused" })), + ), + status: Effect.succeed(stopped), + credentials: Effect.succeed(credentials), + prepare: Effect.succeed({ + instances: [{ id: serviceDescriptor.id, service: "database", artifacts: [] }], + }), + start: Effect.gen(function* () { + if (events.blockStart) { + events.starts = (events.starts ?? 0) + 1; + if (events.startEntered !== undefined) + yield* Deferred.succeed(events.startEntered, undefined); + yield* Effect.never; + } + return ready; + }), + sleep: Effect.succeed(serviceStatus(serviceDescriptor.id, name, "dormant")), + stop: Effect.succeed(stopped), + restart: () => Effect.succeed(ready), + destroy: Effect.sync(() => { + events.destroys?.push(serviceDescriptor.id); + }), + exportSnapshot: ({ destination }: { readonly destination: string }) => + Effect.promise(() => Bun.write(destination, "snapshot")).pipe( + Effect.as(snapshotDescriptor(serviceDescriptor.id)), + ), + restoreSnapshot: ({ source }: { readonly source: string }) => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - return yield* withShadowCacheHome( - home, - "1", - Effect.gen(function* () { - const first = yield* stackAcquireShadowDatabase(input(fs, path), nativeAcquire); - expect(first.baselinePresent).toBe(false); - expect(catalog.applied).toEqual(["ephemeral"]); - expect(first.artifactIdentity).toBe("native:17.6.1"); - expect(ephemeral.restores).toEqual([undefined]); - expect(ephemeral.exports).toHaveLength(1); - expect(ephemeral.exports[0]?.endsWith(`.${String(process.pid)}.partial`)).toBe(true); - const names = (yield* fs.readDirectory( - path.join(home, "cache", "shadow-baseline"), - )).filter((name) => name.endsWith(".tar") && !name.includes(".partial")); - expect(names).toHaveLength(1); - const info = yield* fs.stat(path.join(home, "cache", "shadow-baseline", names[0]!)); - expect((Number(info.mode) & 0o777).toString(8)).toBe("600"); - expect(names[0]?.startsWith("stack-shadow-baseline-")).toBe(true); - expect(names[0]).toBe(stackShadowBaselineTarFileName(expectedCacheKey())); - - const warm = yield* stackAcquireShadowDatabase(input(fs, path), nativeAcquire); - expect(warm.baselinePresent).toBe(true); - expect(ephemeral.restores[1]?.endsWith(names[0] ?? "")).toBe(true); - expect(catalog.applied).toEqual(["ephemeral"]); - }), - ); + events.restores.push(source); + if (events.restoreFailures !== undefined && events.restoreFailures > 0) { + events.restoreFailures -= 1; + return yield* Effect.fail(new StackRuntimeError({ message: "snapshot is corrupt" })); + } + return snapshotDescriptor(serviceDescriptor.id); }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral.layer, - catalog.layer, + logs: () => Effect.succeed({ cursor: { opaque: "" }, entries: [], running: false }), + followLogs: () => Stream.empty, + followStatus: events.retainedSnapshotRecovery + ? Stream.concat(Stream.succeed(retainedSnapshot), Stream.never) + : Stream.fromIterable( + events.pendingSnapshot + ? [ + { + ...stopped, + pendingOperation: { id: "restore", kind: "restoreSnapshot" as const }, + }, + stopped, + ] + : [stopped], ), + }; + }; + const primaryService = makeService("database"); + function createService( + options: EffectCreateServiceOptions<"database">, + ): Effect.Effect, StackServiceError>; + function createService( + options: EffectCreateServiceOptions, + ): Effect.Effect, StackServiceError>; + function createService(options: AnyEffectCreateServiceOptions) { + if (options.service !== "database") return Effect.die("unused service kind"); + const name = options.name ?? "unnamed"; + events.creates.push(name); + if (options.initialization !== undefined) events.initializations?.push(options.initialization); + const candidate = makeService(events.mismatchedCandidate ? "other-shadow" : name); + if (events.uncertainCreate) { + uncertainCandidate = candidate; + return Effect.fail( + Object.assign( + new UncertainOperationError({ + stackId, + mutation: "create", + message: "create response was lost", + }), + { expectedCreationInputsId: "creation:shadow" }, ), ); - }, - ); + } + return Effect.succeed(candidate); + } + const services: EffectServiceCollection = { + create: createService, + get: (ref) => + "name" in ref && ref.name === "database" + ? Effect.succeed(primaryService) + : uncertainCandidate === undefined + ? Effect.die("unused") + : Effect.succeed(uncertainCandidate), + list: Effect.succeed([]), + }; + const status: StackStatus = { + id: stackId, + lifecycle: "running", + desiredLifecycle: "running", + runtime: nativeRuntime, + endpoints: {}, + versions: {}, + capabilities: [], + artifacts: [], + instances: [], + }; + return { + id: stackId, + services, + status: Effect.succeed(status), + followStatus: Stream.empty, + credentials: Effect.succeed({ + database: { url: Redacted.make(credentialsUrl), password: Redacted.make("postgres") }, + }), + prepare: () => Effect.succeed({ instances: [] }), + start: () => Effect.succeed(status), + sleep: () => Effect.succeed(status), + restart: () => Effect.succeed(status), + stop: () => Effect.succeed(status), + destroy: () => Effect.void, + logs: () => Effect.succeed({ cursor: { opaque: "" }, entries: [], running: false }), + followLogs: () => Stream.empty, + }; +}; - it.live("skips the cache when SUPABASE_SHADOW_CACHE is 0", () => { - const ephemeral = mockEphemeral(); - const out = mockOutput(); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - return yield* withShadowCacheHome( - home, - "0", - Effect.gen(function* () { - const handle = yield* stackAcquireShadowDatabase(input(fs, path), nativeAcquire); - expect(handle.baselinePresent).toBe(false); - expect(ephemeral.exports).toHaveLength(0); - const names = yield* fs - .readDirectory(path.join(home, "cache", "shadow-baseline")) - .pipe(Effect.orElseSucceed(() => [])); - expect(names.filter((name) => name.endsWith(".tar"))).toEqual([]); - }), - ); +const credentialsUrl = "postgresql://postgres:postgres@127.0.0.1:55430/postgres"; + +const apiLayer = (stack: EffectStack) => { + let exists = false; + const found = { + id: stack.id, + projectRoot: tmp.current, + name: "default", + branchContext: "main", + runtime: nativeRuntime, + desiredLifecycle: "stopped" as const, + }; + return Layer.succeed(StackApi, { + findStack: () => Effect.succeed(exists ? Option.some(found) : Option.none()), + createStack: () => + Effect.sync(() => { + exists = true; + return stack; }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral.layer, - noopStackCatalogSetupLayer, - ), - ), - ); + openStack: () => Effect.succeed(stack), + inspectStack: () => Effect.die("unused"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), }); +}; - it.live("keeps the cluster uncached when the baseline export fails", () => { - const restores: Array = []; - const out = mockOutput(); - const layer = Layer.succeed(StackEphemeralPostgres, { - create: (options) => - Effect.sync(() => { - restores.push(options.restoreFrom); - return { - host: "127.0.0.1", - port: 59999, - version: "17.6.1", - runtime: { kind: "native" as const }, - artifactIdentity: "native:17.6.1", - url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: Effect.void, - stop: Effect.void, - exportPgData: () => - Effect.fail( - new EphemeralPostgresError({ message: "export failed", reason: "snapshot" }), - ), - }; - }), - resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), - }); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - return yield* withShadowCacheHome( - home, +const layers = (stack: EffectStack, catalog: ReturnType) => + Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + mockOutput().layer, + apiLayer(stack), + catalog.layer, + ); + +describe("stackAcquireShadowDatabase", () => { + it.live("creates and starts a generic database service for a cold shadow", () => { + const events = { creates: [], initializations: [], restores: [], destroys: [] }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup((value) => value.target.kind); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handle = yield* stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }); + expect(events.creates).toHaveLength(1); + expect(events.initializations).toEqual([{ from: serviceId("service-1") }]); + expect(handle.service.service).toBe("database"); + expect(catalog.applied).toEqual(["service"]); + yield* stackReleaseShadowDatabase(handle); + }).pipe(Effect.provide(layers(stack, catalog))); + }); + + it.live("publishes a cold snapshot and restores it through the service API", () => { + const events = { creates: [], restores: [], destroys: [] }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const first = yield* withEnvVar( + "SUPABASE_HOME", + home, + withEnvVar( + SHADOW_CACHE_ENV, "1", - Effect.gen(function* () { - const handle = yield* stackAcquireShadowDatabase(input(fs, path), nativeAcquire); - expect(handle.baselinePresent).toBe(false); - expect(handle.snapshotKey).toBeUndefined(); - expect(restores).toEqual([undefined]); - expect(out.stderrText).toContain("Warning: shadow baseline not cached:"); - const names = yield* fs - .readDirectory(path.join(home, "cache", "shadow-baseline")) - .pipe(Effect.orElseSucceed(() => [])); - expect( - names.filter((name) => name.endsWith(".tar") && !name.includes(".partial")), - ).toEqual([]); - }), - ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - layer, - noopStackCatalogSetupLayer, + stackAcquireShadowDatabase(input(fs, path), { runtime: nativeRuntime }), ), - ), - ); + ); + expect(first.baselinePresent).toBe(false); + const names = yield* fs.readDirectory(path.join(home, "cache", "shadow-baseline")); + const tarNames = names.filter((name) => name.endsWith(".tar")); + expect(tarNames).toHaveLength(1); + expect(tarNames[0]).toMatch(/^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u); + yield* stackReleaseShadowDatabase(first); + const second = yield* withEnvVar( + "SUPABASE_HOME", + home, + withEnvVar( + SHADOW_CACHE_ENV, + "1", + stackAcquireShadowDatabase(input(fs, path), { runtime: nativeRuntime }), + ), + ); + expect(second.baselinePresent).toBe(true); + expect(events.restores).toHaveLength(1); + yield* stackReleaseShadowDatabase(second); + expect(catalog.applied).toEqual([undefined]); + }).pipe(Effect.provide(layers(stack, catalog))); }); - it.live("warns and cold-provisions when a cached baseline restore fails", () => { - const restores: Array = []; - const out = mockOutput(); - const layer = Layer.succeed(StackEphemeralPostgres, { - create: (options) => { - restores.push(options.restoreFrom); - if (options.restoreFrom !== undefined) - return Effect.fail( - new EphemeralPostgresError({ - message: "restore failed", - reason: "restore-mismatch", - }), - ); - return Effect.succeed({ - host: "127.0.0.1", - port: 59999, - version: "17.6.1", - runtime: { kind: "native" as const }, - artifactIdentity: "native:17.6.1", - url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: Effect.void, - stop: Effect.void, - exportPgData: () => Effect.void, - }); - }, - resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), - }); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const cacheDir = path.join(home, "cache", "shadow-baseline"); - yield* fs.makeDirectory(cacheDir, { recursive: true }); - const tarName = stackShadowBaselineTarFileName(expectedCacheKey()); - yield* fs.writeFileString(path.join(cacheDir, tarName), "corrupt"); - return yield* withShadowCacheHome( - home, + it.live("discards a corrupt cached snapshot before creating a fresh service", () => { + const events: { + creates: string[]; + restores: string[]; + destroys: string[]; + restoreFailures?: number; + } = { + creates: [], + restores: [], + destroys: [], + }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + yield* withEnvVar( + "SUPABASE_HOME", + home, + withEnvVar( + SHADOW_CACHE_ENV, "1", - Effect.gen(function* () { - const handle = yield* stackAcquireShadowDatabase(input(fs, path), nativeAcquire); - expect(handle.baselinePresent).toBe(false); - expect(restores).toHaveLength(2); - expect(restores[0]?.endsWith(tarName)).toBe(true); - expect(restores[1]).toBeUndefined(); - expect(out.stderrText).toContain("Warning: shadow baseline not cached: restore failed"); - }), - ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - layer, - noopStackCatalogSetupLayer, + stackAcquireShadowDatabase(input(fs, path), { runtime: nativeRuntime }).pipe( + Effect.flatMap(stackReleaseShadowDatabase), + ), ), - ), - ); + ); + events.restoreFailures = 1; + const handle = yield* withEnvVar( + "SUPABASE_HOME", + home, + withEnvVar( + SHADOW_CACHE_ENV, + "1", + stackAcquireShadowDatabase(input(fs, path), { runtime: nativeRuntime }), + ), + ); + expect(events.creates).toHaveLength(3); + expect(events.restores).toHaveLength(1); + expect(catalog.applied).toEqual([undefined, undefined]); + yield* stackReleaseShadowDatabase(handle); + }).pipe(Effect.provide(layers(stack, catalog))); }); - it.live("stops the cluster when acquire is interrupted during catalog overlay", () => { - const out = mockOutput(); - return Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make(); - let stopped = false; - const ephemeral = Layer.succeed(StackEphemeralPostgres, { - create: () => - Effect.sync(() => ({ - host: "127.0.0.1", - port: 59999, - version: "17.6.1", - runtime: { kind: "native" as const }, - artifactIdentity: "native:17.6.1", - url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:59999/postgres"), - start: Effect.void, - stop: Effect.sync(() => { - stopped = true; - }), - exportPgData: () => Effect.die("export should not run"), - })), - resolveRelease: () => Effect.succeed({ version: "17.6.1", image: "postgres:17.6.1" }), - }); - const catalog = Layer.succeed(StackCatalogSetup, { - apply: () => - Effect.gen(function* () { - yield* Deferred.succeed(started, undefined); - return yield* Effect.never; - }), - }); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const fiber = yield* Effect.forkChild( - withShadowCacheHome( - home, - "0", - stackAcquireShadowDatabase(input(fs, path), nativeAcquire), - ).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral, - catalog, - ), - ), - ), - ); - yield* Deferred.await(started); - yield* Fiber.interrupt(fiber); - expect(stopped).toBe(true); - }), - ).pipe(Effect.provide(BunServices.layer)); + it.live("waits for a pending snapshot operation before destroying its owned service", () => { + const events = { creates: [], restores: [], destroys: [], pendingSnapshot: true }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* stackWithShadowDatabase(input(fs, path), () => Effect.void, { bypassCache: true }); + expect(events.destroys).toHaveLength(1); + }).pipe(Effect.provide(layers(stack, catalog))); }); - it.live("hashes and creates a docker runtime when Docker is installed", () => { - const ephemeral = mockEphemeral(); - const out = mockOutput(); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - return yield* withShadowCacheHome( - home, + it.live("destroys a recovery-fenced snapshot service before falling back to cold cache", () => { + const events = { + creates: [], + restores: [], + destroys: [], + retainedSnapshotRecovery: true, + restoreFailures: 1, + }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + yield* withEnvVar( + "SUPABASE_HOME", + home, + withEnvVar( + SHADOW_CACHE_ENV, "1", - Effect.gen(function* () { - yield* stackAcquireShadowDatabase(input(fs, path)); - expect(ephemeral.runtimes[0]).toEqual({ kind: "container", engine: "docker" }); - const names = (yield* fs.readDirectory( - path.join(home, "cache", "shadow-baseline"), - )).filter((name) => name.endsWith(".tar") && !name.includes(".partial")); - expect(names).toEqual([ - stackShadowBaselineTarFileName( - expectedCacheKey({ - artifactIdentity: "container:docker:postgres:17.6.1", - runtimeKind: "container:docker", - }), - ), - ]); - }), - ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral.layer, - noopStackCatalogSetupLayer, - engineResolver(true), + stackAcquireShadowDatabase(input(fs, path), { runtime: nativeRuntime }).pipe( + Effect.flatMap(stackReleaseShadowDatabase), + ), ), - ), - ); + ); + const handle = yield* withEnvVar( + "SUPABASE_HOME", + home, + withEnvVar( + SHADOW_CACHE_ENV, + "1", + stackAcquireShadowDatabase(input(fs, path), { runtime: nativeRuntime }), + ), + ); + expect(events.restores).toHaveLength(1); + expect(events.creates).toHaveLength(3); + expect(events.destroys).toHaveLength(2); + expect(handle.baselinePresent).toBe(false); + yield* stackReleaseShadowDatabase(handle); + expect(events.destroys).toHaveLength(3); + }).pipe(Effect.provide(layers(stack, catalog))); + }); + + it.live("does not destroy a mismatched candidate after an uncertain create", () => { + const events = { + creates: [], + restores: [], + destroys: [], + uncertainCreate: true, + mismatchedCandidate: true, + }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const acquired = yield* Effect.exit( + stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }), + ); + expect(Exit.isFailure(acquired)).toBe(true); + expect(events.destroys).toHaveLength(0); + }).pipe(Effect.provide(layers(stack, catalog))); + }); + + it.live("reuses an uncertain candidate only when its requested profile matches", () => { + const events = { + creates: [], + restores: [], + destroys: [], + uncertainCreate: true, + }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handle = yield* stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }); + expect(handle.service.name).toContain("shadow-"); + expect(events.destroys).toHaveLength(0); + yield* stackReleaseShadowDatabase(handle); + expect(events.destroys).toHaveLength(1); + }).pipe(Effect.provide(layers(stack, catalog))); }); - it.live("hashes and creates a native runtime when Docker is not installed", () => { - const ephemeral = mockEphemeral(); - const out = mockOutput(); - return Effect.scoped( - Effect.gen(function* () { + it.live( + "retains a candidate with a different initialization profile after uncertain create", + () => { + const events = { + creates: [], + restores: [], + destroys: [], + uncertainCreate: true, + profileMismatch: true, + }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - return yield* withShadowCacheHome( - home, - "1", - Effect.gen(function* () { - yield* stackAcquireShadowDatabase(input(fs, path)); - expect(ephemeral.runtimes[0]).toEqual({ kind: "native" }); - const names = (yield* fs.readDirectory( - path.join(home, "cache", "shadow-baseline"), - )).filter((name) => name.endsWith(".tar") && !name.includes(".partial")); - expect(names).toEqual([stackShadowBaselineTarFileName(expectedCacheKey())]); - }), + const acquired = yield* Effect.exit( + stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }), ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral.layer, - noopStackCatalogSetupLayer, - engineResolver(false), - ), - ), - ); + expect(Exit.isFailure(acquired)).toBe(true); + expect(events.destroys).toHaveLength(0); + }).pipe(Effect.provide(layers(stack, catalog))); + }, + ); + + it.live("keeps two independently acquired targets isolated", () => { + const events = { creates: [], restores: [], destroys: [] }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }); + const second = yield* stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }); + expect(first.service.id).not.toBe(second.service.id); + expect(events.creates).toHaveLength(2); + yield* stackReleaseShadowDatabase(first); + yield* stackReleaseShadowDatabase(second); + expect(events.destroys).toHaveLength(2); + }).pipe(Effect.provide(layers(stack, catalog))); }); - it.live("overlays remotes-disabled auth onto ephemeral catalog and the cache key", () => { - const ephemeral = mockEphemeral(); - const out = mockOutput(); - const catalog = recordingStackCatalogSetup((applied) => applied.target.config); - const remoteRef = "abcdefghijklmnopqrst"; - return Effect.scoped( - Effect.gen(function* () { + it.live("cancels blocked startup and destroys the registered service", () => { + return Effect.gen(function* () { + const startEntered = yield* Deferred.make(); + const events = { + creates: [], + restores: [], + destroys: [], + blockStart: true, + starts: 0, + startEntered, + }; + const stack = fakeStack(events); + const catalog = recordingStackCatalogSetup(() => undefined); + yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const scratch = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(scratch, "supabase"), { recursive: true }); - yield* fs.writeFileString( - path.join(scratch, "supabase", "config.toml"), - [ - 'project_id = "stack-shadow-remotes"', - "[auth]", - "enabled = true", - 'jwt_secret = "super-secret-jwt-token-with-at-least-32-characters-long"', - "", - "[remotes.prod]", - `project_id = "${remoteRef}"`, - "[remotes.prod.auth]", - "enabled = false", - "", - ].join("\n"), - ); - const context = yield* loadLocalProjectContext( - scratch, - (message) => new Error(message), - remoteRef, - ); - const setup = input(fs, path); - const remotesInput = { - ...setup, - workdir: scratch, - context, - setup: { ...setup.setup, authEnabledForSetup: false }, - }; - return yield* withShadowCacheHome( - home, - "1", - Effect.gen(function* () { - yield* stackAcquireShadowDatabase(remotesInput, nativeAcquire); - expect(catalog.applied).toHaveLength(1); - const applied = catalog.applied[0]; - expect(applied).toBeDefined(); - if (applied === undefined) return; - expect(catalogAuthEnabled(applied)).toBe(false); - const names = (yield* fs.readDirectory( - path.join(home, "cache", "shadow-baseline"), - )).filter((name) => name.endsWith(".tar") && !name.includes(".partial")); - expect(names).toEqual([ - stackShadowBaselineTarFileName( - expectedCacheKey({ authEnabled: catalogAuthEnabled(applied) }), - ), - ]); - }), + const fiber = yield* Effect.forkChild( + stackAcquireShadowDatabase(input(fs, path), { bypassCache: true }), ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral.layer, - catalog.layer, - ), - ), - ); + yield* Deferred.await(startEntered); + expect(events.starts).toBe(1); + yield* Fiber.interrupt(fiber); + expect(events.destroys).toHaveLength(1); + }).pipe(Effect.provide(layers(stack, catalog))); + }); }); - - it.live( - "still schema-inits auth when remotes enable it and SUPABASE_AUTH_ENABLED is false", - () => { - const ephemeral = mockEphemeral(); - const out = mockOutput(); - const catalog = recordingStackCatalogSetup((applied) => applied.target.config); - const remoteRef = "abcdefghijklmnopqrst"; - const authArtifact = schemaInitArtifactIdentity("auth") ?? "missing"; - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const scratch = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(scratch, "supabase"), { recursive: true }); - yield* fs.writeFileString( - path.join(scratch, "supabase", "config.toml"), - [ - 'project_id = "stack-shadow-remotes-on"', - "[auth]", - "enabled = true", - 'jwt_secret = "super-secret-jwt-token-with-at-least-32-characters-long"', - "", - "[remotes.prod]", - `project_id = "${remoteRef}"`, - "[remotes.prod.auth]", - "enabled = true", - "", - ].join("\n"), - ); - yield* fs.writeFileString( - path.join(scratch, "supabase", ".env"), - "SUPABASE_AUTH_ENABLED=false\n", - ); - const context = yield* loadLocalProjectContext( - scratch, - (message) => new Error(message), - remoteRef, - ); - const setup = input(fs, path); - const remotesInput = { - ...setup, - workdir: scratch, - context, - setup: { ...setup.setup, authEnabledForSetup: true }, - }; - return yield* withShadowCacheHome( - home, - "1", - Effect.gen(function* () { - yield* stackAcquireShadowDatabase(remotesInput, nativeAcquire); - expect(catalog.applied).toHaveLength(1); - const applied = catalog.applied[0]; - expect(applied).toBeDefined(); - if (applied === undefined) return; - expect(catalogAuthEnabled(applied)).toBe(true); - const names = (yield* fs.readDirectory( - path.join(home, "cache", "shadow-baseline"), - )).filter((name) => name.endsWith(".tar") && !name.includes(".partial")); - expect(names).toEqual([ - stackShadowBaselineTarFileName( - expectedCacheKey({ - authEnabled: catalogAuthEnabled(applied), - authArtifact, - }), - ), - ]); - }), - ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - out.layer, - db, - mockCommandSettings({ workdir: tmp.current }), - stackBackendLayer("stack"), - ephemeral.layer, - catalog.layer, - ), - ), - ); - }, - ); }); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index ed0efb4158..cf313dba78 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -1,50 +1,42 @@ import { scryptSync } from "node:crypto"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- pid scopes the exclusive temp name across processes. -import process from "node:process"; import { + Cause, Clock, - Context, Crypto, Effect, + Exit, FileSystem, - Layer, Option, Path, Predicate, Redacted, Result, Scope, - Semaphore, + Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ContainerEngineResolver, - createEphemeralPostgres, - databaseBootstrapIdentity, - resolveEphemeralPostgresRelease, - schemaInitArtifactIdentity, selectDefaultRuntimeSelection, - type CreateEphemeralPostgresOptions, - type EffectEphemeralPostgres, - type EphemeralPostgresRelease, - type EphemeralPostgresSettings, - type SchemaInitCapabilityName, type StackConfig, + type EffectDatabaseInitialization, type StackRuntime, type StackRuntimePreference, - type StackVersionUnsupportedError, + type EffectStack, + type EffectServiceInstance, + type ServiceDescriptor, + type SnapshotDescriptor, } from "@supabase/stack/effect"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; -import { CommandSettings } from "../config/command-settings.service.ts"; import { DbConnection } from "./db-connection.service.ts"; +import { parseConnectionString } from "./db-config.parse.ts"; import { shadowBaselineCacheDir } from "./pgdelta.paths.ts"; import { SHADOW_BASELINE_KEEP, SHADOW_BASELINE_MAX_AGE_MS, SHADOW_CACHE_ENV, canonicalJson, - shadowBaselineEmbeddedDigest, shadowBaselineTarsToEvict, touchShadowBaselineTar, } from "./db-bootstrap/shadow-cache.ts"; @@ -57,26 +49,12 @@ import { } from "./db-bootstrap/shadow-database.ts"; import { listLocalMigrationPaths } from "./migration-history.ts"; import { applyMigrations } from "./migration-apply.ts"; -import { stackProjectRuntime } from "./stack-local-database.ts"; +import { StackApi } from "./stack-api.ts"; import { loadStackConfig } from "./stack-config.ts"; import { StackCatalogSetup } from "./stack-catalog-setup.ts"; import { resolveSetupWebhooksEnabled, type SetupDatabaseOptions } from "./db-bootstrap/db-setup.ts"; import type { VaultSecret } from "./vault.ts"; -/** Injectable ephemeral-cluster factory for tests. */ -export class StackEphemeralPostgres extends Context.Service< - StackEphemeralPostgres, - { - readonly create: typeof createEphemeralPostgres; - readonly resolveRelease: typeof resolveEphemeralPostgresRelease; - } ->()("supabase/experimental-stack/EphemeralPostgres") {} - -export const ephemeralPostgresLayer = Layer.succeed(StackEphemeralPostgres, { - create: createEphemeralPostgres, - resolveRelease: resolveEphemeralPostgresRelease, -}); - const TAR_PREFIX = "stack-shadow-baseline-"; const ensurePrivateCacheDir = (fs: FileSystem.FileSystem, cacheDir: string) => @@ -101,71 +79,52 @@ const ensurePrivateCacheDir = (fs: FileSystem.FileSystem, cacheDir: string) => ); }); -/** A partial older than 5 minutes is abandoned; a live export finishes in seconds. */ -const STACK_SHADOW_PARTIAL_ABANDON_MS = 5 * 60 * 1000; - export const stackShadowBaselineTarFileName = (key: string): string => `${TAR_PREFIX}${key}.tar`; const isStackShadowBaselineTar = (fileName: string): boolean => /^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u.test(fileName); export function isStackShadowBaselinePartial(fileName: string): boolean { - return /^stack-shadow-baseline-[0-9a-f]{16}\.tar\.\d+\.partial$/u.test(fileName); + return /^stack-shadow-baseline-[0-9a-f]{16}\.tar\.[0-9a-f-]+\.partial$/u.test(fileName); } -const stackShadowExportMutex = Semaphore.makeUnsafe(1); - export interface StackShadowCacheKeyInputs { readonly artifactIdentity: string; - readonly majorVersion: number; - readonly runtimeKind: string; - readonly jwtSecret: string; - readonly jwtExpiry: number; - readonly dbPassword: string; - readonly dbSettings: unknown; + readonly runtimeIdentity: string; + readonly bootstrapRecipeId: string; + readonly bootstrapInputsId: string; + readonly initializationProfileId: string; + /** Resolved catalog recipes supplied by the stack descriptor. */ + readonly initialization: { + readonly profileId: string; + readonly recipes: ReadonlyArray<{ + readonly service: string; + readonly recipeId: string; + readonly artifactIdentity: string; + }>; + }; + /** CLI-owned overlay inputs applied after the stack baseline. */ readonly rolesSql: string; - readonly bootstrapIdentity: string; readonly webhooksEnabled: boolean; readonly apiGrantsKept: boolean; readonly vault: ReadonlyArray; readonly jwks: string; readonly storageTargetMigration: string; - readonly authEnabled: boolean; - readonly storageEnabled: boolean; - readonly realtimeEnabled: boolean; - readonly authArtifact: string; - readonly storageArtifact: string; - readonly realtimeArtifact: string; } export const stackShadowCacheKey = (inputs: StackShadowCacheKeyInputs): string => { const quoted = (value: string) => JSON.stringify(value); const lines: Array = [ `artifact=${quoted(inputs.artifactIdentity)}`, - `major_version=${inputs.majorVersion}`, - `runtime=${quoted(inputs.runtimeKind)}`, - `jwt_secret=${quoted(inputs.jwtSecret)}`, - `jwt_expiry=${inputs.jwtExpiry}`, - `db_password=${quoted(inputs.dbPassword)}`, - `db_settings=${canonicalJson(inputs.dbSettings ?? {})}`, - `bootstrap=${quoted(inputs.bootstrapIdentity)}`, + `runtime_identity=${quoted(inputs.runtimeIdentity)}`, + `bootstrap_recipe=${quoted(inputs.bootstrapRecipeId)}`, + `bootstrap_inputs=${quoted(inputs.bootstrapInputsId)}`, + `initialization_profile=${quoted(inputs.initializationProfileId)}`, + `initialization=${canonicalJson(inputs.initialization)}`, `api_grants_kept=${inputs.apiGrantsKept}`, `webhooks_enabled=${inputs.webhooksEnabled}`, - `baseline_embedded_digest=${shadowBaselineEmbeddedDigest()}`, - `schema_init=auth=${inputs.authEnabled},storage=${inputs.storageEnabled},realtime=${inputs.realtimeEnabled}`, - inputs.authEnabled ? `auth_artifact=${quoted(inputs.authArtifact)}` : "auth_artifact=excluded", - inputs.storageEnabled - ? `storage_artifact=${quoted(inputs.storageArtifact)}` - : "storage_artifact=excluded", - inputs.realtimeEnabled - ? `realtime_artifact=${quoted(inputs.realtimeArtifact)}` - : "realtime_artifact=excluded", - inputs.realtimeEnabled && inputs.majorVersion >= 15 - ? `realtime_jwks=${quoted(inputs.jwks)}` - : "realtime_jwks=excluded", - inputs.storageEnabled && inputs.majorVersion >= 15 - ? `storage_target_migration=${quoted(inputs.storageTargetMigration)}` - : "storage_target_migration=excluded", + `realtime_jwks=${quoted(inputs.jwks)}`, + `storage_target_migration=${quoted(inputs.storageTargetMigration)}`, ]; for (const secret of inputs.vault .filter((secret) => secret.resolved) @@ -181,38 +140,40 @@ export const stackShadowCacheKey = (inputs: StackShadowCacheKeyInputs): string = .slice(0, 16); }; -const capabilityPinVersion = ( - cap: { readonly enabled?: boolean; readonly version?: string } | undefined, -): string | undefined => cap?.version; - -const trioSchemaInitArtifact = ( - enabled: boolean, - name: Extract, - config: StackConfig | undefined, -): string => { - if (!enabled) return ""; - return ( - schemaInitArtifactIdentity(name, capabilityPinVersion(config?.capabilities?.[name])) ?? - "missing" - ); -}; - export interface StackShadowAcquiredHandle { readonly url: string; readonly host: string; readonly port: number; readonly artifactIdentity: string; + readonly runtimeIdentity: string; + readonly bootstrapRecipeId: string; + readonly bootstrapInputsId: string; + readonly initializationProfileId: string; readonly runtime: StackRuntime; readonly baselinePresent: boolean; readonly snapshotKey?: string; - readonly ephemeral: EffectEphemeralPostgres; + readonly snapshotDescriptor?: SnapshotDescriptor; + readonly stack: EffectStack; + readonly service: EffectServiceInstance<"database">; } export interface StackShadowAcquireOpts { readonly bypassCache?: boolean; - readonly port?: number; readonly runtime?: StackRuntimePreference; readonly webhooks?: SetupDatabaseOptions["webhooks"]; + /** Optional resolved primary baseline inputs used by database-only reset. */ + readonly database?: { + readonly version: string; + readonly settings: NonNullable< + Exclude< + NonNullable["database"]>, + { enabled: false } + >["settings"] + >; + readonly initialization?: EffectDatabaseInitialization; + }; + /** Skips CLI overlays when the caller is producing a catalog-only baseline. */ + readonly applyOverlay?: boolean; } const cacheEnabled = (projectEnv: Record | undefined, bypass: boolean): boolean => @@ -250,19 +211,6 @@ const runtimePreference = ( : { kind: "container", engine: runtime.engine }; }; -const postgresSettings = (value: unknown): EphemeralPostgresSettings | undefined => { - if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) - return undefined; - return Object.fromEntries( - Object.entries(value).filter( - (entry): entry is [string, string | number | boolean] => - typeof entry[1] === "string" || - typeof entry[1] === "number" || - typeof entry[1] === "boolean", - ), - ); -}; - // A disabled capability carries no nested pins, so re-enabling one for setup compiles defaults. const overlaySetupEnabled = ( current: C, @@ -290,7 +238,7 @@ const overlaySetupTrio = ( }, }); -const loadEphemeralCatalogConfig = ( +const loadShadowCatalogConfig = ( input: ShadowSetupInput, ): Effect.Effect< StackConfig, @@ -302,35 +250,9 @@ const loadEphemeralCatalogConfig = ( Effect.mapError((cause) => new ShadowDbError({ message: cause.message, reason: "filesystem" })), ); -const createOptions = ( - input: ShadowSetupInput, - runtime: StackRuntime, - restoreFrom: string | undefined, - port: number | undefined, - snapshotKey?: string, -): CreateEphemeralPostgresOptions => ({ - databasePassword: Redacted.make(input.password), - jwtSecret: Redacted.make(input.jwtSecret), - jwtExpiry: input.jwtExpiry, - postgresSettings: postgresSettings(input.db.settings), - healthTimeout: `${String(input.healthTimeoutSeconds)}s`, - version: String(input.setup.majorVersion), - runtime, - ...(port === undefined ? {} : { port }), - ...(restoreFrom === undefined ? {} : { restoreFrom }), - ...(snapshotKey === undefined ? {} : { snapshotKey }), -}); - -const connFrom = (handle: EffectEphemeralPostgres, password: string) => ({ - host: handle.host, - port: handle.port, - user: "postgres", - password, - database: "postgres", -}); - const applyColdCatalog = ( - handle: EffectEphemeralPostgres, + stack: EffectStack, + service: EffectServiceInstance<"database">, input: ShadowSetupInput, webhooks: SetupDatabaseOptions["webhooks"], ): Effect.Effect< @@ -345,19 +267,16 @@ const applyColdCatalog = ( message: "stack catalog setup is unavailable", reason: "database", }); - const config = yield* loadEphemeralCatalogConfig(input); + const config = yield* loadShadowCatalogConfig(input); yield* catalog.value .apply({ target: { - kind: "ephemeral", + kind: "service", + stack, + service, projectRoot: input.workdir, - runtime: handle.runtime, - config, - databaseUrl: Redacted.value(handle.url), - databasePassword: Redacted.make(input.password), - jwtSecret: Redacted.make(input.jwtSecret), - ...(handle.networkId === undefined ? {} : { networkId: handle.networkId }), }, + config, overlay: { webhooks, webhooksEnabled: input.setup.webhooksEnabled, @@ -374,32 +293,6 @@ const applyColdCatalog = ( ); }); -const artifactIdentityFor = (runtime: StackRuntime, version: string, image: string): string => - runtime.kind === "container" ? `container:${runtime.engine}:${image}` : `native:${version}`; - -const sweepAbandonedPartials = ( - fs: FileSystem.FileSystem, - path: Path.Path, - cacheDir: string, -): Effect.Effect => - Effect.gen(function* () { - const names = yield* fs.readDirectory(cacheDir).pipe(Effect.orElseSucceed(() => [])); - const now = yield* Clock.currentTimeMillis; - yield* Effect.forEach( - names.filter(isStackShadowBaselinePartial), - (fileName) => - Effect.gen(function* () { - const filePath = path.join(cacheDir, fileName); - const info = yield* fs.stat(filePath); - const mtime = Option.getOrUndefined(info.mtime); - if (mtime !== undefined && now - mtime.getTime() > STACK_SHADOW_PARTIAL_ABANDON_MS) { - yield* fs.remove(filePath).pipe(Effect.ignore); - } - }).pipe(Effect.ignore), - { discard: true }, - ); - }); - const sweepCache = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -435,53 +328,50 @@ const writeStackShadowBaselineTar = ( tarPath: string, exportPgData: (tempPath: string) => Effect.Effect, skipIfPublished: boolean, + operationId: string, ): Effect.Effect => - stackShadowExportMutex.withPermit( - Effect.gen(function* () { - if (skipIfPublished) { - const published = yield* fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); - if (published) return; - } - yield* ensurePrivateCacheDir(fs, cacheDir); - yield* sweepAbandonedPartials(fs, path, cacheDir); - const tempPath = `${tarPath}.${String(process.pid)}.partial`; - yield* fs.remove(tempPath).pipe(Effect.ignore); - yield* Effect.gen(function* () { - yield* Effect.scoped( - fs.open(tempPath, { flag: "wx", mode: 0o600 }).pipe( - Effect.mapError( - (cause) => + Effect.gen(function* () { + if (skipIfPublished) { + const published = yield* fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); + if (published) return; + } + yield* ensurePrivateCacheDir(fs, cacheDir); + const tempPath = `${tarPath}.${operationId}.partial`; + yield* Effect.gen(function* () { + yield* exportPgData(tempPath); + yield* fs.chmod(tempPath, 0o600).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to restrict ${tempPath}: ${cause.message}`, + reason: "filesystem", + }), + ), + ); + yield* fs.link(tempPath, tarPath).pipe( + Effect.catchTag("PlatformError", (cause) => + Predicate.isTagged(cause.reason, "AlreadyExists") + ? Effect.void + : Effect.fail( new ShadowDbError({ - message: `failed to create ${tempPath}: ${cause.message}`, + message: `failed to publish ${tarPath}: ${cause.message}`, reason: "filesystem", }), - ), - Effect.asVoid, - ), - ); - yield* exportPgData(tempPath); - yield* fs.chmod(tempPath, 0o600).pipe( - Effect.mapError( - (cause) => - new ShadowDbError({ - message: `failed to restrict ${tempPath}: ${cause.message}`, - reason: "filesystem", - }), - ), - ); - yield* fs.rename(tempPath, tarPath).pipe( - Effect.mapError( - (cause) => - new ShadowDbError({ - message: `failed to publish ${tarPath}: ${cause.message}`, - reason: "filesystem", - }), - ), - ); - }).pipe(Effect.onError(() => fs.remove(tempPath).pipe(Effect.ignore))); - yield* sweepCache(fs, path, cacheDir, path.basename(tarPath)); - }), - ); + ), + ), + ); + yield* fs.remove(tempPath).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to remove temporary shadow baseline ${tempPath}: ${cause.message}`, + reason: "filesystem", + }), + ), + ); + }).pipe(Effect.onError(() => fs.remove(tempPath).pipe(Effect.ignore))); + yield* sweepCache(fs, path, cacheDir, path.basename(tarPath)); + }); const mapCreateError = (cause: unknown): ShadowDbError => new ShadowDbError({ @@ -492,26 +382,191 @@ const mapCreateError = (cause: unknown): ShadowDbError => reason: "database", }); -const runtimeKindFor = (runtime: StackRuntime): string => - runtime.kind === "container" ? `container:${runtime.engine}` : "native"; - -const ephemeralApis = (): Effect.Effect<{ - readonly create: typeof createEphemeralPostgres; - readonly resolveRelease: ( - version?: string, - ) => Effect.Effect; -}> => - Effect.serviceOption(StackEphemeralPostgres).pipe( - Effect.map((value) => - Option.getOrElse(value, () => ({ - create: createEphemeralPostgres, - resolveRelease: resolveEphemeralPostgresRelease, - })), +const credentialValue = (value: string | Redacted.Redacted): string => + typeof value === "string" ? value : Redacted.value(value); + +const shadowDatabaseSettings = (value: unknown): Record => { + if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) + return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string | number | boolean] => + typeof entry[1] === "string" || + typeof entry[1] === "number" || + typeof entry[1] === "boolean", + ), + ); +}; + +const shadowServiceName = (id: string): string => `shadow-${id}`; + +const requiredDescriptorValue = ( + value: string | undefined, + field: string, +): Effect.Effect => + value === undefined || value.length === 0 + ? Effect.fail( + new ShadowDbError({ + message: `shadow database descriptor is missing ${field}`, + reason: "database", + }), + ) + : Effect.succeed(value); + +const resolvedShadowDescriptor = ( + descriptor: ServiceDescriptor<"database">, +): Effect.Effect< + { + readonly artifactIdentity: string; + readonly runtimeIdentity: string; + readonly bootstrapRecipeId: string; + readonly bootstrapInputsId: string; + readonly initializationProfileId: string; + readonly initialization: StackShadowCacheKeyInputs["initialization"]; + }, + ShadowDbError +> => + Effect.gen(function* () { + const artifactIdentity = yield* requiredDescriptorValue( + descriptor.artifactIdentity, + "artifact identity", + ); + const runtimeIdentity = yield* requiredDescriptorValue( + descriptor.runtimeIdentity, + "runtime identity", + ); + const bootstrapRecipeId = yield* requiredDescriptorValue( + descriptor.bootstrapRecipeId, + "bootstrap recipe identity", + ); + const bootstrapInputsId = yield* requiredDescriptorValue( + descriptor.bootstrapInputsId, + "bootstrap input identity", + ); + const initializationProfileId = yield* requiredDescriptorValue( + descriptor.initializationProfileId ?? undefined, + "initialization profile identity", + ); + const initialization = descriptor.initialization; + if (initialization === undefined || initialization.profileId !== initializationProfileId) + return yield* new ShadowDbError({ + message: "shadow database descriptor has incomplete initialization metadata", + reason: "database", + }); + return { + artifactIdentity, + runtimeIdentity, + bootstrapRecipeId, + bootstrapInputsId, + initializationProfileId, + initialization: { + profileId: initialization.profileId, + recipes: initialization.recipes.map( + ({ service, recipeId, artifactIdentity: recipeArtifact }) => ({ + service, + recipeId, + artifactIdentity: recipeArtifact, + }), + ), + }, + }; + }); + +const shadowHandleFor = ( + stack: EffectStack, + service: EffectServiceInstance<"database">, + descriptor: ServiceDescriptor<"database">, + runtime: StackRuntime, + baselinePresent: boolean, + snapshotKey: string | undefined, + snapshotDescriptor: SnapshotDescriptor | undefined, +): Effect.Effect => + Effect.gen(function* () { + const metadata = yield* resolvedShadowDescriptor(descriptor); + const credentials = yield* service.credentials.pipe(Effect.mapError(mapCreateError)); + if (credentials === undefined) + return yield* new ShadowDbError({ + message: "shadow database credentials are unavailable", + reason: "database", + }); + const url = credentialValue(credentials.url); + const parsed = new URL(url); + return { + url, + host: parsed.hostname, + port: Number(parsed.port), + artifactIdentity: metadata.artifactIdentity, + runtimeIdentity: metadata.runtimeIdentity, + bootstrapRecipeId: metadata.bootstrapRecipeId, + bootstrapInputsId: metadata.bootstrapInputsId, + initializationProfileId: metadata.initializationProfileId, + runtime, + baselinePresent, + ...(snapshotKey === undefined ? {} : { snapshotKey }), + ...(snapshotDescriptor === undefined ? {} : { snapshotDescriptor }), + stack, + service, + }; + }); + +const isSnapshotOperation = (kind: string | undefined): boolean => + kind === "exportSnapshot" || kind === "restoreSnapshot"; + +/** + * A snapshot call can outlive its caller while the supervisor settles its owned operation. The + * service stream publishes its initial status after subscribing, so this observes that initial + * state and any completion transition without a status polling loop. + */ +const awaitPendingSnapshotSettlement = ( + service: EffectServiceInstance<"database">, +): Effect.Effect => + service.followStatus.pipe( + Stream.filter( + (status) => + status.recovery !== undefined || !isSnapshotOperation(status.pendingOperation?.kind), + ), + Stream.runHead, + Effect.flatMap((settled) => + Option.isSome(settled) + ? Effect.void + : Effect.fail( + new ShadowDbError({ + message: `shadow service ${service.id} disappeared before its snapshot operation settled`, + reason: "database", + }), + ), + ), + Effect.mapError((cause) => + cause instanceof ShadowDbError + ? cause + : new ShadowDbError({ + message: `failed to observe shadow service ${service.id} cleanup state: ${String(cause)}`, + reason: "database", + }), + ), + ); + +const destroyShadowService = ( + service: EffectServiceInstance<"database">, +): Effect.Effect => + awaitPendingSnapshotSettlement(service).pipe( + Effect.andThen( + service.destroy.pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to destroy shadow service ${service.id}: ${mapCreateError(cause).message}`, + reason: "database", + }), + ), + ), ), ); -const ownCluster = (ephemeral: EffectEphemeralPostgres) => - Effect.addFinalizer(() => ephemeral.stop.pipe(Effect.ignore)); +/** Releases a CLI owned shadow after any in-flight snapshot has settled. */ +export const stackReleaseShadowDatabase = ( + handle: StackShadowAcquiredHandle, +): Effect.Effect => destroyShadowService(handle.service); export const stackAcquireShadowDatabase = ( input: ShadowSetupInput, @@ -526,174 +581,322 @@ export const stackAcquireShadowDatabase = ( | RuntimeInfo | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope - | CommandSettings -> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const apis = yield* ephemeralApis(); - const output = yield* Output; - const projectRuntime = yield* stackProjectRuntime; - const preference = runtimePreference(projectRuntime, opts.runtime); - const selected: { - readonly runtime: StackRuntime; - readonly dockerFallbackNotice?: string; - } = - preference === undefined - ? yield* selectDefaultRuntimeSelection( - Option.getOrUndefined(yield* Effect.serviceOption(ContainerEngineResolver)), - ).pipe(Effect.mapError(mapCreateError)) - : { - runtime: - preference.kind === "container" - ? { kind: "container", engine: preference.engine ?? "docker" } - : { kind: "native" }, - }; - if (selected.dockerFallbackNotice !== undefined) - yield* output.raw(`${selected.dockerFallbackNotice}\n`, "stderr"); - const runtime = selected.runtime; - const rolesSql = yield* readRolesSql(input.fs, input.path, input.workdir); - const cacheOn = cacheEnabled(input.setup.projectEnvValues, opts.bypassCache === true); - const cacheDir = shadowBaselineCacheDir(path); - const webhooks = opts.webhooks; - yield* ensurePrivateCacheDir(fs, cacheDir); - - const startEmpty = () => - apis - .create(createOptions(input, runtime, undefined, opts.port)) - .pipe(Effect.mapError(mapCreateError)); - - if (!cacheOn) { - const ephemeral = yield* startEmpty(); - yield* ownCluster(ephemeral); - yield* applyColdCatalog(ephemeral, input, webhooks); - return { - url: Redacted.value(ephemeral.url), - host: ephemeral.host, - port: ephemeral.port, - artifactIdentity: ephemeral.artifactIdentity, - runtime: ephemeral.runtime, - baselinePresent: false, - ephemeral, - }; - } - - const jwks = - input.setup.realtimeEnabledForSetup && input.setup.majorVersion >= 15 - ? yield* input.setup.jwks - : ""; - const release = yield* apis - .resolveRelease(String(input.setup.majorVersion)) - .pipe(Effect.mapError(mapCreateError)); - const identity = artifactIdentityFor(runtime, release.version, release.image); - const trioEnabled = - input.setup.authEnabledForSetup || - input.setup.storageEnabledForSetup || - input.setup.realtimeEnabledForSetup; - const stackConfig = trioEnabled ? yield* loadEphemeralCatalogConfig(input) : undefined; - const key = stackShadowCacheKey({ - artifactIdentity: identity, - majorVersion: input.setup.majorVersion, - runtimeKind: runtimeKindFor(runtime), - jwtSecret: input.jwtSecret, - jwtExpiry: input.jwtExpiry, - dbPassword: input.password, - dbSettings: input.db.settings, - rolesSql, - bootstrapIdentity: databaseBootstrapIdentity, - webhooksEnabled: resolveSetupWebhooksEnabled(webhooks, input.setup.webhooksEnabled), - apiGrantsKept: Option.getOrElse(input.setup.apiAutoExposeNewTables, () => true), - vault: input.setup.vault, - jwks, - storageTargetMigration: input.setup.storageTargetMigration, - authEnabled: input.setup.authEnabledForSetup, - storageEnabled: input.setup.storageEnabledForSetup, - realtimeEnabled: input.setup.realtimeEnabledForSetup, - authArtifact: trioSchemaInitArtifact(input.setup.authEnabledForSetup, "auth", stackConfig), - storageArtifact: trioSchemaInitArtifact( - input.setup.storageEnabledForSetup, - "storage", - stackConfig, - ), - realtimeArtifact: trioSchemaInitArtifact( - input.setup.realtimeEnabledForSetup, - "realtime", - stackConfig, - ), - }); - const tarName = stackShadowBaselineTarFileName(key); - const tarPath = path.join(cacheDir, tarName); - const cached = yield* fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); - yield* sweepAbandonedPartials(fs, path, cacheDir); - yield* sweepCache(fs, path, cacheDir, tarName); - - if (cached) { - const restored = yield* Effect.result( - apis.create(createOptions(input, runtime, tarPath, opts.port, key)), - ); - if (Result.isSuccess(restored)) { - yield* ownCluster(restored.success); - yield* touchShadowBaselineTar(fs, tarPath); - return { - url: Redacted.value(restored.success.url), - host: restored.success.host, - port: restored.success.port, - artifactIdentity: restored.success.artifactIdentity, - runtime: restored.success.runtime, - baselinePresent: true, - snapshotKey: key, - ephemeral: restored.success, - }; - } - const output = yield* Output; - yield* output.raw( - `Warning: shadow baseline not cached: ${restored.failure.message}\n`, - "stderr", - ); - } - - const probe = yield* startEmpty(); - yield* ownCluster(probe); - yield* applyColdCatalog(probe, input, webhooks); - const exported = yield* Effect.result( + | StackApi +> => { + return Effect.uninterruptibleMask((restore) => + Effect.suspend(() => Effect.gen(function* () { - const rolesSqlNow = yield* readRolesSql(input.fs, input.path, input.workdir); - if (rolesSqlNow !== rolesSql) { - return yield* new ShadowDbError({ - message: "supabase/roles.sql changed during provisioning", - reason: "filesystem", + let ownedService: EffectServiceInstance<"database"> | undefined; + const acquire = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const api = yield* StackApi; + const output = yield* Output; + const project = yield* api + .findStack({ projectRoot: input.workdir }) + .pipe(Effect.mapError(mapCreateError)); + const projectRuntime = Option.isSome(project) ? project.value.runtime : undefined; + if ( + projectRuntime !== undefined && + opts.runtime !== undefined && + (projectRuntime.kind !== opts.runtime.kind || + (projectRuntime.kind === "container" && + opts.runtime.kind === "container" && + opts.runtime.engine !== undefined && + projectRuntime.engine !== opts.runtime.engine)) + ) + return yield* new ShadowDbError({ + message: "The existing stack runtime does not match the requested shadow runtime", + reason: "database", + }); + const preference = runtimePreference(projectRuntime, opts.runtime); + const selected: { + readonly runtime: StackRuntime; + readonly dockerFallbackNotice?: string; + } = + preference === undefined + ? yield* selectDefaultRuntimeSelection( + Option.getOrUndefined(yield* Effect.serviceOption(ContainerEngineResolver)), + ).pipe(Effect.mapError(mapCreateError)) + : { + runtime: + preference.kind === "container" + ? { kind: "container", engine: preference.engine ?? "docker" } + : { kind: "native" }, + }; + if (selected.dockerFallbackNotice !== undefined) + yield* output.raw(`${selected.dockerFallbackNotice}\n`, "stderr"); + const runtime = selected.runtime; + const applyOverlay = opts.applyOverlay !== false; + const rolesSql = applyOverlay + ? yield* readRolesSql(input.fs, input.path, input.workdir) + : ""; + const cacheOn = cacheEnabled(input.setup.projectEnvValues, opts.bypassCache === true); + const cacheDir = shadowBaselineCacheDir(path); + const webhooks = applyOverlay ? opts.webhooks : undefined; + yield* ensurePrivateCacheDir(fs, cacheDir); + + const descriptorConfig = yield* loadShadowCatalogConfig(input); + const stack = Option.isSome(project) + ? yield* api.openStack(project.value.id).pipe(Effect.mapError(mapCreateError)) + : yield* api + .createStack({ + projectRoot: input.workdir, + runtime: + runtime.kind === "container" + ? { kind: "container", engine: runtime.engine } + : { kind: "native" }, + initialConfig: descriptorConfig, + }) + .pipe(Effect.mapError(mapCreateError)); + const actualStatus = yield* stack.status.pipe(Effect.mapError(mapCreateError)); + const actualRuntime = actualStatus.runtime; + const initialization = + opts.database?.initialization ?? + (yield* stack.services.get({ name: "database" }).pipe( + Effect.mapError(mapCreateError), + Effect.flatMap((primary) => + primary.service === "database" + ? Effect.succeed({ from: primary.id } satisfies EffectDatabaseInitialization) + : Effect.fail( + new ShadowDbError({ + message: "the stack primary service is not a database", + reason: "database", + }), + ), + ), + )); + const nameToken = yield* (yield* Crypto.Crypto).randomUUIDv4.pipe( + Effect.mapError(mapCreateError), + ); + const createShadowService = (name: string) => { + const options = { + service: "database" as const, + name, + config: { + enabled: true as const, + activation: "eager" as const, + version: opts.database?.version ?? String(input.setup.majorVersion), + settings: opts.database?.settings ?? { + settings: shadowDatabaseSettings(input.db.settings), + }, + password: Redacted.make(input.password), + endpoints: { sql: { port: "auto" as const } }, + }, + initialization: initialization, + }; + return stack.services.create(options).pipe( + Effect.catchTag("UncertainOperationError", (uncertain) => + stack.services.get({ name }).pipe( + Effect.flatMap((candidate) => { + if (candidate.service !== "database") return Effect.fail(uncertain); + return Effect.gen(function* () { + const descriptor = yield* candidate.describe; + const expectedCreationInputsId = uncertain.expectedCreationInputsId; + if ( + expectedCreationInputsId === undefined || + descriptor.creationInputsId !== expectedCreationInputsId || + descriptor.name !== name || + descriptor.service !== options.service || + !descriptor.enabled || + descriptor.config.activation !== "eager" || + descriptor.config.idleTimeoutSeconds !== false || + descriptor.artifactIdentity === undefined || + descriptor.runtimeIdentity === undefined + ) + return yield* uncertain; + const actualEndpoint = descriptor.endpoints.sql; + if (actualEndpoint === undefined) return yield* uncertain; + const credentials = yield* candidate.credentials; + if (credentials === undefined) return yield* uncertain; + if (credentialValue(credentials.password) !== input.password) + return yield* uncertain; + const password = yield* Effect.try({ + try: () => + decodeURIComponent(new URL(credentialValue(credentials.url)).password), + catch: () => undefined, + }); + if (password !== input.password) return yield* uncertain; + if ( + descriptor.initializationProfileId == null || + descriptor.initializationProfileId.length === 0 || + descriptor.bootstrapRecipeId === undefined || + descriptor.bootstrapRecipeId.length === 0 || + descriptor.bootstrapInputsId === undefined || + descriptor.bootstrapInputsId.length === 0 || + descriptor.initialization?.profileId !== descriptor.initializationProfileId + ) + return yield* uncertain; + return candidate; + }); + }), + Effect.mapError(() => uncertain), + ), + ), + Effect.mapError(mapCreateError), + ); + }; + const createAndRegister = (name: string) => + Effect.uninterruptibleMask((restoreCreate) => + Effect.gen(function* () { + const created = yield* restoreCreate(createShadowService(name)); + ownedService = created; + return created; + }), + ); + let service = yield* createAndRegister(shadowServiceName(nameToken)); + let exportOperationId = nameToken; + const descriptor = yield* service.describe.pipe(Effect.mapError(mapCreateError)); + const metadata = yield* resolvedShadowDescriptor(descriptor); + const jwks = + input.setup.realtimeEnabledForSetup && input.setup.majorVersion >= 15 + ? yield* input.setup.jwks + : ""; + if (!cacheOn) { + yield* service.start.pipe(Effect.mapError(mapCreateError)); + if (applyOverlay) yield* applyColdCatalog(stack, service, input, webhooks); + const handle = yield* shadowHandleFor( + stack, + service, + descriptor, + actualRuntime, + false, + undefined, + undefined, + ); + return handle; + } + const key = stackShadowCacheKey({ + artifactIdentity: metadata.artifactIdentity, + runtimeIdentity: metadata.runtimeIdentity, + bootstrapRecipeId: metadata.bootstrapRecipeId, + bootstrapInputsId: metadata.bootstrapInputsId, + initializationProfileId: metadata.initializationProfileId, + initialization: metadata.initialization, + rolesSql, + webhooksEnabled: applyOverlay + ? resolveSetupWebhooksEnabled(webhooks, input.setup.webhooksEnabled) + : false, + apiGrantsKept: applyOverlay + ? Option.getOrElse(input.setup.apiAutoExposeNewTables, () => true) + : true, + vault: applyOverlay ? input.setup.vault : [], + jwks: applyOverlay ? jwks : "", + storageTargetMigration: applyOverlay ? input.setup.storageTargetMigration : "", }); - } - yield* probe.stop.pipe(Effect.mapError(mapCreateError)); - yield* writeStackShadowBaselineTar( - fs, - path, - cacheDir, - tarPath, - (tempPath) => probe.exportPgData(tempPath, key).pipe(Effect.mapError(mapCreateError)), - !cached, + const tarName = stackShadowBaselineTarFileName(key); + const tarPath = path.join(cacheDir, tarName); + let cached = yield* fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); + yield* sweepCache(fs, path, cacheDir, tarName); + + if (cached) { + const restored = yield* Effect.result( + service.restoreSnapshot({ source: tarPath }).pipe(Effect.mapError(mapCreateError)), + ); + if (Result.isSuccess(restored)) { + yield* service.start.pipe(Effect.mapError(mapCreateError)); + yield* touchShadowBaselineTar(fs, tarPath); + const handle = yield* shadowHandleFor( + stack, + service, + descriptor, + actualRuntime, + true, + key, + restored.success, + ); + return handle; + } + yield* destroyShadowService(service); + yield* Effect.uninterruptible(Effect.sync(() => (ownedService = undefined))); + yield* fs.remove(tarPath).pipe( + Effect.mapError( + (cause) => + new ShadowDbError({ + message: `failed to remove invalid shadow baseline ${tarPath}: ${cause.message}`, + reason: "filesystem", + }), + ), + ); + cached = false; + const output = yield* Output; + yield* output.raw( + `Warning: shadow baseline not cached: ${restored.failure.message}\n`, + "stderr", + ); + const replacementToken = yield* (yield* Crypto.Crypto).randomUUIDv4.pipe( + Effect.mapError(mapCreateError), + ); + exportOperationId = replacementToken; + service = yield* createAndRegister(shadowServiceName(replacementToken)); + } + + yield* service.start.pipe(Effect.mapError(mapCreateError)); + if (applyOverlay) yield* applyColdCatalog(stack, service, input, webhooks); + let exportedDescriptor: SnapshotDescriptor | undefined; + const exported = yield* Effect.result( + Effect.gen(function* () { + const rolesSqlNow = applyOverlay + ? yield* readRolesSql(input.fs, input.path, input.workdir) + : ""; + if (rolesSqlNow !== rolesSql) { + return yield* new ShadowDbError({ + message: "supabase/roles.sql changed during provisioning", + reason: "filesystem", + }); + } + yield* service.stop.pipe(Effect.mapError(mapCreateError)); + yield* writeStackShadowBaselineTar( + fs, + path, + cacheDir, + tarPath, + (tempPath) => + service.exportSnapshot({ destination: tempPath }).pipe( + Effect.tap((snapshot) => Effect.sync(() => (exportedDescriptor = snapshot))), + Effect.mapError(mapCreateError), + Effect.asVoid, + ), + !cached, + exportOperationId, + ); + }), + ); + // A transport failure may leave exportSnapshot admitted in the supervisor. Wait for its + // owner to publish a terminal state before attempting to wake the instance. + yield* awaitPendingSnapshotSettlement(service); + yield* service.start.pipe(Effect.mapError(mapCreateError)); + if (Result.isFailure(exported)) { + const output = yield* Output; + yield* output.raw( + `Warning: shadow baseline not cached: ${exported.failure.message}\n`, + "stderr", + ); + } + const handle = yield* shadowHandleFor( + stack, + service, + descriptor, + actualRuntime, + false, + Result.isSuccess(exported) ? key : undefined, + exportedDescriptor, + ); + return handle; + }).pipe( + Effect.onExit((exit) => { + if (Exit.isSuccess(exit) || ownedService === undefined) return Effect.void; + const service = ownedService; + return Effect.uninterruptible(destroyShadowService(service)); + }), ); + const result = yield* restore(acquire); + ownedService = undefined; + return result; }), - ); - yield* probe.start.pipe(Effect.mapError(mapCreateError)); - if (Result.isFailure(exported)) { - const output = yield* Output; - yield* output.raw( - `Warning: shadow baseline not cached: ${exported.failure.message}\n`, - "stderr", - ); - } - return { - url: Redacted.value(probe.url), - host: probe.host, - port: probe.port, - artifactIdentity: probe.artifactIdentity, - runtime: probe.runtime, - baselinePresent: false, - snapshotKey: Result.isSuccess(exported) ? key : undefined, - ephemeral: probe, - }; - }); + ), + ); +}; export const stackWithShadowDatabase = ( input: ShadowSetupInput, @@ -709,12 +912,21 @@ export const stackWithShadowDatabase = ( | Crypto.Crypto | RuntimeInfo | ChildProcessSpawner.ChildProcessSpawner - | CommandSettings + | Scope.Scope + | StackApi > => - Effect.scoped( + // Keep only the ownership handoff and cleanup uninterruptible. `acquireUseRelease` masks its + // whole acquisition, which would also mask the supervisor startup and snapshot work. + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const handle = yield* stackAcquireShadowDatabase(input, opts); - return yield* use(handle); + const acquired = yield* restore(Effect.exit(stackAcquireShadowDatabase(input, opts))); + if (Exit.isFailure(acquired)) return yield* Effect.failCause(acquired.cause); + const used = yield* restore(Effect.exit(use(acquired.value))); + const released = yield* Effect.exit(destroyShadowService(acquired.value.service)); + if (Exit.isFailure(used) && Exit.isFailure(released)) + return yield* Effect.failCause(Cause.combine(used.cause, released.cause)); + if (Exit.isFailure(released)) return yield* Effect.failCause(released.cause); + return yield* used; }), ); @@ -746,7 +958,19 @@ export const stackMigrateShadow = ( (cause) => new ShadowDbError({ message: cause.message, reason: "filesystem" }), ), ); - const session = yield* connectShadowDatabase(connFrom(handle.ephemeral, input.password)); + const credentials = yield* handle.service.credentials.pipe(Effect.mapError(mapCreateError)); + if (credentials === undefined) + return yield* new ShadowDbError({ + message: "shadow database credentials are unavailable", + reason: "database", + }); + const conn = parseConnectionString(credentialValue(credentials.url)); + if (conn === undefined) + return yield* new ShadowDbError({ + message: "failed to parse shadow database URL", + reason: "connect", + }); + const session = yield* connectShadowDatabase(conn); yield* applyMigrations( session, input.fs, diff --git a/apps/cli/src/command-internal/stack-shadow.unit.test.ts b/apps/cli/src/command-internal/stack-shadow.unit.test.ts index e7c94a00ef..b3a9629a6f 100644 --- a/apps/cli/src/command-internal/stack-shadow.unit.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.unit.test.ts @@ -1,56 +1,70 @@ import { describe, expect, it } from "@effect/vitest"; -import { databaseBootstrapIdentity } from "@supabase/stack/effect"; import { stackShadowBaselineTarFileName, stackShadowCacheKey, isStackShadowBaselinePartial, } from "./stack-shadow.ts"; -const overlay = { +const base = { + artifactIdentity: "native:17.6.1", + runtimeIdentity: "native:database:17.6.1", + bootstrapRecipeId: "database-bootstrap-v1", + bootstrapInputsId: "inputs:shadow", + initializationProfileId: "profile:shadow", + initialization: { profileId: "profile:shadow", recipes: [] }, + rolesSql: "", webhooksEnabled: false, apiGrantsKept: true, vault: [] as const, jwks: "", storageTargetMigration: "", - authEnabled: false, - storageEnabled: false, - realtimeEnabled: false, - authArtifact: "", - storageArtifact: "", - realtimeArtifact: "", -}; - -const base = { - artifactIdentity: "native:17.6.1", - majorVersion: 17, - runtimeKind: "native", - jwtSecret: "jwt", - jwtExpiry: 3600, - dbPassword: "postgres", - dbSettings: {}, - rolesSql: "", - bootstrapIdentity: databaseBootstrapIdentity, - ...overlay, }; describe("stackShadowCacheKey", () => { - it("changes when the artifact identity or runtime kind changes", () => { + it("changes when authoritative runtime or artifact metadata changes", () => { const native = stackShadowCacheKey(base); - const otherArtifact = stackShadowCacheKey({ - ...base, - artifactIdentity: "native:17.6.2", - }); - const container = stackShadowCacheKey({ - ...base, - artifactIdentity: "container:docker:example", - runtimeKind: "container:docker", - }); expect(native).toMatch(/^[0-9a-f]{16}$/u); - expect(native).not.toBe(otherArtifact); - expect(native).not.toBe(container); + expect(stackShadowCacheKey({ ...base, artifactIdentity: "native:17.6.2" })).not.toBe(native); + expect( + stackShadowCacheKey({ ...base, runtimeIdentity: "container:docker:database:17.6.1" }), + ).not.toBe(native); expect(stackShadowBaselineTarFileName(native)).toBe(`stack-shadow-baseline-${native}.tar`); }); + it("includes resolved bootstrap and initialization identities", () => { + const baseKey = stackShadowCacheKey(base); + expect(stackShadowCacheKey({ ...base, bootstrapRecipeId: "database-bootstrap-v2" })).not.toBe( + baseKey, + ); + expect(stackShadowCacheKey({ ...base, bootstrapInputsId: "inputs:changed" })).not.toBe(baseKey); + expect(stackShadowCacheKey({ ...base, initializationProfileId: "profile:changed" })).not.toBe( + baseKey, + ); + expect( + stackShadowCacheKey({ + ...base, + initialization: { + profileId: "profile:shadow", + recipes: [{ service: "auth", recipeId: "auth", artifactIdentity: "auth" }], + }, + }), + ).not.toBe(baseKey); + }); + + it("changes for CLI overlay inputs", () => { + const baseKey = stackShadowCacheKey(base); + expect(stackShadowCacheKey({ ...base, rolesSql: "create role x;" })).not.toBe(baseKey); + expect(stackShadowCacheKey({ ...base, webhooksEnabled: true })).not.toBe(baseKey); + expect(stackShadowCacheKey({ ...base, apiGrantsKept: false })).not.toBe(baseKey); + expect( + stackShadowCacheKey({ ...base, vault: [{ name: "a", value: "secret", resolved: true }] }), + ).not.toBe(baseKey); + expect(stackShadowCacheKey({ ...base, jwks: '{"keys":[]}' })).not.toBe(baseKey); + expect(stackShadowCacheKey({ ...base, storageTargetMigration: "20240101000000" })).not.toBe( + baseKey, + ); + }); + it("recognizes only this module's own partial temp files as abandoned-sweep candidates", () => { const key = "0123456789abcdef"; expect(isStackShadowBaselinePartial(`stack-shadow-baseline-${key}.tar.4242.partial`)).toBe( @@ -66,58 +80,4 @@ describe("stackShadowCacheKey", () => { expect(isStackShadowBaselinePartial(other), other).toBe(false); } }); - - it("changes when roles.sql, db settings, or bootstrap identity change", () => { - const withRoles = stackShadowCacheKey({ ...base, rolesSql: "create role x;" }); - expect(stackShadowCacheKey(base)).not.toBe(withRoles); - expect(stackShadowCacheKey({ ...base, dbSettings: { max_connections: 20 } })).not.toBe( - stackShadowCacheKey(base), - ); - expect(stackShadowCacheKey({ ...base, bootstrapIdentity: "bootstrap-v2" })).not.toBe( - stackShadowCacheKey(base), - ); - }); - - it("changes when overlay or schema-init membership changes", () => { - expect(stackShadowCacheKey({ ...base, webhooksEnabled: true })).not.toBe( - stackShadowCacheKey(base), - ); - expect(stackShadowCacheKey({ ...base, apiGrantsKept: false })).not.toBe( - stackShadowCacheKey(base), - ); - expect( - stackShadowCacheKey({ - ...base, - vault: [{ name: "a", value: "secret", resolved: true }], - }), - ).not.toBe(stackShadowCacheKey(base)); - expect(stackShadowCacheKey({ ...base, authEnabled: true })).not.toBe(stackShadowCacheKey(base)); - expect( - stackShadowCacheKey({ - ...base, - realtimeEnabled: true, - jwks: '{"keys":[]}', - }), - ).not.toBe(stackShadowCacheKey({ ...base, realtimeEnabled: true, jwks: "{}" })); - expect( - stackShadowCacheKey({ - ...base, - storageEnabled: true, - storageTargetMigration: "20240101000000", - }), - ).not.toBe(stackShadowCacheKey({ ...base, storageEnabled: true })); - expect( - stackShadowCacheKey({ - ...base, - authEnabled: true, - authArtifact: "v2.196.0:ghcr.io/supabase/cli/auth:v2.196.0", - }), - ).not.toBe( - stackShadowCacheKey({ - ...base, - authEnabled: true, - authArtifact: "v2.197.0:ghcr.io/supabase/cli/auth:v2.197.0", - }), - ); - }); }); diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index f6c3defac2..d0ed31c292 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -436,6 +436,11 @@ describe("test db integration", () => { ) => { const stack: EffectStack = { id: PROVE_STACK_ID, + services: { + create: unusedProve, + get: unusedProve, + list: Effect.succeed([]), + }, status: Effect.succeed({ id: PROVE_STACK_ID, lifecycle: "running", @@ -445,13 +450,16 @@ describe("test db integration", () => { versions: { database: databaseVersion }, capabilities: [], artifacts: [], + instances: [], }), credentials: Effect.die("unused"), + followStatus: Stream.empty, prepare: unusedProve, start: unusedProve, - stop: Effect.die("unused"), - destroy: Effect.die("unused"), - resetDatabase: Effect.die("unused"), + sleep: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + restart: () => Effect.die("unused"), + destroy: () => Effect.die("unused"), logs: unusedProve, followLogs: () => Stream.empty, }; diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 62b4391641..3eeaade90e 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -8,8 +8,10 @@ edge-runtime involved). `--use-pg-schema` is the CLI's sole remaining Go delegation on this command — a documented keep-in-Go exception (CLI-1960), not a pending port. -When `[experimental].stack` is on, the shadow is `EphemeralPostgres` under -`$SUPABASE_HOME/managed/ephemeral-postgres//` (`~/.supabase/managed/…` by default). Migra, pgAdmin, and `--use-pg-schema` are +When `[experimental].stack` is on, the shadow is a registered database service in the project stack, +with a unique instance ID and a managed SQL endpoint. Its data is owned under +`$SUPABASE_HOME/managed/stacks//data/instances//` +(`~/.supabase/managed/…` by default). Migra, pgAdmin, and `--use-pg-schema` are rejected because that shadow is always stack. Pg-delta runs in-process. Coverage gaps warn, while `--strict-coverage` makes @@ -31,25 +33,23 @@ it, and JSON `null` disables formatting without disabling safe compaction. | `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | | `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` only — slim-init + stack bootstrap baseline; key includes artifact identity and runtime kind (native vs container). Never mixed with `shadow-baseline-*.tar` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` abandoned-partial sweep — same 5-minute TTL, stack prefix only (legacy `shadow-baseline-*.partial` names are not candidates) | | `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | | `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` COLD export of an `EphemeralPostgres` cluster; same LRU keep-3 + 2-day TTL, separate glob so keys cannot collide with legacy SQL-template baselines | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | `[experimental].stack` cold export temp file — pid-scoped, `chmod` 0600, `rename`d into the stack tar above; abandoned leftovers older than 5 minutes are swept on later acquires | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ------------------------------------------------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar` | tar | `[experimental].stack` COLD export of an registered database instance; same LRU keep-3 + 2-day TTL, separate glob so keys cannot collide with legacy SQL-template baselines | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | A unique operation-owned export file, published to the cache on success and removed by its own failure cleanup. Age alone never authorizes removal of an in-flight export. | +| `~/.supabase/cache/shadow-baseline/stack-shadow-baseline-.tar..partial` | tar | A unique operation-owned export file, published to the cache on success and removed by its own failure cleanup. Age alone never authorizes removal of an in-flight export. | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker @@ -66,7 +66,7 @@ it, and JSON `null` disables formatting without disabling safe compaction. no `targetUrlOverride`. - When `[experimental].stack` / `SUPABASE_EXPERIMENTAL_STACK=1` is on, `--local` inspects the project stack (`findStack` + `status`, database ready) instead of `supabase_db_`, - and shadows are `@supabase/stack` `EphemeralPostgres` clusters (slim-init baseline, cache + and shadows are `@supabase/stack` registered database instances (slim-init baseline, cache prefix `stack-shadow-baseline-`). Every stack runtime rejects `--use-migra` / `--use-pgadmin` / `--use-pg-schema`. - `supabase/migra` container — the migra OOM bash fallback only. diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 13ffebec97..0d0f64f020 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -64,6 +64,7 @@ import { import type { DbDiffFlags } from "./diff.command.ts"; import { dbDiff } from "./diff.handler.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; import { StackNativeEngineError } from "../../../command-internal/stack-local-database.ts"; import { PGADMIN_DESKTOP_NOTE_PREFIX, PGADMIN_DIFF_HEADER } from "./pgadmin-diff.ts"; @@ -412,6 +413,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(DebugFlag, false), Layer.succeed(CliArgs, { args: [] }), mockRuntimeInfo({ platform: opts.platform ?? "linux" }), + stackApiLayer.pipe(Layer.provide(BunServices.layer)), ); // Merged last so its `FileSystem` overrides everything above (last-wins). const failWriteLayer = diff --git a/apps/cli/src/commands/db/diff/diff.stack.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack.e2e.test.ts index fd25a31d9a..2130a9ad54 100644 --- a/apps/cli/src/commands/db/diff/diff.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack.e2e.test.ts @@ -1,81 +1,180 @@ -import { describe, expect, test } from "vitest"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- e2e fixture appends experimental.stack to project config -import { appendFile, readdir } from "node:fs/promises"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- e2e fixture joins project and cache paths -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Config, Crypto, Effect, FileSystem, ManagedRuntime, Option, Path } from "effect"; +import { afterAll, describe, expect, test } from "vitest"; +import { makeTempCliProject, makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; +const host = ManagedRuntime.make(BunServices.layer); +afterAll(() => host.dispose()); +const selectedRuntime = Option.getOrUndefined( + Effect.runSync(Config.option(Config.string("SUPABASE_STACK_E2E_RUNTIME"))), +); +const runtimes = ["native", "container"] as const; +const excluded = "rest,auth,realtime,storage,functions,studio,mail,analytics,pooler"; +const baselineName = /^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u; +const probeFunction = + /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i; -const DB_START_COMMAND_TIMEOUT_MS = 480_000; -const DB_START_CLEANUP_TIMEOUT_MS = 120_000; -const STACK_DB_AUX_TIMEOUT_MS = 180_000; -const STACK_DIFF_TIMEOUT_MS = 180_000; -const STACK_DIFF_TEST_TIMEOUT_MS = - DB_START_COMMAND_TIMEOUT_MS + - STACK_DB_AUX_TIMEOUT_MS + - STACK_DIFF_TIMEOUT_MS + - DB_START_CLEANUP_TIMEOUT_MS; +describe("CLI schema diff with registered shadow databases", () => { + for (const runtime of runtimes) { + test.skipIf(selectedRuntime !== undefined && selectedRuntime !== runtime)( + `reuses and repairs ${runtime} shadow caches, then resets roles and migrations`, + { timeout: 22 * 60_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the compiled CLI subprocess and its real database/cache boundaries. + async () => { + const home = makeTempHome(); + const project = await makeTempCliProject(`supabase-shadow-${runtime}-`); + const cacheDir = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = path.join(project.dir, "supabase"); + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "config.toml"), + 'project_id = "shadow-cache-e2e"\n[experimental]\nstack = true\n[db]\nmajor_version = 17\n', + ); + return path.join(home.dir, "cache", "shadow-baseline"); + }), + ); + const command = (args: string[], cache?: string, timeout = 180_000) => + runSupabase(args, { + cwd: project.dir, + home: home.dir, + exitTimeoutMs: timeout, + env: { SUPABASE_EXPERIMENTAL_STACK: "1", SUPABASE_SHADOW_CACHE: cache }, + }); + const cacheEntries = () => + host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const names = yield* fs.readDirectory(cacheDir); + expect(names.some((name) => name.endsWith(".partial"))).toBe(false); + return yield* Effect.forEach( + names.filter((name) => baselineName.test(name)), + (name) => + Effect.gen(function* () { + const bytes = yield* fs.readFile(path.join(cacheDir, name)); + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest("SHA-256", bytes); + return { name, size: bytes.length, digest: Array.from(digest) }; + }), + ); + }), + ); + const failures: unknown[] = []; + try { + const started = await command( + [ + "stack", + "start", + "--runtime", + runtime === "container" ? "docker" : "native", + "--exclude", + excluded, + "--preparation", + "on-demand", + ], + undefined, + 480_000, + ); + expect(started.exitCode, `${started.stdout}\n${started.stderr}`).toBe(0); + const created = await command([ + "db", + "query", + "CREATE FUNCTION public.probe_fn() RETURNS integer LANGUAGE sql AS $$ SELECT 42; $$;", + "--local", + ]); + expect(created.exitCode, created.stderr).toBe(0); -const STACK_BASELINE_TAR = /^stack-shadow-baseline-[0-9a-f]{16}\.tar$/u; -const COMPOSE_BASELINE_TAR = /^shadow-baseline-[0-9a-f]{16}\.tar$/u; -const PROBE_FN_SQL = - /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i; + const args = ["db", "diff", "--local", "--use-pg-delta"]; + const cold = await command(args); + expect(cold.exitCode, `${cold.stdout}\n${cold.stderr}`).toBe(0); + expect(cold.stdout).toMatch(probeFunction); + const first = await cacheEntries(); + expect(first).toHaveLength(1); + const warm = await command(args); + expect(warm.exitCode, `${warm.stdout}\n${warm.stderr}`).toBe(0); + expect(warm.stdout).toBe(cold.stdout); + expect(await cacheEntries(), warm.stderr).toEqual(first); -describe("supabase db diff (e2e, stack shadow)", () => { - test( - "stack db diff --local publishes a stack-shadow-baseline tar", - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-db-diff-stack-e2e-"); - await appendFile( - path.join(project.dir, "supabase", "config.toml"), - "\n[experimental]\nstack = true\n", - ); - try { - const started = await runSupabase(["db", "start"], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DB_START_COMMAND_TIMEOUT_MS, - }); - expect(started.exitCode, started.stderr).toBe(0); + const baseline = first[0]; + if (baseline === undefined) throw new Error("Cold shadow did not publish a baseline"); + await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(cacheDir, baseline.name), "invalid snapshot"); + }), + ); + const repaired = await command(args); + expect(repaired.exitCode, `${repaired.stdout}\n${repaired.stderr}`).toBe(0); + expect(repaired.stderr).toContain("shadow baseline not cached"); + expect(repaired.stdout).toBe(cold.stdout); + const replacement = await cacheEntries(); + expect(replacement).toHaveLength(1); + expect(replacement[0]?.name).toBe(baseline.name); + expect(replacement[0]?.size).toBeGreaterThan("invalid snapshot".length); - const createFunction = await runSupabase( - [ + const uncached = await command(args, "0"); + expect(uncached.exitCode, `${uncached.stdout}\n${uncached.stderr}`).toBe(0); + expect(uncached.stdout).toBe(cold.stdout); + expect(await cacheEntries()).toEqual(replacement); + const primary = await command([ "db", "query", - `create function public.probe_fn() -returns void -language sql -as $$ select 1; $$;`, + "SELECT public.probe_fn() AS preserved", "--local", - ], - { cwd: project.dir, home: home.dir, exitTimeoutMs: STACK_DB_AUX_TIMEOUT_MS }, - ); - expect(createFunction.exitCode, createFunction.stderr).toBe(0); - - const diff = await runSupabase(["db", "diff", "--local", "--use-pg-delta"], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: STACK_DIFF_TIMEOUT_MS, - env: { SUPABASE_SHADOW_CACHE: undefined }, - }); - expect(diff.exitCode, `${diff.stdout}\n${diff.stderr}`).toBe(0); - expect(diff.stdout).toMatch(PROBE_FN_SQL); + ]); + expect(primary.exitCode, primary.stderr).toBe(0); + expect(primary.stdout).toContain("42"); - const cacheDir = path.join(home.dir, "cache", "shadow-baseline"); - const entries = await readdir(cacheDir).catch(() => [] as Array); - expect(entries.filter((entry) => STACK_BASELINE_TAR.test(entry))).toHaveLength(1); - expect(entries.filter((entry) => COMPOSE_BASELINE_TAR.test(entry))).toHaveLength(0); - expect(entries.filter((entry) => entry.endsWith(".partial"))).toHaveLength(0); - } finally { - await runSupabase(["stack", "destroy", "--yes"], { - cwd: project.dir, - home: home.dir, - env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, - exitTimeoutMs: DB_START_CLEANUP_TIMEOUT_MS, - }).catch(() => undefined); - } - }, - STACK_DIFF_TEST_TIMEOUT_MS, - ); + await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = path.join(project.dir, "supabase"); + const migrations = path.join(configDir, "migrations"); + yield* fs.makeDirectory(migrations, { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "roles.sql"), + "CREATE ROLE reset_reader;\n", + ); + yield* fs.writeFileString( + path.join(migrations, "20260916000000_reset_probe.sql"), + "CREATE TABLE public.reset_probe (value integer PRIMARY KEY);\nGRANT SELECT ON public.reset_probe TO reset_reader;\n", + ); + yield* fs.writeFileString( + path.join(configDir, "seed.sql"), + "INSERT INTO public.reset_probe VALUES (42);\n", + ); + }), + ); + for (let pass = 0; pass < 2; pass += 1) { + const reset = await command(["db", "reset", "--local", "--yes"], "0", 300_000); + const diagnostics = + reset.exitCode === 0 + ? "" + : (await command(["stack", "logs", "--tail", "100"])).stdout; + expect(reset.exitCode, `${reset.stdout}\n${reset.stderr}\n${diagnostics}`).toBe(0); + const restored = await command([ + "db", + "query", + "SELECT CASE WHEN (SELECT value FROM public.reset_probe) = 42 AND has_table_privilege('reset_reader', 'public.reset_probe', 'SELECT') AND to_regprocedure('public.probe_fn()') IS NULL AND EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase') THEN 'reset_verified' ELSE 'reset_incomplete' END AS result", + "--local", + ]); + expect(restored.exitCode, restored.stderr).toBe(0); + expect(restored.stdout).toContain("reset_verified"); + } + } catch (error) { + failures.push(error); + } + const destroyed = await command(["stack", "destroy", "--yes"], undefined, 120_000); + if (destroyed.exitCode !== 0) + failures.push(new Error(`Stack cleanup failed in ${project.dir}: ${destroyed.stderr}`)); + if (failures.length > 0) + throw new AggregateError(failures, `${runtime} shadow cache verification failed`); + }, + ); + } }); diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 895c87b95a..00d6e61651 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -38,7 +38,11 @@ import type { DbDumpFlags } from "./dump.command.ts"; import { dbDump } from "./dump.handler.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { StackApi } from "../../../command-internal/stack-api.ts"; -import { StackIdSchema, type EffectStack } from "@supabase/stack/effect"; +import { + StackIdSchema, + type EffectServiceCollection, + type EffectStack, +} from "@supabase/stack/effect"; const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -1038,6 +1042,11 @@ describe("db dump integration", () => { ) => { const stack: EffectStack = { id: DUMP_STACK_ID, + services: { + create: () => Effect.die("service creation is unused by db dump"), + get: () => Effect.die("service lookup is unused by db dump"), + list: Effect.succeed([]), + } satisfies EffectServiceCollection, status: Effect.succeed({ id: DUMP_STACK_ID, lifecycle: "running", @@ -1047,13 +1056,16 @@ describe("db dump integration", () => { versions: { database: databaseVersion }, capabilities: [], artifacts: [], + instances: [], }), + followStatus: Stream.empty, credentials: unusedDumpEffect, prepare: unusedDump, start: unusedDump, - stop: unusedDumpEffect, - destroy: unusedDumpEffect, - resetDatabase: unusedDumpEffect, + sleep: unusedDump, + restart: unusedDump, + stop: () => unusedDumpEffect, + destroy: () => unusedDumpEffect, logs: unusedDump, followLogs: () => Stream.empty, }; diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 023cb1c078..2d395e4fbc 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -14,8 +14,10 @@ runs the same in-process declarative export (`supabase/schemas` plus `--declarative`. `--experimental --declarative` does not print that line: `--declarative` already selected the export. -When `[experimental].stack` is on, the shadow is `EphemeralPostgres` under -`$SUPABASE_HOME/managed/ephemeral-postgres//` (`~/.supabase/managed/…` by default). Migra (`--diff-engine migra`) is rejected +When `[experimental].stack` is on, the shadow is a registered database service in the project stack, +with a unique instance ID and a managed SQL endpoint. Its data is owned under +`$SUPABASE_HOME/managed/stacks//data/instances//` +(`~/.supabase/managed/…` by default). Migra (`--diff-engine migra`) is rejected because that shadow is always stack. Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them @@ -35,23 +37,22 @@ disables formatting without disabling safe compaction. | `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | | `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative` provisions no shadow); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migration-style pull) — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | | `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | -| `/supabase/schemas/**` | SQL | `--declarative` or deprecated `--experimental` export | -| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled declarative / deprecated-`--experimental` export metadata | -| `/supabase/config.toml` | TOML | declarative export updates `[db.migrations].schema_paths` when `[experimental.pgdelta] enabled` resolves false (section absent, `enabled` omitted, or `enabled = false` — the default); skipped when `enabled = true` | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot, migration-style pull only (never a declarative / deprecated-`--experimental` export, which provisions no shadow); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ------------------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | +| `/supabase/schemas/**` | SQL | `--declarative` or deprecated `--experimental` export | +| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled declarative / deprecated-`--experimental` export metadata | +| `/supabase/config.toml` | TOML | declarative export updates `[db.migrations].schema_paths` when `[experimental.pgdelta] enabled` resolves false (section absent, `enabled` omitted, or `enabled = false` — the default); skipped when `enabled = true` | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot, migration-style pull only (never a declarative / deprecated-`--experimental` export, which provisions no shadow); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | A unique operation-owned export file, published to the cache on success and removed by its own failure cleanup. Age alone never authorizes removal of an in-flight export. | +| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 7138fe8c2d..0917ee72b5 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -56,6 +56,7 @@ import type { DbRemoteCommitFlags } from "../remote/commit/commit.command.ts"; import type { DbPullFlags } from "./pull.command.ts"; import { dbPull } from "./pull.handler.ts"; import { runDbPull } from "../../../command-internal/db-pull-run.ts"; +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; const alwaysReadyHttpClientLayer = Layer.succeed( HttpClient.HttpClient, @@ -456,6 +457,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockRuntimeInfo(), + stackApiLayer.pipe(Layer.provide(BunServices.layer)), ); return { layer: baseLayer, diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index aee64ebd74..b9e14dc9da 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -30,8 +30,11 @@ removed `DeclarativeSeam.execInherit` seam — see those commands' own When the `experimental.stack` feature flag is on (`SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence, same rules as [`docs/stack-commands.md`](../../../../docs/stack-commands.md)), the -local path calls `resetDatabase` on the project stack instead of the container recreate -described above. After the reset, buckets are seeded — reusing the `seed buckets` local path — +local path uses the project stack's registered-instance reset instead of the container recreate +described above. The reset keeps the designated primary instance identity, creates a matching +temporary database baseline, replaces the primary databases through managed SQL, and resumes the +same previously started dependent instances after migrations and seed complete. After the reset, +buckets are seeded — reusing the `seed buckets` local path — when Storage is `ready`, `dormant`, `starting`, or `stopping`; the gateway holds requests during lazy activation and wakes a stopping capability after cleanup, so neither state waits. The command never fails the reset for a Storage problem: an unusable capability state @@ -51,7 +54,8 @@ the stack runtime's own storage workload/catalog setup responsibility; bucket cr `objects_path` upload from `[storage.buckets]` remain this command's (via `seed buckets`) responsibility — the runtime never creates buckets itself. Durable stack state lives under `$SUPABASE_HOME/managed/stacks//`. A missing stack reports "The local stack is not -running." Config, including `functions/.env`, is validated before the wipe. +running." Config, including `functions/.env`, is validated before the wipe. Storage files and +unrelated registered instances remain in place. ## Files Read @@ -66,7 +70,7 @@ running." Config, including `functions/.env`, is validated before the wipe. | seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | | schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) | | `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | -| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated | +| `/supabase/roles.sql` | SQL | local PG15 path and stack baseline setup — missing file tolerated | | `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the local path's running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | ## Files Written @@ -76,11 +80,19 @@ running." Config, including `functions/.env`, is validated before the wipe. | `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | | `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -On the local path, the native recreate additionally recreates the +On the non-stack local path, the native recreate additionally recreates the `supabase_db_` container/volume (PG15) or the `postgres`/`_supabase` databases in place (PG14), and applies the initial schema (`SetupLocalDatabase` equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14). +On the stack local path, reset keeps the registered primary instance and its +owned storage. It creates a fresh registered database baseline, uses managed +SQL to recreate the primary `postgres` and `_supabase` databases, then streams +the baseline into `postgres` with matching PostgreSQL clients. The catalog +overlay, migrations, and seed run against the same primary instance afterward. +Managed cluster roles remain owned by the primary instance across this logical reset; +custom roles declared in `roles.sql` are dropped before the catalog overlay reapplies that file. + ## Subprocesses | Command | When | Purpose | @@ -93,6 +105,7 @@ equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14). | `docker restart ` | local path, both PG14 and PG15 | concurrent satellite-container restart, not-found tolerated per service | | `docker container inspect ` + `docker exec kong reload --nginx-conf /home/kong/custom_nginx.template` | local path, both PG14 and PG15 | reload Kong so it re-resolves the restarted containers' addresses (issue #6016) — the `--nginx-conf` flag is load-bearing: a bare `kong reload` regenerates nginx.conf from Kong's default template and drops the custom `email_templates` server (#6059) | | `docker container inspect supabase_storage_` | local path | storage-health gate before bucket seeding | +| `pg_dump --format=plain ... \| psql ...` | stack local path | stream the fresh registered baseline into the designated primary through managed SQL endpoints; native clients are version-checked, container tools use host networking | No subprocess delegation remains on either target — the remote path's `--experimental` schema-files apply (formerly delegated to a `supabase-go db reset` @@ -113,13 +126,13 @@ child) is fully native as of CLI-1958. ### Local path (native, in TS) -**PG15+:** the container/volume are removed and recreated (see "Subprocesses"), then +**Non-stack PG15+:** the container/volume are removed and recreated (see "Subprocesses"), then the reused `startSetupLocalDatabase` pipeline runs the initial schema (as one-shot Docker jobs, not SQL over a session), `ApplyApiPrivileges`, a vault upsert, a `roles.sql` seed, and `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) — over a fresh host-facing Postgres connection. -**PG14:** connects as `supabase_admin` to `template1` and disconnects other clients +**Non-stack PG14:** connects as `supabase_admin` to `template1` and disconnects other clients (`ALTER DATABASE ... ALLOW_CONNECTIONS false` ×2, `pg_terminate_backend`, then polls `pg_replication_slots` on a 1-second backoff up to 10 times — a failure here is swallowed unless it's a PgError whose code isn't `3D000`/`invalid_catalog_name`), then @@ -130,7 +143,7 @@ path) + `ApplyApiPrivileges`. After the container itself is restarted (see below reconnects as `postgres`/`postgres` for `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`). -**Both branches** then restart the storage/auth/realtime/pooler containers +**Non-stack branches** then restart the storage/auth/realtime/pooler containers concurrently (per-service "not found" tolerated, no health wait afterward — "those services may be excluded from starting"), then reload Kong (`docker exec kong reload`; skipped, not failed, when the gateway is absent or stopped) so its nginx @@ -138,11 +151,12 @@ re-resolves the restarted containers' addresses — otherwise routes to a moved container keep returning 502 after the reset succeeds (issue #6016). **A Kong reload failure fails the WHOLE command** (unlike `functions serve`'s best-effort reload), with an actionable `Suggestion:` line (`docker restart ` / `docker logs `). -Bucket objects are then seeded over the Storage gateway (reusing the `seed buckets` -local path), gated on a native storage-health check: absent (any inspect error, not -just "not found") skips buckets without failing; present-but-unhealthy waits up to a -**hardcoded 30 seconds** (independent of `db.health_timeout`) and, on timeout, **fails -the whole reset** (not just "skip buckets"). +For the non-stack local path, bucket objects are then seeded over the Storage gateway (reusing the +`seed buckets` local path), gated on a native storage-health check: absent (any inspect error, not +just "not found") skips buckets without failing; present-but-unhealthy waits up to a **hardcoded +30 seconds** (independent of `db.health_timeout`) and, on timeout, **fails the whole reset** (not +just "skip buckets"). The stack path uses the gateway's lazy activation and wake-up behavior +described above instead of this container health wait. ## API Routes diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index d61178b048..a1eb3ee1e0 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -51,13 +51,22 @@ import { recordingStackCatalogSetup } from "../../../command-internal/stack-cata import { CAPABILITY_NAMES, StackIdSchema, + ServiceInstanceIdSchema, + StackLifecycleConflictError, type CapabilityState, + type AnyServiceDescriptor, + type EffectServiceCollection, + type EffectServiceInstance, type EffectStack, + type ServiceDescriptor, + type ServiceStatus, + type StackStatus, } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts"; +import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -478,6 +487,132 @@ function recordingStackStorageHttpClientBucketListTransportFails() { const RESET_STACK_ID = StackIdSchema.make("c".repeat(64)); +const serviceStatus = ( + id: ServiceStatus["id"], + service: ServiceStatus["service"], + name: string, + intent: ServiceStatus["intent"], +): ServiceStatus => ({ + id, + service, + name, + enabled: true, + intent, + phase: intent === "started" ? "ready" : "stopped", + activation: "eager", + endpoints: [], +}); + +const databaseDescriptor = ( + id: ServiceStatus["id"], + name: string, + port: number, + version = "17.6.1", +): ServiceDescriptor<"database"> => ({ + id, + service: "database", + name, + enabled: true, + config: { + enabled: true, + activation: "eager", + idleTimeoutSeconds: false, + version, + settings: {}, + }, + dependencies: {}, + snapshotSupport: "supported", + endpoints: { sql: { enabled: true, address: "127.0.0.1", port } }, + artifactIdentity: `container:postgres:${version}`, + runtimeIdentity: `container:database:${version}`, + effectiveConfigFingerprint: `config:${name}`, + initializationProfileId: "profile:primary", + initialization: { profileId: "profile:primary", recipes: [] }, + bootstrapRecipeId: "database-bootstrap-v1", + bootstrapInputsId: "inputs:primary", + data: { origin: "fresh", lineageId: `lineage:${name}` }, +}); + +const dependentDescriptor = ( + id: ServiceStatus["id"], + name: string, + service: "rest" | "storage", + databaseId: ServiceStatus["id"], +): AnyServiceDescriptor => ({ + id, + service, + name, + enabled: true, + config: { + enabled: true, + activation: "eager", + idleTimeoutSeconds: false, + version: "1.0.0", + settings: {}, + }, + dependencies: { database: databaseId }, + snapshotSupport: "unsupported", + endpoints: {}, + artifactIdentity: `container:${service}:1.0.0`, + runtimeIdentity: `container:${service}:1.0.0`, + effectiveConfigFingerprint: `config:${name}`, + initializationProfileId: null, + data: { origin: "absent" }, +}); + +const makeDatabaseService = (input: { + readonly descriptor: ServiceDescriptor<"database">; + readonly password: string; + readonly onStart?: () => void; + readonly onStop?: () => void; + readonly onRestart?: ( + config?: import("@supabase/stack/effect").EffectServiceConfig<"database">, + ) => void; + readonly onDestroy?: () => void; +}): EffectServiceInstance<"database"> => { + const { descriptor } = input; + const credentials = { + url: `postgresql://postgres:${input.password}@127.0.0.1:${String(descriptor.endpoints.sql?.port ?? 54329)}/postgres`, + password: input.password, + }; + const ready = serviceStatus(descriptor.id, "database", descriptor.name ?? "database", "started"); + const stopped = serviceStatus( + descriptor.id, + "database", + descriptor.name ?? "database", + "stopped", + ); + return { + id: descriptor.id, + service: "database", + name: descriptor.name, + describe: Effect.succeed(descriptor), + status: Effect.succeed(ready), + credentials: Effect.succeed(credentials), + prepare: Effect.succeed({ instances: [] }), + start: Effect.sync(() => { + input.onStart?.(); + return ready; + }), + sleep: Effect.succeed(stopped), + stop: Effect.sync(() => { + input.onStop?.(); + return stopped; + }), + restart: (options) => + Effect.sync(() => { + input.onRestart?.(options?.config); + return ready; + }), + destroy: Effect.sync(() => input.onDestroy?.()), + exportSnapshot: () => Effect.die("snapshot export is not used by reset tests"), + restoreSnapshot: () => Effect.die("snapshot restore is not used by reset tests"), + logs: () => Effect.die("logs are not used by reset tests"), + followLogs: () => Stream.empty, + followStatus: Stream.fromEffect(Effect.suspend(() => Effect.succeed(ready))), + }; +}; + function mockResetStackApi(opts: { readonly workdir: string; readonly ready: boolean; @@ -488,47 +623,122 @@ function mockResetStackApi(opts: { readonly apiEndpoint?: { readonly url: string; readonly port: number }; readonly serviceRoleJwt?: string; readonly capabilityStates?: Partial>; + readonly resumeFails?: boolean; }) { - let resetCalls = 0; - const unused = Effect.die("unused"); - const unusedFn = () => unused; - const stack: EffectStack = { + const primaryId = ServiceInstanceIdSchema.make("database-primary"); + const primaryDescriptor = databaseDescriptor(primaryId, "database", 54329); + const restId = ServiceInstanceIdSchema.make("rest-primary"); + const storageId = ServiceInstanceIdSchema.make("storage-primary"); + const unrelatedId = ServiceInstanceIdSchema.make("database-unrelated"); + const descriptors: Array = [ + primaryDescriptor, + dependentDescriptor(restId, "rest", "rest", primaryDescriptor.id), + dependentDescriptor(storageId, "storage", "storage", primaryDescriptor.id), + databaseDescriptor(unrelatedId, "unrelated", 54331), + ]; + const events = { + stopped: [] as Array, + started: [] as Array, + restarts: 0, + restartConfigs: [] as Array< + import("@supabase/stack/effect").EffectServiceConfig<"database"> | undefined + >, + }; + const primary = makeDatabaseService({ + descriptor: primaryDescriptor, + password: "postgres", + onRestart: (config) => { + events.restarts++; + events.restartConfigs.push(config); + }, + }); + function createService( + options: import("@supabase/stack/effect").EffectCreateServiceOptions<"database">, + ): Effect.Effect, never>; + function createService( + options: import("@supabase/stack/effect").EffectCreateServiceOptions, + ): Effect.Effect, never>; + function createService(options: import("@supabase/stack/effect").AnyEffectCreateServiceOptions) { + if (options.service !== "database") return Effect.die(`unsupported service ${options.service}`); + const id = ServiceInstanceIdSchema.make("shadow-database"); + const descriptor = databaseDescriptor( + id, + options.name ?? "shadow", + 54330, + options.config.version ?? "17.6.1", + ); + descriptors.push(descriptor); + return Effect.succeed( + makeDatabaseService({ + descriptor, + password: Redacted.value(options.config.password ?? Redacted.make("postgres")), + onDestroy: () => { + const index = descriptors.findIndex((item) => item.id === id); + if (index >= 0) descriptors.splice(index, 1); + }, + }), + ); + } + const services: EffectServiceCollection = { + create: createService, + get: (ref) => + "name" in ref && ref.name === "database" + ? Effect.succeed(primary) + : Effect.die(`unknown service ${"name" in ref ? ref.name : ref.id}`), + list: Effect.succeed(descriptors), + }; + const stackStatus = (): StackStatus => ({ id: RESET_STACK_ID, - status: Effect.succeed({ - id: RESET_STACK_ID, - lifecycle: opts.ready ? "running" : "stopped", - desiredLifecycle: opts.ready ? "running" : "stopped", - runtime: { kind: "native" }, - endpoints: - opts.apiEndpoint === undefined - ? {} - : { - api: { - protocol: "http" as const, - address: "127.0.0.1", - port: opts.apiEndpoint.port, - url: opts.apiEndpoint.url, - }, + lifecycle: opts.ready ? "running" : "stopped", + desiredLifecycle: opts.ready ? "running" : "stopped", + runtime: { kind: "container", engine: "docker" }, + endpoints: + opts.apiEndpoint === undefined + ? {} + : { + api: { + protocol: "http", + address: "127.0.0.1", + port: opts.apiEndpoint.port, + url: opts.apiEndpoint.url, }, - versions: {}, - capabilities: CAPABILITY_NAMES.map((name) => ({ - name, - activation: name === "database" ? "eager" : "lazy", - state: - opts.capabilityStates?.[name] ?? - (name === "database" - ? opts.ready - ? "ready" - : "stopped" - : name === "storage" - ? (opts.storageState ?? (opts.storageReady === true ? "ready" : "stopped")) + }, + versions: { database: "17.6.1" }, + capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-primary`), + name, + activation: name === "database" ? "eager" : "lazy", + state: + opts.capabilityStates?.[name] ?? + (name === "database" && opts.ready + ? "ready" + : name === "storage" && opts.storageReady === true + ? "ready" + : name === "storage" && opts.storageState !== undefined + ? opts.storageState : "stopped"), - ...(name === "storage" && opts.storageError !== undefined - ? { error: opts.storageError } - : {}), - })), - artifacts: [], - }), + ...(name === "storage" && opts.storageError !== undefined + ? { error: opts.storageError } + : {}), + })), + artifacts: [], + instances: [ + serviceStatus( + primaryDescriptor.id, + "database", + "database", + opts.ready ? "started" : "stopped", + ), + serviceStatus(restId, "rest", "rest", "started"), + serviceStatus(storageId, "storage", "storage", "stopped"), + serviceStatus(unrelatedId, "database", "unrelated", "started"), + ], + }); + const stack: EffectStack = { + id: RESET_STACK_ID, + services, + status: Effect.sync(stackStatus), + followStatus: Stream.empty, credentials: Effect.succeed({ database: { url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:54329/postgres"), @@ -541,33 +751,34 @@ function mockResetStackApi(opts: { serviceRoleJwt: Redacted.make(opts.serviceRoleJwt ?? "service"), }, }), - prepare: unusedFn, - start: unusedFn, - stop: unused, - destroy: unused, - resetDatabase: Effect.sync(() => { - resetCalls++; - return { - id: RESET_STACK_ID, - lifecycle: "running" as const, - desiredLifecycle: "running" as const, - runtime: { kind: "native" as const }, - endpoints: {}, - versions: {}, - capabilities: CAPABILITY_NAMES.map((name) => ({ - name, - activation: name === "database" ? ("eager" as const) : ("lazy" as const), - state: name === "database" ? ("ready" as const) : ("dormant" as const), - })), - artifacts: [], - }; - }), - logs: unusedFn, + prepare: () => Effect.succeed({ instances: [] }), + start: (options) => { + if (opts.resumeFails && (options?.services?.length ?? 0) > 0) + return Effect.fail( + new StackLifecycleConflictError({ + message: "dependent resume failed", + stackId: RESET_STACK_ID, + }), + ); + return Effect.sync(() => { + for (const id of options?.services ?? []) events.started.push(id); + return stackStatus(); + }); + }, + stop: (options) => + Effect.sync(() => { + for (const id of options?.services ?? []) events.stopped.push(id); + return stackStatus(); + }), + sleep: () => Effect.succeed(stackStatus()), + restart: () => Effect.succeed(stackStatus()), + destroy: () => Effect.void, + logs: () => Effect.die("logs are not used by reset tests"), followLogs: () => Stream.empty, }; return { layer: Layer.succeed(StackApi, { - createStack: unusedFn, + createStack: () => Effect.succeed(stack), findStack: () => Effect.succeed( Option.some({ @@ -575,16 +786,30 @@ function mockResetStackApi(opts: { projectRoot: opts.workdir, name: "default", branchContext: "main", - runtime: { kind: "native" as const }, - desiredLifecycle: "running" as const, + runtime: { kind: "container", engine: "docker" }, + desiredLifecycle: "running", }), ), - discoverStacks: unusedFn, + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), openStack: () => Effect.succeed(stack), - inspectStack: unusedFn, + inspectStack: () => + Effect.succeed({ + descriptor: { + id: RESET_STACK_ID, + projectRoot: opts.workdir, + name: "default", + branchContext: "main", + runtime: { kind: "container", engine: "docker" }, + desiredLifecycle: "running", + }, + owner: "running", + }), }), - get resetCalls() { - return resetCalls; + get events() { + return events; + }, + get serviceIds() { + return descriptors.map((descriptor) => descriptor.id); }, }; } @@ -639,6 +864,9 @@ function setup( stackApiEndpoint?: { readonly url: string; readonly port: number }; stackServiceRoleJwt?: string; stackCapabilityStates?: Partial>; + stackResumeFails?: boolean; + bundledExitCode?: number; + bundledStderr?: string; httpClient?: Layer.Layer; }, ) { @@ -676,6 +904,7 @@ function setup( apiEndpoint: opts.stackApiEndpoint, serviceRoleJwt: opts.stackServiceRoleJwt, capabilityStates: opts.stackCapabilityStates, + resumeFails: opts.stackResumeFails, }); const catalog = opts.stackBackend === true @@ -685,6 +914,12 @@ function setup( })) : undefined; const requests: Array<{ method: string; url: string; body: unknown }> = []; + const bundledRuns: Array<{ + readonly version: string | undefined; + readonly argv: ReadonlyArray; + readonly env: Readonly> | undefined; + readonly runtime: unknown; + }> = []; const storageRoutes = opts.storageRoutes; const httpLayer = storageRoutes === undefined @@ -722,6 +957,18 @@ function setup( mockProcessControl().layer, opts.httpClient ?? httpLayer, dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), + Layer.succeed(BundledPostgresClient, { + run: (options) => + Effect.sync(() => { + bundledRuns.push({ + version: options.version, + argv: options.argv, + env: options.env, + runtime: options.runtime, + }); + return { exitCode: opts.bundledExitCode ?? 0, stderr: opts.bundledStderr ?? "" }; + }), + }), Layer.succeed(NetworkIdFlag, Option.none()), // Default: a TTY whose remote-reset confirmation is answered through mockOutput's // `promptConfirmResponses` (the clack path); `stdinIsTty: false` + `pipedStdin` model a @@ -767,6 +1014,7 @@ function setup( stackApi, catalogApplied: catalog?.applied ?? [], requests, + bundledRuns, }; } @@ -940,16 +1188,52 @@ describe("db reset", () => { ); it.live("resets the stack database without Compose volume recreate", () => { - const { layer, child, stackApi, catalogApplied, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], - isLocal: true, - stackBackend: true, - }); + const { layer, child, conn, stackApi, catalogApplied, out, bundledRuns } = setup( + tmp.current, + { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + }, + ); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(stackApi.resetCalls).toBe(1); - expect(catalogApplied).toEqual([{ kind: "live", analytics: undefined }]); + expect(stackApi.events.restarts).toBe(2); + expect(stackApi.events.restartConfigs[0]?.settings?.settings?.max_worker_processes).toBe(0); + expect(stackApi.events.restartConfigs[1]).toEqual({ + version: "17.6.1", + activation: "eager", + settings: {}, + }); + expect(stackApi.serviceIds).toEqual( + expect.arrayContaining([ + ServiceInstanceIdSchema.make("database-primary"), + ServiceInstanceIdSchema.make("database-unrelated"), + ]), + ); + expect(catalogApplied).toEqual([{ kind: "service", analytics: undefined }]); + expect(stackApi.events.stopped).toEqual([ServiceInstanceIdSchema.make("rest-primary")]); + expect(stackApi.events.started).toEqual([ServiceInstanceIdSchema.make("rest-primary")]); + expect(conn.execs).toEqual( + expect.arrayContaining([ + "DROP DATABASE IF EXISTS postgres WITH (FORCE)", + "CREATE DATABASE postgres WITH OWNER postgres", + "DROP DATABASE IF EXISTS _supabase WITH (FORCE)", + "CREATE DATABASE _supabase WITH OWNER postgres", + ]), + ); + expect(bundledRuns).toHaveLength(2); + expect(bundledRuns.map((run) => run.version)).toEqual(["17.6.1", "17.6.1"]); + expect(bundledRuns[0]?.argv.slice(0, 2)).toEqual(["bash", "-c"]); + expect(bundledRuns[0]?.env).toMatchObject({ + PGHOST: "127.0.0.1", + PGPORT: "54330", + PGDATABASE: "postgres", + TARGET_HOST: "127.0.0.1", + TARGET_PORT: "54329", + TARGET_DATABASE: "postgres", + }); expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( false, ); @@ -959,6 +1243,27 @@ describe("db reset", () => { }); }); + it.live("reports a bundled logical restore exit and resumes dependents", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + bundledExitCode: 7, + bundledStderr: "psql failed", + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to restore stack database baseline: exit 7: psql failed", + ); + } + expect(stackApi.events.started).toEqual([ServiceInstanceIdSchema.make("rest-primary")]); + }); + }); + it.live("skips optional analytics catalog when the running stack has it disabled", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -969,7 +1274,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(catalogApplied).toEqual([{ kind: "live", analytics: false }]); + expect(catalogApplied).toEqual([{ kind: "service", analytics: false }]); }); }); @@ -983,7 +1288,64 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(catalogApplied).toEqual([{ kind: "live", analytics: undefined }]); + expect(catalogApplied).toEqual([{ kind: "service", analytics: undefined }]); + }); + }); + + it.live("repeats logical stack reset while reconciling declared custom roles", () => { + const { layer, conn, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + "supabase/roles.sql": [ + "-- Role declarations are parsed before the database is restored.", + "CREATE ROLE app_reader;", + 'CREATE USER "AppWriter";', + "GRANT USAGE ON SCHEMA public TO app_reader;", + ].join("\n"), + }, + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + }); + return Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs.filter((sql) => sql === 'DROP ROLE IF EXISTS "app_reader"')).toHaveLength( + 2, + ); + expect(conn.execs.filter((sql) => sql === 'DROP ROLE IF EXISTS "AppWriter"')).toHaveLength( + 2, + ); + expect(stackApi.events.restarts).toBe(4); + }); + }); + + it.live("retains both reset and dependent resume failures", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + execFailsOn: "CREATE DATABASE postgres", + stackResumeFails: true, + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).toContain("failed to recreate primary databases"); + expect(causeText).toContain("rest-primary"); + } + expect(stackApi.events.stopped).toEqual([ServiceInstanceIdSchema.make("rest-primary")]); + expect(stackApi.events.started).toHaveLength(0); + expect(stackApi.events.restartConfigs).toHaveLength(2); + expect(stackApi.events.restartConfigs[0]?.settings?.settings?.max_worker_processes).toBe(0); + expect(stackApi.events.restartConfigs[1]).toEqual({ + version: "17.6.1", + activation: "eager", + settings: {}, + }); }); }); @@ -1052,7 +1414,7 @@ describe("db reset", () => { const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); - expect(stackApi.resetCalls).toBe(0); + expect(stackApi.events.restarts).toBe(0); }); }); @@ -1342,7 +1704,7 @@ describe("db reset", () => { const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("functions/.env"); - expect(stackApi.resetCalls).toBe(0); + expect(stackApi.events.restarts).toBe(0); }); }); diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index ab0007dcf4..1c163ff007 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -17,6 +17,7 @@ import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { stackApiLayer } from "../../../command-internal/stack-api.ts"; import { stackCatalogSetupLayer } from "../../../command-internal/stack-catalog-setup.ts"; +import { bundledPostgresClientLayer } from "../../../command-internal/bundled-postgres-client.ts"; /** * Runtime layer for `supabase db reset`: the Postgres connection, the db-config resolver, @@ -80,8 +81,9 @@ export const dbResetRuntimeLayer = Layer.mergeAll( dockerRunLayer, // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace). localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)), - // Exposed so `db reset --local` can open the project stack and call `resetDatabase`. + // Exposed so `db reset --local` can open the project stack and manage registered instances. stackApiLayer, + bundledPostgresClientLayer, stackCatalogSetupLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts index f66452527b..d115c156e0 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts @@ -206,7 +206,7 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( Effect.mapError( (error) => new DeclarativeApplyError({ - message: `database reset failed: ${error.message}`, + message: `database reset failed: ${error instanceof Error ? error.message : String(error)}`, suggestion: readErrorSuggestion(error), }), ), diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index bf68e2a5fb..92409647c3 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -41,7 +41,10 @@ import { GoProxy } from "../../../../../command-internal/go-proxy.service.ts"; import { CommandPlatformApi } from "../../../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../../../auth/command-platform-api-factory.service.ts"; import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; +import { DockerRun } from "../../../../../command-internal/docker-run.service.ts"; +import { BundledPostgresClient } from "../../../../../command-internal/bundled-postgres-client.ts"; import { stackBackendLayer } from "../../../../../command-internal/stack-backend.ts"; +import { stackApiLayer } from "../../../../../command-internal/stack-api.ts"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; import { type DbSession, @@ -211,10 +214,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const networkIdFlag = Layer.succeed(NetworkIdFlag, opts.networkId ?? Option.none()); const debugFlag = Layer.succeed(DebugFlag, false); - const dockerRun = dockerRunLayer.pipe( + const dockerRun: Layer.Layer = dockerRunLayer.pipe( Layer.provide(child.layer), Layer.provide(processControl.layer), ); + const backendLayer = stackBackendLayer(opts.stackBackend === true ? "stack" : "legacy"); const layer = Layer.mergeAll( out.layer, telemetry.layer, @@ -248,7 +252,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { processControl.layer, alwaysReadyHttpClientLayer, dockerRun, - ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), + Layer.succeed(BundledPostgresClient, { + run: () => Effect.succeed({ exitCode: 0, stderr: "" }), + }), + stackApiLayer.pipe(Layer.provide(BunServices.layer)), + backendLayer, ); return { layer, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 9623bfa722..c236329d19 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -3,8 +3,10 @@ Diffs local migrations state against declarative schema files and writes the delta as a new timestamped migration. -When `[experimental].stack` is on, shadows are `EphemeralPostgres` clusters under -`$SUPABASE_HOME/managed/ephemeral-postgres//` (`~/.supabase/managed/…` by default). +When `[experimental].stack` is on, shadows are registered database services in the project stack. +Each has a unique instance ID, managed SQL endpoint, and owned data under +`$SUPABASE_HOME/managed/stacks//data/instances//` +(`~/.supabase/managed/…` by default). Pg-delta runs in-process and uses two scoped shadow databases. Coverage gaps warn; `--strict-coverage` makes @@ -18,25 +20,24 @@ disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | -| `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow | -| `/supabase/roles.sql` | SQL | hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included, and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | -| `/supabase/schemas/.pgdelta-export.json` | JSON | export metadata, when present | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migrations/declarative shadows); every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| Path | Format | When | +| --------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | +| `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | +| `/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow | +| `/supabase/roles.sql` | SQL | hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included, and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | +| `/supabase/schemas/.pgdelta-export.json` | JSON | export metadata, when present | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migrations/declarative shadows); every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | -| `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — migrations/declarative shadows (`--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | +| Path | Format | When | +| ------------------------------------------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | +| `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — migrations/declarative shadows (`--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | A unique operation-owned export file, published to the cache on success and removed by its own failure cleanup. Age alone never authorizes removal of an in-flight export. | ## Subprocesses / Containers diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 489cedfc7b..0da46b9fdd 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -303,7 +303,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f Effect.mapError( (error) => new DeclarativeApplyError({ - message: `database reset failed: ${error.message}`, + message: `database reset failed: ${error instanceof Error ? error.message : String(error)}`, suggestion: readErrorSuggestion(error), }), ), @@ -651,7 +651,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // real typed failure and reuse that one value for message, suggestion, and bundle. const rawResetFailure = resetFailure.success.error; const resetError = new DeclarativeApplyError({ - message: rawResetFailure.message, + message: + rawResetFailure instanceof Error ? rawResetFailure.message : String(rawResetFailure), suggestion: readErrorSuggestion(rawResetFailure), }); yield* output.raw( diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 93ae6241b6..38baecf32c 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -39,15 +39,25 @@ import { import { CommandPlatformApi } from "../../../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../../../auth/command-platform-api-factory.service.ts"; import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; +import { DockerRun } from "../../../../../command-internal/docker-run.service.ts"; +import { BundledPostgresClient } from "../../../../../command-internal/bundled-postgres-client.ts"; import { stackBackendLayer } from "../../../../../command-internal/stack-backend.ts"; -import { StackApi } from "../../../../../command-internal/stack-api.ts"; -import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; +import { StackApi, stackApiLayer } from "../../../../../command-internal/stack-api.ts"; +import { + CAPABILITY_NAMES, + StackIdSchema, + ServiceInstanceIdSchema, + type EffectStack, + type EffectServiceCollection, + type StackStatus, +} from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; import { type DbBatchStatement, DbConnection, type PgConnInput, } from "../../../../../command-internal/db-connection.service.ts"; +import { DbExecError } from "../../../../../command-internal/db-connection.errors.ts"; import { PgDeltaEngine, PgDeltaEngineError, @@ -91,22 +101,30 @@ const unusedSyncFn = () => unusedSync; const STACK_APPLY_PORT = 54329; function syncStackApi(workdir: string, port: number) { + const initialStatus: StackStatus = { + id: SYNC_STACK_ID, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), + name, + activation: name === "database" ? "eager" : "lazy", + state: name === "database" ? "ready" : "dormant", + })), + artifacts: [], + instances: [], + }; const stack: EffectStack = { id: SYNC_STACK_ID, - status: Effect.succeed({ - id: SYNC_STACK_ID, - lifecycle: "running", - desiredLifecycle: "running", - runtime: { kind: "native" }, - endpoints: {}, - versions: {}, - capabilities: CAPABILITY_NAMES.map((name) => ({ - name, - activation: name === "database" ? "eager" : "lazy", - state: name === "database" ? "ready" : "dormant", - })), - artifacts: [], - }), + services: { + create: () => Effect.die("service creation is unused by declarative sync"), + get: () => Effect.die("service lookup is unused by declarative sync"), + list: Effect.succeed([]), + } satisfies EffectServiceCollection, + status: Effect.succeed(initialStatus), credentials: Effect.succeed({ database: { url: Redacted.make(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), @@ -120,10 +138,12 @@ function syncStackApi(workdir: string, port: number) { }, }), prepare: unusedSyncFn, + followStatus: Stream.empty, start: unusedSyncFn, - stop: unusedSync, - destroy: unusedSync, - resetDatabase: unusedSync, + sleep: unusedSyncFn, + restart: unusedSyncFn, + stop: () => unusedSync, + destroy: () => unusedSync, logs: unusedSyncFn, followLogs: () => Stream.empty, }; @@ -191,7 +211,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { return Effect.succeed({ exec: (sql: string) => opts.applyFails === true && sql.startsWith("ALTER") - ? Effect.fail({ _tag: "DbExecError", message: "boom" } as never) + ? Effect.fail(new DbExecError({ message: "boom" })) : Effect.sync(() => { if (cfg.port !== SHADOW_PORT) dbExec.push(sql); }), @@ -202,11 +222,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? sql.findIndex((statement) => statement.startsWith("ALTER")) : -1; return failureIndex >= 0 - ? Effect.fail({ - _tag: "DbExecError", - message: "boom", - statementIndex: failureIndex, - } as never) + ? Effect.fail(new DbExecError({ message: "boom", statementIndex: failureIndex })) : Effect.sync(() => { if (cfg.port !== SHADOW_PORT) { dbBatches.push(sql); @@ -252,7 +268,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ); const debugFlag = Layer.succeed(DebugFlag, false); - const dockerRun = dockerRunLayer.pipe( + const dockerRun: Layer.Layer = dockerRunLayer.pipe( Layer.provide(child.layer), Layer.provide(processControl.layer), ); @@ -301,6 +317,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }), ); + const backendLayer = Layer.merge( + stackBackendLayer(opts.stackBackend === true ? "stack" : "legacy"), + syncStackApi(workdir, STACK_APPLY_PORT), + ); const layer = Layer.mergeAll( out.layer, telemetry.layer, @@ -333,9 +353,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { processControl.layer, alwaysReadyHttpClientLayer, dockerRun, - ...(opts.stackBackend === true - ? [stackBackendLayer("stack"), syncStackApi(workdir, STACK_APPLY_PORT)] - : []), + Layer.succeed(BundledPostgresClient, { + run: () => Effect.succeed({ exitCode: 0, stderr: "" }), + }), + stackApiLayer.pipe(Layer.provide(BunServices.layer)), + backendLayer, ); return { layer, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index f88c638d98..8a1d2a14b9 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -50,8 +50,10 @@ import { } from "./pgdelta-next-shadow.service.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; import { stackAcquireShadowDatabase, + stackReleaseShadowDatabase, stackMigrateShadow, } from "../../../command-internal/stack-shadow.ts"; import { stackCatalogSetupLayer } from "../../../command-internal/stack-catalog-setup.ts"; @@ -95,25 +97,30 @@ interface NativeShadowBase { } interface ProvisionedMigrationsShadow extends PgDeltaNextMigrationsShadow { - readonly snapshotKey: string | undefined; + readonly snapshotLineageId: string | undefined; } interface ProvisionedDeclarativeShadow { readonly declarativeUrl: string; readonly restoredFromPgDataSnapshot: boolean; - readonly snapshotKey: string | undefined; + readonly snapshotLineageId: string | undefined; } /** - * Bypass pg-delta's same-database guard when both shadows share one snapshot key and the - * declarative side was restored from that tar — a cold-exported migrations handle is still - * that tar's lineage. + * Bypass pg-delta's same-database guard only when the declarative side restored a validated + * service snapshot and both handles report the same durable snapshot lineage. Cache keys are + * lookup metadata and never establish database identity. */ export function allowSameDatabaseIdentityForPlanShadows(opts: { readonly declarativeRestoredFromPgDataSnapshot: boolean; - readonly sameSnapshotKey: boolean; + readonly migrationsSnapshotLineageId: string | undefined; + readonly declarativeSnapshotLineageId: string | undefined; }): boolean { - return opts.declarativeRestoredFromPgDataSnapshot && opts.sameSnapshotKey; + return ( + opts.declarativeRestoredFromPgDataSnapshot && + opts.migrationsSnapshotLineageId !== undefined && + opts.migrationsSnapshotLineageId === opts.declarativeSnapshotLineageId + ); } const setupRunInput = (input: NativeShadowInput, handle: ShadowAcquiredHandle) => ({ @@ -164,7 +171,11 @@ export const pgDeltaNextShadowLayer = Layer.effect( Layer.succeed(CommandSettings, cliSettings), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - return Layer.mergeAll(deps, stackCatalogSetupLayer.pipe(Layer.provide(deps))); + return Layer.mergeAll( + deps, + stackCatalogSetupLayer.pipe(Layer.provide(deps)), + stackApiLayer.pipe(Layer.provide(deps)), + ); }; const runtime = runtimeWith(output); @@ -198,7 +209,7 @@ export const pgDeltaNextShadowLayer = Layer.effect( ); const image = (yield* currentStackBackend).kind === "stack" - ? "stack-ephemeral" + ? "stack" : yield* localInputs.resolvePostgresImage; // One JWKS memo shared by every input built from this base: `provisionPlan`'s two // shadows must hash identical JWKS bytes or their snapshot keys can never match. @@ -268,7 +279,7 @@ export const pgDeltaNextShadowLayer = Layer.effect( yield* migrateNextShadowDatabase(input.spawner, setup, seamHandle); return { migrationsUrl: toPostgresURL(setup.connConfig), - snapshotKey: seamHandle.snapshotKey, + snapshotLineageId: undefined, } satisfies ProvisionedMigrationsShadow; }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); @@ -285,36 +296,40 @@ export const pgDeltaNextShadowLayer = Layer.effect( return { declarativeUrl: toPostgresURL(setup.connConfig), restoredFromPgDataSnapshot: handle.baselinePresent, - snapshotKey: handle.snapshotKey, + snapshotLineageId: undefined, } satisfies ProvisionedDeclarativeShadow; }).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError)); const stackAcquire = (input: NativeShadowInput, opts: ShadowCacheOpts) => stackAcquireShadowDatabase(input.base, { ...(opts.bypassCache === true ? { bypassCache: true } : {}), - port: input.base.shadowPort, ...(opts.webhooks === undefined ? {} : { webhooks: opts.webhooks }), }); const stackProvisionMigrations = (input: NativeShadowInput, opts: ShadowCacheOpts) => - Effect.gen(function* () { - const handle = yield* stackAcquire(input, opts); - yield* stackMigrateShadow(handle, input.base); - return { - migrationsUrl: handle.url, - snapshotKey: handle.snapshotKey, - } satisfies ProvisionedMigrationsShadow; - }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); + Effect.acquireUseRelease( + stackAcquire(input, opts), + (handle) => + stackMigrateShadow(handle, input.base).pipe( + Effect.as({ + migrationsUrl: handle.url, + snapshotLineageId: handle.snapshotDescriptor?.lineageId, + } satisfies ProvisionedMigrationsShadow), + ), + (handle) => stackReleaseShadowDatabase(handle).pipe(Effect.mapError(nextShadowError)), + ).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); const stackProvisionDeclarative = (input: NativeShadowInput, opts: ShadowCacheOpts) => - Effect.gen(function* () { - const handle = yield* stackAcquire(input, opts); - return { - declarativeUrl: handle.url, - restoredFromPgDataSnapshot: handle.baselinePresent, - snapshotKey: handle.snapshotKey, - } satisfies ProvisionedDeclarativeShadow; - }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); + Effect.acquireUseRelease( + stackAcquire(input, opts), + (handle) => + Effect.succeed({ + declarativeUrl: handle.url, + restoredFromPgDataSnapshot: handle.baselinePresent, + snapshotLineageId: handle.snapshotDescriptor?.lineageId, + } satisfies ProvisionedDeclarativeShadow), + (handle) => stackReleaseShadowDatabase(handle).pipe(Effect.mapError(nextShadowError)), + ).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); const cacheOpts = ( opts: PgDeltaNextShadowInput, @@ -327,22 +342,24 @@ export const pgDeltaNextShadowLayer = Layer.effect( return PgDeltaNextShadow.of({ provisionMigrations: (opts) => Effect.gen(function* () { - const port = yield* nextPort(); + const backend = yield* currentStackBackend; + const port = backend.kind === "stack" ? opts.toml.shadowPort : yield* nextPort(); const built = yield* buildNativeBase(opts); const input = buildNativeInput(opts, built, port); - const backend = yield* currentStackBackend; return backend.kind === "stack" ? yield* stackProvisionMigrations(input, cacheOpts(opts, "config")) : yield* provisionMigrations(input, cacheOpts(opts, "config")); }).pipe(Effect.mapError(nextShadowError)), provisionPlan: (opts) => Effect.gen(function* () { - const migrationsPort = yield* nextPort(); - const declarativePort = yield* nextPort(migrationsPort); + const backend = yield* currentStackBackend; + const migrationsPort = + backend.kind === "stack" ? opts.toml.shadowPort : yield* nextPort(); + const declarativePort = + backend.kind === "stack" ? opts.toml.shadowPort : yield* nextPort(migrationsPort); const built = yield* buildNativeBase(opts); const migrationsInput = buildNativeInput(opts, built, migrationsPort); const declarativeInput = buildNativeInput(opts, built, declarativePort); - const backend = yield* currentStackBackend; if (backend.kind === "stack") { const migrations = yield* stackProvisionMigrations( migrationsInput, @@ -357,9 +374,8 @@ export const pgDeltaNextShadowLayer = Layer.effect( declarativeUrl: declarative.declarativeUrl, allowSameDatabaseIdentity: allowSameDatabaseIdentityForPlanShadows({ declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot, - sameSnapshotKey: - migrations.snapshotKey !== undefined && - migrations.snapshotKey === declarative.snapshotKey, + migrationsSnapshotLineageId: migrations.snapshotLineageId, + declarativeSnapshotLineageId: declarative.snapshotLineageId, }), } satisfies PgDeltaNextPlanShadows; } @@ -401,9 +417,8 @@ export const pgDeltaNextShadowLayer = Layer.effect( declarativeUrl: declarative.declarativeUrl, allowSameDatabaseIdentity: allowSameDatabaseIdentityForPlanShadows({ declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot, - sameSnapshotKey: - migrations.snapshotKey !== undefined && - migrations.snapshotKey === declarative.snapshotKey, + migrationsSnapshotLineageId: migrations.snapshotLineageId, + declarativeSnapshotLineageId: declarative.snapshotLineageId, }), } satisfies PgDeltaNextPlanShadows; }).pipe(Effect.mapError(nextShadowError)), diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts index df59e236f6..b999de257b 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts @@ -5,43 +5,50 @@ import { allowSameDatabaseIdentityForPlanShadows } from "./pgdelta-next-shadow.l describe("allowSameDatabaseIdentityForPlanShadows", () => { it.each([ { - scenario: "the declarative shadow restored the tar the migrations shadow just exported", + scenario: "the declarative shadow restored the migrations snapshot lineage", restored: true, - sameKey: true, + migrationsLineage: "lineage-1", + declarativeLineage: "lineage-1", expected: true, }, { - scenario: "both shadows warm-restored the same key", + scenario: "both shadows warm-restored the same snapshot lineage", restored: true, - sameKey: true, + migrationsLineage: "lineage-2", + declarativeLineage: "lineage-2", expected: true, }, { scenario: "the declarative shadow was cold-provisioned", restored: false, - sameKey: true, + migrationsLineage: "lineage-3", + declarativeLineage: "lineage-3", expected: false, }, { - // Also covers an absent key on either side (uncached/bypassed/uncachable acquisitions), - // which the caller folds into `sameSnapshotKey: false`. - scenario: "the shadows carry different or absent snapshot keys", + scenario: "the shadows carry different or absent snapshot lineage", restored: true, - sameKey: false, + migrationsLineage: "lineage-4", + declarativeLineage: "lineage-5", expected: false, }, { scenario: "neither shadow came from a snapshot", restored: false, - sameKey: false, + migrationsLineage: undefined, + declarativeLineage: undefined, expected: false, }, - ])("returns $expected when $scenario", ({ restored, sameKey, expected }) => { - expect( - allowSameDatabaseIdentityForPlanShadows({ - declarativeRestoredFromPgDataSnapshot: restored, - sameSnapshotKey: sameKey, - }), - ).toBe(expected); - }); + ])( + "returns $expected when $scenario", + ({ restored, migrationsLineage, declarativeLineage, expected }) => { + expect( + allowSameDatabaseIdentityForPlanShadows({ + declarativeRestoredFromPgDataSnapshot: restored, + migrationsSnapshotLineageId: migrationsLineage, + declarativeSnapshotLineageId: declarativeLineage, + }), + ).toBe(expected); + }, + ); }); diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index c9424cdd71..304453b1ae 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -40,7 +40,15 @@ import { noopStackCatalogSetupLayer, recordingStackCatalogSetup, } from "../../../command-internal/stack-catalog-setup.ts"; -import { CAPABILITY_NAMES, StackIdSchema, type EffectStack } from "@supabase/stack/effect"; +import { + CAPABILITY_NAMES, + StackIdSchema, + ServiceInstanceIdSchema, + type EffectCreateServiceOptions, + type EffectServiceCollection, + type EffectStack, + type ServiceKind, +} from "@supabase/stack/effect"; const DEFAULT_FLAGS: DbStartFlags = { fromBackup: Option.none() }; const PG_NET_CREATE_FINGERPRINT = "create extension if not exists pg_net schema extensions"; @@ -1550,6 +1558,12 @@ describe("db start stack backend", () => { const startConfigs: Array = []; const stack: EffectStack = { id: STACK_ID, + services: { + create: (_options: EffectCreateServiceOptions) => + Effect.die("unused"), + get: () => Effect.die("unused"), + list: Effect.succeed([]), + } satisfies EffectServiceCollection, status: Effect.succeed({ id: STACK_ID, lifecycle: opts.databaseReady === true ? "running" : "stopped", @@ -1558,11 +1572,13 @@ describe("db start stack backend", () => { endpoints: {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: name === "database" ? "eager" : "lazy", state: name === "database" && opts.databaseReady === true ? "ready" : "stopped", })), artifacts: [], + instances: [], }), credentials: Effect.succeed({ database: { @@ -1577,9 +1593,9 @@ describe("db start stack backend", () => { }, }), prepare: unusedFn, - start: (startOpts) => + followStatus: Stream.empty, + start: () => Effect.sync(() => { - startConfigs.push(startOpts?.config); return { id: STACK_ID, lifecycle: "running" as const, @@ -1588,21 +1604,27 @@ describe("db start stack backend", () => { endpoints: {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: name === "database" ? ("eager" as const) : ("lazy" as const), state: name === "database" ? ("ready" as const) : ("dormant" as const), })), artifacts: [], + instances: [], }; }), - stop: unused, - destroy: unused, - resetDatabase: unused, + sleep: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + restart: () => Effect.die("unused"), + destroy: () => Effect.die("unused"), logs: unusedFn, followLogs: () => Stream.empty, }; const api = Layer.succeed(StackApi, { - createStack: () => Effect.succeed(stack), + createStack: (options) => { + startConfigs.push(options.initialConfig); + return Effect.succeed(stack); + }, findStack: () => Effect.succeed( opts.existing === true @@ -1687,7 +1709,7 @@ describe("db start stack backend", () => { yield* dbStart(DEFAULT_FLAGS).pipe( Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), ); - expect(stack.startConfigs).toEqual([undefined]); + expect(stack.startConfigs).toEqual([]); expect(catalogApplied).toEqual([]); expect(out.stderrText).not.toContain("Applying migration"); }); @@ -1705,12 +1727,7 @@ describe("db start stack backend", () => { yield* dbStart(DEFAULT_FLAGS).pipe( Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"), stack.api)), ); - expect(stack.startConfigs).toHaveLength(1); - expect(stack.startConfigs[0]).toMatchObject({ - capabilities: { - rest: { enabled: false }, - }, - }); + expect(stack.startConfigs).toEqual([]); expect(catalogApplied).toEqual([{ kind: "live", analytics: false }]); expect(out.stderrText).toContain("Applying migration 20240101000000_dogfood.sql"); }); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 09972c87b4..eea67d4587 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -126,7 +126,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( .openStack(StackIdSchema.make(target.id)) .pipe(Effect.mapError(destroyError)); const destroying = yield* output.task(`Destroying stack ${target.id}...`); - yield* stack.destroy.pipe( + yield* stack.destroy().pipe( Effect.tapError((error) => destroying.fail(error.message)), Effect.tap(() => destroying.clear()), Effect.mapError(destroyError), diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 4ba59a508c..1353b0ccea 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -60,15 +60,23 @@ function setup(options: { status: Effect.die("unused"), credentials: Effect.die("unused"), prepare: () => Effect.die("unused"), + services: { + create: () => Effect.die("services.create not used"), + get: () => Effect.die("services.get not used"), + list: Effect.succeed([]), + }, start: () => Effect.die("unused"), - stop: Effect.die("unused"), - destroy: options.destroyContainerFailure - ? Effect.fail(new ContainerEngineError({ message: "container engine unavailable" })) - : options.destroyFailure - ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) - : Effect.sync(() => void state.destroyed++), - resetDatabase: Effect.die("unused"), + sleep: () => Effect.die("sleep not used"), + stop: () => Effect.die("unused"), + restart: () => Effect.die("restart not used"), + destroy: () => + options.destroyContainerFailure + ? Effect.fail(new ContainerEngineError({ message: "container engine unavailable" })) + : options.destroyFailure + ? Effect.fail(new StackDestructionError({ message: "destroy failed" })) + : Effect.sync(() => void state.destroyed++), logs: () => Effect.die("unused"), + followStatus: Stream.empty, followLogs: () => Stream.empty, }; return { diff --git a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts index 3ae03db451..171df54bbe 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -131,6 +131,7 @@ describe("stack list", () => { projectRoot: project, name: "healthy", runtime: { kind: "native" }, + initialConfig: {}, }); const registry = path.join(home, "managed", "stacks"); const corrupt = "b".repeat(64); @@ -149,7 +150,7 @@ describe("stack list", () => { expect(entries.map(({ id }) => id)).toEqual([healthy.id, corrupt, unsupported]); expect(text.stdoutText).toContain("NAME"); expect(text.stdoutText).toContain("healthy"); - expect(text.stdoutText).toContain("unconfigured"); + expect(text.stdoutText).toContain("stopped"); expect(text.stdoutText).toContain(healthy.id.slice(0, 8)); expect(text.stdoutText).not.toContain(healthy.id); expect(text.stdoutText).toContain("Unreadable stacks:"); @@ -165,7 +166,7 @@ describe("stack list", () => { readable: true, name: "healthy", runtime: { kind: "native" }, - desired_lifecycle: "unconfigured", + desired_lifecycle: "stopped", }), { id: corrupt, diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index bbc408bfce..f81ea7450e 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -78,7 +78,7 @@ emits no follow events, and a stopped stack exits successfully. A missing named status `1`. The legacy `-o`/`--output` flag is rejected; use `--output-format`. -`--service` accepts one capability name and excludes supervisor and gateway entries, including +`--service` accepts one registered service instance ID and excludes supervisor and gateway entries, including their startup diagnostics. Omit it to include all sources. Retained logs are bounded to the newest 1000 entries or 1 MiB, whichever is reached first; `--tail` further limits the returned entries. diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 3a5333ce24..21626be5b0 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -1,6 +1,5 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { CAPABILITY_NAMES } from "@supabase/stack/effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackLogs } from "./logs.handler.ts"; @@ -18,7 +17,7 @@ const config = { Flag.withDescription("Read logs from an existing stack by id."), Flag.optional, ), - service: Flag.choice("service", CAPABILITY_NAMES).pipe( + service: Flag.string("service").pipe( Flag.withDescription( "Limit logs to one stack service; omit this flag to include supervisor and gateway diagnostics.", ), @@ -48,7 +47,7 @@ export const stackLogsCommand = Command.make("logs", config).pipe( Command.withShortDescription("Read managed local stack logs"), Command.withExamples([ { - command: "supabase stack logs --service database --tail 50", + command: "supabase stack logs --service --tail 50", description: "Print the latest database logs", }, { diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index eb17e81c0b..c55c4dfbd2 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -5,6 +5,7 @@ import type { StackLogEntry, StackLogsError as ApiStackLogsError, } from "@supabase/stack/effect"; +import { isServiceInstanceId } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; @@ -62,7 +63,7 @@ const logsError = ( reason: "invalid-config" as const, })), Match.tag("InvalidProjectRootError", () => ({ reason: "invalid-config" as const })), - Match.exhaustive, + Match.orElse(() => ({ reason: "unknown" as const })), ); return new StackCommandLogsError({ ...classification, @@ -72,12 +73,14 @@ const logsError = ( }; const renderEntry = (entry: StackLogEntry) => - `${entry.timestamp} ${entry.source}/${entry.stream}: ${stripControlSequences(entry.message)}\n`; + `${entry.timestamp} ${entry.instanceName ?? entry.instanceId ?? entry.source}/${entry.stream}: ${stripControlSequences(entry.message)}\n`; const eventForEntry = (entry: StackLogEntry, source: "history" | "live") => ({ type: "log-entry" as const, timestamp: entry.timestamp, service: entry.source, + ...(entry.instanceId === undefined ? {} : { instance_id: entry.instanceId }), + ...(entry.instanceName === undefined ? {} : { instance_name: entry.instanceName }), stream: entry.stream, line: entry.message, source, @@ -129,8 +132,19 @@ export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags: return; } const stack = yield* stackApi.openStack(targetOption.value.id).pipe(Effect.mapError(logsError)); + const requestedServiceId = Option.getOrUndefined(flags.service); + const serviceId = + requestedServiceId === undefined + ? undefined + : isServiceInstanceId(requestedServiceId) + ? requestedServiceId + : yield* new StackCommandLogsError({ + reason: "flags", + message: `Invalid service instance id: ${requestedServiceId}`, + suggestion: "Pass a registered service instance id from `supabase stack status`.", + }); const query = { - ...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}), + ...(serviceId === undefined ? {} : { services: [serviceId] }), tail: flags.tail, }; const batch = yield* stack.logs(query).pipe(Effect.mapError(logsError)); @@ -151,7 +165,7 @@ export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags: yield* emitEntries("history", batch.entries); if (!batch.running) return; const followQuery = { - ...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}), + ...(serviceId === undefined ? {} : { services: [serviceId] }), cursor: batch.cursor, }; yield* stack.followLogs(followQuery).pipe( diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index a5a3b6d635..bef9331054 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -16,6 +16,7 @@ import { CliError, CliOutput, Command } from "effect/unstable/cli"; import { InvalidProjectRootError, StackIdSchema, + ServiceInstanceIdSchema, StackNotFoundError, StackOwnershipConflictError, StackUpgradeRequiredError, @@ -46,6 +47,7 @@ import { textCliOutputFormatter } from "../../../../shared/output/text-formatter import { streamJsonOutputLayer } from "../../../../shared/output/output.layer.ts"; const id = StackIdSchema.make("a".repeat(64)); +const serviceId = ServiceInstanceIdSchema.make("b".repeat(64)); const streamResultSchema = Schema.Struct({ type: Schema.Literal("result"), data: Schema.Unknown, @@ -84,12 +86,13 @@ const status: StackStatus = { versions: {}, capabilities: [], artifacts: [], + instances: [], }; const flags = (overrides: Partial[0]> = {}) => ({ stack: Option.none(), stackId: Option.none(), - service: Option.none<"database" | "functions">(), + service: Option.none(), tail: 100, follow: false, ...overrides, @@ -123,15 +126,22 @@ function setup(opts: { credentials: Effect.die("unused"), prepare: () => Effect.die("unused"), start: () => Effect.die("must not start"), - stop: Effect.sync(() => void calls.stopCalls++), - destroy: Effect.sync(() => void calls.destroyCalls++), - resetDatabase: Effect.die("unused"), + services: { + create: () => Effect.die("services.create not used"), + get: () => Effect.die("services.get not used"), + list: Effect.succeed([]), + }, + sleep: () => Effect.die("sleep not used"), + stop: () => Effect.succeed(status), + restart: () => Effect.die("restart not used"), + destroy: () => Effect.sync(() => void calls.destroyCalls++), logs: (query?: unknown) => { calls.queries.push(query); return ( opts.logs?.(query) ?? Effect.succeed({ entries, cursor: { opaque: "2" }, running: false }) ); }, + followStatus: Stream.empty, followLogs: (query?: unknown) => { calls.queries.push(query); return opts.followLogs?.(query) ?? Stream.fromIterable(entries); @@ -260,8 +270,8 @@ describe("experimental stack logs", () => { return setup({}).pipe( Effect.flatMap((setupResult) => Effect.gen(function* () { - yield* stackLogs(flags({ service: Option.some("database"), tail: 2 })); - expect(setupResult.calls.queries).toEqual([{ capabilities: ["database"], tail: 2 }]); + yield* stackLogs(flags({ service: Option.some(serviceId), tail: 2 })); + expect(setupResult.calls.queries).toEqual([{ services: [serviceId], tail: 2 }]); expect(setupResult.calls.opened).toEqual([id]); expect(setupResult.out.stdoutText).toContain("database ready"); expect(setupResult.out.stdoutText).toContain("function failed"); diff --git a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md index f7a3484ce6..d50f825607 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md @@ -52,9 +52,9 @@ Unknown capability names are rejected by the flag parser; disabled capabilities The legacy `-o/--output` flag is rejected; use `--output-format`. -Text output lists the stack ID and each prepared capability, version, and outcome (`cached`, -`downloaded`, or `pulled`). JSON output returns `{ id, capabilities, message: "" }`. Stream-JSON -output returns `{ type: "result", data: { id, capabilities, message: "" }, timestamp }`. Errors +Text output lists the stack ID and each prepared service instance with its artifact count. JSON +output returns `{ id, instances, message: "" }`. Stream-JSON output returns +`{ type: "result", data: { id, instances, message: "" }, timestamp }`. Errors use typed actionability and retain package diagnostics. ## Exit codes diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts index 46d1a63c72..4dec5a3dc7 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -1,5 +1,9 @@ import { Cause, Effect, Exit, Option } from "effect"; -import type { PrepareStackResult, StackRuntimePreference } from "@supabase/stack/effect"; +import type { + PrepareStackResult, + ServiceInstanceId, + StackRuntimePreference, +} from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; @@ -17,14 +21,14 @@ import { StackCommandPrepareError, stackPrepareError } from "./prepare.errors.ts const payload = (id: string, result: PrepareStackResult) => ({ id, - capabilities: result.capabilities, + instances: result.instances, }); const render = (id: string, result: PrepareStackResult) => { const lines = [`Stack ${id} prepared.`]; - if (result.capabilities.length > 0) { - lines.push("Capabilities:"); - for (const capability of result.capabilities) - lines.push(` ${capability.capability} ${capability.version} (${capability.outcome})`); + if (result.instances.length > 0) { + lines.push("Instances:"); + for (const instance of result.instances) + lines.push(` ${instance.id} ${instance.service} (${instance.artifacts.length} artifacts)`); } return `${lines.join("\n")}\n`; }; @@ -71,22 +75,35 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( ), ); const runtime: StackRuntimePreference | undefined = target.runtime; - const stack = - target.id !== undefined - ? yield* api.openStack(target.id).pipe(Effect.mapError(stackPrepareError)) - : yield* api - .createStack({ + const existing = + target.id === undefined + ? yield* api + .findStack({ projectRoot: target.projectRoot, ...(target.name === undefined ? {} : { name: target.name }), - ...(runtime === undefined ? {} : { runtime }), }) - .pipe(Effect.mapError(stackPrepareError)); + .pipe(Effect.mapError(stackPrepareError)) + : Option.some({ id: target.id }); + const stack = Option.isSome(existing) + ? yield* api.openStack(existing.value.id).pipe(Effect.mapError(stackPrepareError)) + : yield* api + .createStack({ + projectRoot: target.projectRoot, + ...(target.name === undefined ? {} : { name: target.name }), + ...(runtime === undefined ? {} : { runtime }), + initialConfig: config, + }) + .pipe(Effect.mapError(stackPrepareError)); + const descriptors = yield* stack.services.list.pipe(Effect.mapError(stackPrepareError)); + const selectedIds: ReadonlyArray = + flags.capability.length === 0 + ? descriptors.filter((descriptor) => descriptor.enabled).map((descriptor) => descriptor.id) + : descriptors + .filter((descriptor) => flags.capability.includes(descriptor.service)) + .map((descriptor) => descriptor.id); const task = yield* output.task("Preparing local Supabase stack..."); const result = yield* stack - .prepare({ - config, - ...(flags.capability.length === 0 ? {} : { capabilities: flags.capability }), - }) + .prepare(selectedIds.length === 0 ? {} : { services: selectedIds }) .pipe( Effect.onExit((exit) => Exit.isSuccess(exit) diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index c8dfcd94ac..4aa66e4415 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -21,6 +21,7 @@ import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer. import { InvalidStackConfigError, StackIdSchema, + ServiceInstanceIdSchema, StackPreparationError, StackRuntimeMismatchError, type EffectStack, @@ -62,18 +63,24 @@ const makeStack = ( status: Effect.die("unused"), credentials: Effect.die("unused"), prepare, + services: { + create: () => Effect.die("services.create not used in prepare test"), + get: () => Effect.die("services.get not used in prepare test"), + list: Effect.succeed([]), + }, start: () => Effect.sync(() => { calls.start += 1; }).pipe(Effect.flatMap(() => Effect.die("start should not be called"))), - stop: Effect.sync(() => { - calls.stop += 1; - }), - destroy: Effect.sync(() => { - calls.destroy += 1; - }), - resetDatabase: Effect.die("unused"), + sleep: () => Effect.die("sleep not used in prepare test"), + stop: () => Effect.die("stop not used in prepare test"), + restart: () => Effect.die("restart not used in prepare test"), + destroy: () => + Effect.sync(() => { + calls.destroy += 1; + }), logs: () => Effect.die("unused"), + followStatus: Stream.empty, followLogs: () => Stream.empty, }); @@ -111,7 +118,7 @@ const handlerLayer = (opts: { }), opts.api ?? Layer.succeed(StackApi, { - findStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), discoverStacks: () => Effect.die("unused"), inspectStack: () => Effect.die("unused"), openStack: () => Effect.die("unused"), @@ -205,9 +212,17 @@ describe("stack prepare", () => { Effect.sync(() => { options = value; return { - capabilities: [ - { capability: "database", version: "1", outcome: "cached" as const }, - { capability: "rest", version: "1", outcome: "cached" as const }, + instances: [ + { + id: ServiceInstanceIdSchema.make("1".repeat(64)), + service: "database" as const, + artifacts: [{ identity: "1", outcome: "cached" as const }], + }, + { + id: ServiceInstanceIdSchema.make("2".repeat(64)), + service: "rest" as const, + artifacts: [{ identity: "1", outcome: "cached" as const }], + }, ], }; }), @@ -217,10 +232,9 @@ describe("stack prepare", () => { Effect.provide(fixture.layer), Effect.tap(() => Effect.sync(() => { - expect(options).toMatchObject({ config: expect.anything() }); - expect(options).not.toHaveProperty("capabilities"); + expect(options).not.toHaveProperty("config"); expect(fixture.output.stdoutText).toContain(`Stack ${id} prepared.`); - expect(fixture.output.stdoutText).toContain("database 1 (cached)"); + expect(fixture.output.stdoutText).toContain("database (1 artifacts)"); expect(fixture.telemetry.flushed).toBe(true); }), ), @@ -256,7 +270,7 @@ describe("stack prepare", () => { stack, telemetry, api: Layer.succeed(StackApi, { - findStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), discoverStacks: () => Effect.die("unused"), inspectStack: () => Effect.die("unused"), openStack: () => Effect.die("unused"), @@ -276,9 +290,7 @@ describe("stack prepare", () => { expect(error[ErrorActionabilityId]).toEqual(actionability.invalidConfig); expect(error.message).toContain("Capability studio is disabled"); expect(error.suggestion).toBeUndefined(); - expect(receivedConfig).toMatchObject({ - capabilities: { studio: { enabled: false } }, - }); + expect(receivedConfig).toBeUndefined(); expect(created).toBe(true); expect(prepared).toBe(true); expect(calls).toEqual({ start: 0, stop: 0, destroy: 0 }); @@ -302,9 +314,17 @@ describe("stack prepare", () => { Effect.sync(() => { options = value; return { - capabilities: [ - { capability: "rest", version: "1", outcome: "downloaded" as const }, - { capability: "auth", version: "2", outcome: "cached" as const }, + instances: [ + { + id: ServiceInstanceIdSchema.make("3".repeat(64)), + service: "rest" as const, + artifacts: [{ identity: "1", outcome: "downloaded" as const }], + }, + { + id: ServiceInstanceIdSchema.make("4".repeat(64)), + service: "auth" as const, + artifacts: [{ identity: "2", outcome: "cached" as const }], + }, ], }; }), @@ -328,7 +348,7 @@ describe("stack prepare", () => { resolve: () => Effect.succeed({ projectRoot: root }), }), Layer.succeed(StackApi, { - findStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), discoverStacks: () => Effect.die("unused"), inspectStack: () => Effect.die("unused"), openStack: () => Effect.die("unused"), @@ -344,7 +364,7 @@ describe("stack prepare", () => { Effect.provide(layer), Effect.tap(() => Effect.sync(() => { - expect(options).toMatchObject({ capabilities: ["rest", "auth"] }); + expect(options).not.toHaveProperty("capabilities"); expect(telemetry.flushed).toBe(true); }), ), @@ -361,9 +381,17 @@ describe("stack prepare", () => { Effect.sync(() => { const data = { id, - capabilities: [ - { capability: "rest", version: "1", outcome: "downloaded" }, - { capability: "auth", version: "2", outcome: "cached" }, + instances: [ + { + id: ServiceInstanceIdSchema.make("3".repeat(64)), + service: "rest", + artifacts: [{ identity: "1", outcome: "downloaded" }], + }, + { + id: ServiceInstanceIdSchema.make("4".repeat(64)), + service: "auth", + artifacts: [{ identity: "2", outcome: "cached" }], + }, ], }; expect(result).toEqual( @@ -390,11 +418,9 @@ describe("stack prepare", () => { const id = "c".repeat(64); let inspected = false; let opened: string | undefined; - let preparedConfig: unknown; - const stack = makeStack(id, (value) => + const stack = makeStack(id, () => Effect.sync(() => { - preparedConfig = value?.config; - return { capabilities: [] }; + return { instances: [] }; }), ); const api = Layer.succeed(StackApi, { @@ -434,7 +460,6 @@ describe("stack prepare", () => { Effect.sync(() => { expect(inspected).toBe(true); expect(opened).toBe(id); - expect(preparedConfig).toMatchObject({ listeners: { api: { port: 55432 } } }); }), ), ); @@ -450,7 +475,7 @@ describe("stack prepare", () => { root, stack, api: Layer.succeed(StackApi, { - findStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), discoverStacks: () => Effect.die("unused"), inspectStack: () => Effect.die("unused"), openStack: () => Effect.die("unused"), @@ -559,7 +584,7 @@ describe("stack prepare", () => { Effect.succeed({ projectRoot: root, name: "feature", runtime: { kind: "native" } }), }), api: Layer.succeed(StackApi, { - findStack: () => Effect.die("unused"), + findStack: () => Effect.succeed(Option.none()), discoverStacks: () => Effect.die("unused"), inspectStack: () => Effect.die("unused"), openStack: () => Effect.die("unused"), @@ -598,7 +623,7 @@ describe("stack prepare", () => { () => Effect.gen(function* () { yield* Deferred.succeed(started, undefined); - return yield* Effect.never.pipe(Effect.as({ capabilities: [] })); + return yield* Effect.never.pipe(Effect.as({ instances: [] })); }).pipe(Effect.ensuring(Effect.sync(() => (canceled = true)))), calls, ); diff --git a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md index 821c2b699e..2dfe9e18c3 100644 --- a/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/restart/SIDE_EFFECTS.md @@ -54,10 +54,12 @@ no custom telemetry event and does not emit configuration or credential values. ## Output -- `--output-format text`: stack ID, runtime, lifecycle, configured endpoints, and - dormant capabilities. Progress is cleared after success or failed before propagation. +- `--output-format text`: stack ID, runtime, lifecycle, configured endpoints, dormant + capabilities, and registered service instance IDs/names/phases. Progress is cleared after + success or failure before propagation. - `--output-format json`: one status object containing `id`, `lifecycle`, - `desired_lifecycle`, `runtime`, `endpoints`, `versions`, `capabilities`, and `artifacts`. + `desired_lifecycle`, `runtime`, `endpoints`, `versions`, `capabilities`, `artifacts`, and + `instances`. - `--output-format stream-json`: standard progress events, followed by a `result` event carrying the same status object, or an `error` event on failure. @@ -66,8 +68,8 @@ Legacy `-o/--output` is rejected with guidance to use `--output-format`. ## Notes Targets one existing stack through `--stack`, `--stack-id`, or the current -project. The command stops and starts without an explicit configuration. Stop failure prevents -start; start failure leaves the same stack stopped and available for recovery. Interrupting +project. The command invokes the package restart lifecycle without an explicit configuration. A +restart failure leaves the same stack available for recovery. Interrupting the CLI waiter follows the package's owner lifecycle contract and does not invoke destroy from the command handler. An unconfigured stack must be initialized with `supabase stack start` before it can be restarted. diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts index 8354213d9b..49f4dbf4cb 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -137,11 +137,7 @@ export const stackRestart = Effect.fn("experimental.stack.restart")(function* ( }); const stack = yield* api.openStack(id).pipe(Effect.mapError(mapStackError)); const task = yield* output.task("Restarting local Supabase stack..."); - yield* stack.stop.pipe( - Effect.mapError(mapStackError), - Effect.tapError((error) => task.fail(error.message)), - ); - const status = yield* stack.start().pipe( + const status = yield* stack.restart().pipe( Effect.mapError(mapStackError), Effect.tapError((error) => task.fail(error.message)), Effect.tap(() => task.clear()), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts index e71ae3e232..cdbc74c94b 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -55,6 +55,7 @@ const status = (): StackStatus => ({ versions: {}, capabilities: [], artifacts: [], + instances: [], }); const flags = (overrides: Partial[0]> = {}) => ({ @@ -84,7 +85,6 @@ const fixture = (options: { ); const calls: string[] = []; let lifecycle: StackStatus["lifecycle"] = "running"; - let startedConfig: unknown; let selectedName: string | undefined; const output = mockOutput({ format: options.format }); const telemetry = mockTelemetryStateTracked(); @@ -93,20 +93,22 @@ const fixture = (options: { status: Effect.sync(() => ({ ...status(), lifecycle })), credentials: Effect.die("unused"), prepare: () => Effect.die("restart must not prepare explicitly"), - resetDatabase: Effect.die("unused"), - stop: Effect.gen(function* () { - calls.push("stop"); - if (options.stop === "fail") - return yield* new StackCleanupError({ message: "stop failed" }); - lifecycle = "stopped"; - }), - start: (input) => + services: { + create: () => Effect.die("services.create not used"), + get: () => Effect.die("services.get not used"), + list: Effect.succeed([]), + }, + sleep: () => Effect.die("sleep not used"), + stop: () => + Effect.gen(function* () { + calls.push("stop"); + if (options.stop === "fail") + return yield* new StackCleanupError({ message: "stop failed" }); + lifecycle = "stopped"; + return status(); + }), + start: () => Effect.sync(() => calls.push("start")).pipe( - Effect.tap(() => - Effect.sync(() => { - startedConfig = input?.config; - }), - ), Effect.flatMap(() => options.start === "fail" ? Effect.fail( @@ -120,8 +122,14 @@ const fixture = (options: { }), ), ), - destroy: Effect.die("unused"), + restart: () => + Effect.gen(function* () { + yield* stack.stop(); + return yield* stack.start(); + }), + destroy: () => Effect.die("unused"), logs: () => Effect.die("unused"), + followStatus: Stream.empty, followLogs: () => Stream.empty, }; const layer = Layer.mergeAll( @@ -178,7 +186,7 @@ const fixture = (options: { return lifecycle; }, get startedConfig() { - return startedConfig; + return undefined; }, get selectedName() { return selectedName; @@ -419,6 +427,7 @@ describe("stack restart", () => { versions: {}, capabilities: [], artifacts: [], + instances: [], }); const stack: EffectStack = { id, @@ -430,20 +439,32 @@ describe("stack restart", () => { }, }), prepare: () => Effect.die("restart must not prepare explicitly"), - resetDatabase: Effect.die("unused"), - stop: Effect.sync(() => { - calls.push("stop"); - lifecycle = "stopped"; - }), - start: (input) => + services: { + create: () => Effect.die("services.create not used"), + get: () => Effect.die("services.get not used"), + list: Effect.succeed([]), + }, + sleep: () => Effect.die("sleep not used"), + stop: () => + Effect.sync(() => { + calls.push("stop"); + lifecycle = "stopped"; + return state(); + }), + start: () => Effect.sync(() => { calls.push("start"); - if (input?.config !== undefined) persisted.config = input.config; lifecycle = "running"; return state(); }), - destroy: Effect.die("unused"), + restart: () => + Effect.gen(function* () { + yield* stack.stop(); + return yield* stack.start(); + }), + destroy: () => Effect.die("unused"), logs: () => Effect.die("unused"), + followStatus: Stream.empty, followLogs: () => Stream.empty, }; const descriptor = { @@ -477,7 +498,11 @@ describe("stack restart", () => { }), Layer.succeed(StackApi, { findStack: () => Effect.succeed(Option.some(descriptor)), - createStack: () => Effect.succeed(stack), + createStack: (options) => + Effect.sync(() => { + persisted.config = options.initialConfig; + return stack; + }), openStack: () => Effect.succeed(stack), inspectStack: () => Effect.die("inspect unused"), discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), diff --git a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts index 94e2ec35b1..9273b4c25b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts @@ -3,9 +3,14 @@ import { Buffer } from "node:buffer"; import { encrypt, PrivateKey } from "eciesjs"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Path, Redacted } from "effect"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; -import { compileStack } from "../../../../../../packages/stack/src/model/Compiler.ts"; +import { + compileStack, + seedServiceRegistry, + type CompiledStack, +} from "../../../../../../packages/stack/src/model/Compiler.ts"; +import { createExecutionPlan } from "../../../../../../packages/stack/src/model/ExecutionPlan.ts"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { StackConfigError, loadStackConfig } from "../../../command-internal/stack-config.ts"; @@ -40,6 +45,17 @@ const load = (projectRoot: string) => Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), ); +const planFor = (compiled: CompiledStack, projectRoot: string) => + Effect.gen(function* () { + const seeded = yield* seedServiceRegistry( + compiled.definition, + { projectRoot, runtime: { kind: "native" }, path: yield* Path.Path }, + compiled.sourceConfig, + compiled.secrets, + ); + return yield* createExecutionPlan({ kind: "native" }, seeded.registry); + }).pipe(Effect.provide(BunServices.layer)); + const encrypted = (privateKey: string, plaintext: string): string => `encrypted:${Buffer.from( encrypt(PrivateKey.fromHex(privateKey).publicKey.toHex(false), Buffer.from(plaintext, "utf8")), @@ -562,7 +578,12 @@ auto_expose_new_tables = true if (config.capabilities.storage === undefined || !("settings" in config.capabilities.storage)) throw new Error("storage settings missing"); expect(config.capabilities.storage.settings?.image_transformation).toEqual({ enabled: true }); - expect(config.capabilities.database?.settings?.health_timeout).toBe("45s"); + if ( + config.capabilities.database === undefined || + !("settings" in config.capabilities.database) + ) + throw new Error("database settings missing"); + expect(config.capabilities.database.settings?.health_timeout).toBe("45s"); expect(config.security?.jwt?.signing).toEqual({ kind: "jwks-file", path: "supabase/overridden-keys.json", @@ -651,7 +672,12 @@ openai_api_key = "config-studio-key" if (config.capabilities?.studio === undefined || !("settings" in config.capabilities.studio)) throw new Error("Studio settings missing"); expect(config.capabilities.rest.settings?.schemas).toEqual(["public", "storage"]); - expect(config.capabilities.database?.version).toBe("17"); + if ( + config.capabilities.database === undefined || + !("version" in config.capabilities.database) + ) + throw new Error("database version missing"); + expect(config.capabilities.database.version).toBe("17"); if ( config.capabilities.analytics === undefined || !("settings" in config.capabilities.analytics) @@ -739,10 +765,11 @@ openai_api_key = "config-studio-key" true, ); expect(compiled.definition.capabilities.storage.activation).toBe("lazy"); - expect(compiled.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy")).toBe( + const compiledPlan = yield* planFor(compiled, root); + expect(compiledPlan.workloads.some(({ recipeId }) => recipeId === "storage:imgproxy")).toBe( true, ); - expect(compiled.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( + expect(compiledPlan.workloads.some(({ recipeId }) => recipeId === "analytics:vector")).toBe( true, ); }); @@ -793,8 +820,9 @@ enabled = false expect( enabledCompiled.definition.capabilities.storage.settings.image_transformation?.enabled, ).toBe(true); + const enabledCompiledPlan = yield* planFor(enabledCompiled, enabled); expect( - enabledCompiled.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy"), + enabledCompiledPlan.workloads.some(({ recipeId }) => recipeId === "storage:imgproxy"), ).toBe(true); const disabledConfig = yield* load(disabled); @@ -807,8 +835,9 @@ enabled = false expect( disabledCompiled.definition.capabilities.storage.settings.image_transformation?.enabled, ).toBe(false); + const disabledCompiledPlan = yield* planFor(disabledCompiled, disabled); expect( - disabledCompiled.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy"), + disabledCompiledPlan.workloads.some(({ recipeId }) => recipeId === "storage:imgproxy"), ).toBe(false); }); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 08e77bff83..16bd354a64 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -301,35 +301,47 @@ jwt_secret = "encrypted:not-a-real-ciphertext" }).pipe(Effect.provide(BunServices.layer)); }); - it.effect("rejects function paths outside the function root", () => { + it.effect("accepts function paths outside the function root within the project", () => { return Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-outside-function" [functions.hello] import_map = "./import_map.json" +entrypoint = "./external/index.ts" +static_files = ["./external/asset.txt"] `); - const exit = yield* load(root).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("functions.hello.import_map"); + const config = yield* load(root); + if ( + config.capabilities?.functions === undefined || + !("settings" in config.capabilities.functions) + ) + throw new Error("Functions settings missing"); + expect(config.capabilities.functions.settings?.functions?.hello).toMatchObject({ + import_map: "../../import_map.json", + entrypoint: "../../external/index.ts", + static_files: ["../../external/asset.txt"], + }); }); }); - it.effect( - "rejects supabase-prefixed function paths that resolve outside the project root", - () => { - return Effect.gen(function* () { - const root = yield* project(`project_id = "stack-config-nested-supabase" + it.effect("preserves trusted function paths outside the project root", () => { + return Effect.gen(function* () { + const root = yield* project(`project_id = "stack-config-outside-project" [functions.hello] -entrypoint = "supabase/functions/hello/index.ts" +entrypoint = "../../outside.ts" `); - const exit = yield* load(root).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) - expect(String(exit.cause)).toContain("functions.hello.entrypoint"); - }); - }, - ); + const config = yield* load(root); + if ( + config.capabilities?.functions === undefined || + !("settings" in config.capabilities.functions) + ) + throw new Error("Functions settings missing"); + expect(config.capabilities.functions.settings?.functions?.hello?.entrypoint).toBe( + "../../../../outside.ts", + ); + }); + }); it.effect("resolves supabase-prefixed signing paths beneath the config directory", () => { return Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 1d1fe94f0d..5403d94d1a 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -98,6 +98,7 @@ export const stackStatusPayload = (status: StackStatus) => ({ versions: status.versions, capabilities: status.capabilities, artifacts: status.artifacts, + instances: status.instances, ...(status.recovery === undefined ? {} : { recovery: status.recovery }), }); @@ -140,6 +141,11 @@ export const renderStackStatus = (status: StackStatus): string => { const dormant = status.capabilities.filter(({ state }) => state === "dormant"); if (dormant.length > 0) lines.push(`Dormant capabilities: ${dormant.map(({ name }) => name).join(", ")}`); + if (status.instances.length > 0) { + lines.push("Instances:"); + for (const instance of status.instances) + lines.push(` ${instance.id} ${instance.name ?? instance.service}: ${instance.phase}`); + } lines.push(...stackStatusIssueLines(status)); return `${lines.join("\n")}\n`; }; diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 28c6ab4826..0bfa608828 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -76,6 +76,8 @@ and its dependents are disabled together, so excluding `rest` also disables `stu the platform trio still fail-closes against the full enabled config. Listeners are derived by the runtime from enabled capability routes; route-less listeners are therefore omitted. Eager activation never re-enables an excluded capability. +When an addressed stack is already running, `--eager` or a service policy change is rejected; +use `supabase stack restart` to apply the requested policy through the package lifecycle. The command owns only the start request. Once the package reports readiness, the detached stack owner remains alive after the CLI process exits. If the CLI diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 6f73b01c94..e02feba670 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -50,19 +50,13 @@ const eagerlyActivate = < value: T, ): T => (value.enabled === false ? value : Object.assign({}, value, { activation: "eager" })); -const isPostgresOnlyStatus = (status: StackStatus): boolean => { - const database = status.capabilities.find((capability) => capability.name === "database"); - if (status.lifecycle !== "running" || database?.state !== "ready") return false; - return STACK_START_EXCLUDABLE_CAPABILITIES.every( - (name) => - status.capabilities.find((capability) => capability.name === name)?.state === "disabled", - ); -}; - -const isPostgresOnlyConfig = (config: StackConfig): boolean => - STACK_START_EXCLUDABLE_CAPABILITIES.every( - (name) => config.capabilities?.[name]?.enabled === false, - ); +const hasServicePolicyDifference = (status: StackStatus, config: StackConfig): boolean => + STACK_START_EXCLUDABLE_CAPABILITIES.some((name) => { + const requestedDisabled = config.capabilities?.[name]?.enabled === false; + const instance = status.instances.find((candidate) => candidate.service === name); + const actualDisabled = instance?.enabled === false || instance?.phase === "stopped"; + return requestedDisabled !== actualDisabled; + }); const validateExclusions = (exclusions: ReadonlyArray) => { const unknown = exclusions.filter( @@ -182,6 +176,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags projectRoot: target.projectRoot, ...(target.name === undefined ? {} : { name: target.name }), ...(runtime === undefined ? {} : { runtime }), + initialConfig: startConfig, }) .pipe(Effect.mapError(stackStartError)); if (stack.dockerFallbackNotice !== undefined) @@ -190,13 +185,16 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const addressed = yield* stack.status.pipe(Effect.mapError(stackStartError)); const firstCreate = addressed.desiredLifecycle === "unconfigured"; const starting = yield* output.task("Starting local Supabase stack..."); - if (isPostgresOnlyStatus(addressed) && !isPostgresOnlyConfig(startConfig)) { - yield* stack.stop.pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackStartError), - ); - } - const status = yield* stack.start({ config: startConfig }).pipe( + if ( + addressed.lifecycle === "running" && + (flags.eager || hasServicePolicyDifference(addressed, startConfig)) + ) + return yield* new StackCommandStartError({ + reason: "lifecycle", + message: "The selected stack is already running with a different service policy.", + suggestion: "Run supabase stack restart to apply --eager or --exclude changes.", + }); + const status = yield* stack.start().pipe( Effect.tapError((error) => starting.fail(error.message)), Effect.mapError(stackStartError), ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 6365b73145..b209f808c3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -19,6 +19,7 @@ import { ContainerPullError, StackConfigSchema, StackIdSchema, + ServiceInstanceIdSchema, StackRuntimeError, StackStateInvalidError, } from "@supabase/stack/effect"; @@ -155,11 +156,13 @@ const status = (id: string, runtime: "native" | "container" = "native") => "pooler", ] as const ).map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: name === "database" ? ("eager" as const) : ("lazy" as const), state: "ready" as const, })), artifacts: [], + instances: [], }) satisfies StackStatus; /** `status()` with the `storage` capability's state overridden. */ @@ -242,10 +245,11 @@ function recordingStackStorageHttpClientGet503() { ); return { layer, requests }; } +type Start = (config?: unknown) => Effect.Effect; function fakeStack( id: string, - start: (config?: { readonly config?: unknown }) => Effect.Effect, + start: Start, desiredLifecycle: "unconfigured" | "stopped" | "running" = "unconfigured", ) { // Mirrors a real stack: `.status` reflects the pre-start lifecycle (read by `firstCreate`) @@ -256,6 +260,7 @@ function fakeStack( lifecycle: desiredLifecycle === "unconfigured" ? "unconfigured" : desiredLifecycle, desiredLifecycle, }; + let initialConfig: unknown; return { id: StackIdSchema.make(id), status: Effect.suspend(() => Effect.succeed(currentStatus)), @@ -272,20 +277,37 @@ function fakeStack( }, }), prepare: () => Effect.die("prepare not used in start test"), - start: (config?: { readonly config?: unknown }) => - start(config).pipe( + start: () => + start(initialConfig).pipe( Effect.tap((result) => Effect.sync(() => { - currentStatus = result; + const currentStorage = currentStatus.capabilities.find( + (capability) => capability.name === "storage", + )?.state; + const nextStorage = result.capabilities.find( + (capability) => capability.name === "storage", + )?.state; + if (desiredLifecycle !== "unconfigured" || currentStorage !== nextStorage) + currentStatus = result; }), ), ), - stop: Effect.void, - destroy: Effect.die("destroy not used in start test"), - resetDatabase: Effect.die("resetDatabase not used in start test"), + services: { + create: () => Effect.die("services.create not used in start test"), + get: () => Effect.die("services.get not used in start test"), + list: Effect.die("services.list not used in start test"), + }, + setInitialConfig: (config: unknown) => { + initialConfig = config; + }, + sleep: () => Effect.succeed(status(id)), + stop: () => Effect.succeed(status(id)), + restart: () => Effect.succeed(status(id)), + destroy: () => Effect.void, logs: () => Effect.die("logs not used in start test"), + followStatus: Stream.empty, followLogs: () => Stream.empty, - } satisfies EffectStack; + } satisfies EffectStack & { readonly setInitialConfig: (config: unknown) => void }; } const flags = ( @@ -308,7 +330,7 @@ function handlerLayer(opts: { id?: string; runtime?: { kind: "native" } | { kind: "container"; engine: "docker" }; }; - stack: EffectStack; + stack: EffectStack & { readonly setInitialConfig?: (config: unknown) => void }; onCreate?: (options: unknown) => void; onOpen?: () => void; /** Overrides the default dying `HttpClient` stub, for tests exercising bucket seeding. */ @@ -326,6 +348,7 @@ function handlerLayer(opts: { const apiLayer = Layer.succeed(StackApi, { findStack: () => Effect.succeed(Option.none()), createStack: (options) => { + opts.stack.setInitialConfig?.(options.initialConfig); opts.onCreate?.(options); return Effect.succeed(opts.stack); }, @@ -395,7 +418,7 @@ describe("stack start targeting", () => { const stack = fakeStack("c".repeat(64), (input) => Effect.gen(function* () { const stackConfig = yield* Schema.decodeUnknownEffect(StackConfigSchema)( - input?.config, + input, ).pipe( Effect.mapError( (error) => new StackStateInvalidError({ message: error.message }), @@ -443,7 +466,7 @@ describe("stack start targeting", () => { const stack = fakeStack("e".repeat(64), (config) => Effect.gen(function* () { startedConfig = config; - yield* Schema.decodeUnknownEffect(StackConfigSchema)(config?.config, { + yield* Schema.decodeUnknownEffect(StackConfigSchema)(config, { onExcessProperty: "error", }).pipe( Effect.mapError((error) => new StackStateInvalidError({ message: error.message })), @@ -457,11 +480,9 @@ describe("stack start targeting", () => { ); expect(startedConfig).toEqual( expect.objectContaining({ - config: expect.objectContaining({ - capabilities: expect.objectContaining({ - studio: expect.objectContaining({ enabled: false }), - analytics: expect.objectContaining({ enabled: false }), - }), + capabilities: expect.objectContaining({ + studio: expect.objectContaining({ enabled: false }), + analytics: expect.objectContaining({ enabled: false }), }), }), ); @@ -480,7 +501,7 @@ describe("stack start targeting", () => { yield* writeStartMigration(root); const catalog = recordingStackCatalogSetup((input) => ({ kind: input.target.kind, - authEnabled: input.target.config.capabilities?.auth?.enabled, + authEnabled: input.target.config?.capabilities?.auth?.enabled, })); const stack = fakeStack("f".repeat(64), () => Effect.succeed(status("f".repeat(64)))); const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); @@ -595,9 +616,7 @@ describe("stack start targeting", () => { ).pipe(Effect.provide(setup.layer)); expect(startedConfig).toEqual( expect.objectContaining({ - config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { port: 55421 } }), - }), + listeners: expect.objectContaining({ api: { port: 55421 } }), }), ); }), @@ -628,9 +647,7 @@ describe("stack start targeting", () => { yield* stackStart(flags({ exclude: ["rest"] })).pipe(Effect.provide(setup.layer)); expect(startedConfig).toEqual( expect.objectContaining({ - config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { port: 55421 } }), - }), + listeners: expect.objectContaining({ api: { port: 55421 } }), }), ); }), @@ -673,9 +690,7 @@ enabled = false yield* stackStart(flags({ exclude: ["rest"] })).pipe(Effect.provide(setup.layer)); expect(startedConfig).toEqual( expect.objectContaining({ - config: expect.objectContaining({ - listeners: expect.objectContaining({ api: { port: 55421 } }), - }), + listeners: expect.objectContaining({ api: { port: 55421 } }), }), ); }), @@ -698,7 +713,9 @@ enabled = false }, }); yield* stackStart(flags({ runtime: "auto" })).pipe(Effect.provide(setup.layer)); - expect(createOptions).toEqual({ projectRoot: root }); + expect(createOptions).toEqual( + expect.objectContaining({ projectRoot: root, initialConfig: expect.any(Object) }), + ); }).pipe(Effect.provide(BunServices.layer)); }); @@ -722,11 +739,14 @@ enabled = false yield* stackStart(flags({ stack: Option.some("feature-docker"), runtime: "docker" })).pipe( Effect.provide(setup.layer), ); - expect(createOptions).toEqual({ - projectRoot: root, - name: "feature-docker", - runtime: { kind: "container", engine: "docker" }, - }); + expect(createOptions).toEqual( + expect.objectContaining({ + projectRoot: root, + name: "feature-docker", + runtime: { kind: "container", engine: "docker" }, + initialConfig: expect.any(Object), + }), + ); }).pipe(Effect.provide(BunServices.layer)); }); @@ -829,19 +849,20 @@ enabled = false exclude: ["studio"], }), ).pipe(Effect.provide(setup.layer)); - expect(createOptions).toEqual({ - projectRoot: root, - name: "feature-a", - runtime: { kind: "native" }, - }); - expect(startConfig).toMatchObject({ config: { preparation: "on-demand" } }); + expect(createOptions).toEqual( + expect.objectContaining({ + projectRoot: root, + name: "feature-a", + runtime: { kind: "native" }, + initialConfig: expect.any(Object), + }), + ); + expect(startConfig).toMatchObject({ preparation: "on-demand" }); expect(startConfig).toEqual( expect.objectContaining({ - config: expect.objectContaining({ - capabilities: expect.objectContaining({ - rest: expect.objectContaining({ activation: "eager" }), - studio: expect.objectContaining({ enabled: false }), - }), + capabilities: expect.objectContaining({ + rest: expect.objectContaining({ activation: "eager" }), + studio: expect.objectContaining({ enabled: false }), }), }), ); @@ -905,7 +926,7 @@ enabled = false Effect.provide(setup.layer), ); expect(opened).toBe(true); - expect(startConfig).toMatchObject({ config: { listeners: { api: { port: 55421 } } } }); + expect(startConfig).toBeUndefined(); }).pipe(Effect.provide(BunServices.layer)); }); @@ -918,12 +939,18 @@ enabled = false ...fakeStack("c".repeat(64), () => Effect.fail(new ContainerEngineError({ message: "Docker is unavailable" })), ), - stop: Effect.sync(() => { - stopped = true; - }), - destroy: Effect.sync(() => { - destroyed = true; - }), + stop: () => + Effect.succeed(status("c".repeat(64))).pipe( + Effect.tap(() => + Effect.sync(() => { + stopped = true; + }), + ), + ), + destroy: () => + Effect.sync(() => { + destroyed = true; + }), } satisfies EffectStack; const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); const failure = yield* stackStart(flags()).pipe(Effect.flip, Effect.provide(setup.layer)); @@ -948,12 +975,18 @@ enabled = false let destroyed = false; const stack = { ...fakeStack("f".repeat(64), () => Effect.succeed(status("f".repeat(64)))), - stop: Effect.sync(() => { - stopped = true; - }), - destroy: Effect.sync(() => { - destroyed = true; - }), + stop: () => + Effect.succeed(status("f".repeat(64))).pipe( + Effect.tap(() => + Effect.sync(() => { + stopped = true; + }), + ), + ), + destroy: () => + Effect.sync(() => { + destroyed = true; + }), } satisfies EffectStack; const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); yield* stackStart(flags()).pipe(Effect.provide(setup.layer)); @@ -962,7 +995,7 @@ enabled = false }); }); - it.live("stops a running postgres-only stack before starting the full config", () => { + it.live("requires restart before changing a running service policy", () => { return Effect.gen(function* () { const root = yield* project(); const events: Array = []; @@ -984,14 +1017,33 @@ enabled = false ...capability, state: capability.name === "database" ? ("ready" as const) : ("disabled" as const), })), + instances: [ + { + id: ServiceInstanceIdSchema.make("r".repeat(64)), + service: "rest", + name: "rest", + enabled: false, + intent: "stopped", + phase: "stopped", + activation: "lazy", + endpoints: [], + }, + ], }), - stop: Effect.sync(() => { - events.push("stop"); - }), + stop: () => + Effect.succeed(status(id)).pipe( + Effect.tap(() => + Effect.sync(() => { + events.push("stop"); + }), + ), + ), } satisfies EffectStack; const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); - yield* stackStart(flags()).pipe(Effect.provide(setup.layer)); - expect(events).toEqual(["stop", "start"]); + const failure = yield* stackStart(flags()).pipe(Effect.flip, Effect.provide(setup.layer)); + expect(failure.reason).toBe("lifecycle"); + expect(failure.suggestion).toContain("stack restart"); + expect(events).toEqual([]); }); }); @@ -1058,7 +1110,7 @@ enabled = false const stack = fakeStack("9".repeat(64), (config) => { started = true; expect(config).toMatchObject({ - config: { capabilities: { database: { settings: { health_timeout: "2m" } } } }, + capabilities: { database: { settings: { health_timeout: "2m" } } }, }); return Effect.succeed(status("9".repeat(64))); }); @@ -1084,12 +1136,18 @@ enabled = false return yield* Effect.never.pipe(Effect.as(status("7".repeat(64)))); }), ), - stop: Effect.sync(() => { - stopped = true; - }), - destroy: Effect.sync(() => { - destroyed = true; - }), + stop: () => + Effect.succeed(status("7".repeat(64))).pipe( + Effect.tap(() => + Effect.sync(() => { + stopped = true; + }), + ), + ), + destroy: () => + Effect.sync(() => { + destroyed = true; + }), } satisfies EffectStack; const setup = handlerLayer({ root, target: { projectRoot: root }, stack }); const fiber = yield* Effect.forkChild(Effect.provide(stackStart(flags()), setup.layer)); @@ -1440,12 +1498,15 @@ describe("stack start bucket seeding", () => { ...fakeStack("5".repeat(64), () => Effect.succeed(statusWithStorageState("5".repeat(64), "dormant")), ), - stop: Effect.sync(() => { - stopped = true; - }), - destroy: Effect.sync(() => { - destroyed = true; - }), + stop: () => + Effect.sync(() => { + stopped = true; + return status("5".repeat(64)); + }), + destroy: () => + Effect.sync(() => { + destroyed = true; + }), } satisfies EffectStack; const setup = handlerLayer({ root, diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index adf67e5036..fe1600f22f 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -29,11 +29,11 @@ inspection available, appears as `Config warning:` in text output, and as `config_drift.message` with `status: "unavailable"` in JSON. Text output includes identity, runtime, owner, lifecycle, readiness, -endpoints, and config drift. JSON output nests only the identity fields under +endpoints, registered service instance IDs/names and phases, and config drift. JSON output nests only the identity fields under `identity`; runtime, lifecycle, readiness, endpoints, and config drift remain top-level fields. -Each capability includes its current state and may include a diagnostic when +Each registered service instance includes its ID, optional name, service kind, intent, and phase. Each capability includes its current state and may include a diagnostic when cleanup or runtime readiness failed. When recovery is required, JSON adds a `recovery` object with the `operation` (`stop` or `destroy`) and its message. Text output includes that message and the matching recovery command; stop diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 216a0d1012..23d0d78227 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -57,7 +57,9 @@ export const stackEnvValues = ( names: ReadonlyMap, ): Readonly> => { const values: Record = { - DB_URL: Redacted.value(credentials.database.url), + ...(credentials.database === undefined + ? {} + : { DB_URL: Redacted.value(credentials.database.url) }), ...(credentials.api === undefined ? {} : { diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index f9136f83d7..8af3dfcf98 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -102,6 +102,7 @@ const payload = (inspection: StackInspection, configWarning?: string) => ({ readiness: readiness(inspection.status), ...(inspection.status === undefined ? {} : { endpoints: inspection.status.endpoints }), ...(inspection.status === undefined ? {} : { capabilities: inspection.status.capabilities }), + ...(inspection.status === undefined ? {} : { instances: inspection.status.instances }), ...(inspection.status?.recovery === undefined ? {} : { recovery: inspection.status.recovery }), config_drift: inspection.configDrift ?? @@ -137,6 +138,11 @@ const render = (inspection: StackInspection, configWarning?: string): string => for (const [name, endpoint] of endpoints) if (endpoint !== undefined) lines.push(` ${name}: ${endpoint.url}`); } + if (inspection.status.instances.length > 0) { + lines.push("Instances:"); + for (const instance of inspection.status.instances) + lines.push(` ${instance.id} ${instance.name ?? instance.service}: ${instance.phase}`); + } lines.push(...stackStatusIssueLines(inspection.status)); } const drift = inspection.configDrift; diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 65cdb5f045..1757cf8907 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -20,6 +20,7 @@ import { StackNotFoundError, StackNotRunningError, StackIdSchema, + ServiceInstanceIdSchema, StackStateFormatUnsupportedError, type EffectStack, type StackInspection, @@ -73,11 +74,24 @@ const makeStatus = ( }, versions: {}, capabilities: capabilityNames.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: "lazy" as const, state: "dormant" as const, })), artifacts: [], + instances: [ + { + id: ServiceInstanceIdSchema.make("9".repeat(64)), + service: "database", + name: "primary", + enabled: true, + intent: "started", + phase: "dormant", + activation: "lazy", + endpoints: [], + }, + ], }); const runStatus = (options: { @@ -168,11 +182,18 @@ const runStatus = (options: { : {}), }), prepare: () => Effect.die("unused"), + services: { + create: () => Effect.die("services.create not used"), + get: () => Effect.die("services.get not used"), + list: Effect.succeed([]), + }, start: () => Effect.die("unused"), - stop: Effect.die("unused"), - destroy: Effect.die("unused"), - resetDatabase: Effect.die("unused"), + sleep: () => Effect.die("sleep not used"), + stop: () => Effect.die("stop not used"), + restart: () => Effect.die("restart not used"), + destroy: () => Effect.die("destroy not used"), logs: () => Effect.die("unused"), + followStatus: Stream.empty, followLogs: () => Stream.empty, } satisfies EffectStack), inspectStack: (_stackId, inspectOptions) => { @@ -226,6 +247,8 @@ describe("stack status", () => { expect(run.inspectInputs[0]).toEqual({ config: expect.any(Object) }); expect(run.out.stdoutText).toContain("Runtime: native"); expect(run.out.stdoutText).toContain("Readiness: dormant"); + expect(run.out.stdoutText).toContain("primary"); + expect(run.out.stdoutText).toContain("9".repeat(64)); expect(run.out.stdoutText).toContain("http://127.0.0.1:54321"); expect(run.out.stdoutText).toContain("definition.listeners.api.port"); expect(run.out.stdoutText).not.toContain("candidate-secret"); @@ -236,6 +259,27 @@ describe("stack status", () => { }, ); + it.effect("includes registered instance identity in the JSON status payload", () => + withRunStatus({ status: makeStatus(id), outputFormat: "json" }, (run) => + run.effect.pipe( + Effect.tap(() => + Effect.sync(() => { + const success = run.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + instances: [ + expect.objectContaining({ + id: "9".repeat(64), + name: "primary", + service: "database", + }), + ], + }); + }), + ), + ), + ), + ); + it.effect("forwards a named stack target with the settings project root", () => { return withRunStatus( { flags: flags(Option.some("feature-a")), status: makeStatus(id) }, diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index dc2ef35ce2..d59861e784 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -51,7 +51,7 @@ const stopError = (error: StackDiscoveryError | OpenStackError | ApiStackStopErr reason: "unknown" as const, suggestion: "Retry the stack stop with --debug and inspect cleanup diagnostics.", })), - Match.exhaustive, + Match.orElse(() => ({ reason: "unknown" as const })), ); return new StackCommandStopError({ ...classification, @@ -92,7 +92,7 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: discovered.stacks, (descriptor) => stackApi.openStack(descriptor.id).pipe( - Effect.flatMap((stack) => stack.stop), + Effect.flatMap((stack) => stack.stop()), Effect.result, Effect.map((result) => ({ descriptor, result })), ), @@ -176,7 +176,7 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: const target = targetOption.value; const stack = yield* stackApi.openStack(target.id).pipe(Effect.mapError(stopError)); const stopping = yield* output.task(`Stopping stack ${target.id}...`); - yield* stack.stop.pipe( + yield* stack.stop().pipe( Effect.tapError((error) => stopping.fail(error.message)), Effect.tap(() => stopping.clear()), Effect.mapError(stopError), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index 139da02e70..ecba0b078d 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -46,6 +46,7 @@ const status = (id: string): StackStatus => ({ versions: {}, capabilities: [], artifacts: [], + instances: [], }); const flags = ( @@ -61,7 +62,7 @@ function setup(opts: { root: string; format?: "text" | "json" | "stream-json"; found?: { id: string; name?: string }; - stop?: Effect.Effect; + stop?: Effect.Effect; openFailure?: OpenStackError; findFailure?: StackDiscoveryError; discoveryFailure?: StackDiscoveryError; @@ -87,17 +88,26 @@ function setup(opts: { status: Effect.succeed(status(id)), credentials: Effect.die("unused"), prepare: () => Effect.die("unused"), + services: { + create: () => Effect.die("services.create not used"), + get: () => Effect.die("services.get not used"), + list: Effect.succeed([]), + }, start: () => Effect.die("unused"), - stop: + sleep: () => Effect.die("sleep not used"), + stop: () => opts.stop ?? Effect.sync(() => { state.stopCalls += 1; + return status(id); + }), + restart: () => Effect.die("restart not used"), + destroy: () => + Effect.sync(() => { + state.destroyCalled = true; }), - destroy: Effect.sync(() => { - state.destroyCalled = true; - }), - resetDatabase: Effect.die("unused"), logs: () => Effect.die("unused"), + followStatus: Stream.empty, followLogs: () => Stream.empty, } satisfies EffectStack; const descriptor = opts.found diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 0444f0821e..1d1c4fe11c 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { mkdirSync, writeFileSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; import { Effect, Exit, Layer, Option, Stdio } from "effect"; import { YesFlag } from "../../../command-internal/global-flags.ts"; @@ -524,6 +524,104 @@ describe("functions deploy", () => { ); }); + it.live("does not upload a symlinked file outside an external import-map root", () => { + const repoRoot = tempRoot.current; + const workdir = join(repoRoot, "app"); + const fixtureName = basename(repoRoot); + const externalMaps = join(repoRoot, "..", `${fixtureName}-external-maps`); + const outsideFile = join(repoRoot, "..", `${fixtureName}-external-secret.ts`); + const multipartFileNames: string[] = []; + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.body._tag === "FormData") { + multipartFileNames.push( + ...request.body.formData + .getAll("file") + .flatMap((part) => (part instanceof File ? [part.name] : [])), + ); + } + if (request.method === "GET") return Effect.succeed(jsonResponse(request, 200, [])); + return Effect.succeed( + jsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "supabase/functions/hello-world/index.ts", + import_map_path: "supabase/functions/hello-world/maps/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir }), + runtimeInfo: mockRuntimeInfo({ cwd: workdir }), + }), + Layer.succeed(YesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(repoRoot, ".git"), { recursive: true })); + yield* Effect.tryPromise(() => + writeCliConfig( + workdir, + [ + 'project_id = "test-project"', + "[functions.hello-world]", + 'import_map = "./functions/hello-world/maps/deno.json"', + "", + ].join("\n"), + ), + ); + yield* Effect.tryPromise(() => + writeLocalFunction( + workdir, + "hello-world", + 'import "@vendor/inside.ts"\nDeno.serve(() => new Response("ok"))\n', + ), + ); + yield* Effect.tryPromise(() => mkdir(externalMaps, { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(externalMaps, "deno.json"), '{"imports":{"@vendor/":"./"}}'), + ); + yield* Effect.tryPromise(() => + writeFile(join(externalMaps, "inside.ts"), "export const inside = true\n"), + ); + yield* Effect.tryPromise(() => writeFile(outsideFile, "export const secret = true\n")); + yield* Effect.tryPromise(() => symlink(outsideFile, join(externalMaps, "leak.ts"))); + yield* Effect.tryPromise(() => + symlink(externalMaps, join(workdir, "supabase", "functions", "hello-world", "maps"), "dir"), + ); + + yield* functionsDeploy(baseFlags); + + expect(multipartFileNames).toContain("supabase/functions/hello-world/maps/inside.ts"); + expect(multipartFileNames.some((name) => name.includes("leak.ts"))).toBe(false); + expect(stripControlSequences(out.stderrText)).toContain( + "WARN: Skipping import path outside source root:", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.tryPromise(() => rm(externalMaps, { recursive: true, force: true }))), + Effect.ensuring(Effect.tryPromise(() => rm(outsideFile, { force: true }))), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + it.live("deploys config-declared custom entrypoints when deploying all functions", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 0015111a41..571cbef499 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -2,129 +2,75 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`resolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | +| Path | Format | When | +| ------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | On every startup and watch restart. | +| Project dotenv files and `/supabase/functions/.env` | dotenv | On every startup and watch restart; the explicit `--env-file` takes precedence. | +| `/supabase/functions//.env` | dotenv | On every startup and watch restart when `--env-file` is unset; values override the shared file for that Function. | +| `` | dotenv | When `--env-file` is set; relative paths resolve from the caller cwd. | +| `/supabase/functions/*/index.ts` | TypeScript | To discover filesystem-backed Functions. | +| Config-declared entrypoints, import maps, static files, and imports | mixed | To build the effective service configuration and watch roots. | ## Files Written -| Path | Format | When | -| ---------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` | -| `/supabase/.temp/start-secrets/supabase_edge_runtime_/env/docker.env` | dotenv | per start, when single-line container env exists; passed via `--env-file`; mode `0600`; removed after the run | -| `/supabase/.temp/start-secrets/supabase_edge_runtime_/multiline-env/…` | shell + raw | per start, only when an env value contains a newline; bind-mounted read-only into the container; mode `0600`; removed after the run | +| Path | Format | When | +| ---------------------------- | ----------------- | -------------------------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | At command exit through `Effect.ensuring`. | +| Managed stack service state | typed stack state | The stack owner persists service configuration, intent, endpoint plans, and operation state. | -The env files hold secrets (JWT secret, anon/service-role keys, JWKS), so they are -written owner-only (`0600`, in `0700` directories) under the project's own -`supabase/.temp/` (gitignored) — a deterministic, persistent path rather than -`os.tmpdir()`, so `supabase stop` and a failed-start rollback can reclaim it via -`cleanupStartSecrets` even when this command's own cleanup was bypassed -(e.g. `SIGKILL`). +Secret values stay redacted in the service configuration and are materialized only by the stack +owner during runtime startup. ## API Routes -Management API: none. When a third-party auth provider (`auth.third_party.*`) is -enabled, two outbound HTTPS GETs are made per start to build `SUPABASE_JWKS`: - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------- | ---- | ------------ | ---------------------- | -| `GET` | `/.well-known/openid-configuration` | none | `—` | `jwks_uri` | -| `GET` | `` (from discovery) | none | `—` | `keys` | - -Both fetches use a 10s timeout and are best-effort: failure logs nothing and falls -back to local keys. No scheme/host validation is performed on the discovered URLs. +The CLI opens or creates the project stack through `StackApi`, resolves the registered `functions` +service, and observes that service's logs and status. The service uses the shared stack gateway for +its routes. Functions can start while PostgreSQL, Auth, and REST are disabled or absent. ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_PROFILE` | resolves the profile / API base URL | no (defaults to `supabase`) | -| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | config-service override for project identity | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no | -| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | -| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | -| `BITBUCKET_CLONE_DIR` | when defined (including an empty value), skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker create` (Bitbucket's restricted Docker environment rejects both); project values are passed explicitly to Docker setup | no | +| Variable | Purpose | Required? | +| ------------------------------------- | ----------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROFILE` | Resolves the profile and API base URL. | no | +| `SUPABASE_WORKDIR` | Overrides the project workdir. | no | +| `SUPABASE_PROJECT_ID` | Config-service override for project identity. | no | +| `SUPABASE_ENV` | Selects environment-specific dotenv files. | no | +| Variables referenced by `config.toml` | Config interpolation; ambient values are layered under project dotenv values. | no | +| `SUPABASE_NETWORK_ID` | Retained for project environment compatibility. | no | +| `BITBUCKET_CLONE_DIR` | Retained for project environment compatibility. | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | clean shutdown after `SIGINT` or `SIGTERM` | -| `0` | the edge-runtime container stops on its own with exit code `0` | -| `0` | the edge-runtime container is torn down by an external supervisor — exit `129`/`130`/`131`/`143` (`SIGHUP`/`SIGINT`/`SIGQUIT`/`SIGTERM`), e.g. `supabase stop` run in another terminal | -| `0` | the edge-runtime container is already gone by the time a follow-up `docker container inspect` runs after the log stream ended | -| `0` | an edge-runtime startup failure or log-stream failure lands within the shutdown grace period (~50ms) of a `SIGINT`/`SIGTERM` | -| `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | -| `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | -| `1` | env file, signing key, import map, or function bind resolution failure | -| `1` | edge-runtime container startup, log streaming, or restart loop failure — including the edge-runtime container crashing with any exit code other than `0`, `137`, or `129`/`130`/`131`/`143` | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | Clean shutdown after `SIGINT` or `SIGTERM`. | +| `0` | The managed Functions service exits normally or is retired by another stack owner. | +| `1` | Invalid inspect flags, config, environment file, import map, Function input, service preparation, startup, log stream, or watch restart. | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | Post-run, success or failure, through the command wrapper. | `exit_code`, `duration_ms`, `flags` | ## Output -### `--output-format text` - -Writes lifecycle text to stderr / stdout while the command is running: - -- `Setting up Edge Functions runtime...` before each container start -- `Skipped serving Function: ` for disabled functions -- `File change detected: ()` when a watched file triggers a restart -- live `docker logs -f --timestamps` output from the edge-runtime container -- `Stopped serving supabase/functions` on a user-initiated shutdown (`SIGINT`/`SIGTERM`) -- `Edge Runtime exited (code 0). Stopped serving supabase/functions` when the container stops on its own with exit code `0` -- `Edge Runtime container stopped (exit ). Stopped serving supabase/functions` when an external supervisor tears the container down (exit `129`/`130`/`131`/`143`) -- `Edge Runtime container is no longer available. Stopped serving supabase/functions` when the container is already gone by the time a follow-up inspect runs - -### `--output-format json` - -Long-running raw log / error output only; there is no final success payload object for this command. +`--output-format text` writes lifecycle text to the established streams: -### `--output-format stream-json` +- `Setting up Edge Functions runtime...` before startup. +- `Skipped serving Function: ` for disabled Functions. +- `File change detected: ()` when a watched file triggers a restart. +- Live logs attributed to the registered Functions service. +- `Stopped serving supabase/functions` on a user-initiated shutdown. +- `Edge Runtime exited ...` when the service exits on its own. -Long-running raw log / error events only; there is no terminal `result` event on success. +Machine-readable modes carry the service log and error stream; there is no final success payload. ## Notes -- Any legacy Function name positional arguments are accepted and ignored. The command always - serves every discovered Function, preserving invocations such as - `supabase functions serve `. -- Environment precedence is `--env-file` over automatic discovery. Without the flag, - `supabase/functions/.env` supplies values shared by every Function and each - `supabase/functions//.env` overrides matching values for that Function only. -- The hidden `--all` flag is still parsed but ignored; the native port always serves every discovered function. -- Each restart re-reads config, rebuilds per-function bind mounts, recreates the `supabase_edge_runtime_` container, and best-effort reloads Kong afterwards. -- The command creates or reuses Docker resources derived from the resolved project id: - - container: `supabase_edge_runtime_` - - named volume: `supabase_edge_runtime_` (mounted at `/root/.cache/deno`) - - network: `supabase_network_` unless `--network-id` overrides it -- Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`. -- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`) and passed into `loadCliConfig`. The command does not move/hide any project files. The resolved `BITBUCKET_CLONE_DIR` value is passed explicitly to Docker setup without changing the process environment. -- Config, project dotenv discovery, and function discovery all resolve from `` with no ancestor search (CLI-2285), so they can never disagree. -- Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. -- Runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). -- A container that stops on its own with exit code `0`, or that is torn down by an external supervisor (exit `129`/`130`/`131`/`143`, e.g. `supabase stop` in another terminal), or that is already gone by the time a follow-up inspect runs, all end the command successfully — each prints its own distinct line (see Output above) rather than the user-initiated `Stopped serving …` line, so scrollback can tell "I stopped it" from "the runtime walked out" or "a supervisor tore it down". In a `functions serve &` CI step this means a runtime that exits on its own does not fail the step; the distinct message is the only signal, and a downstream failure otherwise only surfaces later as connection-refused. Exit `137` (SIGKILL, e.g. an OOM kill) is retried by re-attaching to the log stream rather than failing the command. Any other non-zero container exit fails the command; the error message includes the container id. Only a watched-file change restarts the container itself — none of these outcomes ever restart it. -- A `docker logs -f` re-attach (the daemon can close the stream while the container keeps running) resumes with `--since ` instead of replaying the full log history, and is capped at 5 consecutive re-attaches that forward no new output; exceeding the cap fails the command with a tagged error instead of looping forever. -- On the log-stream path, the Docker-daemon-unreachable classification comes from a follow-up `docker container inspect` failure, not from `docker logs -f`'s own stderr text. -- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. -- The aggregated bind mount list is pruned before `docker create`: a bind is dropped when another bind of the same mode already supplies the same content at the same container path — a file bind nested inside an already-bound read-only package directory would otherwise make the bootstrap `docker cp` fail with `destination ":/" must be a directory` (supabase/supabase#50088). Pruned paths remain visible in the container through their covering parent mounts; the `--workdir` gate and the file-watch set are computed from the unpruned aggregate. -- Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. -- **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. +- Legacy Function name positional arguments are accepted and ignored. The command serves every discovered Function. +- `--all` remains parsed but hidden; all discovered and config-declared Functions, including disabled entries, are preserved in the candidate configuration. +- Every startup resolves flags, dotenv precedence, entrypoints, import maps, static files, reserved environment names, inspector mode, and debugger wallclock behavior into one effective Functions service configuration. +- The command compares a non-mutating stack preparation fingerprint with the registered service descriptor. An unchanged compatible service is joined; a changed configuration explicitly restarts the same instance ID. +- A watched source change re-resolves all inputs, restarts the same registered instance with the full candidate configuration, and rebuilds watcher roots. Import-map scope targets outside the project are warned once and excluded from watch roots; redundant mounts are pruned while covering mounts remain visible. +- The command closes log and watch subscriptions on exit. It does not stop, sleep, destroy, or restore the service when the CLI client exits. diff --git a/apps/cli/src/commands/functions/serve/serve.command.ts b/apps/cli/src/commands/functions/serve/serve.command.ts index 2802233206..dbd0e761f3 100644 --- a/apps/cli/src/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/commands/functions/serve/serve.command.ts @@ -11,6 +11,7 @@ import { commandSettingsLayer } from "../../../config/command-settings.layer.ts" import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; import { functionsServe } from "./serve.handler.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -20,6 +21,7 @@ const functionsServeRuntimeLayer = Layer.mergeAll( cliSettings, debugLoggerLayer, telemetryStateLayer, + stackApiLayer, commandRuntimeLayer(["functions", "serve"]), ); diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 234f2963c4..6ef51c1081 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -1,197 +1,41 @@ -import { FetchHttpClient } from "effect/unstable/http"; -import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Deferred, - Duration, - Effect, - Exit, - Fiber, - Layer, - Option, - PubSub, - Queue, - Sink, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { beforeEach, vi } from "vitest"; +import { Deferred, Effect, Fiber, Layer, Option, Queue, Redacted, Stream } from "effect"; +import { TestClock } from "effect/testing"; import { buildTestRuntime, - mockCommandSettings, mockCommandPlatformApiService, + mockCommandSettings, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { toDockerPath } from "../../../shared/functions/functions-docker.ts"; -import { - mockOutput, - mockProcessControl, - mockRuntimeInfo, -} from "../../../../tests/helpers/mocks.ts"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { functionsGoConfigCompat } from "../../../command-internal/functions-go-config.ts"; import { DebugFlag, NetworkIdFlag } from "../../../command-internal/global-flags.ts"; +import { StackApi } from "../../../command-internal/stack-api.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; import { FileWatcher, type FileWatchEvent } from "../../../shared/runtime/file-watcher.service.ts"; import { ProcessControl, type CliProcessSignal, } from "../../../shared/runtime/process-control.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; -import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; -import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { - DockerLogsStreamError, - EdgeRuntimeContainerCrashedError, - EdgeRuntimeLogStreamLostError, - ServeLocalDbInspectError, - ServeLocalDbNotRunningError, -} from "../../../shared/functions/serve.errors.ts"; -import { - actionability, - ErrorActionabilityId, -} from "../../../shared/telemetry/error-actionability.ts"; -import { - serveFunctions, - type FunctionsServeFlags, - type FunctionsServeTimers, -} from "../../../shared/functions/serve.ts"; - -const deployMockState = vi.hoisted(() => ({ - runCalls: [] as Array<{ - command: string; - args: ReadonlyArray; - options: unknown; - }>, - networkCalls: [] as Array<{ - networkMode: string; - projectId: string; - }>, - volumeCalls: [] as Array<{ - volumeName: string; - projectId: string; - }>, - runHandler: undefined as - | undefined - | (( - command: string, - args: ReadonlyArray, - options: unknown, - ) => - | { - exitCode: number; - stdout: string; - stderr: string; - } - // Never resolves — lets a test fork+interrupt while this specific call is in flight, - // matching Effect's own canonical "forever pending, interruptible" primitive. - | { pending: true } - // Fails the effect itself — models `spawnContainerCli` failing to spawn - // any container runtime (neither docker nor podman on PATH), as opposed - // to a spawned process exiting non-zero. - | { failure: Error }), - reset() { - this.runCalls = []; - this.networkCalls = []; - this.volumeCalls = []; - this.runHandler = undefined; - }, -})); - -vi.mock("../../../shared/functions/functions-docker.ts", async () => { - const actual = await vi.importActual< - typeof import("../../../shared/functions/functions-docker.ts") - >("../../../shared/functions/functions-docker.ts"); - const { Effect } = await import("effect"); - const { getRegistryImageUrl } = await import("../../../command-internal/docker-registry.ts"); - - return { - ...actual, - ensureDockerNetwork: (networkMode: string, projectId: string) => - Effect.sync(() => { - deployMockState.networkCalls.push({ networkMode, projectId }); - }), - ensureDockerNamedVolume: (volumeName: string, projectId: string) => - Effect.sync(() => { - deployMockState.volumeCalls.push({ volumeName, projectId }); - }), - // Stubbed to the pure registry-mapping step, skipping the real - // cache-check/pull (`docker image inspect`/`pull` via the real - // `ChildProcessSpawner`, not this file's mocked `runChildProcess`), - // which would otherwise insert real 4s/8s retry backoffs into every - // test that reaches container start. See `functions-docker.unit.test.ts` - // for that coverage. - resolveFunctionsDockerImage: ( - image: string, - projectEnvValues?: Readonly>, - ) => getRegistryImageUrl(image, projectEnvValues), - runChildProcess: (command: string, args: ReadonlyArray, options?: unknown) => - Effect.suspend(() => { - const envFile = args.flatMap((value, index) => - args[index - 1] === "--env-file" ? [value] : [], - )[0]; - const multilineEnvDir = args - .flatMap((value, index) => (args[index - 1] === "-v" ? [value] : [])) - .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z")) - ?.slice(0, -":/root/.supabase/multiline-env:ro,Z".length); - const enrichedOptions = - envFile === undefined && multilineEnvDir === undefined - ? options - : { - ...(typeof options === "object" && options !== null ? options : {}), - ...(envFile === undefined - ? {} - : { envFileContents: readFileSync(envFile, "utf8") }), - ...(multilineEnvDir === undefined - ? {} - : { - multilineEnvScript: readFileSync( - join(multilineEnvDir, "multiline-env.sh"), - "utf8", - ), - multilineEnvFiles: Object.fromEntries( - readdirSync(join(multilineEnvDir, "values")) - .filter((name) => name.startsWith("env-")) - .map((name) => [ - name, - readFileSync(join(multilineEnvDir, "values", name), "utf8"), - ]), - ), - }), - }; - deployMockState.runCalls.push({ command, args: [...args], options: enrichedOptions }); - const result = deployMockState.runHandler?.(command, args, options) ?? { - exitCode: 0, - stdout: "", - stderr: "", - }; - if ("pending" in result) return Effect.never; - if ("failure" in result) return Effect.fail(result.failure); - return Effect.succeed(result); - }), - }; -}); - -const tempRoot = useTempWorkdir("supabase-functions-serve-int-"); - -// Root bypasses POSIX permission bits, so chmod-based failure tests can't run there. -const isRoot = typeof process.getuid === "function" && process.getuid() === 0; - -const { functionsServe } = await import("./serve.handler.ts"); - -interface LogProcessBehavior { - readonly exitCode?: number; - readonly stdout?: string; - readonly stderr?: string; - readonly pending?: boolean; - readonly onSpawn?: () => void; -} +import { serveFunctions, type FunctionsServeFlags } from "../../../shared/functions/serve.ts"; +import type { + EffectServiceConfig, + EffectStack, + ServiceInstanceId, + StackDescriptor, + StackConfig, + StackLogEntry, + ServiceStatus, +} from "@supabase/stack/effect"; +import { ServiceInstanceIdSchema, StackIdSchema } from "@supabase/stack/effect"; + +const tempRoot = useTempWorkdir("supabase-functions-serve-managed-"); function baseFlags(overrides: Partial = {}): FunctionsServeFlags { return { @@ -206,59 +50,192 @@ function baseFlags(overrides: Partial = {}): FunctionsServe }; } -function extractFlagValues(args: ReadonlyArray, flag: string) { - return args.flatMap((value, index) => (args[index - 1] === flag ? [value] : [])); +async function writeProjectConfig(content = 'project_id = "test-project"\n') { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", "config.toml"), content); } -async function extractDockerEnvEntries(call: { args: ReadonlyArray; options: unknown }) { - const values = extractFlagValues(call.args, "-e"); - if (values.some((value) => value.includes("="))) { - return values; - } - - const envFile = extractFlagValues(call.args, "--env-file")[0]; - if (envFile !== undefined) { - const options = - typeof call.options === "object" && call.options !== null ? call.options : undefined; - const envFileContents = - options !== undefined && "envFileContents" in options - ? (options.envFileContents as string | undefined) - : undefined; - const contents = envFileContents ?? (await readFile(envFile, "utf8")); - return contents - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - } +async function writeFunction(slug: string, file = "index.ts", content = "export default {}\n") { + const path = join(tempRoot.current, "supabase", "functions", slug, file); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); +} - const options = - typeof call.options === "object" && call.options !== null ? call.options : undefined; - const env = - options !== undefined && "env" in options - ? (options.env as Readonly> | undefined) - : undefined; - if (env === undefined) { - return values; - } - return values.map((name) => `${name}=${env[name] ?? ""}`); +function serviceStatus( + id: ServiceInstanceId, + phase: "stopped" | "dormant" | "starting" | "ready" | "failed" = "stopped", +) { + return { + id, + service: "functions" as const, + name: undefined, + enabled: true, + intent: + phase === "stopped" || phase === "dormant" ? ("stopped" as const) : ("started" as const), + phase, + activation: "eager" as const, + endpoints: [], + }; } -function waitFor(condition: () => boolean, message: string) { - return Effect.gen(function* () { - const deadline = Date.now() + 3_000; - while (!condition()) { - if (Date.now() >= deadline) { - return yield* Effect.fail(new Error(message)); - } - yield* Effect.sleep(Duration.millis(20)); - } - }); +function makeStack(options: { + fingerprint: string; + preparedFingerprint?: string; + prepare?: "normal" | "blocked"; + restart?: "normal" | "blocked"; + logStream?: Stream.Stream; + logQueue?: Queue.Queue; + statusQueue?: Queue.Queue; +}) { + const id = ServiceInstanceIdSchema.make("functions"); + const stackId = StackIdSchema.make("a".repeat(64)); + let phase: "stopped" | "starting" | "ready" = "stopped"; + const starts: number[] = []; + const restarts: Array | undefined> = []; + const preparedConfigs: Array = []; + const prepareStarted = Effect.runSync(Deferred.make()); + const prepareCompleted = Effect.runSync(Deferred.make()); + const started = Effect.runSync(Deferred.make()); + const restarted = Effect.runSync(Deferred.make()); + const restartStarted = Effect.runSync(Deferred.make()); + const subscriptions = { logs: 0, status: 0 }; + const startsBeforeObservation: boolean[] = []; + const restartsWithObservation: boolean[] = []; + + const descriptor = { + id, + service: "functions" as const, + name: undefined, + enabled: true, + config: { + enabled: true, + activation: "eager" as const, + idleTimeoutSeconds: false as const, + version: "1", + settings: {}, + }, + dependencies: {}, + snapshotSupport: "unsupported" as const, + endpoints: {}, + effectiveConfigFingerprint: options.fingerprint, + data: { origin: "absent" as const }, + }; + + const service = { + id, + service: "functions" as const, + name: undefined, + describe: Effect.succeed(descriptor), + status: Effect.sync(() => serviceStatus(id, phase)), + credentials: Effect.succeed({ + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + anonJwt: "anon-test", + serviceRoleJwt: "service-role-test", + }), + prepare: Effect.succeed({ instances: [] }), + start: Effect.gen(function* () { + startsBeforeObservation.push(subscriptions.logs > 0 && subscriptions.status > 0); + starts.push(starts.length + 1); + phase = "ready"; + yield* Deferred.succeed(started, undefined); + return serviceStatus(id, phase); + }), + sleep: Effect.succeed(serviceStatus(id, "stopped")), + stop: Effect.sync(() => { + phase = "stopped"; + return serviceStatus(id, phase); + }), + restart: (input?: { readonly config?: EffectServiceConfig<"functions"> }) => + Effect.gen(function* () { + restartsWithObservation.push(subscriptions.logs > 0 && subscriptions.status > 0); + restarts.push(input?.config); + phase = "ready"; + yield* Deferred.succeed(restartStarted, undefined); + if (options.restart === "blocked") return yield* Effect.never; + yield* Deferred.succeed(restarted, undefined); + return serviceStatus(id, phase); + }), + destroy: Effect.void, + exportSnapshot: () => Effect.die("unexpected snapshot export"), + restoreSnapshot: () => Effect.die("unexpected snapshot restore"), + logs: () => Effect.die("unexpected logs query"), + followLogs: () => { + subscriptions.logs += 1; + return ( + options.logStream ?? + (options.logQueue === undefined ? Stream.never : Stream.fromQueue(options.logQueue)) + ); + }, + followStatus: Stream.unwrap( + Effect.sync(() => { + subscriptions.status += 1; + return options.statusQueue === undefined + ? Stream.never + : Stream.fromQueue(options.statusQueue); + }), + ), + }; + + const stack: EffectStack = { + id: stackId, + services: { + get: () => Effect.succeed(service), + list: Effect.succeed([descriptor]), + create: () => Effect.die("unexpected service create"), + }, + prepare: (input?: { + readonly services?: ReadonlyArray; + readonly config?: StackConfig; + }) => + Effect.gen(function* () { + preparedConfigs.push(input?.config); + yield* Deferred.succeed(prepareStarted, undefined); + if (options.prepare === "blocked") return yield* Effect.never; + yield* Deferred.succeed(prepareCompleted, undefined); + return { + instances: [ + { + id, + service: "functions" as const, + artifacts: [], + effectiveConfigFingerprint: options.preparedFingerprint ?? options.fingerprint, + }, + ], + capabilities: [], + }; + }), + status: Effect.die("unexpected stack status"), + followStatus: Stream.never, + credentials: Effect.die("unexpected stack credentials"), + start: () => Effect.die("unexpected stack start"), + sleep: () => Effect.die("unexpected stack sleep"), + stop: () => Effect.die("unexpected stack stop"), + restart: () => Effect.die("unexpected stack restart"), + destroy: () => Effect.die("unexpected stack destroy"), + logs: () => Effect.die("unexpected stack logs"), + followLogs: () => Stream.never, + }; + + return { + stack, + service, + starts, + restarts, + preparedConfigs, + prepareStarted, + prepareCompleted, + started, + restarted, + restartStarted, + subscriptions, + startsBeforeObservation, + restartsWithObservation, + }; } -function mockQueuedProcessControl() { +function processControl() { const signals = Effect.runSync(Queue.unbounded()); - let exitCode: number | undefined; - return { layer: Layer.succeed( ProcessControl, @@ -266,4000 +243,362 @@ function mockQueuedProcessControl() { awaitSignal: () => Queue.take(signals), awaitShutdown: Effect.never, holdSignals: () => Effect.void, - exit: (code: number) => - Effect.gen(function* () { - exitCode = code; - return yield* Effect.never; - }), - setExitCode: (code: number) => - Effect.sync(() => { - exitCode = code; - }), - getExitCode: Effect.sync(() => exitCode), + exit: () => Effect.never, + setExitCode: () => Effect.void, + getExitCode: Effect.succeed(undefined), }), ), - signal(signal: CliProcessSignal = "SIGINT") { - Effect.runSync(Queue.offer(signals, signal)); - }, + signal: () => Effect.runSync(Queue.offer(signals, "SIGINT")), }; } -function mockFileWatcher(expectedPaths: ReadonlyArray = []) { - const pubsub = Effect.runSync(PubSub.unbounded>({ replay: 8 })); - const expectedWatch = Effect.runSync(Deferred.make()); - const watchCalls: Array<{ - path: string; - ignore?: ReadonlyArray; - recursive?: boolean; - }> = []; - +function fileWatcher() { + const events = Effect.runSync(Queue.unbounded>()); + const watched = Effect.runSync(Deferred.make()); + const paths: string[] = []; return { layer: Layer.succeed( FileWatcher, FileWatcher.of({ - watch: (path, options) => { - watchCalls.push({ - path, - ignore: options?.ignore, - recursive: options?.recursive, - }); - if ( - expectedPaths.every((expectedPath) => - watchCalls.some((call) => call.path === expectedPath), - ) - ) { - Effect.runSync(Deferred.succeed(expectedWatch, undefined)); - } - return Stream.fromPubSub(pubsub); + watch: (path) => { + paths.push(path); + Effect.runSync(Deferred.succeed(watched, undefined)); + return Stream.fromQueue(events); }, }), ), - emit(events: ReadonlyArray) { - PubSub.publishUnsafe(pubsub, events); - }, - get watchCalls() { - return watchCalls; - }, - awaitExpectedWatch: Deferred.await(expectedWatch), - }; -} - -function mockDockerLogSpawner(behaviors: ReadonlyArray) { - const spawned: Array<{ command: string; args: ReadonlyArray }> = []; - let index = 0; - - return { - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - if (command._tag !== "StandardCommand") { - throw new Error(`unexpected child process kind: ${command._tag}`); - } - - const record = { - command: command.command, - args: [...command.args], - }; - spawned.push(record); - const behavior = behaviors[Math.min(index, behaviors.length - 1)] ?? {}; - index += 1; - behavior.onSpawn?.(); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1_000 + spawned.length), - exitCode: - behavior.pending === true - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(behavior.exitCode ?? 0)), - isRunning: Effect.succeed(behavior.pending === true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: - behavior.stdout === undefined - ? Stream.empty - : Stream.make(new TextEncoder().encode(behavior.stdout)), - stderr: - behavior.stderr === undefined - ? Stream.empty - : Stream.make(new TextEncoder().encode(behavior.stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - get spawned() { - return spawned; - }, + paths, + watched, + emit: (event: FileWatchEvent) => Effect.runSync(Queue.offer(events, [event])), }; } -interface SetupOptions { - readonly fetch?: typeof globalThis.fetch; - readonly debug?: boolean; - readonly workdir?: string; - readonly networkId?: Option.Option; - readonly projectId?: Option.Option; - readonly processControl?: - | ReturnType - | ReturnType; - readonly fileWatcher?: ReturnType; - readonly childSpawner?: ReturnType; -} - -function setupServe(options: SetupOptions = {}) { - const workdir = options.workdir ?? tempRoot.current; +function setup( + stackState: ReturnType, + control: ReturnType, + watcher = fileWatcher(), + existingStack = true, +) { const out = mockOutput({ format: "text", interactive: false }); const telemetry = mockTelemetryStateTracked(); - const cliSettings = mockCommandSettings({ - workdir, - projectId: options.projectId ?? Option.none(), - }); + const settings = mockCommandSettings({ workdir: tempRoot.current }); const api = mockCommandPlatformApiService({ v1: {} }); - const processControl = options.processControl ?? mockProcessControl(); - const fileWatcher = options.fileWatcher ?? mockFileWatcher(); - const childSpawner = options.childSpawner ?? mockDockerLogSpawner([{ exitCode: 1 }]); - + const descriptor: StackDescriptor = { + id: stackState.stack.id, + projectRoot: tempRoot.current, + name: "test", + branchContext: "main", + runtime: { kind: "native" }, + desiredLifecycle: "running", + }; + const stackApi = Layer.succeed( + StackApi, + StackApi.of({ + findStack: () => Effect.succeed(existingStack ? Option.some(descriptor) : Option.none()), + openStack: () => Effect.succeed(stackState.stack), + createStack: () => Effect.succeed(stackState.stack), + inspectStack: () => Effect.die("unexpected stack inspect"), + discoverStacks: () => Effect.succeed({ stacks: [], errors: [] }), + }), + ); const layer = Layer.mergeAll( buildTestRuntime({ out, - api: { - ...api, - ...(options.fetch === undefined - ? {} - : { - httpClientLayer: FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch, options.fetch)), - ), - }), - }, - cliSettings, + api, + cliSettings: settings, telemetry: telemetry.layer, runtimeInfo: mockRuntimeInfo({ - cwd: workdir, - homeDir: workdir, + cwd: tempRoot.current, + homeDir: tempRoot.current, platform: "linux", }), - processControl, + processControl: control, }), - fileWatcher.layer, - childSpawner.layer, - Layer.succeed(DebugFlag, options.debug ?? false), - Layer.succeed(NetworkIdFlag, options.networkId ?? Option.none()), + stackApi, + watcher.layer, + Layer.succeed(DebugFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), ); - - return { layer, out, telemetry, processControl, fileWatcher, childSpawner }; + return { layer, out, watcher }; } -/** - * Mirrors `serve.handler.ts`'s wiring but calls `serveFunctions` directly so a test can override - * its shutdown-grace/log-retry timers, which the handler's own signature doesn't expose. - */ -function serveWithTimers(flags: FunctionsServeFlags, timers: FunctionsServeTimers) { +function serve(flags: FunctionsServeFlags) { return Effect.gen(function* () { - const cliSettings = yield* CommandSettings; - const runtimeInfo = yield* RuntimeInfo; - const telemetryState = yield* TelemetryState; - const debug = yield* DebugFlag; - const networkId = yield* NetworkIdFlag; - + const settings = yield* CommandSettings; + const runtime = yield* RuntimeInfo; yield* serveFunctions(flags, { - projectRoot: cliSettings.workdir, - supabaseDir: join(cliSettings.workdir, "supabase"), - flagCwd: runtimeInfo.cwd, - platform: runtimeInfo.platform, - debug, - networkId, - projectIdOverride: cliSettings.projectId, + projectRoot: settings.workdir, + supabaseDir: join(settings.workdir, "supabase"), + flagCwd: runtime.cwd, + platform: runtime.platform, + debug: false, + networkId: Option.none(), + projectIdOverride: settings.projectId, goViperCompat: true, goConfigCompat: functionsGoConfigCompat, - timers, - }).pipe(Effect.ensuring(telemetryState.flush)); + }); }); } -async function writeCliConfig(content: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "config.toml"), content); -} - -async function writeFunctionFile(slug: string, relativePath: string, contents: string) { - const pathname = join(tempRoot.current, "supabase", "functions", slug, relativePath); - await mkdir(dirname(pathname), { recursive: true }); - await writeFile(pathname, contents); -} - -async function writeProjectFile(relativePath: string, contents: string) { - const pathname = join(tempRoot.current, relativePath); - await mkdir(dirname(pathname), { recursive: true }); - await writeFile(pathname, contents); -} - -beforeEach(() => { - deployMockState.reset(); -}); +describe("managed functions serve integration", () => { + it.live("starts cold without database or auth services and preserves function config", () => + Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + 'project_id = "test-project"\n\n[functions.disabled]\nenabled = false\n', + ), + ); + yield* Effect.promise(() => writeFunction("hello")); + yield* Effect.promise(() => writeFunction("disabled")); + const state = makeStack({ fingerprint: "same" }); + const control = processControl(); + const { layer } = setup(state, control, undefined, false); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); + yield* Deferred.await(state.started); + control.signal(); + yield* Fiber.join(fiber); + + expect((yield* state.service.status).phase).toBe("ready"); + expect(state.starts).toHaveLength(1); + expect(state.restarts).toHaveLength(0); + const config = state.preparedConfigs[0]; + const functionsCapability = config?.capabilities?.functions; + const settings = + functionsCapability !== undefined && "settings" in functionsCapability + ? functionsCapability.settings + : undefined; + expect(settings?.functions?.hello?.enabled).toBe(true); + expect(settings?.functions?.disabled?.enabled).toBe(false); + }), + ); -describe("functions serve integration", () => { - it.live("overlays each Function's env file on the shared fallback", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; + it.live("reuses a ready instance when the prepared effective config is unchanged", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const state = makeStack({ fingerprint: "same" }); + state.service.status = Effect.succeed(serviceStatus(state.service.id, "ready")); + const control = processControl(); + const { layer } = setup(state, control); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); + yield* Deferred.await(state.prepareCompleted); + control.signal(); + yield* Fiber.join(fiber); + expect(state.starts).toHaveLength(0); + expect(state.restarts).toHaveLength(0); + }), + ); - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + it.live("restarts the same registered instance when the effective config changes", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const state = makeStack({ fingerprint: "old", preparedFingerprint: "new" }); + const control = processControl(); + const { layer } = setup(state, control); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); + yield* Deferred.await(state.prepareCompleted); + control.signal(); + yield* Fiber.join(fiber); + expect(state.restarts).toHaveLength(1); + expect(state.service.id).toBe("functions"); + }), + ); - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile("world", "index.ts", 'Deno.serve(() => new Response("world"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["SHARED=shared", "GLOBAL_ONLY=global", ""].join("\n"), - ), - ); + it.live("forwards inspector settings and env precedence to the service candidate", () => + Effect.gen(function* () { yield* Effect.promise(() => - writeFunctionFile( - "hello", - ".env", - ["SHARED=hello", "FUNCTION_ONLY=hello", "SUPABASE_SKIP=ignored", ""].join("\n"), + writeProjectConfig( + [ + 'project_id = "test-project"', + "[edge_runtime]", + "deno_version = 2", + "[functions.hello]", + "verify_jwt = true", + 'entrypoint = "./functions/hello/main.ts"', + 'static_files = ["./functions/hello/data.json"]', + "", + ].join("\n"), ), ); + yield* Effect.promise(() => writeFunction("hello", "main.ts")); + yield* Effect.promise(() => writeFunction("hello", "data.json", "asset\n")); yield* Effect.promise(() => - writeFunctionFile("world", ".env", ["SHARED=world", "FUNCTION_ONLY=world", ""].join("\n")), - ); - - const { layer, out } = setupServe({ childSpawner }); - yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SHARED=shared"); - expect(envs).toContain("GLOBAL_ONLY=global"); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing functions config env"); - } - - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual({ - hello: expect.objectContaining({ - env: { SHARED: "hello", FUNCTION_ONLY: "hello" }, - }), - world: expect.objectContaining({ - env: { SHARED: "world", FUNCTION_ONLY: "world" }, - }), - }); - expect(out.stderrText).toContain( - "Env name cannot start with SUPABASE_, skipping: SUPABASE_SKIP\n", - ); - }); - }); - - it.live("uses an explicit env file instead of automatic Function env files", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + writeFile(join(tempRoot.current, "custom-map.json"), '{"imports":{}}\n'), ); yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["SOURCE=shared", "GLOBAL_ONLY=global", ""].join("\n"), + writeFile( + join(tempRoot.current, "supabase", "functions", ".env"), + "SHARED=shared\nTOKEN=shared\n", ), ); yield* Effect.promise(() => - writeFunctionFile("hello", ".env", "INVALID-KEY=must-not-be-read\n"), - ); - yield* Effect.promise(() => - writeProjectFile( - "custom.env", - ["SOURCE=explicit", "EXPLICIT_ONLY=explicit", ""].join("\n"), + writeFile( + join(tempRoot.current, "supabase", "functions", "hello", ".env"), + "TOKEN=function\n", ), ); - - const { layer } = setupServe({ childSpawner }); - yield* functionsServe(baseFlags({ envFile: Option.some("custom.env") })).pipe( - Effect.provide(layer), - Effect.flip, - ); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SOURCE=explicit"); - expect(envs).toContain("EXPLICIT_ONLY=explicit"); - expect(envs).not.toContain("GLOBAL_ONLY=global"); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), + const state = makeStack({ fingerprint: "old", preparedFingerprint: "new" }); + const control = processControl(); + const { layer } = setup(state, control); + const fiber = yield* Effect.forkChild( + serve( + baseFlags({ + inspectMode: Option.some("wait"), + inspectMain: true, + noVerifyJwt: Option.some(true), + importMap: Option.some("custom-map.json"), + }), + ).pipe(Effect.provide(layer)), ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing functions config env"); - } + yield* Deferred.await(state.prepareCompleted); + control.signal(); + yield* Fiber.join(fiber); + const config = state.restarts[0]; + const inspectorEndpoint = config?.endpoints?.inspector; expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual({ - hello: { - verifyJWT: true, - entrypointPath: "supabase/functions/hello/index.ts", - }, - }); - }); - }); - - it.live("fails before starting the runtime when a Function env file is malformed", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - const functionEnvPath = join(tempRoot.current, "supabase", "functions", "hello", ".env"); - yield* Effect.promise(() => writeFunctionFile("hello", ".env", "API-KEY=secret-value\n")); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + inspectorEndpoint !== undefined && "port" in inspectorEndpoint + ? inspectorEndpoint.port + : undefined, + ).toBe("auto"); + expect(config?.settings?.inspector).toEqual({ mode: "wait", main: true }); + expect(config?.settings?.functions?.hello?.verify_jwt).toBe(false); + expect(config?.settings?.functions?.hello?.entrypoint).toContain("main.ts"); + expect(config?.settings?.functions?.hello?.import_map).toContain("custom-map.json"); + expect(config?.settings?.functions?.hello?.static_files).toHaveLength(1); + expect(config?.settings?.edge_runtime?.deno_version).toBe(2); + const token = config?.settings?.functions?.hello?.env?.TOKEN; + expect(token === undefined ? undefined : Redacted.value(token)).toBe("function"); + const shared = config?.settings?.functions?.hello?.env?.SHARED; + expect(shared === undefined ? undefined : Redacted.value(shared)).toBe("shared"); + }), + ); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`); - expect(error.message).toContain("unexpected character '-' in variable name"); - expect(error.message).not.toContain("secret-value"); - expect(error.message).not.toContain('near "API-KEY=secret-value"'); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", + it.live("restarts on a source change and rebuilds watcher roots", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const externalName = `${basename(tempRoot.current)}-external.ts`; + const externalMapName = `${basename(tempRoot.current)}-map.json`; + const externalPath = join(tempRoot.current, "..", externalName); + const externalMapPath = join(tempRoot.current, "..", externalMapName); + yield* Effect.promise(() => writeFile(externalPath, "export const value = 1\n")); + yield* Effect.promise(() => + writeFile(externalMapPath, JSON.stringify({ imports: { external: `./${externalName}` } })), + ); + const state = makeStack({ fingerprint: "same" }); + const control = processControl(); + const watcher = fileWatcher(); + const { layer } = setup(state, control, watcher); + const fiber = yield* Effect.forkChild( + serve(baseFlags({ importMap: Option.some(`../${externalMapName}`) })).pipe( + Effect.provide(layer), ), - ).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }); - - it.live( - "starts the runtime from config-defined functions and wires env, binds, and telemetry", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/src/main.ts"', - 'import_map = "./functions/hello/deno.json"', - 'static_files = ["./shared/index.html"]', - "", - "[functions.disabled]", - "enabled = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "src/main.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - yield* Effect.promise(() => - writeProjectFile("supabase/shared/index.html", "

hello

\n"), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["HELLO=WORLD", "SUPABASE_SKIP=1", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", ".temp", "edge-runtime-version"), "1.73.13\n"), - ); - - const { layer, out, telemetry } = setupServe({ childSpawner }); - - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("error running container: exit 1"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_test-project", - projectId: "test-project", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_test-project", - projectId: "test-project", - }, - ]); - expect(telemetry.flushed).toBe(true); - expect(out.stderrText).toContain("Setting up Edge Functions runtime...\n"); - expect(out.stderrText).toContain("Skipped serving Function: disabled\n"); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain("--network"); - expect(dockerRun.args).toContain("supabase_network_test-project"); - expect(dockerRun.args).toContain("--add-host"); - expect(dockerRun.args).toContain("host.docker.internal:host-gateway"); - // The pin's content is applied verbatim as the tag: a bare pin - // stays bare, no `v` synthesized. - expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13"); - // The main service is `docker cp`-streamed in, never a single-file host bind. - expect( - extractFlagValues(dockerRun.args, "-v").some((value) => - value.includes(":/root/index.ts"), - ), - ).toBe(false); - const bringUpSteps = deployMockState.runCalls - .map((call) => call.args[0]) - .filter((step) => step === "create" || step === "cp" || step === "start"); - expect(bringUpSteps).toEqual(["create", "cp", "start"]); - expect(deployMockState.runCalls.map((call) => call.args.slice(0, 3))).toContainEqual([ - "cp", - "-", - "supabase_edge_runtime_test-project:/", - ]); - expect(extractFlagValues(dockerRun.args, "--workdir")).toEqual([ - toDockerPath(tempRoot.current), - ]); - expect(dockerRun.args[dockerRun.args.length - 1]).toBe( - "exec edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", - ); - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("HELLO=WORLD"); - expect(envs).not.toContain("SUPABASE_SKIP=1"); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing functions config env"); - } - - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual({ - hello: { - verifyJWT: true, - entrypointPath: "supabase/functions/hello/src/main.ts", - importMapPath: "supabase/functions/hello/deno.json", - staticFiles: ["supabase/shared/index.html"], - }, - }); + ); + yield* Deferred.await(state.prepareCompleted); + yield* Deferred.await(watcher.watched); + const changed = join(tempRoot.current, "supabase", "functions", "hello", "changed.ts"); + yield* Effect.promise(() => writeFunction("hello", "changed.ts")); + watcher.emit({ path: changed, type: "update" }); + yield* Effect.yieldNow; + yield* TestClock.adjust("600 millis"); + yield* Deferred.await(state.restarted); + control.signal(); + yield* Fiber.join(fiber); + expect(state.restarts).toHaveLength(1); + expect(watcher.paths.length).toBeGreaterThan(1); + expect(watcher.paths).not.toContain(dirname(externalPath)); + expect(state.subscriptions.logs).toBe(1); + expect(state.subscriptions.status).toBe(1); + expect(state.restartsWithObservation).toEqual([true]); + }).pipe(Effect.provide(TestClock.layer())), + ); - // The reload must carry bring-up's `--nginx-conf`; a bare `kong reload` - // re-renders nginx.conf from Kong's default template and drops the - // `email_templates` server GoTrue fetches. - expect(deployMockState.runCalls).toContainEqual({ - command: "docker", - args: [ - "exec", - "supabase_kong_test-project", - "kong", - "reload", - "--nginx-conf", - "/home/kong/custom_nginx.template", - ], - options: { stdout: "ignore", stderr: "pipe" }, - }); + it.live("subscribes to the instance before startup and reports a failed runtime", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const statusQueue = yield* Queue.unbounded(); + const state = makeStack({ fingerprint: "same", statusQueue }); + const control = processControl(); + const { layer, out } = setup(state, control); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); + + yield* Deferred.await(state.started); + expect(state.startsBeforeObservation).toEqual([true]); + yield* Queue.offer(statusQueue, serviceStatus(state.service.id, "failed")); + yield* Fiber.join(fiber); + + expect(out.stdoutText).toContain("Edge Runtime container is no longer available"); + }), + ); - expect(childSpawner.spawned).toEqual([ - { - command: "docker", - args: ["logs", "-f", "--timestamps", "supabase_edge_runtime_test-project"], - }, + it.live("attaches logs before startup and reports a completed log stream", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const functionsId = ServiceInstanceIdSchema.make("functions"); + const state = makeStack({ + fingerprint: "same", + logStream: Stream.fromIterable([ { - command: "docker", - args: [ - "container", - "inspect", - "supabase_edge_runtime_test-project", - "--format", - "{{json .State}}", - ], + cursor: { opaque: "function-log-1" }, + timestamp: new Date(0).toISOString(), + source: "functions" as const, + stream: "stdout" as const, + message: "function booted\n", + instanceId: functionsId, }, - ]); + ]), }); - }, - ); - - it.live("mounts multiline env values without placing their contents in docker argv", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - let multilineEnvDirWhenLogsStarted: string | undefined; - let multilineEnvDirExistedWhenLogsStarted = false; - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - onSpawn: () => { - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - if (dockerRun === undefined) { - throw new Error("expected docker create call before docker logs spawn"); - } - multilineEnvDirWhenLogsStarted = extractFlagValues(dockerRun.args, "-v") - .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z")) - ?.slice(0, -":/root/.supabase/multiline-env:ro,Z".length); - multilineEnvDirExistedWhenLogsStarted = - multilineEnvDirWhenLogsStarted !== undefined && - existsSync(multilineEnvDirWhenLogsStarted); - }, - }, - ]); - - const multilineValue = ["-----BEGIN KEY-----", "EOF_ENV_0", "line-3", "-----END KEY-----"].join( - "\n", - ); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - [`MULTILINE_SECRET="${multilineValue}"`, ""].join("\n"), - ), - ); - - const { layer } = setupServe({ childSpawner }); - - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - expect(error).toBeInstanceOf(Error); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain( - Effect.runSync(getRegistryImageUrl(dockerfileServiceImage("edgeruntime"))), - ); - expect(dockerRun.args.join(" ")).not.toContain(multilineValue); - expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); - - const multilineBind = extractFlagValues(dockerRun.args, "-v").find((value) => - value.endsWith(":/root/.supabase/multiline-env:ro,Z"), - ); - expect(multilineBind).toBeDefined(); - if (multilineBind === undefined) { - throw new Error("expected multiline env bind"); - } - - const options = - typeof dockerRun.options === "object" && dockerRun.options !== null - ? dockerRun.options - : undefined; - const script = - options !== undefined && "multilineEnvScript" in options - ? (options.multilineEnvScript as string | undefined) - : undefined; - const files = - options !== undefined && "multilineEnvFiles" in options - ? (options.multilineEnvFiles as Record | undefined) - : undefined; - - expect(script).toBeDefined(); - expect(files).toBeDefined(); - expect(script).toContain( - 'MULTILINE_SECRET="$(cat /root/.supabase/multiline-env/values/env-0; printf x)"', - ); - expect(script).toContain('export MULTILINE_SECRET="${MULTILINE_SECRET%x}"'); - expect(script).not.toContain(multilineValue); - expect(script).not.toContain("EOF_ENV_0"); - expect(files?.["env-0"]).toBe(multilineValue); - expect(multilineEnvDirWhenLogsStarted).toBeDefined(); - if (multilineEnvDirWhenLogsStarted === undefined) { - throw new Error("expected multiline env dir when docker logs started"); - } - expect(multilineEnvDirExistedWhenLogsStarted).toBe(true); - expect(existsSync(multilineEnvDirWhenLogsStarted)).toBe(false); - }); - }); - - it.live( - "cleans up a stale multiline-env directory from a previous run even when this run has no multiline secrets", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - }, - ]); + const control = processControl(); + const { layer, out } = setup(state, control); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); - const staleMultilineEnvDir = join( - tempRoot.current, - "supabase", - ".temp", - "start-secrets", - "supabase_edge_runtime_test-project", - "multiline-env", - ); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", "functions", ".env"), ["HELLO=WORLD", ""].join("\n")), - ); - // Simulates a stale directory left behind by an earlier run that had multiline secrets. - yield* Effect.promise(async () => { - await mkdir(join(staleMultilineEnvDir, "values"), { recursive: true, mode: 0o700 }); - await writeFile(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n"); - await writeFile(join(staleMultilineEnvDir, "values", "env-0"), "stale secret\n"); - }); - - const { layer } = setupServe({ childSpawner }); - - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - expect(error).toBeInstanceOf(Error); + yield* Deferred.await(state.started); + yield* Fiber.join(fiber); - expect(existsSync(staleMultilineEnvDir)).toBe(false); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - expect( - extractFlagValues(dockerRun.args, "-v").some((value) => - value.endsWith(":/root/.supabase/multiline-env:ro,Z"), - ), - ).toBe(false); - }); - }, + expect(out.stdoutText).toContain("function booted\n"); + expect(out.stdoutText).toContain("Edge Runtime container is no longer available"); + expect(state.subscriptions.logs).toBe(1); + }), ); - it.live("fails before startup when a multiline env name is not a shell identifier", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ['FOO.BAR="line-1\nline-2"', ""].join("\n"), - ), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("invalid multiline environment variable name"); - expect(error.message).toContain("FOO.BAR"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }); - - it.live("sanitizes dotenv parse failures from config env files", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => writeProjectFile(".env.development", "API-KEY=secret-value\n")); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("failed to parse environment file:"); - expect(error.message).toContain(".env.development"); - expect(error.message).toContain("unexpected character '-' in variable name"); - expect(error.message).not.toContain("secret-value"); - expect(error.message).not.toContain('near "API-KEY=secret-value"'); - } - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("skips missing unused import map targets during serve startup", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "unused-alias/": "../missing-shared/", - }, - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + it.live("returns on shutdown while a watcher restart is in flight", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const state = makeStack({ fingerprint: "same", restart: "blocked" }); + const control = processControl(); + const watcher = fileWatcher(); + const { layer } = setup(state, control, watcher); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); + yield* Deferred.await(state.prepareCompleted); + yield* Deferred.await(watcher.watched); + const changed = join(tempRoot.current, "supabase", "functions", "hello", "changed.ts"); + yield* Effect.promise(() => writeFunction("hello", "changed.ts")); + watcher.emit({ path: changed, type: "update" }); + yield* Effect.yieldNow; + yield* TestClock.adjust("600 millis"); + yield* Deferred.await(state.restartStarted); + control.signal(); + yield* Fiber.join(fiber); + expect(state.restarts).toHaveLength(1); + }).pipe(Effect.provide(TestClock.layer())), + ); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("error running container: exit 1"); - } - expect( - deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toBe(true); - }); - }); - - it.live("binds deno.json import map references outside the project root", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "external import map logs failed", - }, - ]); - - return Effect.gen(function* () { - const externalImportMapPath = join(dirname(tempRoot.current), "shared-import-map.json"); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFile(externalImportMapPath, JSON.stringify({ imports: {} })), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - importMap: "../../../../shared-import-map.json", - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("external import map logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create invocation"); - } - // `buildDockerBinds` realpath-resolves host paths, so compare against the - // resolved path (on macOS the temp dir lives under /var -> /private/var). - const resolvedExternalImportMapPath = realpathSync(externalImportMapPath); - expect( - extractFlagValues(dockerRun.args, "-v").some( - (value) => - value.startsWith(`${resolvedExternalImportMapPath}:`) && - value.endsWith("/shared-import-map.json:ro"), - ), - ).toBe(true); - }); - }); - - it.live("binds git-root workspace imports for serve", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "workspace import logs failed", - }, - ]); - - return Effect.gen(function* () { - const sharedPath = join(tempRoot.current, "packages", "shared", "src", "index.ts"); - - yield* Effect.promise(() => mkdir(join(tempRoot.current, ".git"), { recursive: true })); - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile("packages/shared/src/index.ts", 'export const shared = "hello"\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "index.ts", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "@repo/shared": "../../../packages/shared/src/index.ts", - }, - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("workspace import logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create invocation"); - } - const resolvedSharedPath = realpathSync(sharedPath); - expect( - extractFlagValues(dockerRun.args, "-v").some( - (value) => - value.startsWith(`${resolvedSharedPath}:`) && - value.endsWith("/packages/shared/src/index.ts:ro"), - ), - ).toBe(true); - }); - }); - - it.live( - "mounts a workspace package once when functions import the directory and its files", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "overlapping bind logs failed", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => mkdir(join(tempRoot.current, ".git"), { recursive: true })); - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile("packages/orm/index.ts", 'export * from "./core/foo.ts";\n'), - ); - yield* Effect.promise(() => - writeProjectFile("packages/orm/core/foo.ts", 'export const foo = "foo";\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "index.ts", - [ - 'import { foo } from "@proj/orm/core/foo.ts";', - 'import "@proj/orm/index.ts";', - "Deno.serve(() => new Response(foo))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "@proj/orm/": "../../../packages/orm/", - }, - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("overlapping bind logs failed"); - } - - const dockerCreate = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerCreate).toBeDefined(); - if (dockerCreate === undefined) { - throw new Error("expected docker create invocation"); - } - const bindValues = extractFlagValues(dockerCreate.args, "-v"); - const resolvedOrmDir = realpathSync(join(tempRoot.current, "packages", "orm")); - expect(bindValues.some((value) => value.startsWith(`${resolvedOrmDir}:`))).toBe(true); - expect(bindValues.filter((value) => value.startsWith(`${resolvedOrmDir}/`))).toEqual([]); - }); - }, - ); - - it.live("keeps --workdir when an import-map ancestor mount absorbs every project bind", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "ancestor mount logs failed", - }, - ]); - - return Effect.gen(function* () { - const realRoot = realpathSync(tempRoot.current); - const projectDir = join(realRoot, "apps", "api"); - const functionDir = join(projectDir, "supabase", "functions", "hello"); - yield* Effect.promise(async () => { - await mkdir(join(realRoot, ".git"), { recursive: true }); - await mkdir(functionDir, { recursive: true }); - await mkdir(join(realRoot, "apps", "shared"), { recursive: true }); - await writeFile( - join(projectDir, "supabase", "config.toml"), - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ); - await writeFile(join(realRoot, "apps", "shared", "index.ts"), 'export const s = "s";\n'); - await writeFile( - join(functionDir, "index.ts"), - ['import { s } from "~/shared/index.ts";', "Deno.serve(() => new Response(s))", ""].join( - "\n", - ), - ); - await writeFile( - join(functionDir, "deno.json"), - JSON.stringify({ imports: { "~/": "../../../../" } }), - ); - }); - - const { layer } = setupServe({ workdir: projectDir, childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("ancestor mount logs failed"); - } - - const dockerCreate = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerCreate).toBeDefined(); - if (dockerCreate === undefined) { - throw new Error("expected docker create invocation"); - } - const appsDir = join(realRoot, "apps"); - const bindValues = extractFlagValues(dockerCreate.args, "-v"); - expect(bindValues.some((value) => value.startsWith(`${appsDir}:`))).toBe(true); - expect(bindValues.filter((value) => value.startsWith(`${appsDir}/`))).toEqual([]); - expect(extractFlagValues(dockerCreate.args, "--workdir")).toEqual([toDockerPath(projectDir)]); - }); - }); - - it.live("leaves the existing container alone when create loses a name conflict", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create") { - return { - exitCode: 1, - stdout: "", - stderr: - 'Conflict. The container name "/supabase_edge_runtime_test-project" is already in use', - }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig('project_id = "test-project"\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - const steps = deployMockState.runCalls.map((call) => call.args.slice(0, 2)); - const createIndex = steps.findIndex(([first]) => first === "create"); - expect(createIndex).toBeGreaterThan(-1); - expect( - steps - .slice(createIndex + 1) - .some(([first, second]) => first === "container" && second === "rm"), - ).toBe(false); - }); - }); - - it.live("removes the created container when the bootstrap copy fails", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "cp") { - return { exitCode: 1, stdout: "", stderr: "cp target is gone" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig('project_id = "test-project"\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - expect(String(error)).toContain( - "failed to copy edge runtime main service into container: cp target is gone", - ); - const steps = deployMockState.runCalls.map((call) => call.args.slice(0, 2)); - const createIndex = steps.findIndex(([first]) => first === "create"); - expect(createIndex).toBeGreaterThan(-1); - expect(steps.some(([first]) => first === "start")).toBe(false); - expect( - steps - .slice(createIndex + 1) - .some(([first, second]) => first === "container" && second === "rm"), - ).toBe(true); - }); - }); - - it.live("binds per-function deno.json scope targets outside a nested project repository", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const processControl = mockQueuedProcessControl(); - const childSpawner = mockDockerLogSpawner([{ pending: true }]); - - return Effect.gen(function* () { - const workspaceRoot = tempRoot.current; - const projectRoot = join(workspaceRoot, "infra", "my-project"); - const rootDenoJson = join(workspaceRoot, "deno.json"); - const libsDir = join(workspaceRoot, "libs"); - - yield* Effect.promise(() => mkdir(join(workspaceRoot, ".git"), { recursive: true })); - yield* Effect.promise(async () => { - await writeProjectFile(join("infra", "my-project", ".git"), "gitdir: ignored\n"); - await writeProjectFile( - "deno.json", - JSON.stringify({ - workspace: ["./libs/*", "./infra/*/supabase/functions/*"], - imports: { "@acme/thing": "./libs/thing/index.ts" }, - }), - ); - await writeProjectFile( - join("infra", "my-project", "supabase", "config.toml"), - 'project_id = "test-project"\n', - ); - await writeProjectFile( - join("libs", "thing", "deno.json"), - JSON.stringify({ name: "@acme/thing", version: "1.0.0", exports: "./index.ts" }), - ); - await writeProjectFile(join("libs", "thing", "index.ts"), "export const thing = 1\n"); - const functionRelative = join("infra", "my-project", "supabase", "functions", "hello"); - await writeProjectFile( - join(functionRelative, "index.ts"), - 'import { thing } from "@acme/thing"\nDeno.serve(() => new Response(String(thing)))\n', - ); - const sharedDenoJson = JSON.stringify({ - imports: { "@std/assert": "jsr:@std/assert@1" }, - scopes: { - __local: { - __workspace: "../../../../../deno.json", - __libs: "../../../../../libs", - }, - }, - }); - await writeProjectFile(join(functionRelative, "deno.json"), sharedDenoJson); - const worldRelative = join("infra", "my-project", "supabase", "functions", "world"); - await writeProjectFile( - join(worldRelative, "index.ts"), - 'import { thing } from "@acme/thing"\nDeno.serve(() => new Response(String(thing)))\n', - ); - await writeProjectFile(join(worldRelative, "deno.json"), sharedDenoJson); - }); - - const resolvedWorkspaceRoot = realpathSync(workspaceRoot); - const resolvedLibsDir = realpathSync(libsDir); - const watchedFunctionsDir = join(projectRoot, "supabase", "functions"); - const fileWatcher = mockFileWatcher([watchedFunctionsDir]); - const { layer, out } = setupServe({ - childSpawner, - fileWatcher, - processControl, - workdir: projectRoot, - }); - const fiber = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* fileWatcher.awaitExpectedWatch; - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create invocation"); - } - const bindValues = extractFlagValues(dockerRun.args, "-v"); - const resolvedRootDenoJson = realpathSync(rootDenoJson); - expect(bindValues).toContain(`${resolvedRootDenoJson}:${toDockerPath(rootDenoJson)}:ro`); - expect(bindValues).toContain(`${resolvedLibsDir}:${toDockerPath(libsDir)}:ro`); - const rootDenoJsonWarn = `WARN: Mounting import map scope target outside the project root: ${resolvedRootDenoJson}\n`; - const libsWarn = `WARN: Mounting import map scope target outside the project root: ${resolvedLibsDir}\n`; - expect(out.rawChunks.filter((chunk) => chunk.text === rootDenoJsonWarn)).toEqual([ - { text: rootDenoJsonWarn, stream: "stderr" }, - ]); - expect(out.rawChunks.filter((chunk) => chunk.text === libsWarn)).toEqual([ - { text: libsWarn, stream: "stderr" }, - ]); - const watchedPaths = fileWatcher.watchCalls.map((call) => call.path); - expect(watchedPaths).toContain(watchedFunctionsDir); - expect(watchedPaths).not.toContain(resolvedWorkspaceRoot); - expect(watchedPaths).not.toContain(resolvedLibsDir); - expect(fileWatcher.watchCalls).toContainEqual( - expect.objectContaining({ path: watchedFunctionsDir, recursive: true }), - ); - - processControl.signal("SIGINT"); - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - }); - }); - - it.live( - "does not let an ancestor project's deno.json get misattributed to this project's own function when --workdir names a config-less subdirectory of it", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - const nestedWorkdir = join(tempRoot.current, "nested", "dir"); - - return Effect.gen(function* () { - // Ancestor project: a config.toml plus a function with an entrypoint - // and a deno.json, at the same slug the sub-project below serves. - yield* Effect.promise(() => writeCliConfig('project_id = "ancestor-project"\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("ancestor"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - // The sub-project has its own entrypoint but no deno.json or - // config.toml, making it "config-less" relative to the ancestor. - yield* Effect.promise(() => - mkdir(join(nestedWorkdir, "supabase", "functions", "hello"), { recursive: true }), - ); - yield* Effect.promise(() => - writeFile( - join(nestedWorkdir, "supabase", "functions", "hello", "index.ts"), - "Deno.serve(() => new Response())\n", - ), - ); - - const { layer } = setupServe({ childSpawner, workdir: nestedWorkdir }); - yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const functionsConfigEntry = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfigEntry).toBeDefined(); - if (functionsConfigEntry === undefined) { - throw new Error("missing functions config env"); - } - const functionsConfig = JSON.parse( - functionsConfigEntry.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length), - ); - // "hello" is still served, just with no import map, since the - // ancestor's deno.json must never be borrowed for it. - expect(functionsConfig).toHaveProperty("hello"); - expect(functionsConfig.hello).not.toHaveProperty("importMapPath"); - }); - }, - ); - - it.live("restarts the runtime when watched files change", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const fileWatcher = mockFileWatcher(); - const childSpawner = mockDockerLogSpawner([ - { pending: true }, - { exitCode: 1, stderr: "docker logs exited with 1" }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer, out } = setupServe({ fileWatcher, childSpawner }); - const fiber = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ).length === 1, - "timed out waiting for first docker create", - ); - - fileWatcher.emit([ - { - path: join(tempRoot.current, "supabase", "functions", "hello", "index.ts"), - type: "update", - }, - { - path: join(tempRoot.current, "supabase", "functions", "hello", "helper.ts"), - type: "create", - }, - ]); - - const error = yield* Fiber.join(fiber).pipe(Effect.flip); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("docker logs exited with 1"); - } - - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(2); - // Prints the fsnotify op token (WRITE/CREATE/REMOVE), not the - // internal event-type name. - expect(out.stderrText).toContain( - `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "index.ts")} (WRITE)`, - ); - expect(out.stderrText).toContain( - `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "helper.ts")} (CREATE)`, - ); - - // The restart wrapper reloads Kong after each successful bring-up: - // once for the initial start, once for the file-change-triggered restart. - expect( - deployMockState.runCalls.filter( - (call) => - call.command === "docker" && - call.args[0] === "exec" && - call.args.includes("supabase_kong_test-project") && - call.args.includes("reload"), - ), - ).toHaveLength(2); - }); - }); - - it.live("stops serving cleanly on a process signal", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const processControl = mockQueuedProcessControl(); - const childSpawner = mockDockerLogSpawner([{ pending: true }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer, out } = setupServe({ processControl, childSpawner }); - const fiber = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "create", - ), - "timed out waiting for docker create", - ); - processControl.signal("SIGINT"); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - out.stdoutText - .replaceAll("\u001b[1m", "") - .replaceAll("\u001b[22m", "") - .replaceAll("\\", "/"), - ).toContain("Stopped serving supabase/functions\n"); - }); - }); - - it.live("does not remove the existing runtime when interrupted before startup owns it", () => { - const processControl = mockQueuedProcessControl(); - // Blocks startup at the DB assertion (`container inspect`), the last - // pre-ownership step before removing the existing container. If JWKS - // resolution ever moves before this assertion, the pending fetch would - // hang here and this test would fail on the waitFor timeout instead. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { pending: true }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( - () => - new Promise(() => { - // Intentionally pending — must never be reached before the assertion. - }), - ); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example.com"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer, out } = setupServe({ processControl }); - const fiber = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "container" && - call.args[1] === "inspect", - ), - "timed out waiting for the DB inspect", - ); - processControl.signal("SIGINT"); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - deployMockState.runCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "container" && - call.args[1] === "rm" && - call.args.includes("supabase_edge_runtime_test-project"), - ), - ).toBe(false); - // No remote JWKS request either — JWKS resolves only after the DB - // assertion succeeds. - expect(fetchMock).not.toHaveBeenCalled(); - expect(out.stdoutText).toContain("Stopped serving"); - }); - }); - - it.live( - "cleans up staged secrets when interrupted while reloading Kong after a successful bring-up", - () => { - const processControl = mockQueuedProcessControl(); - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - // Hangs Kong reload so the interrupt lands after bring-up succeeds - // (secrets staged, runtime started) but before `reloadKong` returns. - return { pending: true }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ pending: true }]); - - const stagingDir = join( - tempRoot.current, - "supabase", - ".temp", - "start-secrets", - "supabase_edge_runtime_test-project", - ); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ processControl, childSpawner }); - const fiber = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "exec", - ), - "timed out waiting for Kong reload to start", - ); - expect(existsSync(stagingDir)).toBe(true); - processControl.signal("SIGINT"); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - - expect( - deployMockState.runCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "container" && - call.args[1] === "rm" && - call.args.includes("supabase_edge_runtime_test-project"), - ), - ).toBe(true); - expect(existsSync(stagingDir)).toBe(false); - }); - }, - ); - - describe("shutdown vs. container-exit outcomes", () => { - function baseDockerRunHandler() { - return (command: string, args: ReadonlyArray) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - // The plain pre-create DB check and stale-container removal, never the - // `--format`-qualified `inspectContainerState` calls: those go through - // `childSpawner`, the same `ChildProcessSpawner` `docker logs -f` uses. - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - } - - // Models `inspectContainerState`'s `docker container inspect --format {{json .State}}` reply. - function inspectStateBehavior(running: boolean, exitCode = 0): LogProcessBehavior { - return { - exitCode: 0, - stdout: JSON.stringify({ - Status: running ? "running" : "exited", - Running: running, - ExitCode: exitCode, - }), - stderr: "", - }; - } - - function containerInspectCalls(childSpawner: ReturnType) { - return childSpawner.spawned.filter( - (call) => - call.command === "docker" && call.args[0] === "container" && call.args[1] === "inspect", - ); - } - - async function writeHelloFunction() { - await writeCliConfig(['project_id = "test-project"', ""].join("\n")); - await writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); - await writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); - } - - it.live( - "exits cleanly when a shutdown signal and a docker-logs failure land in the same tick (Windows console-signal tie-break)", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const processControl = mockQueuedProcessControl(); - // Signaling from `onSpawn` fires the instant the mocked `docker logs -f` spawns, - // forcing the shutdown signal and its failure into the same tick. - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "docker logs killed by signal", - onSpawn: () => processControl.signal("SIGINT"), - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ processControl, childSpawner }); - const fiber = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - out.stdoutText - .replaceAll("\u001b[1m", "") - .replaceAll("\u001b[22m", "") - .replaceAll("\\", "/"), - ).toContain("Stopped serving supabase/functions\n"); - }); - }, - ); - - it.live( - "downgrades a docker-logs failure to a clean shutdown when the signal lands within the grace window", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const processControl = mockQueuedProcessControl(); - // Delays the signal past the log-stream failure so only the grace window, not a - // same-tick race, can produce a clean exit. A generous injected grace period keeps - // this margin independent of the real clock. - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "docker logs killed by signal", - onSpawn: () => { - Effect.runFork( - Effect.sleep(Duration.millis(15)).pipe( - Effect.andThen(Effect.sync(() => processControl.signal("SIGINT"))), - ), - ); - }, - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ processControl, childSpawner }); - const fiber = yield* serveWithTimers(baseFlags(), { - shutdownSignalGracePeriod: Duration.seconds(2), - }).pipe(Effect.provide(layer), Effect.forkChild({ startImmediately: true })); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - out.stdoutText - .replaceAll("\u001b[1m", "") - .replaceAll("\u001b[22m", "") - .replaceAll("\\", "/"), - ).toContain("Stopped serving supabase/functions\n"); - }); - }, - ); - - it.live( - "downgrades a startup failure to a clean shutdown when the signal lands within the grace window", - () => { - const processControl = mockQueuedProcessControl(); - // Delays the signal past the startup failure so only the grace window, not a - // same-tick race, can produce a clean exit. A generous injected grace period keeps - // this margin independent of the real clock. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - Effect.runFork( - Effect.sleep(Duration.millis(15)).pipe( - Effect.andThen(Effect.sync(() => processControl.signal("SIGINT"))), - ), - ); - return { - exitCode: 1, - stdout: "", - stderr: "Error: No such container: supabase_db_test-project", - }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ processControl }); - const fiber = yield* serveWithTimers(baseFlags(), { - shutdownSignalGracePeriod: Duration.seconds(2), - }).pipe(Effect.provide(layer), Effect.forkChild({ startImmediately: true })); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - out.stdoutText - .replaceAll("\u001b[1m", "") - .replaceAll("\u001b[22m", "") - .replaceAll("\\", "/"), - ).toContain("Stopped serving supabase/functions\n"); - }); - }, - ); - - it.live( - "still fails with a tagged error when the container crashes with a non-zero exit code", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - // `docker logs -f` itself exits 0 (the container it tails stopped), so - // `streamContainerLogs` inspects the container's own exit code next. - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0 }, - inspectStateBehavior(false, 1), - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(EdgeRuntimeContainerCrashedError); - if (error instanceof EdgeRuntimeContainerCrashedError) { - expect(error.exitCode).toBe(1); - expect(error.message).toContain("supabase_edge_runtime_test-project"); - expect(error.message).toContain("exit 1"); - // A runtime we launched died on its own: our bug, not the user's, - // and specifically not `unknown`. - expect(error[ErrorActionabilityId]).toEqual(actionability.runtimeCrash); - } - }); - }, - ); - - it.live( - "ends the session successfully, with a distinct message, when a supervisor tears the container down (exit 143)", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0 }, - inspectStateBehavior(false, 143), - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ childSpawner }); - const exit = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stdoutText).toContain("Edge Runtime container stopped (exit 143)."); - expect(out.stdoutText).toContain("Stopped serving"); - }); - }, - ); - - it.live( - "still fails as an internal runtime crash for a real crash signal (SIGSEGV, 139)", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0 }, - inspectStateBehavior(false, 139), - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(EdgeRuntimeContainerCrashedError); - if (error instanceof EdgeRuntimeContainerCrashedError) { - expect(error.exitCode).toBe(139); - expect(error[ErrorActionabilityId]).toEqual(actionability.runtimeCrash); - } - }); - }, - ); - - it.live( - "ends the session normally, with a distinct message, when the container exits gracefully (exit 0)", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0 }, - inspectStateBehavior(false, 0), - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ childSpawner }); - const exit = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stdoutText).toContain("Edge Runtime exited (code 0)."); - expect(out.stdoutText).toContain("Stopped serving"); - }); - }, - ); - - it.live( - "ends the session normally when the container is removed before the follow-up inspect can run", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: - "Error response from daemon: No such container: supabase_edge_runtime_test-project", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ childSpawner }); - const exit = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stdoutText).toContain("Edge Runtime container is no longer available."); - expect(out.stdoutText).toContain("Stopped serving"); - }); - }, - ); - - it.live( - "re-attaches instead of reporting a graceful exit when docker logs exits 0 but the container is still running", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - // The first `docker logs -f` exit is a stream EOF while the container - // keeps running; only the second is its real stop. - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0 }, - inspectStateBehavior(true, 0), - { exitCode: 0 }, - inspectStateBehavior(false, 0), - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer, out } = setupServe({ childSpawner }); - const exit = yield* serveWithTimers(baseFlags(), { - dockerLogRetryDelay: Duration.millis(1), - }).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stdoutText).toContain("Stopped serving"); - expect( - childSpawner.spawned.filter( - (call) => call.command === "docker" && call.args[0] === "logs", - ), - ).toHaveLength(2); - expect(containerInspectCalls(childSpawner)).toHaveLength(2); - }); - }, - ); - - it.live( - "re-attaches on the second consecutive-since timestamp when logs resume with progress", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const childSpawner = mockDockerLogSpawner([ - { exitCode: 0, stdout: "2024-01-01T00:00:00.000000000Z hello\n" }, - inspectStateBehavior(true, 0), - { exitCode: 0 }, - inspectStateBehavior(false, 0), - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer } = setupServe({ childSpawner }); - const exit = yield* serveWithTimers(baseFlags(), { - dockerLogRetryDelay: Duration.millis(1), - }).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - const logsCalls = childSpawner.spawned.filter( - (call) => call.command === "docker" && call.args[0] === "logs", - ); - expect(logsCalls).toHaveLength(2); - expect(logsCalls[0]?.args).not.toContain("--since"); - expect(logsCalls[1]?.args).toContain("--since"); - expect(logsCalls[1]?.args).toContain("2024-01-01T00:00:00.000000000Z"); - }); - }, - ); - - it.live( - "fails with a tagged error after repeatedly losing the log stream while the container stays running", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const reattachPair: ReadonlyArray = [ - { exitCode: 1, stderr: "docker logs connection reset" }, - inspectStateBehavior(true, 0), - ]; - const childSpawner = mockDockerLogSpawner( - Array.from({ length: 6 }, () => reattachPair).flat(), - ); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer } = setupServe({ childSpawner }); - const error = yield* serveWithTimers(baseFlags(), { - dockerLogRetryDelay: Duration.millis(1), - }).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(EdgeRuntimeLogStreamLostError); - if (error instanceof EdgeRuntimeLogStreamLostError) { - expect(error.containerId).toBe("supabase_edge_runtime_test-project"); - expect(error.message).toContain("supabase_edge_runtime_test-project"); - expect(error.message).toContain("5 times"); - } - }); - }, - ); - - it.live( - "classifies an unreachable docker daemon as user-actionable rather than unknown", - () => { - deployMockState.runHandler = baseDockerRunHandler(); - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(DockerLogsStreamError); - if (error instanceof DockerLogsStreamError) { - expect(error.daemonDown).toBe(true); - expect(error[ErrorActionabilityId]).toEqual({ - ...actionability.dockerNotRunning, - fingerprint_suffix: "docker_not_running", - }); - } - }); - }, - ); - - it.live("still fails when the edge runtime container never comes up", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create") { - return { exitCode: 1, stdout: "", stderr: "failed to create container" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(writeHelloFunction); - - const { layer } = setupServe({}); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("failed to create container"); - } - }); - }); - }); - - it.live("passes inspect, debug, and custom network settings through to edge-runtime", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "inspect failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ - debug: true, - networkId: Option.some("custom-network"), - childSpawner, - }); - - const error = yield* functionsServe( - baseFlags({ - inspectMode: Option.some("wait"), - inspectMain: true, - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("inspect failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain("--network"); - expect(dockerRun.args).toContain("custom-network"); - expect(dockerRun.args).toContain("-p"); - expect(dockerRun.args).toContain("8083:8083"); - - const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; - expect(commandScript).toContain("--inspect-wait=0.0.0.0:8083"); - expect(commandScript).toContain("--inspect-main"); - expect(commandScript).toContain("--verbose"); - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SUPABASE_INTERNAL_DEBUG=true"); - expect(envs).toContain("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0"); - expect(deployMockState.networkCalls).toEqual([ - { networkMode: "custom-network", projectId: "test-project" }, - ]); - }); - }); - - it.live("injects the Deno runtime template without the TypeScript-only preamble", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "template logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe({ childSpawner }); - yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; - expect(commandScript).toBe( - "exec edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", - ); - - const cp = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "cp", - ); - expect(cp).toBeDefined(); - if (cp === undefined) { - throw new Error("expected docker cp call"); - } - const cpOptions: unknown = cp.options; - const stdin = - typeof cpOptions === "object" && cpOptions !== null && "stdin" in cpOptions - ? cpOptions.stdin - : undefined; - // Narrows the mock-recorded `unknown`; the `instanceof Uint8Array` check still guards. - const isCpArchiveStream = (value: unknown): value is Stream.Stream => - Stream.isStream(value); - expect(isCpArchiveStream(stdin)).toBe(true); - if (!isCpArchiveStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); - const chunks = yield* Stream.runCollect(stdin); - const archiveBytes = chunks[0]; - if (!(archiveBytes instanceof Uint8Array)) { - return yield* Effect.die("docker cp stdin did not contain archive bytes"); - } - const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); - const mainService = files.get("root/index.ts"); - if (mainService === undefined) { - return yield* Effect.die("docker cp archive did not contain root/index.ts"); - } - const template = yield* Effect.promise(() => mainService.text()); - expect(template.length).toBeGreaterThan(0); - expect(template).not.toContain("@ts-nocheck"); - expect(template).not.toContain("declare const Deno"); - expect(template).not.toContain("declare const EdgeRuntime"); - expect(commandScript).not.toContain("@ts-nocheck"); - }); - }); - - it.live("maps the configured inspector_port to the container inspector port", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }; - - const childSpawner = mockDockerLogSpawner([ - { exitCode: 1, stderr: "inspect port logs failed" }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime]", - 'policy = "per_worker"', - "inspector_port = 9229", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe({ childSpawner }); - yield* functionsServe(baseFlags({ inspect: true })).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain("-p"); - expect(dockerRun.args).toContain("9229:8083"); - expect(dockerRun.args).not.toContain("8083:8083"); - }); - }); - - it.live("fetches remote jwks for enabled third-party auth providers", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - - return Effect.gen(function* () { - const remoteKeys = [ - { - kty: "RSA", - kid: "remote-key", - alg: "RS256", - use: "sig", - n: "abc", - e: "AQAB", - }, - ]; - - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://issuer.example/.well-known/openid-configuration") { - return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === "https://issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner, fetch: fetchMock }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("jwks logs failed"); - } - - expect(fetchMock).toHaveBeenCalledTimes(2); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ - keys: expect.arrayContaining([ - expect.objectContaining({ kid: "remote-key" }), - expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), - expect.objectContaining({ kty: "oct" }), - ]), - }); - }); - }); - - it.live( - "falls back to local jwks when remote jwks resolution fails for enabled third-party auth providers", - () => { - return Effect.gen(function* () { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("oidc discovery failed"); - }); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner, fetch: fetchMock }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("jwks logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ - keys: expect.arrayContaining([ - expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), - expect.objectContaining({ kty: "oct" }), - ]), - }); - }); - }, - ); - - it.live( - "does not fail startup on a malformed third-party provider config when auth is disabled", - () => { - // Config validation's "required field" check for third-party providers - // only runs when auth is enabled, and `functions serve`'s JWKS - // resolution discards its own error unconditionally either way. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - - return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch"); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth]", - "enabled = false", - "", - "[auth.third_party.workos]", - "enabled = true", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("jwks logs failed"); - } - expect(fetchMock).not.toHaveBeenCalled(); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ - keys: expect.arrayContaining([ - expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), - expect.objectContaining({ kty: "oct" }), - ]), - }); - }); - }, - ); - - it.live("includes config-defined edge runtime secrets in the runtime env", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime]", - 'policy = "per_worker"', - "inspector_port = 8083", - "", - "[edge_runtime.secrets]", - 'FROM_CONFIG = "config-value"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("secrets logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("FROM_CONFIG=config-value"); - }); - }); - - it.live("uppercases config secret names, skipping empty and unresolved values", () => { - // Config secret keys are uppercased before the map is read; only entries - // with a resolved (non-empty) value are kept, skipping empty or - // still-unresolved `env(VAR)` literals. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime.secrets]", - 'my_lower_secret = "keep-me"', - 'EMPTY_SECRET = ""', - 'UNRESOLVED_SECRET = "env(SERVE_SECRET_NEVER_SET)"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("secrets logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("MY_LOWER_SECRET=keep-me"); - expect(envs.some((entry) => entry.startsWith("my_lower_secret="))).toBe(false); - expect(envs.some((entry) => entry.startsWith("EMPTY_SECRET="))).toBe(false); - expect(envs.some((entry) => entry.startsWith("UNRESOLVED_SECRET="))).toBe(false); - }); - }); - - it.live("uses the resolved project_id when deriving docker resource names", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const envName = "SUPABASE_SERVE_PROJECT_ID"; - const previous = process.env[envName]; - process.env[envName] = "env-backed-project"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env[envName]; - } else { - process.env[envName] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeCliConfig([`project_id = "env(${envName})"`, ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("serve logs failed"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_env-backed-project", - projectId: "env-backed-project", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_env-backed-project", - projectId: "env-backed-project", - }, - ]); - expect(deployMockState.runCalls).toContainEqual( - expect.objectContaining({ - command: "docker", - args: ["container", "inspect", "supabase_db_env-backed-project"], - }), - ); - }); - }); - - it.live( - "prefers the legacy SUPABASE_PROJECT_ID override when deriving docker resource names", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "config-project"', - "", - "[functions.hello]", - "verify_jwt = true", - "", - "[remotes.override]", - 'project_id = "overrideprojectaaaaa"', - "", - "[remotes.override.functions.hello]", - "verify_jwt = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ - childSpawner, - projectId: Option.some("overrideprojectaaaaa"), - }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("serve logs failed"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_overrideprojectaaaaa", - projectId: "overrideprojectaaaaa", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_overrideprojectaaaaa", - projectId: "overrideprojectaaaaa", - }, - ]); - expect(deployMockState.runCalls).toContainEqual( - expect.objectContaining({ - command: "docker", - args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], - }), - ); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); - } - - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual( - expect.objectContaining({ - hello: expect.objectContaining({ - verifyJWT: false, - }), - }), - ); - }); - }, - ); - - it.live("fails inspect flag conflicts before startup work begins", () => { - return Effect.gen(function* () { - const { layer } = setupServe(); - const error = yield* functionsServe( - baseFlags({ - inspect: true, - inspectMode: Option.some("run"), - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain( - "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", - ); - } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - }); - }); - - it.live("fails when the project config is malformed", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig("not valid toml ][")); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(JSON.stringify(error)).toContain("CliConfigParseError"); - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("fails when the local database is not running", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { - exitCode: 1, - stdout: "", - stderr: "Error: No such container: supabase_db_test-project", - }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(ServeLocalDbNotRunningError); - if (error instanceof ServeLocalDbNotRunningError) { - expect(error.message).toContain("supabase start is not running."); - expect(error[ErrorActionabilityId]).toEqual(actionability.startStack); - } - }); - }); - - it.live("surfaces a down docker daemon as the inspect failure with the install hint", () => { - // No upfront docker precheck: a down daemon surfaces from the DB - // container inspect as `failed to inspect service: `, - // with the Docker Desktop install hint attached as a suggestion. - const daemonDownStderr = - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 1, stdout: "", stderr: daemonDownStderr }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(ServeLocalDbInspectError); - if (error instanceof ServeLocalDbInspectError) { - expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); - expect(error.message).not.toContain("failed to run docker"); - expect(error.daemonDown).toBe(true); - expect(error[ErrorActionabilityId]).toEqual({ - ...actionability.dockerNotRunning, - fingerprint_suffix: "docker_not_running", - }); - } - expect(error).toHaveProperty( - "suggestion", - "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", - ); - - expect(deployMockState.runCalls).toEqual([ - expect.objectContaining({ - command: "docker", - args: ["container", "inspect", "supabase_db_test-project"], - }), - ]); - expect(deployMockState.volumeCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - }); - }); - - it.live("keeps the install hint when no container runtime is installed at all", () => { - // Missing docker/podman binaries are treated the same as a missing - // daemon socket; the spawn-failure cause must survive into the - // `failed to inspect service: …` message instead of being blanked. - const runtimeNotFoundMessage = - "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { failure: new Error(runtimeNotFoundMessage) }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(ServeLocalDbInspectError); - if (error instanceof ServeLocalDbInspectError) { - expect(error.message).toBe(`failed to inspect service: ${runtimeNotFoundMessage}`); - expect(error.message).not.toContain("failed to run docker"); - expect(error.daemonDown).toBe(true); - } - expect(error).toHaveProperty( - "suggestion", - "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", - ); - }); - }); - - it.live("fails with the config error, not a docker error, when both are broken", () => { - deployMockState.runHandler = () => ({ - exitCode: 1, - stdout: "", - stderr: - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", - }); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig("not valid toml ][")); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toHaveProperty("_tag", "CliConfigParseError"); - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("makes no remote JWKS request when docker is down", () => { - // JWKS is fetched only after the DB assertion, so a down daemon's error - // surfaces immediately without waiting on any OIDC/JWKS request. - const daemonDownStderr = - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 1, stdout: "", stderr: daemonDownStderr }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - throw new Error(`unexpected fetch before the DB assertion: ${url}`); - }); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(ServeLocalDbInspectError); - if (error instanceof ServeLocalDbInspectError) { - expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); - expect(error.daemonDown).toBe(true); - } - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - it.live("fails with the auth config error, not a docker error, when both are broken", () => { - deployMockState.runHandler = () => ({ - exitCode: 1, - stdout: "", - stderr: - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", - }); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - ['project_id = "test-project"', "", "[auth]", 'jwt_secret = "short"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe( - "Invalid config for auth.jwt_secret. Must be at least 16 characters", - ); - } - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("resolves env() config values from root env development files", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "root env logs failed" }]); - const previousSupabaseEnv = process.env["SUPABASE_ENV"]; - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig([`project_id = "env(ROOT_PROJECT_ID)"`, ""].join("\n")), - ); - yield* Effect.promise(() => - writeProjectFile(".env.development", "ROOT_PROJECT_ID=root-env-project\n"), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - process.env["SUPABASE_ENV"] = "development"; - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("root env logs failed"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_root-env-project", - projectId: "root-env-project", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_root-env-project", - projectId: "root-env-project", - }, - ]); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousSupabaseEnv === undefined) { - delete process.env["SUPABASE_ENV"]; - } else { - process.env["SUPABASE_ENV"] = previousSupabaseEnv; - } - }), - ), - ); - }); - - it.live( - "resolves numeric env() config values from root env development files before decode", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { exitCode: 1, stderr: "root api env logs failed" }, - ]); - const previousSupabaseEnv = process.env["SUPABASE_ENV"]; - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - ['project_id = "test-project"', "[api]", 'port = "env(ROOT_API_PORT)"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => writeProjectFile(".env.development", "ROOT_API_PORT=5544\n")); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - process.env["SUPABASE_ENV"] = "development"; - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("root api env logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SUPABASE_INTERNAL_HOST_PORT=5544"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousSupabaseEnv === undefined) { - delete process.env["SUPABASE_ENV"]; - } else { - process.env["SUPABASE_ENV"] = previousSupabaseEnv; - } - }), - ), - ); - }, - ); - - it.live( - "does not publish default jwks fallbacks when signing_keys_path is configured but empty", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { exitCode: 1, stderr: "empty signing keys logs failed" }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[auth]", - 'signing_keys_path = "./signing-keys.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", "signing-keys.json"), "[]\n"), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("empty signing keys logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - - const parsed = JSON.parse(jwks.slice("SUPABASE_JWKS=".length)) as { - readonly keys: ReadonlyArray>; - }; - expect( - parsed.keys.some((key) => key["kid"] === "b81269f1-21d8-4f2e-b719-c2240a840d90"), - ).toBe(false); - expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); - }); - }, - ); - - it.live("fails when the explicit env file is missing", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* functionsServe( - baseFlags({ - envFile: Option.some(".env"), - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain(".env"); - expect(error.message).toContain("no such file or directory"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }); - - it.live("surfaces the real filesystem error when the functions path is not a directory", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - writeFileSync(join(tempRoot.current, "supabase", "functions"), "not a directory\n"); - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - "", - ].join("\n"), - ), - ); - - const { layer, out } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("ENOTDIR"); - expect(error.message).toContain(join("supabase", "functions")); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect(out.stderrText).toContain("Setting up Edge Functions runtime...\n"); - expect(deployMockState.runCalls.map((call) => call.args.slice(0, 2))).toEqual([ - ["container", "inspect"], - ["container", "rm"], - ]); - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }); - - it.live("preserves the primary error when artifact cleanup also fails", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ['FOO.BAR="line-1\nline-2"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", ".temp", "start-secrets"), "not a directory\n"), - ); - - const { layer, out } = setupServe(); - const exit = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) { - throw new Error("expected functions serve to fail"); - } - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("invalid multiline environment variable name"); - expect(error.message).toContain("FOO.BAR"); - expect(error.message).not.toContain("ENOTDIR"); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect(out.messages).toContainEqual({ - type: "warn", - message: expect.stringContaining("Failed to clean up Edge Runtime artifacts: ENOTDIR"), - }); - expect(out.messages).toContainEqual({ - type: "warn", - message: expect.stringContaining(join("supabase", ".temp", "start-secrets")), - }); - expect(out.messages).not.toContainEqual({ - type: "warn", - message: expect.stringContaining("An error occurred in Effect.tryPromise"), - }); - expect(deployMockState.runCalls.filter((call) => call.args[0] === "create")).toHaveLength(0); - }); - }); - - describe("Config.Validate / dotenv / env-override parity (CLI-1963)", () => { - it.live( - "fails before any Docker work when config.toml has an explicit empty project_id", - () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig('project_id = ""\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe("Missing required field in config: project_id"); - } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }, - ); - - it.live( - "fails before any Docker work on an unrelated Config.Validate branch (unsupported Postgres major version)", - () => { - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe( - "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", - ); - } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }, - ); - - it.live( - "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeCliConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); - }); - }, - ); - - it.live( - "uses SUPABASE_NETWORK_ID as the docker network when no --network-id flag is passed", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeCliConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(deployMockState.networkCalls).toEqual([ - { networkMode: "env-network", projectId: "test-project" }, - ]); - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun?.args).toContain("env-network"); - }); - }, - ); - - it.live("prefers an explicit --network-id flag over SUPABASE_NETWORK_ID", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ); - - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner, networkId: Option.some("flag-network") }); - yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(deployMockState.networkCalls).toEqual([ - { networkMode: "flag-network", projectId: "test-project" }, - ]); - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun?.args).toContain("flag-network"); - expect(dockerRun?.args).not.toContain("env-network"); - }); - }); - }); - - it.live("surfaces the real filesystem error when the fallback env file is unreadable", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - // A directory at the fallback path makes the read fail with a non-ENOENT error (EISDIR). - yield* Effect.promise(() => - mkdir(join(tempRoot.current, "supabase", "functions", ".env"), { recursive: true }), - ); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("EISDIR"); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }); - - it.live.skipIf(isRoot)( - "surfaces the real filesystem error when the env staging dir cannot be created", - () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - // A read-only parent makes the per-container staging-dir mkdir fail with EACCES. - const stagingRoot = join(tempRoot.current, "supabase", ".temp", "start-secrets"); - yield* Effect.promise(() => mkdir(stagingRoot, { recursive: true })); - yield* Effect.promise(() => chmod(stagingRoot, 0o555)); - - const { layer } = setupServe(); - const error = yield* functionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - Effect.ensuring(Effect.promise(() => chmod(stagingRoot, 0o755))), - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("EACCES"); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }, + it.live("returns on shutdown while startup preparation is blocked", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig()); + yield* Effect.promise(() => writeFunction("hello")); + const state = makeStack({ fingerprint: "same", prepare: "blocked" }); + const control = processControl(); + const { layer } = setup(state, control); + const fiber = yield* Effect.forkChild(serve(baseFlags()).pipe(Effect.provide(layer))); + yield* Deferred.await(state.prepareStarted); + control.signal(); + yield* Fiber.join(fiber); + expect(state.service.id).toBe("functions"); + }), ); }); diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts new file mode 100644 index 0000000000..e5b2360a1e --- /dev/null +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -0,0 +1,192 @@ +import { BunServices } from "@effect/platform-bun"; +import { + ConfigProvider, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Schema, + Stream, +} from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { afterAll, expect, test } from "vitest"; +import { + makeTempCliProject, + makeTempHome, + runSupabase, + spawnSupabase, +} from "../../../../tests/helpers/cli.ts"; +import { openStack, StackIdSchema } from "@supabase/stack/effect"; + +const host = ManagedRuntime.make(Layer.merge(BunServices.layer, FetchHttpClient.layer)); +afterAll(() => host.dispose()); +const statusSchema = Schema.fromJsonString( + Schema.Struct({ + endpoints: Schema.Struct({ api: Schema.Struct({ url: Schema.String }) }), + instances: Schema.Array(Schema.Struct({ service: Schema.String, phase: Schema.String })), + }), +); +const stackIdentitySchema = Schema.fromJsonString( + Schema.Struct({ identity: Schema.Struct({ id: StackIdSchema }) }), +); + +test.each(["native", "container"] as const)( + "keeps managed Functions available after the serving CLI exits without starting PostgreSQL (%s)", + { timeout: 240_000 }, + async (runtime) => { + const home = makeTempHome(); + const project = await makeTempCliProject("supabase-functions-client-"); + const options = { cwd: project.dir, home: home.dir, env: { SUPABASE_EXPERIMENTAL_STACK: "1" } }; + await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functions = path.join(project.dir, "supabase", "functions", "hello"); + const external = path.join(project.dir, "external"); + yield* fs.makeDirectory(functions, { recursive: true }); + yield* fs.makeDirectory(external, { recursive: true }); + yield* fs.writeFileString( + path.join(project.dir, "supabase", "config.toml"), + [ + 'project_id = "functions-client"', + "[experimental]", + "stack = true", + "[functions.external]", + "verify_jwt = false", + 'entrypoint = "../external/index.ts"', + 'import_map = "../external/deno.json"', + 'static_files = ["../external/asset.txt"]', + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(functions, "index.ts"), + 'Deno.serve(() => new Response("still-serving"));\n', + ); + yield* fs.writeFileString( + path.join(external, "deno.json"), + '{"imports":{"external-helper":"./helper.ts"}}\n', + ); + yield* fs.writeFileString( + path.join(external, "helper.ts"), + 'export const externalMessage = "external-helper";\n', + ); + yield* fs.writeFileString(path.join(external, "asset.txt"), "external-asset\n"); + yield* fs.writeFileString( + path.join(external, "index.ts"), + [ + 'import { externalMessage } from "external-helper";', + "", + "Deno.serve(async () =>", + ' new Response(`${externalMessage}:${await Deno.readTextFile(new URL("./asset.txt", import.meta.url))}`),', + ");", + "", + ].join("\n"), + ); + }), + ); + let serving: ReturnType | undefined; + let functionsReady: Promise | undefined; + const failures: unknown[] = []; + try { + const prepared = await runSupabase( + [ + "stack", + "prepare", + "--runtime", + runtime === "container" ? "docker" : "native", + "--capability", + "functions", + ], + { ...options, exitTimeoutMs: 180_000 }, + ); + expect(prepared.exitCode, prepared.stderr).toBe(0); + const preparedStatus = await runSupabase( + ["stack", "status", "--output-format", "json"], + options, + ); + expect(preparedStatus.exitCode, preparedStatus.stderr).toBe(0); + const identity = await host.runPromise( + Schema.decodeUnknownEffect(stackIdentitySchema)(preparedStatus.stdout), + ); + serving = spawnSupabase(["functions", "serve", "--no-verify-jwt"], options); + await serving.waitForOutput(/Serving functions on/, 120_000); + const stack = await host.runPromise( + openStack(identity.identity.id).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ SUPABASE_HOME: home.dir })), + ), + ), + ); + const functions = await host.runPromise(stack.services.get({ name: "functions" })); + // The stream begins with the current snapshot, so it covers startup already in progress. + functionsReady = host.runPromise( + Stream.runHead( + functions.followStatus.pipe( + Stream.filter( + (status) => + status.phase === "ready" || status.phase === "failed" || status.phase === "stopped", + ), + ), + ).pipe( + Effect.flatMap((snapshot) => + Option.match(snapshot, { + onNone: () => Effect.fail(new Error("Functions status stream ended before ready")), + onSome: (status) => + status.phase === "ready" + ? Effect.succeed(status) + : Effect.fail(new Error(`Functions startup ended in ${status.phase}`)), + }), + ), + Effect.asVoid, + Effect.timeout("120 seconds"), + ), + ); + await functionsReady; + const result = await runSupabase(["stack", "status", "--output-format", "json"], options); + expect(result.exitCode, result.stderr).toBe(0); + const status = await host.runPromise(Schema.decodeUnknownEffect(statusSchema)(result.stdout)); + expect(status.instances.find(({ service }) => service === "database")?.phase).toBe("stopped"); + expect(status.instances.find(({ service }) => service === "functions")?.phase).toBe("ready"); + const request = HttpClient.get(new URL("/functions/v1/hello", status.endpoints.api.url)).pipe( + Effect.flatMap((response) => response.text), + Effect.timeout("15 seconds"), + ); + expect(await host.runPromise(request)).toBe("still-serving"); + const externalRequest = HttpClient.get( + new URL("/functions/v1/external", status.endpoints.api.url), + ).pipe( + Effect.flatMap((response) => response.text), + Effect.timeout("15 seconds"), + ); + const externalResponse = await host.runPromise(externalRequest); + expect(externalResponse).toBe("external-helper:external-asset\n"); + serving.kill("SIGINT"); + const exited = await serving.waitForExit(30_000); + expect(exited.exitCode, exited.stderr).toBe(0); + serving = undefined; + expect(await host.runPromise(request)).toBe("still-serving"); + } catch (error) { + failures.push(error); + } + try { + if (serving !== undefined) { + serving.kill("SIGTERM"); + await serving.waitForExit(30_000); + } + if (functionsReady !== undefined) await Promise.allSettled([functionsReady]); + const destroyed = await runSupabase(["stack", "destroy", "--yes"], { + ...options, + exitTimeoutMs: 120_000, + }); + expect(destroyed.exitCode, destroyed.stderr).toBe(0); + await project.cleanup(); + home[Symbol.dispose](); + } catch (error) { + failures.push(error); + } + if (failures.length > 0) throw new AggregateError(failures, "Functions CLI lifecycle failed"); + }, +); diff --git a/apps/cli/src/commands/migration/migration.layers.ts b/apps/cli/src/commands/migration/migration.layers.ts index a599f2f9be..96c87eee45 100644 --- a/apps/cli/src/commands/migration/migration.layers.ts +++ b/apps/cli/src/commands/migration/migration.layers.ts @@ -13,7 +13,6 @@ import { linkedDbResolverRuntimeLayer } from "../../command-internal/management- import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; import { stackApiLayer } from "../../command-internal/stack-api.ts"; import { bundledPostgresClientLayer } from "../../command-internal/bundled-postgres-client.ts"; -import { ephemeralPostgresLayer } from "../../command-internal/stack-shadow.ts"; import { stackCatalogSetupLayer } from "../../command-internal/stack-catalog-setup.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -70,6 +69,5 @@ export const migrationSquashRuntimeLayer = Layer.mergeAll( debugLoggerLayer, stackApiLayer, bundledPostgresClientLayer, - ephemeralPostgresLayer, stackCatalogSetupLayer, ); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index c3c13cfe3d..0e4ab0e3c6 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -7,9 +7,12 @@ full schema into the target file, and deleting the merged files — then either suggests `migration repair` (local target) or prompts to baseline the remote migration-history table to match. -When `[experimental].stack` is on, the shadow is `EphemeralPostgres` under -`$SUPABASE_HOME/managed/ephemeral-postgres//` (`~/.supabase/managed/…` by default). -On the stack backend, squash dumps through catalog `pg_dump` (native artifact or a one-shot container of the same image). There is no PATH fallback. +When `[experimental].stack` is on, the shadow is a registered database service in the project stack, +with a unique instance ID and a managed SQL endpoint. Its data is owned under +`$SUPABASE_HOME/managed/stacks//data/instances//` +(`~/.supabase/managed/…` by default). +The shadow uses the catalog `pg_dump` client: native shadows use the prepared artifact and +container shadows use a one-shot container of the matching image. ## Files Read diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index cf02f3e25d..c3d945aa60 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -1,8 +1,6 @@ import { Effect, FileSystem, Option, Path, Predicate } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; -import { resolveEphemeralPostgresRelease } from "@supabase/stack/effect"; - import { cobraMutuallyExclusiveErrorMessage } from "../../../shared/cli/cobra-flag-groups.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -100,9 +98,7 @@ const squashMigrations = Effect.fnUntraced(function* ( toml: DbTomlValues, ) { const stackBackend = (yield* currentStackBackend).kind === "stack"; - const resolvedShadowImage = stackBackend - ? "stack-ephemeral" - : yield* localInputs.resolvePostgresImage; + const resolvedShadowImage = stackBackend ? "stack" : yield* localInputs.resolvePostgresImage; const shadowInput = shadowRunInputFromLocalContainerInputs( localInputs, resolvedShadowImage, @@ -126,27 +122,36 @@ const squashMigrations = Effect.fnUntraced(function* ( return yield* stackWithShadowDatabase(shadowInput, (handle) => Effect.scoped( Effect.gen(function* () { + const credentials = yield* handle.service.credentials; + if (credentials === undefined) + return yield* new MigrationSquashDumpError({ + message: "stack shadow database credentials are unavailable", + }); const stackConn: PgConnInput = { host: handle.host, port: handle.port, user: "postgres", - password: toml.password, + password: credentials.password, database: "postgres", }; const networkIdFlag = yield* NetworkIdFlag; const networkId = Option.getOrUndefined(networkIdFlag); const dumpUsesHostNetwork = toolContainerUsesHostNetwork(networkId); + const descriptor = yield* handle.service.describe; const dumpRuntime = bundledPostgresClientRuntime(handle.runtime, runtimeInfo.platform, runtimeInfo.arch) ?? handle.runtime; - const release = yield* resolveEphemeralPostgresRelease(handle.ephemeral.version).pipe( - Effect.orElseSucceed(() => undefined), - ); - const image = release?.image ?? localInputs.bootstrapConfig.postgresImage; + const containerArtifactPrefix = + handle.runtime.kind === "container" ? `container:${handle.runtime.engine}:` : ""; + const image = + containerArtifactPrefix.length > 0 && + handle.artifactIdentity.startsWith(containerArtifactPrefix) + ? handle.artifactIdentity.slice(containerArtifactPrefix.length) + : localInputs.bootstrapConfig.postgresImage; const dumpClient = { kind: "bundled" as const, command: "pg_dump" as const, - version: handle.ephemeral.version, + version: descriptor.config.version, runtime: dumpRuntime, }; const dumpConn: PgConnInput = diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 2d4a6cb9bf..804ec6f560 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -51,6 +51,7 @@ import { import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; +import { stackApiLayer } from "../../../command-internal/stack-api.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; import { migrationSquash } from "./squash.handler.ts"; @@ -345,6 +346,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), debugLogger, alwaysReadyHttpClientLayer, + stackApiLayer.pipe(Layer.provide(BunServices.layer)), mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(DebugFlag, false), diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index e7aefc2c06..7af9e69168 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -57,6 +57,7 @@ import { DbPullMigrationConflictError } from "../../command-internal/db-pull-run import { BundledPostgresClient } from "../../command-internal/bundled-postgres-client.ts"; import { DockerRun } from "../../command-internal/docker-run.service.ts"; import { EdgeRuntimeScript } from "../../command-internal/edge-runtime-script.service.ts"; +import { stackApiLayer } from "../../command-internal/stack-api.ts"; import { MigrationFetchWriteError } from "../migration/fetch/fetch.errors.ts"; import { PgDeltaSslProbe } from "../../command-internal/pgdelta-ssl-probe.service.ts"; import { Output } from "../../shared/output/output.service.ts"; @@ -642,6 +643,9 @@ function setup(opts: SetupOpts = {}) { opts.dbHistoryUpdateFails ?? false, ); const pgDelta = makePgDeltaEngine(opts.diffOutcome ?? (() => ({ changes: false }))); + const stackApi = stackApiLayer.pipe( + Layer.provide(Layer.mergeAll(BunServices.layer, spawner.layer)), + ); const cliSettings = mockCommandSettings({ workdir: opts.workdir ?? tempRoot.current, @@ -667,6 +671,7 @@ function setup(opts: SetupOpts = {}) { capturingStdio?.layer ?? Stdio.layerTest({ args: Effect.succeed(["pull"]) }), dbConfig.layer, pgDelta.layer, + stackApi, Layer.succeed(EdgeRuntimeScript, { run: () => Effect.die("migra edge runtime unused — every db step forces pg-delta"), }), diff --git a/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts index ba942485dc..f1ff5f2291 100644 --- a/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts @@ -2,8 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { edgeRuntimeNofileUlimit } from "../../../shared/stack-constants.ts"; -import { ConfigProvider, Deferred, Effect, Exit, Sink, Stream } from "effect"; +import { ConfigProvider, Deferred, Effect, Exit, Layer, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { afterEach, beforeEach, vi } from "vitest"; @@ -100,6 +101,12 @@ function baseInput(workdir: string): EdgeRuntimeBringUpInput { }; } +function startEdgeRuntimeContainerForTest(input: EdgeRuntimeBringUpInput) { + return startStackEdgeRuntimeContainer(input).pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ); +} + function envEntries(runCall: { args: ReadonlyArray; env?: Readonly>; @@ -133,7 +140,7 @@ describe("startStackEdgeRuntimeContainer", () => { ...baseInput(tempWorkdir.current), projectEnvValues: { BITBUCKET_CLONE_DIR: tempWorkdir.current }, }; - yield* startStackEdgeRuntimeContainer(input).pipe( + yield* startEdgeRuntimeContainerForTest(input).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord({})), @@ -151,7 +158,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -168,7 +175,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -193,7 +200,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -211,7 +218,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -242,7 +249,7 @@ describe("startStackEdgeRuntimeContainer", () => { const out = mockOutput(); const input = baseInput(tempWorkdir.current); - yield* startStackEdgeRuntimeContainer({ + yield* startEdgeRuntimeContainerForTest({ ...input, configDeclaredFunctions: { [slug]: fnConfig }, configFunctions: { [slug]: fnConfig }, @@ -262,7 +269,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -278,7 +285,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -300,7 +307,7 @@ describe("startStackEdgeRuntimeContainer", () => { image: "registry.example.com/supabase/edge-runtime:v1.99.9", }; - yield* startStackEdgeRuntimeContainer(input).pipe( + yield* startEdgeRuntimeContainerForTest(input).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -319,7 +326,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -370,7 +377,7 @@ describe("startStackEdgeRuntimeContainer", () => { image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", }; - yield* startStackEdgeRuntimeContainer(input).pipe( + yield* startEdgeRuntimeContainerForTest(input).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -413,7 +420,7 @@ describe("startStackEdgeRuntimeContainer", () => { ); const out = mockOutput(); - const error = yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + const error = yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), Effect.flip, @@ -441,7 +448,7 @@ describe("startStackEdgeRuntimeContainer", () => { ); const out = mockOutput(); - const error = yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + const error = yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), Effect.flip, @@ -463,7 +470,9 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - const started = yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + const started = yield* startEdgeRuntimeContainerForTest( + baseInput(tempWorkdir.current), + ).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -500,7 +509,7 @@ describe("startStackEdgeRuntimeContainer", () => { }; const exit = yield* Effect.exit( - startStackEdgeRuntimeContainer(input).pipe( + startEdgeRuntimeContainerForTest(input).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ), @@ -518,7 +527,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); @@ -536,7 +545,7 @@ describe("startStackEdgeRuntimeContainer", () => { const mock = mockDockerSpawner(); const out = mockOutput(); - yield* startStackEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + yield* startEdgeRuntimeContainerForTest(baseInput(tempWorkdir.current)).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), Effect.provide(out.layer), ); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index f70502d2d3..e3e6024d28 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1,13 +1,49 @@ #!/usr/bin/env bun +import { BunRuntime } from "@effect/platform-bun"; +import { Cause, Data, Effect } from "effect"; import { + NATIVE_PROCESS_DISPATCH_SENTINEL, + SUPERVISOR_DISPATCH_SENTINEL, runNativeProcessIfDispatched, runSupervisorProcessIfDispatched, } from "@supabase/stack/internal/supervisor"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "./shared/telemetry/error-actionability.ts"; + +export class CliEntrypointError extends Data.TaggedError("CliEntrypointError")<{ + readonly message: string; + readonly cause: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.unknown; + } +} const argv = process.argv.slice(2); -if ( - !(await runSupervisorProcessIfDispatched(argv)) && - !(await runNativeProcessIfDispatched(argv)) -) { - await import("./cli/main.ts"); +const main = Effect.gen(function* () { + if (yield* runSupervisorProcessIfDispatched(argv)) return; + if (yield* runNativeProcessIfDispatched(argv)) return; + yield* Effect.tryPromise({ + try: () => import("./cli/main.ts"), + catch: (cause) => new CliEntrypointError({ message: "CLI entrypoint failed", cause }), + }); +}); + +if (argv[0] === NATIVE_PROCESS_DISPATCH_SENTINEL || argv[0] === SUPERVISOR_DISPATCH_SENTINEL) { + BunRuntime.runMain(main); +} else { + // The imported CLI runner owns signals and process lifetime, so avoid a second SIGINT owner. + Effect.runFork( + main.pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + process.stderr.write(`${Cause.pretty(cause)}\n`); + process.exitCode = 1; + }), + ), + ), + ); } diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index 6b9198ca21..634c2f79c3 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -50,3 +50,12 @@ export class FunctionImportNotDirectoryError extends Data.TaggedError( return actionability.invalidConfig; } } + +export class FunctionDeployError extends Data.TaggedError("FunctionDeployError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.unknown; + } +} diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index bca11feef2..c22955520b 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1,6 +1,4 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; -import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { URL } from "node:url"; import { FunctionResponse_Output, @@ -12,7 +10,19 @@ import { inferFunctionsManifest, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config/effect"; -import { Duration, Effect, Option, Schema } from "effect"; +import { + Cause, + Clock, + Config, + Duration, + Effect, + FileSystem, + Predicate, + Option, + Path, + Schema, +} from "effect"; +import * as PlatformError from "effect/PlatformError"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { promptYesNo } from "../../command-internal/prompt-yes-no.ts"; @@ -21,7 +31,6 @@ import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; import { bold } from "../../command-internal/colors.ts"; import { viperEnvStringWithProjectFallback } from "../../command-internal/viper-env.ts"; -import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, explicitBooleanLongFlag, @@ -37,6 +46,7 @@ import { import { ConflictingFunctionDeployFlagsError, FunctionDeployCancelledError, + FunctionDeployError, FunctionImportNotDirectoryError, InvalidFunctionDeploySlugError, NoFunctionsToDeployError, @@ -56,14 +66,21 @@ import { } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; +import { FunctionFilesError, planFunctionFiles } from "@supabase/stack/internal/functions/files"; + +const mapFunctionDeployError = ( + message: string, + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.mapError((cause) => new FunctionDeployError({ message, cause }))); +const JsonString = Schema.fromJsonString(Schema.Unknown); +const decodeJson = Schema.decodeUnknownSync(JsonString); +const encodeJson = Schema.encodeSync(JsonString); const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DEPLOY_RATE_LIMIT_MAX_RETRIES = 8; const SUPABASE_FUNCTIONS_DIR = "supabase/functions"; const IMPORT_MAP_GUIDE_URL = "https://supabase.com/docs/guides/functions/import-maps"; -const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:\//; -const importPathPattern = - /(?:import|export)\s+(?:type\s+)?(?:{[^{}]+}|.*?)\s*(?:from)?\s*['"](.*?)['"]|import\(\s*['"](.*?)['"]\)/gi; export function shouldChmodBundleOutputDirectory(platform: NodeJS.Platform) { return platform !== "win32"; @@ -204,7 +221,7 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray 0 ? relativePath : basename(resolved)); +function toApiRelativePath(path: Path.Path, cwd: string, hostPath: string) { + const resolved = path.resolve(hostPath); + const relativePath = path.relative(cwd, resolved); + return toSlash(relativePath.length > 0 ? relativePath : path.basename(resolved)); } -function isContainedPath(root: string, candidate: string) { - const relativePath = relative(resolve(root), resolve(candidate)); +function isContainedPath(path: Path.Path, root: string, candidate: string) { + const relativePath = path.relative(path.resolve(root), path.resolve(candidate)); return ( relativePath === "" || - (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)) + (!path.isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`)) ); } -function isContainedInAnyPath(roots: ReadonlyArray, candidate: string) { - return roots.some((root) => isContainedPath(root, candidate)); -} - /** * Rejects any path containing a `..` segment before it's uploaded. A workdir that differs from * the git root can otherwise produce a multipart `File` name like @@ -389,24 +404,36 @@ function hasParentPathSegment(relativePath: string) { .some((segment) => segment === ".."); } -async function realpathIfExists(pathname: string) { - try { - return await realpath(resolve(pathname)); - } catch (error) { - // ENOTDIR (a path routed through a file) is as nonexistent as ENOENT here. - if ( - error instanceof Error && - "code" in error && - (error.code === "ENOENT" || error.code === "ENOTDIR") - ) { - return resolve(pathname); - } - throw error; +const resolveFunctionsSourceRoot = Effect.fnUntraced(function* (projectRoot: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(projectRoot); + for (;;) { + const hasGitMarker = yield* fs.stat(path.join(current, ".git")).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(false) : Effect.fail(error), + ), + ); + if (hasGitMarker) return current; + const parent = path.dirname(current); + if (parent === current) return path.resolve(projectRoot); + current = parent; } +}); + +const isNotSymbolicLink = (error: PlatformError.PlatformError): boolean => { + if (!Predicate.isTagged(error.reason, "Unknown")) return false; + const cause = error.reason.cause; + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EINVAL"; +}; + +function defaultFunctionEntrypoint(path: Path.Path, functionsDir: string, slug: string) { + return path.join(functionsDir, slug, "index.ts"); } -async function resolveFunctionsSourceRoot(projectRoot: string) { - return (await findGitRootPath(projectRoot)) ?? resolve(projectRoot); +function defaultFunctionImportMap(path: Path.Path, functionsDir: string, slug: string) { + return path.join(functionsDir, slug, "deno.json"); } function humanSize(bytes: number) { @@ -424,730 +451,134 @@ function humanSize(bytes: number) { return `${value.toFixed(precision)} ${units[index]}`; } -function stripJsonComments(contents: string): string { - const src = contents.replace(/^\uFEFF/, ""); - const out: Array = []; - let pendingCommaIndex = -1; - let index = 0; - while (index < src.length) { - const char = src.charAt(index); - - if (char === '"') { - pendingCommaIndex = -1; - out.push(char); - index += 1; - while (index < src.length) { - const stringChar = src.charAt(index); - out.push(stringChar); - index += 1; - if (stringChar === "\\") { - if (index < src.length) { - out.push(src.charAt(index)); - index += 1; - } - } else if (stringChar === '"') { - break; - } - } - continue; - } - - if (char === "/" && src.charAt(index + 1) === "/") { - index += 2; - while (index < src.length && src.charAt(index) !== "\n") { - index += 1; - } - continue; - } - - if (char === "/" && src.charAt(index + 1) === "*") { - index += 2; - while (index < src.length && !(src.charAt(index) === "*" && src.charAt(index + 1) === "/")) { - index += 1; - } - index += 2; - continue; - } - - if (char === ",") { - pendingCommaIndex = out.length; - out.push(char); - index += 1; - continue; - } - - if (char === "}" || char === "]") { - if (pendingCommaIndex >= 0) { - out[pendingCommaIndex] = ""; - pendingCommaIndex = -1; - } - out.push(char); - index += 1; - continue; - } - - if (char === " " || char === "\t" || char === "\n" || char === "\r") { - out.push(char); - index += 1; - continue; - } - - pendingCommaIndex = -1; - out.push(char); - index += 1; - } - return out.join(""); -} - -function resolveImportTarget(jsonPath: string, target: string) { - if (target.startsWith("/")) { - return target; - } - - try { - const parsed = new URL(target); - if (parsed.protocol.length > 0) { - return target; - } - } catch { - // Fall through. - } - - const resolved = toSlash(join(dirname(jsonPath), target)); - const normalized = - resolved.startsWith("/") || - WINDOWS_ABSOLUTE_PATH.test(resolved) || - resolved.startsWith("./") || - resolved.startsWith("../") - ? resolved - : `./${resolved}`; - return target.endsWith("/") && !normalized.endsWith("/") ? `${normalized}/` : normalized; -} - -function isRemoteImportTarget(target: string) { - if (target.startsWith("/") || WINDOWS_ABSOLUTE_PATH.test(target)) { - return false; - } - try { - const parsed = new URL(target); - return parsed.protocol.length > 0; - } catch { - return false; - } -} - -function getObjectProperty(input: object, key: string): unknown { - return Reflect.get(input, key); -} - -function readStringMap(input: unknown, fieldName: string): Record { - if (input === undefined) { - return {}; - } - if (typeof input !== "object" || input === null || Array.isArray(input)) { - throw new Error(`failed to parse import map: expected ${fieldName} to be an object`); - } - - const values: Record = {}; - for (const [key, value] of Object.entries(input)) { - if (typeof value !== "string") { - throw new Error(`failed to parse import map: expected ${fieldName}.${key} to be a string`); - } - values[key] = value; - } - return values; -} - -class ImportMapFile { - readonly imports: Record; - readonly scopes: Record>; - readonly importMapReference: string; - - constructor( - imports: Record = {}, - scopes: Record> = {}, - importMapReference = "", - ) { - this.imports = imports; - this.scopes = scopes; - this.importMapReference = importMapReference; - } - - static fromUnknown(input: unknown) { - const imports: Record = {}; - const scopes: Record> = {}; - let importMapReference = ""; - - if (typeof input === "object" && input !== null) { - const importMap = getObjectProperty(input, "importMap"); - if (typeof importMap === "string") { - importMapReference = importMap; - } - - Object.assign(imports, readStringMap(getObjectProperty(input, "imports"), "imports")); - - const rawScopes = getObjectProperty(input, "scopes"); - if (rawScopes === undefined) { - return new ImportMapFile(imports, scopes, importMapReference); - } - if (typeof rawScopes !== "object" || rawScopes === null || Array.isArray(rawScopes)) { - throw new Error("failed to parse import map: expected scopes to be an object"); - } - for (const [scopeName, scopeValue] of Object.entries(rawScopes)) { - scopes[scopeName] = readStringMap(scopeValue, `scopes.${scopeName}`); - } - } - - return new ImportMapFile(imports, scopes, importMapReference); - } - - isReference() { - return ( - Object.keys(this.imports).length === 0 && - Object.keys(this.scopes).length === 0 && - this.importMapReference.length > 0 - ); - } - - resolve(jsonPath: string) { - const imports = Object.fromEntries( - Object.entries(this.imports).map(([key, value]) => [ - key, - resolveImportTarget(jsonPath, value), - ]), - ); - const scopes = Object.fromEntries( - Object.entries(this.scopes).map(([scopeName, scopeValue]) => [ - resolveImportTarget(jsonPath, scopeName), - Object.fromEntries( - Object.entries(scopeValue).map(([key, value]) => [ - key, - resolveImportTarget(jsonPath, value), - ]), +const listPathsRecursive = ( + root: string, +): Effect.Effect< + ReadonlyArray, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolvedRoot = path.resolve(root); + const entries = yield* fs.readDirectory(resolvedRoot); + const paths: string[] = []; + for (const name of entries) { + const pathname = path.join(resolvedRoot, name); + paths.push(pathname); + const isSymbolicLink = yield* fs.readLink(pathname).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => + isNotSymbolicLink(error) ? Effect.succeed(false) : Effect.fail(error), ), - ]), - ); - return new ImportMapFile(imports, scopes, this.importMapReference); - } -} - -async function loadImportMapFile( - pathname: string, - onRead?: (pathname: string, contents: Uint8Array) => Promise, - seen = new Set(), -): Promise { - const resolvedPath = resolve(pathname); - if (seen.has(resolvedPath)) { - throw new Error(`cyclic import map reference: ${pathname}`); - } - seen.add(resolvedPath); - const contents = await readFile(pathname); - if (onRead !== undefined) { - await onRead(pathname, contents); - } - const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); - const importMap = ImportMapFile.fromUnknown(parsed).resolve(toSlash(pathname)); - if (isDenoConfigFile(pathname) && importMap.isReference()) { - const nestedPath = join(dirname(pathname), importMap.importMapReference); - return loadImportMapFile(nestedPath, onRead, seen); - } - return importMap; -} - -function substituteImportMapValue( - mappings: Readonly>, - specifier: string, -): string | undefined { - let match: [string, string] | undefined; - for (const entry of Object.entries(mappings)) { - const [prefix, value] = entry; - if (prefix.length === 0) { - continue; - } - // Import-maps spec (implemented by Deno): a key matches exactly, or as a prefix only when it - // ends with "/" — see go-cli-divergences.md for why this differs from a naive prefix match. - if (prefix.endsWith("/")) { - // Spec normalization: a `/`-suffixed key whose address lacks a trailing - // `/` is an invalid mapping — dropped, not concatenated. - if (!value.endsWith("/") || !specifier.startsWith(prefix)) { - continue; - } - } else if (specifier !== prefix) { - continue; - } - if (match === undefined || prefix.length > match[0].length) { - match = entry; - } - } - if (match === undefined) { - return undefined; - } - return match[1] + specifier.slice(match[0].length); -} - -function resolveImportSpecifier( - importMap: ImportMapFile, - currentPath: string, - specifier: string, -): { readonly path: string; readonly substituted: boolean } { - let resolved = specifier; - let substituted = false; - - let scopedMappings: Readonly> | undefined; - let scopedPrefixLength = -1; - for (const [scopeName, scopeValue] of Object.entries(importMap.scopes)) { - // Same import-maps spec rule as key matching: a scope matches exactly, or - // as a prefix only when it ends with "/". - const scopeMatches = - scopeName === currentPath || (scopeName.endsWith("/") && currentPath.startsWith(scopeName)); - if (!scopeMatches || scopeName.length <= scopedPrefixLength) { - continue; - } - scopedMappings = scopeValue; - scopedPrefixLength = scopeName.length; - } - - if (scopedMappings !== undefined) { - const scopedResolved = substituteImportMapValue(scopedMappings, resolved); - if (scopedResolved !== undefined) { - resolved = scopedResolved; - substituted = true; - } - } - - if (!substituted) { - const importResolved = substituteImportMapValue(importMap.imports, resolved); - if (importResolved !== undefined) { - resolved = importResolved; - substituted = true; - } - } - - return { path: resolved, substituted }; -} - -async function walkImportPaths( - importMap: ImportMapFile, - srcPath: string, - allowedRoots: ReadonlyArray, - displayRoot: string, - onFile: (pathname: string, contents: Uint8Array) => Promise, - onWarning: (message: string) => Promise, -) { - const seen = new Set(); - const queue = [toSlash(srcPath)]; - - while (queue.length > 0) { - const current = queue.pop(); - if (current === undefined || seen.has(current)) { - continue; - } - seen.add(current); - - let contents: Uint8Array; - try { - const resolvedCurrent = await realpath(resolve(current)); - if (!isContainedInAnyPath(allowedRoots, resolvedCurrent)) { - await onWarning(`WARN: Skipping import path outside source root: ${current}\n`); - continue; - } - contents = await readFile(resolvedCurrent); - } catch (error) { - if (error instanceof Error && "code" in error) { - if (error.code === "ENOENT") { - const message = `failed to read file: open ${toApiRelativePath(displayRoot, current)}: no such file or directory`; - await onWarning(`WARN: ${message}\n`); - continue; - } - // An ENOTDIR (import path routed through a file) gets a classified, user-facing message - // instead of an unhandled raw Node error, so telemetry books it as user-fixable config. - if (error.code === "ENOTDIR") { - throw new FunctionImportNotDirectoryError({ - message: `failed to read file: open ${toApiRelativePath(displayRoot, current)}: not a directory`, - }); - } - } - throw error; - } - - await onFile(current, contents); - const text = new TextDecoder().decode(contents); - importPathPattern.lastIndex = 0; - for (const match of text.matchAll(importPathPattern)) { - const raw = match[1] ?? match[2]; - if (raw === undefined) { - continue; - } - - const currentPath = toSlash(current); - let { path: modulePath, substituted } = resolveImportSpecifier( - importMap, - currentPath, - raw.trim(), ); - modulePath = toSlash(modulePath); - - // A module file needs a dot in the final path segment: a dot earlier in the path - // (`dist/index.mjs/core`) is a directory-shaped path, not a module file. Not basename(): - // a trailing-slash directory import must yield an empty final segment here. - const finalSegment = modulePath.slice(modulePath.lastIndexOf("/") + 1); - if (!finalSegment.includes(".")) { - continue; - } - if ( - !modulePath.startsWith("./") && - !modulePath.startsWith("../") && - !modulePath.startsWith("/") && - !WINDOWS_ABSOLUTE_PATH.test(modulePath) - ) { - continue; - } - - if (!substituted && (modulePath.startsWith("./") || modulePath.startsWith("../"))) { - modulePath = toSlash(join(dirname(current), modulePath)); - } - - const resolvedModule = resolve(modulePath); - const containmentPath = await realpathIfExists(resolvedModule); - if (!isContainedInAnyPath(allowedRoots, containmentPath)) { - await onWarning(`WARN: Skipping import path outside source root: ${modulePath}\n`); - continue; - } - queue.push(toSlash(resolvedModule)); - } - } -} - -function hasGlobMeta(pattern: string) { - return pattern.includes("*") || pattern.includes("?") || pattern.includes("["); -} - -function defaultFunctionEntrypoint(functionsDir: string, slug: string) { - return join(functionsDir, slug, "index.ts"); -} - -function defaultFunctionImportMap(functionsDir: string, slug: string) { - return join(functionsDir, slug, "deno.json"); -} - -function globToRegExp(pattern: string) { - let source = "^"; - for (let index = 0; index < pattern.length; index += 1) { - const char = pattern[index]; - if (char === undefined) { - continue; - } - const next = pattern[index + 1]; - if (char === "*" && next === "*") { - source += ".*"; - index += 1; - continue; - } - if (char === "*") { - source += "[^/]*"; - continue; - } - if (char === "?") { - source += "[^/]"; - continue; - } - if (char === "[") { - const closeIndex = pattern.indexOf("]", index + 1); - if (closeIndex > index + 1) { - const content = pattern.slice(index + 1, closeIndex); - source += `[${content.startsWith("!") ? `^${content.slice(1)}` : content}]`; - index = closeIndex; - continue; - } - } - source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); - } - source += "$"; - return new RegExp(source); -} - -function globBaseDirectory(pattern: string) { - const normalized = toSlash(pattern); - if (!hasGlobMeta(normalized)) { - return dirname(normalized); - } - const parts = normalized.split("/"); - const stableParts: string[] = []; - for (const part of parts) { - if (part.includes("*") || part.includes("?") || part.includes("[")) { - break; - } - stableParts.push(part); - } - if (stableParts.length === 0) { - return "."; - } - return stableParts.join("/"); -} - -async function listPathsRecursive(root: string): Promise> { - const resolvedRoot = resolve(root); - const entries = await readdir(resolvedRoot, { withFileTypes: true }); - const paths: string[] = []; - for (const entry of entries) { - const pathname = join(resolvedRoot, entry.name); - paths.push(pathname); - if (entry.isDirectory()) { - paths.push(...(await listPathsRecursive(pathname))); + if (!isSymbolicLink && (yield* fs.stat(pathname)).type === "Directory") + paths.push(...(yield* listPathsRecursive(pathname))); } - } - return paths; -} - -async function expandStaticPattern(pattern: string): Promise> { - if (!hasGlobMeta(pattern)) { - try { - await stat(pattern); - } catch { - throw new Error(`no files matched pattern: ${pattern}`); - } - return [pattern]; - } - - const baseDir = globBaseDirectory(pattern); - const matcher = globToRegExp(toSlash(resolve(pattern))); - let candidates: ReadonlyArray; - try { - candidates = await listPathsRecursive(baseDir); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - throw new Error(`no files matched pattern: ${pattern}`); - } - throw error; - } - const matches = candidates.filter((candidate) => matcher.test(toSlash(resolve(candidate)))); - if (matches.length === 0) { - throw new Error(`no files matched pattern: ${pattern}`); - } - return matches; -} - -async function forEachLocalImportMapTarget( - importMap: ImportMapFile, - onTarget: (pathname: string, kind: "import" | "scope") => Promise, -) { - for (const target of Object.values(importMap.imports)) { - if (isRemoteImportTarget(target)) { - continue; - } - await onTarget(target, "import"); - } - for (const scope of Object.values(importMap.scopes)) { - for (const target of Object.values(scope)) { - if (isRemoteImportTarget(target)) { - continue; - } - await onTarget(target, "scope"); - } - } -} - -async function walkLocalImportMapTargetImports( - importMap: ImportMapFile, - pathname: string, - allowedRoots: ReadonlyArray, - displayRoot: string, - onFile: (pathname: string, contents: Uint8Array) => Promise, - onWarning: (message: string) => Promise, -) { - if ((await stat(pathname)).isDirectory()) { - return; - } - await walkImportPaths(importMap, pathname, allowedRoots, displayRoot, onFile, onWarning); -} - -async function isFile(pathname: string): Promise { - try { - return (await stat(pathname)).isFile(); - } catch { - return false; - } -} - -async function resolveImportMapAllowedRoots(projectRoot: string, importMapPath: string) { - const realProjectRoot = await realpath(projectRoot); - const allowedRoots = [realProjectRoot]; - if (importMapPath.length === 0) { - return allowedRoots; - } + return paths; + }); - const realImportMapPath = await realpath(importMapPath); - if (!isContainedPath(realProjectRoot, realImportMapPath)) { - allowedRoots.push(dirname(realImportMapPath)); - } - if (isDenoConfigFile(importMapPath)) { - const contents = await readFile(importMapPath); - const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); - const importMap = ImportMapFile.fromUnknown(parsed); - if (importMap.importMapReference.length > 0) { - const referencedImportMapPath = await realpath( - join(dirname(importMapPath), importMap.importMapReference), - ); - if (!isContainedPath(realProjectRoot, referencedImportMapPath)) { - allowedRoots.push(dirname(referencedImportMapPath)); - } - } - } - return allowedRoots; -} +const isFile = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(pathname).pipe( + Effect.map((info) => info.type === "File"), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(false) : Effect.fail(error), + ), + ); +}); -async function writeSourceDeployForm( +const writeSourceDeployForm = Effect.fnUntraced(function* ( sourceRoot: string, workdir: string, config: ResolvedDeployFunctionConfig, metadata: SourceDeployMetadata, outputRaw: (text: string) => Effect.Effect, ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const form = new FormData(); - form.append("metadata", JSON.stringify(metadata)); - const realSourceRoot = await realpath(sourceRoot); - const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); + form.append("metadata", encodeJson(metadata)); const uploadedAssets = new Set(); - const appendAsset = async (pathname: string, contents: Uint8Array, realPathname: string) => { + const appendAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + realPathname: string, + ) { if (uploadedAssets.has(realPathname)) { return; } uploadedAssets.add(realPathname); // Uploaded file names are anchored at the workdir, not at `sourceRoot` — see the note in // `deployViaApi`. - const relativePath = toApiRelativePath(workdir, pathname); + const relativePath = toApiRelativePath(path, workdir, pathname); if (hasParentPathSegment(relativePath)) { - throw new Error(`failed to read file: open ${relativePath}: invalid argument`); + return yield* new FunctionFilesError({ + message: `failed to read file: open ${relativePath}: invalid argument`, + reason: "filesystem", + pathname: relativePath, + }); } - await Effect.runPromise(outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`)); + yield* outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`); form.append("file", new File([contents], relativePath)); - }; - - const uploadAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedPath(realSourceRoot, realPathname)) { - throw new Error(`refusing to upload asset outside source root: ${pathname}`); - } - await appendAsset(pathname, contents, realPathname); - }; - - const uploadImportMapAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { - throw new Error(`refusing to upload import map outside allowed roots: ${pathname}`); - } - await appendAsset(pathname, contents, realPathname); - }; + }); - const uploadImportMapTargetAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), - ); - return; - } - await appendAsset(pathname, contents, realPathname); - }; + const plan = yield* planFunctionFiles({ + projectRoot: workdir, + sourceRoot, + entrypoint: config.entrypoint, + importMap: config.importMap, + staticFiles: config.staticFiles, + }).pipe( + Effect.mapError((error) => + error instanceof FunctionFilesError && error.reason === "import-not-directory" + ? new FunctionImportNotDirectoryError({ message: error.message }) + : error, + ), + ); - const uploadScopeTarget = async (pathname: string) => { - let resolvedPath: string; - let pathInfo: Awaited>; - try { - resolvedPath = await realpath(pathname); - pathInfo = await stat(pathname); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOTDIR") { - await Effect.runPromise( - outputRaw(`WARN: Skipping import map target that is not a directory: ${pathname}\n`), - ); - return; - } - throw error; - } - if (!isContainedInAnyPath(importMapAllowedRoots, resolvedPath)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), - ); - return; - } - if (!pathInfo.isDirectory()) { - await uploadImportMapTargetAsset(pathname, await readFile(pathname)); - await walkLocalImportMapTargetImports( - importMap, - pathname, - importMapAllowedRoots, - workdir, - uploadImportMapTargetAsset, - async (message) => { - await Effect.runPromise(outputRaw(message)); - }, - ); - return; - } - const nestedPaths = await listPathsRecursive(pathname); - for (const nestedPath of nestedPaths) { - if ((await stat(nestedPath)).isDirectory()) { - continue; - } - const resolvedNestedPath = await realpath(nestedPath); - if (!isContainedInAnyPath(importMapAllowedRoots, resolvedNestedPath)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${nestedPath}\n`), - ); - continue; - } - await uploadImportMapTargetAsset(nestedPath, await readFile(nestedPath)); + for (const warning of plan.warnings) { + if (warning.startsWith("WARN: Mounting import map scope target outside the project root:")) { + continue; } - }; - - if (metadata.import_map_path !== undefined && metadata.import_map_path.length > 0) { - await loadImportMapFile(config.importMap, uploadImportMapAsset); + yield* outputRaw(warning); } - for (const pattern of config.staticFiles) { - let files: ReadonlyArray; - try { - files = await expandStaticPattern(pattern); - } catch (error) { - await Effect.runPromise( - outputRaw(`WARN: ${error instanceof Error ? error.message : String(error)}\n`), - ); + for (const file of plan.files) { + if (file.externalScope) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${file.hostPath}\n`); + continue; + } + const fileInfo = yield* fs.stat(file.hostPath); + if (fileInfo.type !== "Directory") { + yield* appendAsset(file.targetPath, yield* fs.readFile(file.hostPath), file.hostPath); continue; } - for (const pathname of files) { - if ((await stat(pathname)).isDirectory()) { - throw new Error(`file path is a directory: ${pathname}`); + for (const nestedPath of yield* listPathsRecursive(file.hostPath)) { + const nestedInfo = yield* fs.stat(nestedPath); + if (nestedInfo.type === "Directory") continue; + const nestedRealPath = yield* fs.realPath(nestedPath); + if (!plan.allowedRoots.some((root) => isContainedPath(path, root, nestedRealPath))) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${nestedPath}\n`); + continue; } - await uploadAsset(pathname, await readFile(pathname)); + const nestedRelativePath = path.relative(file.hostPath, nestedPath); + const targetPath = path.join(file.targetPath, nestedRelativePath); + yield* appendAsset(targetPath, yield* fs.readFile(nestedPath), nestedRealPath); } } - const importMap = - metadata.import_map_path !== undefined && metadata.import_map_path.length > 0 - ? await loadImportMapFile(config.importMap) - : new ImportMapFile(); - await walkImportPaths( - importMap, - config.entrypoint, - [realSourceRoot], - workdir, - uploadAsset, - async (message) => { - await Effect.runPromise(outputRaw(message)); - }, - ); - await forEachLocalImportMapTarget(importMap, uploadScopeTarget); - return form; -} +}); /** * Server-recorded metadata paths are anchored at the workdir, with forward slashes regardless of * platform — see the note in `deployViaApi`. */ function createSourceMetadata( + path: Path.Path, workdir: string, config: ResolvedDeployFunctionConfig, remote?: RemoteFunction, @@ -1156,10 +587,12 @@ function createSourceMetadata( return { name: config.slug, ...(verifyJwt === undefined ? {} : { verify_jwt: verifyJwt }), - entrypoint_path: toApiRelativePath(workdir, config.entrypoint), + entrypoint_path: toApiRelativePath(path, workdir, config.entrypoint), import_map_path: - config.importMap.length > 0 ? toApiRelativePath(workdir, config.importMap) : "", - static_patterns: config.staticFiles.map((pathname) => toApiRelativePath(workdir, pathname)), + config.importMap.length > 0 ? toApiRelativePath(path, workdir, config.importMap) : "", + static_patterns: config.staticFiles.map((pathname) => + toApiRelativePath(path, workdir, pathname), + ), }; } @@ -1180,17 +613,18 @@ function createBundledMetadata( } function sanitizeDockerBinds( + path: Path.Path, binds: ReadonlyArray, functionsDir: string, outputDir: string, ) { - const normalizedFunctionsDir = `${toSlash(resolve(functionsDir))}/`; - const normalizedOutputDir = `${toSlash(resolve(outputDir))}/`; + const normalizedFunctionsDir = `${toSlash(path.resolve(functionsDir))}/`; + const normalizedOutputDir = `${toSlash(path.resolve(outputDir))}/`; const seen = new Set(); const result: DockerBind[] = []; for (const bind of binds) { - const normalizedHostPath = toSlash(resolve(bind.hostPath)); + const normalizedHostPath = toSlash(path.resolve(bind.hostPath)); if ( normalizedHostPath.startsWith(normalizedFunctionsDir) || normalizedHostPath.startsWith(normalizedOutputDir) @@ -1207,39 +641,24 @@ function sanitizeDockerBinds( return result; } -export async function buildDockerBinds( +export const buildDockerBinds = Effect.fnUntraced(function* ( projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, options: { readonly additionalModuleRoots?: ReadonlyArray; - readonly onWarning?: (message: string) => Promise; + readonly onWarning?: (message: string) => Effect.Effect; readonly skipMissingImportMapTargets?: boolean; /** Resolved marker presence, including an explicitly empty project value. */ readonly bitbucketCloneDirDefined?: boolean; } = {}, -): Promise> { - const hostFunctionsDir = resolve(functionsDir); - const hostOutputDir = resolve(outputDir); - const projectRoot = resolve(functionsDir, "..", ".."); - const sourceRoot = await resolveFunctionsSourceRoot(projectRoot); - const realSourceRoot = await realpath(sourceRoot); - const moduleRoots = [ - realSourceRoot, - ...( - await Promise.all( - (options.additionalModuleRoots ?? []).map(async (root) => { - try { - return await realpath(root); - } catch { - return undefined; - } - }), - ) - ).flatMap((root) => (root === undefined ? [] : [root])), - ]; - const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); +) { + const path = yield* Path.Path; + const hostFunctionsDir = path.resolve(functionsDir); + const hostOutputDir = path.resolve(outputDir); + const projectRoot = path.resolve(functionsDir, "..", ".."); + const sourceRoot = yield* resolveFunctionsSourceRoot(projectRoot); const binds: DockerBind[] = [ { hostPath: hostFunctionsDir, @@ -1267,134 +686,69 @@ export async function buildDockerBinds( }); } - const warn = options.onWarning ?? (async () => {}); - const extraBinds: DockerBind[] = []; - const explicitScopeBinds = new Map(); - const appendBindWithinRoots = async (roots: ReadonlyArray, pathname: string) => { - const hostPath = await realpath(pathname); - const contained = isContainedInAnyPath(roots, hostPath); - if (contained) { - extraBinds.push({ - hostPath, - containerPath: toDockerPath(hostPath), - mode: "ro", - externalScope: false, - }); - } - return { hostPath, contained }; - }; - const appendProjectBind = async (pathname: string, _contents: Uint8Array) => { - await appendBindWithinRoots([realSourceRoot], pathname); - }; - const appendModuleBind = async (pathname: string, _contents: Uint8Array) => { - await appendBindWithinRoots(moduleRoots, pathname); - }; - const appendImportMapBind = async (pathname: string, _contents: Uint8Array) => { - await appendBindWithinRoots(importMapAllowedRoots, pathname); - }; - const importMap = - config.importMap.length > 0 - ? await loadImportMapFile(config.importMap, appendImportMapBind) - : new ImportMapFile(); - await walkImportPaths( - importMap, - config.entrypoint, - moduleRoots, + const plan = yield* planFunctionFiles({ + projectRoot, sourceRoot, - appendModuleBind, - warn, + entrypoint: config.entrypoint, + importMap: config.importMap, + staticFiles: config.staticFiles, + additionalModuleRoots: options.additionalModuleRoots, + skipMissingImportMapTargets: options.skipMissingImportMapTargets, + }).pipe( + Effect.mapError((error) => + error instanceof FunctionFilesError && error.reason === "import-not-directory" + ? new FunctionImportNotDirectoryError({ message: error.message }) + : error, + ), ); - await forEachLocalImportMapTarget(importMap, async (target, kind) => { - try { - const { hostPath, contained } = await appendBindWithinRoots(importMapAllowedRoots, target); - const isDirectory = (await stat(target)).isDirectory(); - if (!contained && kind === "scope") { - const scopeBind: DockerBind = { - hostPath, - containerPath: toDockerPath(target), - mode: "ro", - externalScope: true, - }; - explicitScopeBinds.set(formatDockerBind(scopeBind), scopeBind); - } - if (isDirectory) { - return; - } - await walkLocalImportMapTargetImports( - importMap, - target, - importMapAllowedRoots, - sourceRoot, - appendImportMapBind, - async () => {}, - ); - } catch (error) { - if (error instanceof Error && "code" in error) { - // ENOTDIR (a trailing-slash value routed through a file) is never a - // walkable target regardless of caller: an import that actually - // reaches through that file still fails via the walker's - // FunctionImportNotDirectoryError. - if (error.code === "ENOTDIR") { - await warn(`WARN: Skipping import map target that is not a directory: ${target}\n`); - return; - } - if (options.skipMissingImportMapTargets === true && error.code === "ENOENT") { - await warn(`WARN: Skipping missing import map target: ${target}\n`); - return; - } - } - throw error; - } + const warn = options.onWarning ?? (() => Effect.void); + const extraBinds = plan.files.map((file) => ({ + hostPath: file.hostPath, + containerPath: toDockerPath(file.externalScope ? file.targetPath : file.hostPath), + mode: "ro", + externalScope: file.externalScope, + })); + const sanitizedExtraBinds = sanitizeDockerBinds( + path, + extraBinds, + hostFunctionsDir, + hostOutputDir, + ); + const occupiedContainerPaths = new Set(binds.map((bind) => bind.containerPath)); + const uniqueExtraBinds = sanitizedExtraBinds.filter((bind) => { + if (occupiedContainerPaths.has(bind.containerPath)) return false; + occupiedContainerPaths.add(bind.containerPath); + return true; }); - for (const pattern of config.staticFiles) { - let files: ReadonlyArray; - try { - files = await expandStaticPattern(pattern); - } catch { - continue; - } - for (const pathname of files) { - if ((await stat(pathname)).isDirectory()) { - throw new Error(`file path is a directory: ${pathname}`); - } - await appendProjectBind(pathname, new Uint8Array()); - } - } - - const sanitizedExtraBinds = sanitizeDockerBinds(extraBinds, hostFunctionsDir, hostOutputDir); - const occupiedContainerPaths = new Set( - [...binds, ...sanitizedExtraBinds].map((bind) => bind.containerPath), + const retainedExternalHosts = new Set( + uniqueExtraBinds.filter((bind) => bind.externalScope).map((bind) => bind.hostPath), ); - const uniqueScopeBinds: DockerBind[] = []; - for (const bind of explicitScopeBinds.values()) { - if (occupiedContainerPaths.has(bind.containerPath)) { + for (const warning of plan.warnings) { + if ( + warning.startsWith("WARN: Mounting import map scope target outside the project root:") && + ![...retainedExternalHosts].some((hostPath) => warning.includes(hostPath)) + ) { continue; } - occupiedContainerPaths.add(bind.containerPath); - uniqueScopeBinds.push(bind); - await warn( - `WARN: Mounting import map scope target outside the project root: ${bind.hostPath}\n`, - ); + yield* warn(warning); } + return [...binds, ...uniqueExtraBinds]; +}); - return [...binds, ...sanitizedExtraBinds, ...uniqueScopeBinds]; -} - -function shouldUseDenoJsonDiscovery(entrypoint: string, importMap: string) { - return isDenoConfigFile(importMap) && dirname(importMap) === dirname(entrypoint); +function shouldUseDenoJsonDiscovery(path: Path.Path, entrypoint: string, importMap: string) { + return isDenoConfigFile(importMap) && path.dirname(importMap) === path.dirname(entrypoint); } -async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: string) { +const shouldUsePackageJsonDiscovery = Effect.fnUntraced(function* ( + path: Path.Path, + entrypoint: string, + importMap: string, +) { if (importMap.length > 0) { return false; } - try { - await stat(join(dirname(entrypoint), "package.json")); - return true; - } catch { - return false; - } -} + return yield* isFile(path.join(path.dirname(entrypoint), "package.json")); +}); interface BundleFunctionWithDockerOptions { readonly projectId: string; @@ -1425,47 +779,46 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( const output = yield* Output; yield* output.raw(`Bundling Function: ${styleEmphasis(config.slug)}\n`, "stderr"); - const outputRoot = resolve(functionsDir, "..", ".temp"); - yield* Effect.tryPromise(() => mkdir(outputRoot, { recursive: true })); - const outputDir = yield* Effect.tryPromise(() => - mkdtemp(join(outputRoot, `.supabase-output-${config.slug}-`)), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outputRoot = path.resolve(functionsDir, "..", ".temp"); + yield* fs.makeDirectory(outputRoot, { recursive: true }); + const outputDir = yield* fs.makeTempDirectory({ + directory: outputRoot, + prefix: `.supabase-output-${config.slug}-`, + }); try { // Go passes 0777 to MkdirAll, which Windows ignores. Calling chmod separately // adds an NTFS WRITE_ATTRIBUTES requirement that the Go CLI does not have. if (shouldChmodBundleOutputDirectory(process.platform)) { - yield* Effect.tryPromise({ - try: () => chmod(outputDir, 0o777), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + yield* fs.chmod(outputDir, 0o777); } - const outputPath = join(outputDir, "output.eszip"); + const outputPath = path.join(outputDir, "output.eszip"); // `edgeRuntimeImage` applies the tag verbatim — a `.temp/edge-runtime-version` pin flows // through unmodified, `v` prefix or not (see the helper's doc in `functions.shared.ts`). const rawImage = edgeRuntimeImage(edgeRuntimeVersion); - const binds = yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, outputDir, config, { - bitbucketCloneDirDefined, - onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), - }), - ); + const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { + bitbucketCloneDirDefined, + onWarning: (message) => output.raw(message, "stderr"), + }); // Resolved per function rather than hoisted out of the loop (unlike `download.ts`'s // `PulledEdgeRuntimeImage`): the first resolve failure aborts the loop, and the only added // cost is one cached `docker image inspect` per function. - const image = yield* resolveFunctionsDockerImage(rawImage, projectEnvValues); - yield* ensureDockerNetwork(networkMode, projectId); - yield* ensureDockerNamedVolume( - edgeRuntimeCacheVolume(projectId).name, - projectId, - projectEnvValues, + const image = yield* mapFunctionDeployError( + "failed to resolve Docker image", + resolveFunctionsDockerImage(rawImage, projectEnvValues), + ); + yield* mapFunctionDeployError( + "failed to prepare Docker network", + ensureDockerNetwork(networkMode, projectId), + ); + yield* mapFunctionDeployError( + "failed to prepare Edge Runtime volume", + ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId, projectEnvValues), ); const env: Array = []; - if ( - !(yield* Effect.promise(() => - shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap), - )) - ) { + if (!(yield* shouldUsePackageJsonDiscovery(path, config.entrypoint, config.importMap))) { env.push("DENO_NO_PACKAGE_JSON=1"); } env.push(...dockerNpmEnv()); @@ -1479,14 +832,15 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( ]; if ( config.importMap.length > 0 && - !shouldUseDenoJsonDiscovery(config.entrypoint, config.importMap) + !shouldUseDenoJsonDiscovery(path, config.entrypoint, config.importMap) ) { containerArgs.push("--import-map", toDockerPath(config.importMap)); } for (const staticFile of config.staticFiles) { containerArgs.push("--static", toDockerPath(staticFile)); } - if (verbose || process.env["DEBUG"] === "true") { + const debug = yield* Config.string("DEBUG").pipe(Config.withDefault("")); + if (verbose || debug === "true") { containerArgs.push("--verbose"); } @@ -1498,29 +852,28 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( env, // `functionsDir` is `/supabase/functions`, same derivation as `deployViaApi`'s // own `projectRoot`. - workingDir: toDockerPath(resolve(functionsDir, "..", "..")), + workingDir: toDockerPath(path.resolve(functionsDir, "..", "..")), containerArgs, }); // Live-tees each chunk to `output.raw` as it arrives, rather than buffering the whole run // until exit. - const result = yield* runChildProcess("docker", command, { - stdout: "pipe", - stderr: "pipe", - onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), - onStderr: (chunk) => output.raw(chunk, "stderr"), - }); + const result = yield* mapFunctionDeployError( + "failed to run Docker bundler", + runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), + }), + ); if (result.exitCode !== 0) { - return yield* Effect.fail(new Error(`failed to bundle function: exit ${result.exitCode}`)); + return yield* new FunctionDeployError({ + message: `failed to bundle function: exit ${result.exitCode}`, + }); } - const eszip = yield* Effect.tryPromise({ - try: () => readFile(outputPath), - catch: (error) => - new Error( - `failed to open eszip: ${error instanceof Error ? error.message : String(error)}`, - ), - }); + const eszip = yield* fs.readFile(outputPath); const compressed = new Uint8Array( Buffer.concat([ Buffer.from(COMPRESSED_ESZIP_MAGIC), @@ -1531,7 +884,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }), ]), ); - const sha256 = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", compressed)); + const sha256 = yield* Effect.tryPromise(() => crypto.subtle.digest("SHA-256", compressed)); const hash = Buffer.from(sha256).toString("hex"); return { slug: config.slug, @@ -1539,14 +892,18 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( body: compressed, } satisfies BundledFunction; } finally { - yield* Effect.tryPromise(() => rm(outputDir, { recursive: true, force: true })).pipe( - Effect.orElseSucceed(() => undefined), - ); + yield* fs.remove(outputDir, { recursive: true, force: true }).pipe(Effect.ignore); } }); const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { - let lastError: Error | FunctionsApiStatusError | undefined; + let lastError: + | FunctionDeployError + | FunctionsApiStatusError + | FunctionsApiTransportError + | SupabaseApiInputError + | HttpBody.HttpBodyError + | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* api .executeRaw(operationDefinitions.v1ListAllFunctions, { ref: projectRef }) @@ -1567,7 +924,7 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project // not a transport failure — surface it via FunctionsApiStatusError so it // classifies as api_status rather than network. return yield* Effect.try({ - try: () => decodeFunctionListResponse(JSON.parse(body)), + try: () => decodeFunctionListResponse(decodeJson(body)), catch: (error) => new FunctionsApiStatusError({ status: result.response.status, @@ -1581,24 +938,25 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project message: `unexpected list functions status ${result.response.status}: ${body}`, }); if (result.response.status < 500 && result.response.status !== 429) { - return yield* Effect.fail(lastError); + return yield* Effect.failCause(Cause.fail(lastError)); } } else { - lastError = result.error; + lastError = mapTransportError("failed to list functions", result.error); } if (attempt < 3) { yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } } - return yield* Effect.fail(lastError ?? new Error("failed to list functions")); + if (lastError !== undefined) return yield* Effect.failCause(Cause.fail(lastError)); + return yield* new FunctionDeployError({ message: "failed to list functions" }); }); function headerValue(headers: Readonly>, name: string) { return headers[name.toLowerCase()] ?? headers[name]; } -function parseRateLimitDelay(value: string | undefined): number | undefined { +function parseRateLimitDelay(value: string | undefined, now: number): number | undefined { if (value === undefined || value.length === 0) { return undefined; } @@ -1608,7 +966,7 @@ function parseRateLimitDelay(value: string | undefined): number | undefined { } const timestamp = Date.parse(value); if (!Number.isNaN(timestamp)) { - return Math.max(timestamp - Date.now(), 0); + return Math.max(timestamp - now, 0); } return undefined; } @@ -1616,10 +974,11 @@ function parseRateLimitDelay(value: string | undefined): number | undefined { function rateLimitDelayMillis( headers: Readonly>, attempt: number, + now: number, ) { return ( - parseRateLimitDelay(headerValue(headers, "retry-after")) ?? - parseRateLimitDelay(headerValue(headers, "x-ratelimit-reset")) ?? + parseRateLimitDelay(headerValue(headers, "retry-after"), now) ?? + parseRateLimitDelay(headerValue(headers, "x-ratelimit-reset"), now) ?? 1_000 * 2 ** Math.min(attempt, 5) ); } @@ -1645,7 +1004,7 @@ const rateLimitedRequest = Effect.fnUntraced(function* ( if (response.status !== 429 || attempt >= DEPLOY_RATE_LIMIT_MAX_RETRIES) { return response; } - const delayMs = rateLimitDelayMillis(response.headers, attempt); + const delayMs = rateLimitDelayMillis(response.headers, attempt, yield* Clock.currentTimeMillis); yield* output.raw( `Rate limit exceeded while ${action}. Retrying in ${rateLimitDelayText(delayMs)}.\n`, "stderr", @@ -1664,15 +1023,10 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( bundleOnly: boolean, ) { const output = yield* Output; - const files = yield* Effect.tryPromise({ - try: async () => { - const form = await writeSourceDeployForm(sourceRoot, workdir, config, metadata, (text) => - output.raw(text, "stderr"), - ); - return form.getAll("file").flatMap((part) => (part instanceof Blob ? [part] : [])); - }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const form = yield* writeSourceDeployForm(sourceRoot, workdir, config, metadata, (text) => + output.raw(text, "stderr"), + ); + const files = form.getAll("file").flatMap((part) => (part instanceof Blob ? [part] : [])); const response = yield* rateLimitedRequest(`deploying function ${config.slug}`, () => api .executeRaw(operationDefinitions.v1DeployAFunction, { @@ -1699,18 +1053,16 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( ); const body = yield* response.body; if (response.status !== 201) { - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, + }); } // A 201 whose body is not the expected JSON is an API-response problem, not a // transport failure — surface it via FunctionsApiStatusError so it classifies // as api_status rather than network. return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(JSON.parse(body)), + try: () => decodeDeployFunctionResponse(decodeJson(body)), catch: (error) => new FunctionsApiStatusError({ status: response.status, @@ -1741,7 +1093,13 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( projectRef: string, functions: ReadonlyArray, ) { - let lastError: Error | FunctionsApiStatusError | undefined; + let lastError: + | FunctionDeployError + | FunctionsApiStatusError + | FunctionsApiTransportError + | SupabaseApiInputError + | HttpBody.HttpBodyError + | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* rateLimitedRequest("bulk updating functions", () => api @@ -1779,17 +1137,18 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( message: `unexpected bulk update status ${result.response.status}: ${body}`, }); if (result.response.status < 500) { - return yield* Effect.fail(lastError); + return yield* Effect.failCause(Cause.fail(lastError)); } } else { - lastError = result.error; + lastError = mapTransportError("failed to bulk update", result.error); } if (attempt < 3) { yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } } - return yield* Effect.fail(lastError ?? new Error("failed to bulk update")); + if (lastError !== undefined) return yield* Effect.failCause(Cause.fail(lastError)); + return yield* new FunctionDeployError({ message: "failed to bulk update" }); }); const upsertBundledFunction = Effect.fnUntraced(function* ( @@ -1799,7 +1158,13 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( exists: boolean, ) { let shouldUpdate = exists; - let lastError: Error | FunctionsApiStatusError | undefined; + let lastError: + | FunctionDeployError + | FunctionsApiStatusError + | FunctionsApiTransportError + | SupabaseApiInputError + | HttpBody.HttpBodyError + | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const action = shouldUpdate ? "update" : "create"; @@ -1844,7 +1209,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( // FunctionsApiStatusError so it classifies as api_status not network. const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(JSON.parse(body)), + try: () => decodeDeployFunctionResponse(decodeJson(body)), catch: (error) => new FunctionsApiStatusError({ status: response.value.status, @@ -1864,7 +1229,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( notFoundIsInvalidInput: shouldUpdate, }); } else { - lastError = response.error; + lastError = mapTransportError("failed to upsert function", response.error); } if (attempt < 3) { @@ -1872,7 +1237,8 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( } } - return yield* Effect.fail(lastError ?? new Error("failed to upsert function")); + if (lastError !== undefined) return yield* Effect.failCause(Cause.fail(lastError)); + return yield* new FunctionDeployError({ message: "failed to upsert function" }); }); const deleteRemoteFunction = Effect.fnUntraced(function* ( @@ -1891,42 +1257,45 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( return; } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected delete function status ${response.status}: ${body}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, + }); }); export const discoverFunctionSlugs = Effect.fnUntraced(function* ( projectRoot: string, configDeclaredFunctions: Readonly>, ) { - const functionsDir = join(projectRoot, SUPABASE_FUNCTIONS_DIR); + const path = yield* Path.Path; + const functionsDir = path.join(projectRoot, SUPABASE_FUNCTIONS_DIR); + const fs = yield* FileSystem.FileSystem; const slugs: string[] = []; - const entries = yield* Effect.tryPromise({ - try: () => readdir(functionsDir, { withFileTypes: true }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe( - Effect.catch((error) => { - return "code" in error && error.code === "ENOENT" - ? Effect.succeed(undefined) - : Effect.fail(error); - }), - ); + const entries = yield* fs + .readDirectory(functionsDir) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.void : Effect.fail(error), + ), + ); if (entries !== undefined) { - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { + for (const slug of entries.sort((left, right) => left.localeCompare(right))) { + const pathname = path.join(functionsDir, slug); + const isSymbolicLink = yield* fs.readLink(pathname).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => + isNotSymbolicLink(error) ? Effect.succeed(false) : Effect.fail(error), + ), + ); + if (!isSymbolicLink && (yield* fs.stat(pathname)).type !== "Directory") { continue; } - const slug = entry.name; if (validateFunctionSlugMessage(slug) !== undefined) { continue; } - const hasDefaultEntrypoint = yield* Effect.promise(() => - isFile(defaultFunctionEntrypoint(functionsDir, slug)), + const hasDefaultEntrypoint = yield* isFile( + defaultFunctionEntrypoint(path, functionsDir, slug), ); if (hasDefaultEntrypoint) { slugs.push(slug); @@ -1960,17 +1329,18 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { readonly noVerifyJwtOverride: Option.Option; }) { const output = yield* Output; - const functionsDir = join(input.projectRoot, SUPABASE_FUNCTIONS_DIR); + const path = yield* Path.Path; + const functionsDir = path.join(input.projectRoot, SUPABASE_FUNCTIONS_DIR); const seenDeprecatedImportMap = new Set(); const seenFallbackImportMap = new Set(); const resolved: ResolvedDeployFunctionConfig[] = []; - const fallbackImportMapPath = join(functionsDir, "import_map.json"); - const fallbackExists = yield* Effect.promise(() => isFile(fallbackImportMapPath)); + const fallbackImportMapPath = path.join(functionsDir, "import_map.json"); + const fallbackExists = yield* isFile(fallbackImportMapPath); const importMapOverride = Option.match(input.importMapOverride, { onNone: () => "", - onSome: (pathname) => resolve(input.cwd, pathname), + onSome: (pathname) => path.resolve(input.cwd, pathname), }); for (const slug of input.slugs) { @@ -1983,13 +1353,13 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { onSome: (noVerifyJwt) => !noVerifyJwt, }); - const defaultEntrypoint = defaultFunctionEntrypoint(functionsDir, slug); + const defaultEntrypoint = defaultFunctionEntrypoint(path, functionsDir, slug); const entrypoint = configured.entrypoint === undefined || configured.entrypoint.length === 0 ? defaultEntrypoint - : resolve( - configured.entrypoint.startsWith(".") || !isAbsolute(configured.entrypoint) - ? join(input.supabaseDir, configured.entrypoint) + : path.resolve( + configured.entrypoint.startsWith(".") || !path.isAbsolute(configured.entrypoint) + ? path.join(input.supabaseDir, configured.entrypoint) : configured.entrypoint, ); @@ -1997,9 +1367,9 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { if (importMap.length === 0) { let configuredImportMap = ""; if (configured.import_map.length > 0) { - configuredImportMap = resolve( - configured.import_map.startsWith(".") || !isAbsolute(configured.import_map) - ? join(input.supabaseDir, configured.import_map) + configuredImportMap = path.resolve( + configured.import_map.startsWith(".") || !path.isAbsolute(configured.import_map) + ? path.join(input.supabaseDir, configured.import_map) : configured.import_map, ); } @@ -2009,21 +1379,21 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { !( (override === undefined || override.import_map.length === 0) && entrypoint !== defaultEntrypoint && - configuredImportMap === defaultFunctionImportMap(functionsDir, slug) + configuredImportMap === defaultFunctionImportMap(path, functionsDir, slug) ) ) { importMap = configuredImportMap; } else { - const functionDir = dirname(entrypoint); - const denoJson = join(functionDir, "deno.json"); - const denoJsonc = join(functionDir, "deno.jsonc"); - const deprecatedImportMap = join(functionDir, "import_map.json"); + const functionDir = path.dirname(entrypoint); + const denoJson = path.join(functionDir, "deno.json"); + const denoJsonc = path.join(functionDir, "deno.jsonc"); + const deprecatedImportMap = path.join(functionDir, "import_map.json"); - if (yield* Effect.promise(() => isFile(denoJson))) { + if (yield* isFile(denoJson)) { importMap = denoJson; - } else if (yield* Effect.promise(() => isFile(denoJsonc))) { + } else if (yield* isFile(denoJsonc)) { importMap = denoJsonc; - } else if (yield* Effect.promise(() => isFile(deprecatedImportMap))) { + } else if (yield* isFile(deprecatedImportMap)) { importMap = deprecatedImportMap; seenDeprecatedImportMap.add(slug); } else if (fallbackExists) { @@ -2036,7 +1406,7 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { } const staticFiles = configured.static_files.map((pathname) => - isAbsolute(pathname) ? pathname : join(input.supabaseDir, pathname), + path.isAbsolute(pathname) ? pathname : path.join(input.supabaseDir, pathname), ); resolved.push({ @@ -2079,23 +1449,19 @@ const deployViaApi = Effect.fnUntraced(function* ( jobs: number, ) { const output = yield* Output; + const path = yield* Path.Path; // Uploaded file names and the server-recorded metadata paths are anchored at the workdir // (`projectRoot`), not at `sourceRoot`. The import-walk boundary (which files may be uploaded // at all) is intentionally wider, extending to the nearest git root, so files outside the // workdir but inside a monorepo can still deploy — those upload with `../`-relative names. - const sourceRoot = yield* Effect.tryPromise({ - try: () => resolveFunctionsSourceRoot(projectRoot), - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const sourceRoot = yield* resolveFunctionsSourceRoot(projectRoot); const enabled = configs.filter((config) => config.enabled); for (const skipped of configs.filter((config) => !config.enabled)) { yield* output.raw(`Skipping disabled Function: ${skipped.slug}\n`, "stderr"); } if (enabled.length === 0) { - return yield* Effect.fail( - new NoFunctionsToDeployError({ message: "All Functions are up to date." }), - ); + return yield* new NoFunctionsToDeployError({ message: "All Functions are up to date." }); } const remoteBySlug = enabled.some((config) => config.verifyJwt === undefined) @@ -2110,7 +1476,7 @@ const deployViaApi = Effect.fnUntraced(function* ( sourceRoot, projectRoot, config, - createSourceMetadata(projectRoot, config, remoteBySlug.get(config.slug)), + createSourceMetadata(path, projectRoot, config, remoteBySlug.get(config.slug)), false, ); return; @@ -2132,7 +1498,7 @@ const deployViaApi = Effect.fnUntraced(function* ( sourceRoot, projectRoot, config, - createSourceMetadata(projectRoot, config, remoteBySlug.get(config.slug)), + createSourceMetadata(path, projectRoot, config, remoteBySlug.get(config.slug)), true, ), ); @@ -2219,7 +1585,11 @@ const deployViaDocker = Effect.fnUntraced(function* (options: DeployViaDockerOpt verbose, styleEmphasis, projectEnvValues, - }); + }).pipe( + Effect.mapError( + (cause) => new FunctionDeployError({ message: "failed to bundle function", cause }), + ), + ); const current = remoteBySlug.get(config.slug); if ( current?.ezbr_sha256 === bundled.metadata.sha256 && @@ -2273,9 +1643,7 @@ const pruneFunctions = Effect.fnUntraced(function* ( ].join("\n")}\n\n`; const confirmed = yield* promptYesNo(output, yes, prompt, false); if (!confirmed) { - return yield* Effect.fail( - new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } for (const slug of toDelete) { @@ -2284,209 +1652,204 @@ const pruneFunctions = Effect.fnUntraced(function* ( } }); -export function deployFunctions( +export const deployFunctions = Effect.fn("functions.deploy")(function* < + ResolveError, + ResolveRequirements, +>( flags: FunctionsDeployFlags, dependencies: DeployFunctionsDependencies, ) { - return Effect.gen(function* () { - const output = yield* Output; - const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); - const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); - const commandPath = ["functions", "deploy"] as const; - // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — used only for - // the mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) key - // off the resolved `flags.useApi` value instead. - const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); - const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); - const explicitLegacyBundle = hasExplicitLongFlag( - dependencies.rawArgs, - commandPath, - "legacy-bundle", - ); - - const changedModes = [ - explicitUseApi ? "use-api" : undefined, - explicitUseDocker ? "use-docker" : undefined, - explicitLegacyBundle ? "legacy-bundle" : undefined, - ].filter((flag): flag is string => flag !== undefined); + const output = yield* Output; + const path = yield* Path.Path; + const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); + const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); + const commandPath = ["functions", "deploy"] as const; + // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — used only for + // the mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) key + // off the resolved `flags.useApi` value instead. + const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); + const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); + const explicitLegacyBundle = hasExplicitLongFlag( + dependencies.rawArgs, + commandPath, + "legacy-bundle", + ); - if (changedModes.length > 1) { - return yield* Effect.fail( - new ConflictingFunctionDeployFlagsError({ - message: cobraMutuallyExclusiveErrorMessage(FUNCTIONS_BUNDLER_MUTEX_GROUP, changedModes), - }), - ); - } + const changedModes = [ + explicitUseApi ? "use-api" : undefined, + explicitUseDocker ? "use-docker" : undefined, + explicitLegacyBundle ? "legacy-bundle" : undefined, + ].filter((flag): flag is string => flag !== undefined); - // `--use-api=false` alone must not force the API path — it should fall through to whatever - // `--use-docker`/`--legacy-bundle` already resolved to. - const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); - const configuredJobs = Option.getOrElse(flags.jobs, () => 1); - const jobs = configuredJobs === 0 ? 1 : configuredJobs; - // Keyed on the resolved `--use-api` value alone, not on whether local bundling - // (Docker/legacy-bundle) is in play. - if (!flags.useApi && jobs > 1) { - return yield* Effect.fail(new Error("--jobs must be used together with --use-api")); - } + if (changedModes.length > 1) { + return yield* new ConflictingFunctionDeployFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(FUNCTIONS_BUNDLER_MUTEX_GROUP, changedModes), + }); + } - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this - // already reflects any remote function/edge_runtime overrides, through the same - // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see - // `functions-config.ts`). Must precede the slug-validation loop below, so an invalid - // `config.toml` is reported ahead of a malformed slug when both are wrong. - const context = yield* loadFunctionsCliConfig({ - projectRoot: dependencies.projectRoot, - projectRef, - goConfigCompat: dependencies.goConfigCompat, + // `--use-api=false` alone must not force the API path — it should fall through to whatever + // `--use-docker`/`--legacy-bundle` already resolved to. + const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); + const configuredJobs = Option.getOrElse(flags.jobs, () => 1); + const jobs = configuredJobs === 0 ? 1 : configuredJobs; + // Keyed on the resolved `--use-api` value alone, not on whether local bundling + // (Docker/legacy-bundle) is in play. + if (!flags.useApi && jobs > 1) { + return yield* new FunctionDeployError({ + message: "--jobs must be used together with --use-api", }); + } - if (flags.functionNames.length > 0) { - for (const slug of flags.functionNames) { - yield* validateDeploySlug(slug); - } - } + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this + // already reflects any remote function/edge_runtime overrides, through the same + // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see + // `functions-config.ts`). Must precede the slug-validation loop below, so an invalid + // `config.toml` is reported ahead of a malformed slug when both are wrong. + const context = yield* loadFunctionsCliConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); - const noVerifyJwtOverride = explicitBooleanFlag( - dependencies.rawArgs, - ["functions", "deploy"], - "no-verify-jwt", - flags.noVerifyJwt, - ); - // `--debug=false` must resolve to `false` — a plain presence check would get that backwards - // (same rule as `download.ts`'s own `--debug` read). - const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; - const deployConfig = context.loaded?.config; - const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - context.denoVersion, - dependencies.edgeRuntimeVersion, - ); - const configFunctions = yield* inferFunctionsManifest({ - cwd: dependencies.projectRoot, - config: deployConfig, - // Matches `loadFunctionsCliConfig`'s own options above: no ancestor directory is searched - // past `dependencies.projectRoot` for either load, so they can never resolve two - // different projects. - search: dependencies.goConfigCompat === undefined, - }); - const configDeclaredFunctions = deployConfig?.functions ?? {}; - const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); - yield* validateConfigFunctionSlugs(configDeclaredFunctions); - const slugs = - flags.functionNames.length > 0 - ? [...flags.functionNames] - : yield* discoverFunctionSlugs(dependencies.projectRoot, configDeclaredFunctions); - - if (slugs.length === 0) { - return yield* Effect.fail( - new NoFunctionsToDeployError({ - // Styling is text-mode only: in `--output-format json`/`stream-json` this message - // lands in the structured error payload, which must stay free of ANSI escapes. - message: `No Functions specified or found in ${ - output.format === "text" - ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) - : SUPABASE_FUNCTIONS_DIR - }`, - }), - ); + if (flags.functionNames.length > 0) { + for (const slug of flags.functionNames) { + yield* validateDeploySlug(slug); } + } - const uniqueSlugs = [...new Set(slugs)]; - const configs = yield* resolveFunctionConfigs({ - slugs: uniqueSlugs, - cwd: dependencies.flagCwd, - projectRoot: dependencies.projectRoot, - supabaseDir: dependencies.supabaseDir, - configFunctions, - configDeclaredFunctions, - rawConfigFunctions, - importMapOverride: flags.importMap, - noVerifyJwtOverride, + const noVerifyJwtOverride = explicitBooleanFlag( + dependencies.rawArgs, + ["functions", "deploy"], + "no-verify-jwt", + flags.noVerifyJwt, + ); + // `--debug=false` must resolve to `false` — a plain presence check would get that backwards + // (same rule as `download.ts`'s own `--debug` read). + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; + const deployConfig = context.loaded?.config; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + context.denoVersion, + dependencies.edgeRuntimeVersion, + ); + const configFunctions = yield* inferFunctionsManifest({ + cwd: dependencies.projectRoot, + config: deployConfig, + // Matches `loadFunctionsCliConfig`'s own options above: no ancestor directory is searched + // past `dependencies.projectRoot` for either load, so they can never resolve two + // different projects. + search: dependencies.goConfigCompat === undefined, + }); + const configDeclaredFunctions = deployConfig?.functions ?? {}; + const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); + yield* validateConfigFunctionSlugs(configDeclaredFunctions); + const slugs = + flags.functionNames.length > 0 + ? [...flags.functionNames] + : yield* discoverFunctionSlugs(dependencies.projectRoot, configDeclaredFunctions); + + if (slugs.length === 0) { + return yield* new NoFunctionsToDeployError({ + // Styling is text-mode only: in `--output-format json`/`stream-json` this message + // lands in the structured error payload, which must stay free of ANSI escapes. + message: `No Functions specified or found in ${ + output.format === "text" ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) : SUPABASE_FUNCTIONS_DIR + }`, }); - const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; + } - const deployWithApi = deployViaApi( - projectRef, - dependencies.projectRoot, - configs, - dependencies.api, - jobs, - ).pipe( - Effect.as(true), - Effect.catchIf( - (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, - (error) => - (output.format === "text" - ? output.raw(`${error.message}\n`, "stderr") - : output.success(error.message, { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }) - ).pipe(Effect.as(false)), - ), - ); + const uniqueSlugs = [...new Set(slugs)]; + const configs = yield* resolveFunctionConfigs({ + slugs: uniqueSlugs, + cwd: dependencies.flagCwd, + projectRoot: dependencies.projectRoot, + supabaseDir: dependencies.supabaseDir, + configFunctions, + configDeclaredFunctions, + rawConfigFunctions, + importMapOverride: flags.importMap, + noVerifyJwtOverride, + }); + const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; - const styleWarning = dependencies.styleWarning ?? ((text: string) => text); - const deployed = useLocalBundler - ? yield* Effect.gen(function* () { - if (!(yield* isDockerRunning())) { - yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); - return yield* deployWithApi; - } + const deployWithApi = deployViaApi( + projectRef, + dependencies.projectRoot, + configs, + dependencies.api, + jobs, + ).pipe( + Effect.as(true), + Effect.catchIf( + (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, + (error) => + (output.format === "text" + ? output.raw(`${error.message}\n`, "stderr") + : output.success(error.message, { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }) + ).pipe(Effect.as(false)), + ), + ); - // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs "never touched" - // distinction `resolveDockerNetworkMode` needs — see that function's own doc comment. - // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only, `undefined` for library - // callers. - const networkMode = resolveDockerNetworkMode({ - explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), - envOverride: - context.projectEnvValues === undefined - ? undefined - : viperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - context.projectEnvValues, - ), - projectId: context.projectId, - }); - yield* deployViaDocker({ - projectId: context.projectId, - projectRef, - edgeRuntimeVersion, - functionsDir: join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), - configs, - api: dependencies.api, - networkMode, - verbose: debugEnabled, - styleEmphasis, - projectEnvValues: context.projectEnvValues, - }); - return true; - }) - : yield* deployWithApi; + const styleWarning = dependencies.styleWarning ?? ((text: string) => text); + const deployed = useLocalBundler + ? yield* Effect.gen(function* () { + if (!(yield* isDockerRunning())) { + yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); + return yield* deployWithApi; + } - if (!deployed) { - return; - } + // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs "never touched" + // distinction `resolveDockerNetworkMode` needs — see that function's own doc comment. + // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only, `undefined` for library + // callers. + const networkMode = resolveDockerNetworkMode({ + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), + envOverride: + context.projectEnvValues === undefined + ? undefined + : viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", context.projectEnvValues), + projectId: context.projectId, + }); + yield* deployViaDocker({ + projectId: context.projectId, + projectRef, + edgeRuntimeVersion, + functionsDir: path.join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), + configs, + api: dependencies.api, + networkMode, + verbose: debugEnabled, + styleEmphasis, + projectEnvValues: context.projectEnvValues, + }); + return true; + }) + : yield* deployWithApi; - if (output.format === "text") { - // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints - // "foo, foo". - yield* output.raw( - `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, - ); - yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); - } else { - yield* output.success("Deployed Functions.", { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }); - } + if (!deployed) { + return; + } - if (flags.prune) { - yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); - } - }).pipe(Effect.withSpan("functions.deploy")); -} + if (output.format === "text") { + // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints + // "foo, foo". + yield* output.raw( + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, + ); + yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); + } else { + yield* output.success("Deployed Functions.", { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }); + } + + if (flags.prune) { + yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); + } +}); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index ec46dc9965..afd3012489 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; import { buildDockerBinds, @@ -12,6 +14,28 @@ import { } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; +type BuildDockerBindsArgs = Parameters; +type TestBuildDockerBindsOptions = Omit, "onWarning"> & { + readonly onWarning?: (message: string) => void | Promise; +}; + +const runBuildDockerBinds = ( + projectId: BuildDockerBindsArgs[0], + functionsDir: BuildDockerBindsArgs[1], + outputDir: BuildDockerBindsArgs[2], + config: BuildDockerBindsArgs[3], + options?: TestBuildDockerBindsOptions, +) => + Effect.runPromise( + buildDockerBinds(projectId, functionsDir, outputDir, config, { + ...options, + onWarning: + options?.onWarning === undefined + ? undefined + : (message) => Effect.tryPromise(() => Promise.resolve(options.onWarning?.(message))), + }).pipe(Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer))), + ); + /** * `../../` from `/supabase/functions/hello/deno.json`'s directory lands at * `/supabase/_vendor/package/dist/index.mjs`, outside `functionsDir` @@ -114,7 +138,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -140,7 +164,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { let caught: unknown; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async () => {}, }); } catch (error) { @@ -170,7 +194,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -195,7 +219,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -217,7 +241,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -249,7 +273,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -274,7 +298,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeVendorIndexFile(root); try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); + await runBuildDockerBinds("test-project", functionsDir, outputDir, config); } finally { await rm(root, { recursive: true, force: true }); } @@ -292,7 +316,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -318,14 +342,14 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { let threwWithoutOption = false; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); + await runBuildDockerBinds("test-project", functionsDir, outputDir, config); } catch { threwWithoutOption = true; } expect(threwWithoutOption).toBe(true); const warnings: Array = []; - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -353,7 +377,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -386,7 +410,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeDependency, 'export const dependency = "scope";\n'); try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config); const hostPaths = binds.map((bind) => bind.hostPath); expect(hostPaths).toContain(scopeEntrypoint); @@ -414,7 +438,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeEntrypoint, 'export { util } from "./util.ts";\n'); await writeFile(scopeDependency, 'export const util = "thing";\n'); - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -455,7 +479,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -489,7 +513,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -515,7 +539,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -545,7 +569,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -576,7 +600,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -611,7 +635,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, diff --git a/apps/cli/src/shared/functions/functions-docker.errors.ts b/apps/cli/src/shared/functions/functions-docker.errors.ts new file mode 100644 index 0000000000..28cd70a056 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-docker.errors.ts @@ -0,0 +1,15 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +export class FunctionsDockerError extends Data.TaggedError("FunctionsDockerError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.unknown; + } +} diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 04cb240f6a..58f87ac320 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -5,10 +5,14 @@ import { resolve } from "node:path"; import { Effect, Option, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { spawnContainerCli } from "../../command-internal/container-cli.ts"; +import { + describeContainerCliFailure, + spawnContainerCli, +} from "../../command-internal/container-cli.ts"; import { makeDockerImageResolver } from "../../command-internal/docker-image-resolve.ts"; import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts"; import { bitbucketCloneDir } from "../../command-internal/bitbucket-pipeline.ts"; +import { FunctionsDockerError } from "./functions-docker.errors.ts"; const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; @@ -86,18 +90,26 @@ export function toDockerPath(hostPath: string) { * container root. `Bun.Archive` exposes no per-entry mode option, so entries * carry its `0644` default. */ -export function containerArchiveBytes( +export const containerArchiveBytes = Effect.fnUntraced(function* ( files: Readonly>, -): Promise { - return new Bun.Archive( - Object.fromEntries( - Object.entries(files).map(([containerPath, content]) => [ - containerPath.replace(/^\/+/, ""), - content, - ]), - ), - ).bytes(); -} +) { + return yield* Effect.tryPromise({ + try: () => + new Bun.Archive( + Object.fromEntries( + Object.entries(files).map(([containerPath, content]) => [ + containerPath.replace(/^\/+/, ""), + content, + ]), + ), + ).bytes(), + catch: (cause) => + new FunctionsDockerError({ + message: "failed to create container archive", + cause, + }), + }); +}); export interface FunctionsDockerRunSpec { /** Already registry/pull-resolved image reference. */ @@ -215,6 +227,14 @@ export const runChildProcess = Effect.fnUntraced(function* ( ); return { exitCode, stdout, stderr }; }), + ).pipe( + Effect.mapError( + (cause) => + new FunctionsDockerError({ + message: describeContainerCliFailure(cause), + cause, + }), + ), ); }); @@ -276,7 +296,9 @@ export const ensureDockerNetwork = Effect.fnUntraced(function* ( }, ); if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker network: ${networkMode}`)); + return yield* new FunctionsDockerError({ + message: `failed to create docker network: ${networkMode}`, + }); } }); @@ -307,7 +329,9 @@ export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( }, ); if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker volume: ${volumeName}`)); + return yield* new FunctionsDockerError({ + message: `failed to create docker volume: ${volumeName}`, + }); } }); @@ -352,5 +376,12 @@ export const resolveFunctionsDockerImage = Effect.fnUntraced(function* ( projectEnvValues?: Readonly>, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* makeDockerImageResolver(spawner, projectEnvValues)(image); + return yield* makeDockerImageResolver( + spawner, + projectEnvValues, + )(image).pipe( + Effect.mapError( + (cause) => new FunctionsDockerError({ message: "failed to resolve Docker image", cause }), + ), + ); }); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index e1fce53741..d12beb5c5f 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -246,7 +246,9 @@ describe("containerArchiveBytes", () => { } it("strips leading slashes into root-relative tar entries with the contractual 0644 mode", async () => { - const archive = await containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" }); + const archive = await Effect.runPromise( + containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" }), + ); expect(tarRegularFileEntries(archive)).toEqual([["root/index.ts", 0o644]]); const files = await new Bun.Archive(archive).files(); expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n"); diff --git a/apps/cli/src/shared/functions/serve-file-watcher.unit.test.ts b/apps/cli/src/shared/functions/serve-file-watcher.unit.test.ts new file mode 100644 index 0000000000..7d3c123592 --- /dev/null +++ b/apps/cli/src/shared/functions/serve-file-watcher.unit.test.ts @@ -0,0 +1,38 @@ +import { BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Stream } from "effect"; + +import { FileWatcher } from "../runtime/file-watcher.service.ts"; +import { serveFileWatcherLayer } from "./serve.ts"; + +describe("serveFileWatcherLayer", () => { + it.effect("resolves relative events and rechecks rename paths before classifying them", () => { + const fileSystem = FileSystem.makeNoop({ + watch: () => + Stream.fromIterable([ + { _tag: "Create", path: "new.ts" }, + { _tag: "Update", path: "changed.ts" }, + { _tag: "Remove", path: "renamed.ts" }, + ]), + exists: (path) => Effect.succeed(path === "/project/supabase/functions/renamed.ts"), + }); + + return Effect.gen(function* () { + const watcher = yield* FileWatcher; + const chunks = yield* Stream.runCollect( + watcher.watch("/project/supabase/functions", { recursive: true }), + ); + const events = chunks.flatMap((chunk) => chunk); + + expect(events).toEqual([ + { path: "/project/supabase/functions/new.ts", type: "delete" }, + { path: "/project/supabase/functions/changed.ts", type: "update" }, + { path: "/project/supabase/functions/renamed.ts", type: "create" }, + ]); + }).pipe( + Effect.provide(serveFileWatcherLayer), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provide(Layer.mergeAll(BunPath.layer)), + ); + }); +}); diff --git a/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts index d58d0cf603..90d30ca0d7 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts @@ -1,5 +1,6 @@ import { createContext, SourceTextModule } from "node:vm"; import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; @@ -114,7 +115,7 @@ const baseEnv = (config: string) => ({ describe("CLI functions bootstrap bundle", () => { it("fails startup for missing URL and malformed required config", async () => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const config = JSON.stringify({ hello: { entrypointPath: "hello/index.ts", @@ -140,7 +141,7 @@ describe("CLI functions bootstrap bundle", () => { }); it("authenticates and forwards request, environment, and worker options", async () => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const config = JSON.stringify({ hello: { entrypointPath: "hello/index.ts", @@ -184,7 +185,7 @@ describe("CLI functions bootstrap bundle", () => { }); it("rejects an invalid token", async () => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const config = JSON.stringify({ hello: { entrypointPath: "hello/index.ts", @@ -204,7 +205,7 @@ describe("CLI functions bootstrap bundle", () => { }); it("retains non-abort handler failures", async () => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const metricError = new Error("metrics unavailable"); const loaded = await load( bundle, @@ -225,7 +226,7 @@ describe("CLI functions bootstrap bundle", () => { [InvalidWorkerResponse, 500, "WORKER_ERROR"], [WorkerRequestCancelled, 546, "WORKER_LIMIT"], ] as const)("maps %s worker failure to the runtime response", async (ErrorType, status, code) => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const config = JSON.stringify({ hello: { entrypointPath: "hello/index.ts", @@ -254,7 +255,7 @@ describe("CLI functions bootstrap bundle", () => { }); it("does not fetch after an aborted pending worker creation", async () => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const config = JSON.stringify({ hello: { entrypointPath: "hello/index.ts", @@ -314,7 +315,7 @@ describe("CLI functions bootstrap bundle", () => { verifyJWT: true, }, }); - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const worker = { fetch: async () => new Response("ok") }; const injected = await load( bundle, @@ -365,7 +366,7 @@ describe("CLI functions bootstrap bundle", () => { }); it("uses package discovery when package.json is present or lstat reports NotFound", async () => { - const bundle = await bundleServeMainTemplate(); + const bundle = await Effect.runPromise(bundleServeMainTemplate); const config = JSON.stringify({ hello: { entrypointPath: "hello/index.ts", diff --git a/apps/cli/src/shared/functions/serve-main-bundler.ts b/apps/cli/src/shared/functions/serve-main-bundler.ts index 4572e7b206..e1e003800c 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.ts @@ -1,6 +1,21 @@ import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +export class ServeMainBundleError extends Data.TaggedError("ServeMainBundleError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.unknown; + } +} /** * Absolute path to the edge-runtime bootstrap template. The template runs verbatim @@ -18,21 +33,29 @@ const serveMainEntrypoint = fileURLToPath(new URL("./serve.main.ts", import.meta * `platform: "browser"` selects `jose`'s Web Crypto build for the * edge-runtime's Deno; `Deno` and `EdgeRuntime` are left as free globals. */ -export async function bundleServeMainTemplate(): Promise { - const result = await build({ - entryPoints: [serveMainEntrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", - }); - - const output = result.outputFiles[0]?.text; - if (output === undefined) { - throw new Error("esbuild produced no output for the functions serve runtime template"); - } - return output; -} +export const bundleServeMainTemplate = Effect.tryPromise({ + try: () => + build({ + entryPoints: [serveMainEntrypoint], + bundle: true, + format: "esm", + platform: "browser", + minify: true, + write: false, + legalComments: "none", + logLevel: "silent", + }), + catch: (cause) => + new ServeMainBundleError({ message: "Unable to bundle functions bootstrap", cause }), +}).pipe( + Effect.flatMap((result) => { + const output = result.outputFiles[0]?.text; + return output === undefined + ? Effect.fail( + new ServeMainBundleError({ + message: "esbuild produced no output for the functions serve runtime template", + }), + ) + : Effect.succeed(output); + }), +); diff --git a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts index 2779e430b3..c4dd52c7b6 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; describe("bundleServeMainTemplate", () => { it("produces a self-contained runtime template with no remote import specifiers", async () => { - const bundled = await bundleServeMainTemplate(); + const bundled = await Effect.runPromise(bundleServeMainTemplate); // The offline failure (#45570) was caused by these being resolved over the // network on every container start. They must be inlined into the bundle. @@ -14,7 +15,7 @@ describe("bundleServeMainTemplate", () => { }); it("preserves the template's Deno.serve entrypoint and inlines jose", async () => { - const bundled = await bundleServeMainTemplate(); + const bundled = await Effect.runPromise(bundleServeMainTemplate); // Template body survives bundling (Deno global left as a free reference). expect(bundled).toContain("Deno.serve"); diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index a081624c56..4295a52dc2 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; +import { Effect } from "effect"; import { START_KONG_YML_TEMPLATE } from "../../commands/start/templates/kong.yml.ts"; import { edgeRuntimeDockerfileImage } from "../../command-internal/edge-runtime-image.ts"; @@ -278,7 +279,7 @@ describe("functions serve runtime template (offline)", () => { const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { - await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); + await writeFile(join(dir, "index.ts"), await Effect.runPromise(bundleServeMainTemplate)); const run = spawnSync( "docker", @@ -340,7 +341,7 @@ describe("functions serve runtime template (offline)", () => { const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { - await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); + await writeFile(join(dir, "index.ts"), await Effect.runPromise(bundleServeMainTemplate)); const run = spawnSync( "docker", @@ -439,7 +440,7 @@ describe("functions serve runtime template (offline)", () => { const runtimeContainer = `${network}-runtime`; const kongContainer = `${network}-kong`; try { - await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); + await writeFile(join(dir, "index.ts"), await Effect.runPromise(bundleServeMainTemplate)); await mkdir(join(dir, "functions", "custom"), { recursive: true }); await mkdir(join(dir, "functions", "_shared"), { recursive: true }); await mkdir(join(dir, "functions", "custom", ".supabase-worker", "custom"), { diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index 79aae8e2bc..825e656d96 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -105,3 +105,12 @@ export class ServeLocalDbInspectError extends Data.TaggedError("ServeLocalDbInsp return this.daemonDown ? SUGGEST_DOCKER_INSTALL : undefined; } } + +export class FunctionsServeError extends Data.TaggedError("FunctionsServeError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.unknown; + } +} diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 457deedf63..58374a686b 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -13,53 +13,26 @@ import { resolveCliConfigSubtree, resolveCliConfigValue, } from "@supabase/config/internal"; -import { - defaultJwtSecret, - defaultPublishableKey, - defaultSecretKey, - edgeRuntimeNofileUlimit, -} from "../stack-constants.ts"; -import { - createHmac, - createPrivateKey, - sign as signJwtBytes, - type JsonWebKeyInput, -} from "node:crypto"; -import { existsSync, watch } from "node:fs"; -import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { edgeRuntimeNofileUlimit } from "../stack-constants.ts"; import { styleText } from "node:util"; import { - Cause, + Config, Deferred, Duration, Effect, - Exit, + FileSystem, Layer, + Match, Option, - Queue, + Path, Redacted, - Result, + Ref, Schema, Stream, } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - describeContainerCliFailure, - isContainerNotFoundMessage, - spawnContainerCli, -} from "../../command-internal/container-cli.ts"; -import { inspectContainerState } from "../../command-internal/docker-lifecycle.ts"; -import { isDockerDaemonUnreachable } from "../../command-internal/docker-suggest.ts"; import { parseDotEnv } from "../../command-internal/dotenv.ts"; -import { viperEnvStringWithProjectFallback } from "../../command-internal/viper-env.ts"; -import { - resolveRemoteJwks, - resolveThirdPartyIssuerUrl, - thirdPartyIssuerUrlUnchecked, - toPublicJwk, -} from "../auth/jwks.ts"; import { Output } from "../output/output.service.ts"; +import type { EffectServiceConfig, EffectServiceInstance } from "@supabase/stack/effect"; import { FileWatcher, FileWatcherError, @@ -85,42 +58,23 @@ import { ensureDockerNetwork, localDockerId, normalizeProjectId, - resolveDockerNetworkMode, - resolveEdgeRuntimeVersion, - resolveFunctionsDockerImage, runChildProcess, toDockerPath, } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; -import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; -import { - DockerLogsStreamError, - EdgeRuntimeContainerCrashedError, - EdgeRuntimeLogStreamLostError, - ServeLocalDbInspectError, - ServeLocalDbNotRunningError, -} from "./serve.errors.ts"; +import { StackApi } from "../../command-internal/stack-api.ts"; +import { loadStackConfig } from "../../command-internal/stack-config.ts"; +import { FunctionsServeError } from "./serve.errors.ts"; const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const defaultCliConfig = decodeCliConfig({}); const dockerRuntimeServerPort = 8081; const dockerRuntimeInspectorPort = 8083; // Unix timestamp (~2032-11-30) used as the `exp` claim of the local-dev // default JWTs (anon/service_role tokens). -const defaultJwtExpiry = 1983812996; -const defaultSigningKey = { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", -} as const; -const functionsDirName = join("supabase", "functions"); -const fallbackEnvFilePath = join("supabase", "functions", ".env"); +const functionsDirName = "supabase/functions"; +const fallbackEnvFilePath = "supabase/functions/.env"; const ignoredDirNames = new Set([ ".git", "node_modules", @@ -129,26 +83,20 @@ const ignoredDirNames = new Set([ ".DS_Store", "vendor", ]); -const dockerLogRetryDelay = Duration.millis(400); -const dockerLogDiagnosticTailLength = 4_096; // On Windows, `CTRL_C_EVENT` reaches every console-attached process, so the CLI's own shutdown // signal and a child spawn/stream failure can land microseconds apart — this is their tie-break. -const shutdownSignalGracePeriod = Duration.millis(50); // Exit codes a supervisor uses to tear a container down (`supabase stop`, CI cancellation), // not a self-raised crash signal; 137 is excluded because it gets its own OOM-kill retry. -const externalTerminationExitCodes = new Set([ - 129, // SIGHUP - 130, // SIGINT - 131, // SIGQUIT - 143, // SIGTERM -]); // Consecutive re-attaches to `docker logs -f` without forwarding a new line before giving up on // the stream and failing loudly instead of flooding replayed history forever. -const containerLogReattachCap = 5; const defaultSupabaseEnv = "development"; const serveMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedFunctionsServeMainTemplate: string | undefined; + +const functionsServeError = (message: string, cause?: unknown) => + new FunctionsServeError({ message, cause }); + const watchIgnoreGlobs = [ "**/.git/**", "**/node_modules/**", @@ -198,22 +146,11 @@ export interface FunctionsServeDependencies { } /** @see {@link FunctionsServeDependencies.timers} */ -export interface FunctionsServeTimers { +interface FunctionsServeTimers { readonly shutdownSignalGracePeriod?: Duration.Duration; readonly dockerLogRetryDelay?: Duration.Duration; } -interface PlainServeAuthConfig { - readonly enabled: boolean; - readonly signing_keys_path?: string; - readonly publishable_key?: string; - readonly secret_key?: string; - readonly jwt_secret?: string; - readonly anon_key?: string; - readonly service_role_key?: string; - readonly third_party: CliConfig["auth"]["third_party"]; -} - export interface PlainServeEdgeRuntimeConfig { readonly policy: CliConfig["edge_runtime"]["policy"]; readonly inspector_port: number; @@ -224,7 +161,6 @@ export interface PlainServeEdgeRuntimeConfig { interface ServeResolvedConfig { readonly projectId: string; readonly apiPort: number; - readonly auth: PlainServeAuthConfig; readonly edgeRuntime: PlainServeEdgeRuntimeConfig; readonly configDeclaredFunctions: Readonly>; readonly configFunctions: Readonly>; @@ -257,9 +193,7 @@ export interface StartedRuntime { /** * Every already-resolved secret/key {@link startEdgeRuntimeContainer} needs. * Exported so a caller outside this module (`start`'s own edge-runtime - * bring-up) can build this from values it already resolved, instead of - * {@link resolveLocalAuthArtifacts} re-reading `config.toml`/signing keys - * independently and risking different secrets than the rest of that stack. + * bring-up) can build this from values it already resolved. */ export interface ServeAuthArtifacts { readonly publishableKey: string; @@ -300,10 +234,7 @@ export interface StartEdgeRuntimeContainerInput { readonly config: ServeEdgeRuntimeContainerConfig; readonly authArtifacts: ServeAuthArtifacts; /** - * `SUPABASE_DB_URL`. Not hardcoded in the shared core: standalone - * `functions serve` always uses the `db` network alias (matching - * {@link defaultServeDbUrl} below), while `start`'s direct call uses the - * `db` container's own sanitized name and `config.db.password` instead. + * `SUPABASE_DB_URL`. The caller supplies the database endpoint. * Every caller must supply its own value; this module does not choose one. */ readonly dbUrl: string; @@ -326,52 +257,40 @@ export interface StartEdgeRuntimeContainerInput { readonly projectEnvValues?: Readonly>; } -type SigningKeyJwk = JsonWebKeyInput["key"] & { - readonly kty: "EC" | "RSA"; - readonly kid?: string; - readonly use?: string; - readonly ext?: boolean; - readonly n?: string; - readonly e?: string; - readonly crv?: string; - readonly x?: string; - readonly y?: string; - readonly alg?: "ES256" | "RS256"; - readonly key_ops?: ReadonlyArray; -}; - declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; -export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => - FileWatcher.of({ - watch: (root, options) => - Stream.callback, FileWatcherError>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const recursive = options?.recursive ?? true; - const watcher = watch(root, { recursive }, (eventType, filename) => { - const pathname = - filename === null || filename === undefined || filename.length === 0 - ? root - : resolve(root, filename.toString()); - // `fs.watch` only distinguishes "rename" (create/delete/rename) - // from "change" (write); an existence check on "rename" - // disambiguates create vs delete, "change" always means update. - const type: FileWatchEvent["type"] = - eventType === "rename" ? (existsSync(pathname) ? "create" : "delete") : "update"; - Queue.offerUnsafe(queue, [{ path: pathname, type }]); - }); - watcher.on("error", (cause) => { - Queue.failCauseUnsafe(queue, Cause.fail(new FileWatcherError({ path: root, cause }))); - }); - return watcher; +export const serveFileWatcherLayer = Layer.effect( + FileWatcher, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return FileWatcher.of({ + watch: (root, options) => + fs.watch(root, { recursive: options?.recursive ?? true }).pipe( + Stream.mapEffect((event) => { + const pathname = path.isAbsolute(event.path) + ? event.path + : path.resolve(root, event.path); + return Match.value(event).pipe( + Match.tag("Update", () => + Effect.succeed([{ path: pathname, type: "update" } satisfies FileWatchEvent]), + ), + Match.tag("Create", "Remove", () => + fs.exists(pathname).pipe( + Effect.map((exists) => [ + { + path: pathname, + type: exists ? "create" : "delete", + } satisfies FileWatchEvent, + ]), + ), + ), + Match.exhaustive, + ); }), - (watcher) => - Effect.sync(() => { - watcher.close(); - }), + Stream.mapError((cause) => new FileWatcherError({ path: root, cause })), ), - ), + }); }), ); @@ -386,68 +305,32 @@ export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => * so the shipped binary never bundles at runtime. Running from source * bundles on demand. */ -function getFunctionsServeMainTemplate(): Promise { - if (cachedFunctionsServeMainTemplate !== undefined) { - return Promise.resolve(cachedFunctionsServeMainTemplate); - } - if (typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string") { - cachedFunctionsServeMainTemplate = SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE; - return Promise.resolve(cachedFunctionsServeMainTemplate); - } - // Bundler (and its esbuild dependency) is imported lazily and only here, - // so it's never loaded by shipped binaries, which always take the define - // branch above. - return import("./serve-main-bundler.ts") - .then(({ bundleServeMainTemplate }) => bundleServeMainTemplate()) - .then((bundled) => { - cachedFunctionsServeMainTemplate = bundled; - return bundled; - }); -} - -function reveal(value: string | Redacted.Redacted | undefined): string | undefined { - if (value === undefined) { - return undefined; - } - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} +const getFunctionsServeMainTemplate = Effect.suspend(() => + Effect.gen(function* () { + if (cachedFunctionsServeMainTemplate !== undefined) { + return cachedFunctionsServeMainTemplate; + } + if (typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string") { + cachedFunctionsServeMainTemplate = SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE; + return cachedFunctionsServeMainTemplate; + } + // Bundler (and its esbuild dependency) is imported lazily and only here, + // so it's never loaded by shipped binaries, which always take the define + // branch above. + const { bundleServeMainTemplate } = yield* Effect.tryPromise( + () => import("./serve-main-bundler.ts"), + ); + const bundled = yield* bundleServeMainTemplate; + cachedFunctionsServeMainTemplate = bundled; + return bundled; + }), +); -function toPlainAuthConfig( - auth: CliConfig["auth"] | ResolvedCliConfigValue, -): PlainServeAuthConfig { - return { - enabled: auth.enabled, - signing_keys_path: reveal(auth.signing_keys_path), - publishable_key: reveal(auth.publishable_key), - secret_key: reveal(auth.secret_key), - jwt_secret: reveal(auth.jwt_secret), - anon_key: reveal(auth.anon_key), - service_role_key: reveal(auth.service_role_key), - third_party: { - firebase: { - enabled: auth.third_party.firebase.enabled, - project_id: reveal(auth.third_party.firebase.project_id), - }, - auth0: { - enabled: auth.third_party.auth0.enabled, - tenant: reveal(auth.third_party.auth0.tenant), - tenant_region: reveal(auth.third_party.auth0.tenant_region), - }, - aws_cognito: { - enabled: auth.third_party.aws_cognito.enabled, - user_pool_id: reveal(auth.third_party.aws_cognito.user_pool_id), - user_pool_region: reveal(auth.third_party.aws_cognito.user_pool_region), - }, - clerk: { - enabled: auth.third_party.clerk.enabled, - domain: reveal(auth.third_party.clerk.domain), - }, - workos: { - enabled: auth.third_party.workos.enabled, - issuer_url: reveal(auth.third_party.workos.issuer_url), - }, - }, - }; +function reveal(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (!Redacted.isRedacted(value)) return undefined; + const revealed = Redacted.value(value); + return typeof revealed === "string" ? revealed : undefined; } /** @@ -498,220 +381,13 @@ export function toPlainFunctionRecord( ); } -function normalizeEnvPath(flagCwd: string, pathname: string) { - return isAbsolute(pathname) ? pathname : resolve(flagCwd, pathname); -} - -function encodeBase64Url(input: string) { - return Buffer.from(input).toString("base64url"); -} - -function toJsonWebKey(signingKey: SigningKeyJwk): JsonWebKeyInput["key"] { - return { - ...signingKey, - ...(signingKey.key_ops === undefined ? {} : { key_ops: [...signingKey.key_ops] }), - }; -} - -function jwtPayload(role: string, exp: number) { - return JSON.stringify({ iss: "supabase-demo", role, exp }); -} - -function generateSymmetricJwt(secret: string, role: string) { - const header = encodeBase64Url(JSON.stringify({ alg: "HS256", typ: "JWT" })); - const payload = encodeBase64Url(jwtPayload(role, defaultJwtExpiry)); - const data = `${header}.${payload}`; - const signature = createHmac("sha256", secret).update(data).digest("base64url"); - return `${data}.${signature}`; -} - -function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string) { - const algorithm = signingKey.alg; - if (algorithm !== "ES256" && algorithm !== "RS256") { - throw new Error(`unsupported algorithm: ${String(algorithm)}`); - } - - const header = { - alg: algorithm, - typ: "JWT", - ...(signingKey.kid === undefined ? {} : { kid: signingKey.kid }), - }; - const payload = { - iss: "supabase-demo", - role, - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 10, - }; - const encodedHeader = encodeBase64Url(JSON.stringify(header)); - const encodedPayload = encodeBase64Url(JSON.stringify(payload)); - const data = `${encodedHeader}.${encodedPayload}`; - const key = createPrivateKey({ - key: toJsonWebKey(signingKey), - format: "jwk", - }); - const signature = signJwtBytes("sha256", Buffer.from(data), { - key, - ...(algorithm === "ES256" ? { dsaEncoding: "ieee-p1363" as const } : {}), - }).toString("base64url"); - return `${data}.${signature}`; -} - -async function readSigningKeys(pathname: string): Promise> { - const decoded = JSON.parse(await readFile(pathname, "utf8")); - if (!Array.isArray(decoded)) { - throw new Error("expected a JSON array"); - } - return decoded as ReadonlyArray; -} - -/** - * {@link resolveLocalAuthArtifacts}'s return shape — everything - * {@link finalizeAuthArtifacts} needs to assemble the final - * {@link ServeAuthArtifacts} once the remote-JWKS fetch is allowed to run - * (i.e. after the DB assertion — see {@link startEdgeRuntime}). - */ -interface ServeLocalAuthArtifacts { - readonly publishableKey: string; - readonly secretKey: string; - readonly jwtSecret: string; - readonly anonKey: string; - readonly serviceRoleKey: string; - /** Third-party issuer to fetch remote JWKS from, if one is configured. */ - readonly issuerUrl: string | undefined; - /** Local JWKS entries (signing keys / oct fallback), appended after any remote keys. */ - readonly localKeys: ReadonlyArray; -} - -/** - * Config-load-time auth resolution: signing-keys read, the `auth.jwt_secret` - * ≥16-chars check, and anon/service-role key generation. Does not fetch - * remote JWKS — that half lives in {@link finalizeAuthArtifacts}, run after - * the DB assertion, so a config error here still surfaces before a - * docker-down error. - */ -const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( - auth: PlainServeAuthConfig, - configPath: string | undefined, -) { - const signingKeysPath = - auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 - ? "" - : isAbsolute(auth.signing_keys_path) - ? auth.signing_keys_path - : resolve( - dirname(configPath ?? join(process.cwd(), "supabase", "config.toml")), - auth.signing_keys_path, - ); - - const signingKeys = yield* Effect.tryPromise({ - try: async () => (signingKeysPath.length === 0 ? [] : await readSigningKeys(signingKeysPath)), - catch: (cause) => { - if (cause instanceof SyntaxError) { - return new Error(`failed to decode signing keys: ${cause.message}`); - } - return new Error( - `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - }, - }); - - const jwtSecret = - auth.jwt_secret === undefined || auth.jwt_secret.length === 0 - ? defaultJwtSecret - : auth.jwt_secret; - if (jwtSecret.length < 16) { - return yield* Effect.fail( - new Error("Invalid config for auth.jwt_secret. Must be at least 16 characters"), - ); - } - - const anonKey = - auth.anon_key === undefined || auth.anon_key.length === 0 - ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "anon") - : generateSymmetricJwt(jwtSecret, "anon") - : auth.anon_key; - const serviceRoleKey = - auth.service_role_key === undefined || auth.service_role_key.length === 0 - ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "service_role") - : generateSymmetricJwt(jwtSecret, "service_role") - : auth.service_role_key; - const shouldUseJwtSecretFallback = signingKeysPath.length === 0; - - // A malformed/multi-enabled third-party config must not throw when auth is - // disabled, so this uses the unchecked, no-throw issuer-URL-only builder - // instead of the validating one in that case. - const issuerUrl = auth.enabled - ? resolveThirdPartyIssuerUrl(auth.third_party) - : thirdPartyIssuerUrlUnchecked(auth.third_party); - const localKeys: unknown[] = []; - localKeys.push( - ...(signingKeys.length > 0 - ? signingKeys.map(toPublicJwk) - : shouldUseJwtSecretFallback - ? [defaultSigningKey] - : []), - ); - if (shouldUseJwtSecretFallback) { - localKeys.push({ - kty: "oct", - k: Buffer.from(jwtSecret).toString("base64url"), - }); - } - - return { - publishableKey: - auth.publishable_key === undefined || auth.publishable_key.length === 0 - ? defaultPublishableKey - : auth.publishable_key, - secretKey: - auth.secret_key === undefined || auth.secret_key.length === 0 - ? defaultSecretKey - : auth.secret_key, - jwtSecret, - anonKey, - serviceRoleKey, - issuerUrl, - localKeys, - } satisfies ServeLocalAuthArtifacts; -}); - -/** - * The post-assertion half of auth resolution: fetches the third-party - * provider's remote JWKS (error discarded on failure) and assembles the - * final key set with remote keys first, then local keys. Kept separate from - * {@link resolveLocalAuthArtifacts} so `startEdgeRuntime` can run it strictly - * after the DB assertion — with Docker down, no external JWKS request is made. - */ -const finalizeAuthArtifacts = Effect.fnUntraced(function* (local: ServeLocalAuthArtifacts) { - const keys: unknown[] = []; - if (local.issuerUrl !== undefined) { - const issuerUrl = local.issuerUrl; - const remoteJwks = yield* resolveRemoteJwks(issuerUrl).pipe( - Effect.catchTag("RemoteJwksError", () => Effect.succeed>([])), - ); - keys.push(...remoteJwks); - } - keys.push(...local.localKeys); - - return { - publishableKey: local.publishableKey, - secretKey: local.secretKey, - jwtSecret: local.jwtSecret, - anonKey: local.anonKey, - serviceRoleKey: local.serviceRoleKey, - jwks: yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Struct({ keys: Schema.Array(Schema.Unknown) })), - )({ keys }), - } satisfies ServeAuthArtifacts; -}); - const resolveServeConfig = Effect.fnUntraced(function* ( projectRoot: string, projectIdOverride: Option.Option, goViperCompat: boolean, goConfigCompat: FunctionsGoConfigCompat | undefined, ) { + const path = yield* Path.Path; // Keeps `.env` discovery, config load, and functions-manifest inference // from resolving three different roots: the CLI's `search: false` must // match `loadFunctionsCliConfig`'s own options exactly (see below). @@ -744,12 +420,6 @@ const resolveServeConfig = Effect.fnUntraced(function* ( }); const baseConfig = loadedConfig?.config ?? defaultCliConfig; - const auth = - projectEnv === null - ? toPlainAuthConfig(baseConfig.auth) - : toPlainAuthConfig( - yield* resolveCliConfigSubtree(baseConfig.auth, projectEnv, "auth", { goViperCompat }), - ); const edgeRuntime = projectEnv === null ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) @@ -788,7 +458,7 @@ const resolveServeConfig = Effect.fnUntraced(function* ( }), ) ?? ""); const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); - const fallbackProjectId = basename(resolve(projectRoot)); + const fallbackProjectId = path.basename(path.resolve(projectRoot)); // A second, independent config/dotenv load, run before any Docker check so // an invalid config fails here too; its `search`/`tomlOnly` must match the @@ -808,7 +478,6 @@ const resolveServeConfig = Effect.fnUntraced(function* ( return { projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), apiPort, - auth, edgeRuntime: goContext === undefined ? edgeRuntime @@ -857,23 +526,21 @@ export function buildFunctionsServeInspectArgs( } const readDotEnvFile = Effect.fnUntraced(function* (pathname: string, optional: boolean) { - const contents = yield* Effect.tryPromise({ - try: () => - readFile(pathname, "utf8").then( - (value) => value, - (error) => { - if (optional && error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, - ), - catch: (cause) => - new Error( - `failed to load environment file: ${pathname}${cause instanceof Error ? ` (${cause.message})` : ""}`, - { cause }, + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs + .readFileString(pathname) + .pipe( + Effect.catchTag("PlatformError", (cause) => + cause.reason._tag === "NotFound" && optional + ? Effect.map(Effect.void, () => undefined) + : Effect.fail( + functionsServeError( + `failed to load environment file: ${pathname}: ${cause.message}`, + cause, + ), + ), ), - }); + ); if (contents === undefined) { return {}; } @@ -902,9 +569,10 @@ const parseCustomEnvFile = Effect.fnUntraced(function* ( flagCwd: string, configSecrets: Readonly>, ) { + const path = yield* Path.Path; const envFilePath = Option.match(envFileFlag, { - onNone: () => join(projectRoot, fallbackEnvFilePath), - onSome: (pathname) => normalizeEnvPath(flagCwd, pathname), + onNone: () => path.join(projectRoot, fallbackEnvFilePath), + onSome: (pathname) => (path.isAbsolute(pathname) ? pathname : path.resolve(flagCwd, pathname)), }); const parsed = yield* readDotEnvFile(envFilePath, Option.isNone(envFileFlag)); const filtered = yield* filterCustomEnv({ ...configSecrets, ...parsed }); @@ -916,14 +584,17 @@ const parseFunctionEnvFile = Effect.fnUntraced(function* (pathname: string) { }); function toFunctionContainerConfig( + path: Path.Path, workdir: string, config: ResolvedDeployFunctionConfig, envFile: Readonly>, ): ServeFunctionContainerConfig { const toContainerPath = (pathname: string) => { - const resolvedPath = resolve(pathname); - const relativePath = relative(workdir, resolvedPath); - return relativePath.length === 0 ? basename(resolvedPath) : relativePath.replaceAll("\\", "/"); + const resolvedPath = path.resolve(pathname); + const relativePath = path.relative(workdir, resolvedPath); + return relativePath.length === 0 + ? path.basename(resolvedPath) + : relativePath.replaceAll("\\", "/"); }; return { @@ -948,7 +619,12 @@ function splitEnvEntry(entry: string) { : ([entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)] as const); } -async function writeDockerEnvFile(env: Readonly>, dir: string) { +const writeDockerEnvFile = Effect.fnUntraced(function* ( + env: Readonly>, + dir: string, +) { + const fs = yield* FileSystem.FileSystem; + const pathApi = yield* Path.Path; const entries = Object.entries(env); if (entries.length === 0) { return undefined; @@ -959,12 +635,12 @@ async function writeDockerEnvFile(env: Readonly>, dir: st // process (e.g. `functions serve`'s watch-mode restart loop) is removed // first — otherwise leftover files from a shrinking env set would survive // alongside the fresh write. - await rm(dir, { recursive: true, force: true }); - await mkdir(dir, { recursive: true, mode: 0o700 }); - const path = join(dir, "docker.env"); + yield* fs.remove(dir, { recursive: true, force: true }); + yield* fs.makeDirectory(dir, { recursive: true, mode: 0o700 }); + const path = pathApi.join(dir, "docker.env"); // The file holds the JWT secret, anon/service-role keys, and JWKS, so keep it // owner-only rather than relying on the process umask. - await writeFile( + yield* fs.writeFileString( path, entries .map(([name, value]) => `${name}=${value.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}`) @@ -973,52 +649,55 @@ async function writeDockerEnvFile(env: Readonly>, dir: st ); return { path }; -} +}); -async function writeDockerMultilineEnvScript( +const writeDockerMultilineEnvScript = Effect.fnUntraced(function* ( env: ReadonlyArray, containerDir: string, dir: string, ) { + const fs = yield* FileSystem.FileSystem; + const pathApi = yield* Path.Path; // Self-healing — see the matching comment in `writeDockerEnvFile`. Runs // unconditionally, before the length check, so a stale directory from an // earlier invocation that needed multiline secrets is still reclaimed. - await rm(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true, force: true }); if (env.length === 0) { return undefined; } - await mkdir(dir, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(dir, { recursive: true, mode: 0o700 }); const scriptName = "multiline-env.sh"; - const path = join(dir, scriptName); - const envDir = join(containerDir, "values"); - const hostEnvDir = join(dir, "values"); + const path = pathApi.join(dir, scriptName); + const envDir = pathApi.join(containerDir, "values"); + const hostEnvDir = pathApi.join(dir, "values"); // Names are validated by `validateDockerMultilineEnvNames` before this runs. const script = env .map(([name], index) => { const valueFile = `env-${index}`; - const valuePath = join(envDir, valueFile).replaceAll("\\", "/"); + const valuePath = pathApi.join(envDir, valueFile).replaceAll("\\", "/"); return `${name}="$(cat ${valuePath}; printf x)" export ${name}="\${${name}%x}"`; }) .join("\n"); - await mkdir(hostEnvDir, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(hostEnvDir, { recursive: true, mode: 0o700 }); // The value files hold secret env values, so keep them owner-only. - await Promise.all( - env.map(([, value], index) => - writeFile(join(hostEnvDir, `env-${index}`), value, { mode: 0o600 }), - ), + yield* Effect.forEach( + env, + ([, value], index) => + fs.writeFileString(pathApi.join(hostEnvDir, `env-${index}`), value, { mode: 0o600 }), + { concurrency: "unbounded", discard: true }, ); - await writeFile(path, script, { mode: 0o600 }); + yield* fs.writeFileString(path, script, { mode: 0o600 }); return { // `Z`: private SELinux relabel of this CLI-staged dir (supabase/cli#5989); // single-consumer bind, no-op without SELinux. bind: `${dir}:${containerDir}:ro,Z`, - scriptPath: join(containerDir, scriptName).replaceAll("\\", "/"), + scriptPath: pathApi.join(containerDir, scriptName).replaceAll("\\", "/"), }; -} +}); function partitionDockerEnvEntries(env: Readonly>) { const singleLine: Record = {}; @@ -1049,7 +728,7 @@ function loadDefaultEnvFilenames(env: string) { function sanitizeDotEnvParseError(path: string, cause: unknown) { if (!(cause instanceof Error)) { - return new Error(`failed to parse environment file: ${path}`); + return functionsServeError(`failed to parse environment file: ${path}`, cause); } const message = cause.message; if (message.startsWith('unexpected character "')) { @@ -1060,22 +739,27 @@ function sanitizeDotEnvParseError(path: string, cause: unknown) { const charEnd = message.indexOf('"', charStart); if (charEnd !== -1) { const char = message.slice(charStart, charEnd); - return new Error( + return functionsServeError( `failed to parse environment file: ${path} (unexpected character '${char}' in variable name)`, + cause, ); } } - return new Error( + return functionsServeError( `failed to parse environment file: ${path} (unexpected character in variable name)`, + cause, ); } if (message.startsWith("unterminated quoted value")) { - return new Error(`failed to parse environment file: ${path} (unterminated quoted value)`); + return functionsServeError( + `failed to parse environment file: ${path} (unterminated quoted value)`, + cause, + ); } if (message.includes("\n")) { - return new Error(`failed to parse environment file: ${path} (syntax error)`); + return functionsServeError(`failed to parse environment file: ${path} (syntax error)`, cause); } - return new Error(`failed to load ${path}: ${message}`); + return functionsServeError(`failed to load ${path}: ${message}`, cause); } function ambientProjectEnv() { @@ -1090,6 +774,7 @@ const loadServeCliProjectEnvironment = Effect.fnUntraced(function* ( projectRoot: string, options: { readonly search: boolean }, ) { + const path = yield* Path.Path; const paths = yield* findCliProjectPaths(projectRoot, { search: options.search }); if (paths === null) { return null; @@ -1100,24 +785,21 @@ const loadServeCliProjectEnvironment = Effect.fnUntraced(function* ( Object.keys(values).map((key) => [key, "ambient"]), ); const loadedPaths: string[] = []; - const env = process.env["SUPABASE_ENV"] || defaultSupabaseEnv; + const env = yield* Config.string("SUPABASE_ENV").pipe(Config.withDefault(defaultSupabaseEnv)); for (const dir of [paths.supabaseDir, paths.projectRoot]) { for (const filename of loadDefaultEnvFilenames(env)) { - const envPath = join(dir, filename); - const contents = yield* Effect.tryPromise({ - try: () => - readFile(envPath, "utf8").then( - (value) => value, - (error) => { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, + const envPath = path.join(dir, filename); + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs + .readFileString(envPath) + .pipe( + Effect.catchTag("PlatformError", (cause) => + cause.reason._tag === "NotFound" + ? Effect.map(Effect.void, () => undefined) + : Effect.fail(functionsServeError("failed to load environment file", cause)), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + ); if (contents === undefined) { continue; } @@ -1157,23 +839,23 @@ function hasBindUnder(binds: Iterable, containerPath: string): boole return false; } -async function buildWatchSpecs( - binds: ReadonlyArray, -): Promise> { +const buildWatchSpecs = Effect.fnUntraced(function* (binds: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const specs = new Map(); for (const bind of binds) { const hostPath = bind.hostPath; - if (!isAbsolute(hostPath)) { + if (!path.isAbsolute(hostPath)) { continue; } - try { - const info = await stat(hostPath); - if (info.isDirectory()) { + const info = yield* fs.stat(hostPath).pipe(Effect.catchTag("PlatformError", () => Effect.void)); + if (info !== undefined) { + if (info.type === "Directory") { specs.set(hostPath, { root: hostPath, recursive: true }); } else { - const root = dirname(hostPath); + const root = path.dirname(hostPath); const existing = specs.get(root); if (existing !== undefined && existing.matchPaths === undefined) { continue; @@ -1182,13 +864,11 @@ async function buildWatchSpecs( matchPaths.add(hostPath); specs.set(root, { root, recursive: false, matchPaths }); } - } catch { - continue; } } return [...specs.values()]; -} +}); function shouldIgnoreEvent(pathname: string) { const normalized = pathname.replaceAll("\\", "/"); @@ -1264,262 +944,6 @@ const waitForRestartSignal = Effect.fnUntraced(function* (watchSpecs: ReadonlyAr }); }); -function forwardByteStream( - stream: Stream.Stream, - write: (text: string, stream: "stdout" | "stderr") => Effect.Effect, - streamName: "stdout" | "stderr", -) { - const decoder = new TextDecoder(); - return Stream.runForEach(stream, (chunk) => - write(decoder.decode(chunk, { stream: true }), streamName), - ).pipe(Effect.andThen(write(decoder.decode(), streamName))); -} - -function appendDiagnosticTail(existing: string, text: string) { - const combined = existing + text; - return combined.length <= dockerLogDiagnosticTailLength - ? combined - : combined.slice(combined.length - dockerLogDiagnosticTailLength); -} - -/** - * Extracts the RFC3339 timestamp leading the last complete line in `buffer` (docker's - * `--timestamps` prefixes every line with one), returning the still-incomplete tail to prepend - * to the next chunk. - */ -function consumeCompleteLines(buffer: string): { - readonly timestamp: string | undefined; - readonly remainder: string; -} { - const lastNewline = buffer.lastIndexOf("\n"); - if (lastNewline === -1) { - return { timestamp: undefined, remainder: buffer }; - } - const remainder = buffer.slice(lastNewline + 1); - const completedLines = buffer - .slice(0, lastNewline) - .split("\n") - .filter((line) => line.length > 0); - const lastLine = completedLines.at(-1); - const timestamp = lastLine === undefined ? undefined : /^\S+/u.exec(lastLine)?.[0]; - return { timestamp, remainder }; -} - -/** Why `streamContainerLogs` stopped following the container without failing. */ -type ContainerLogsEndReason = - | { readonly _tag: "containerExited" } - | { readonly _tag: "supervisorTerminated"; readonly exitCode: number } - | { readonly _tag: "containerGone" }; - -type Spawner = ChildProcessSpawner.ChildProcessSpawner["Service"]; - -/** - * One `docker logs -f --timestamps` attach, scoped so its handle's finalizer runs when this - * attempt ends instead of accumulating for the whole `functions serve` session. - */ -function attachToContainerLogsOnce( - spawner: Spawner, - output: Output["Service"], - containerId: string, - since: string | undefined, -) { - return Effect.scoped( - Effect.gen(function* () { - const args = [ - "logs", - "-f", - "--timestamps", - ...(since === undefined ? [] : ["--since", since]), - containerId, - ]; - const child = yield* spawnContainerCli(spawner, args, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - extendEnv: true, - }); - - let stderrText = ""; - let lineBuffer = ""; - let lastTimestamp: string | undefined; - const [exitCode] = yield* Effect.all( - [ - child.exitCode.pipe(Effect.map(Number)), - forwardByteStream( - child.stdout, - (text, streamName) => { - lineBuffer += text; - const parsed = consumeCompleteLines(lineBuffer); - lineBuffer = parsed.remainder; - if (parsed.timestamp !== undefined) lastTimestamp = parsed.timestamp; - return output.raw(text, streamName); - }, - "stdout", - ), - forwardByteStream( - child.stderr, - (text, streamName) => { - stderrText = appendDiagnosticTail(stderrText, text); - return output.raw(text, streamName); - }, - "stderr", - ), - ], - { concurrency: "unbounded" }, - ); - - return { exitCode, stderrText, lastTimestamp }; - }), - ); -} - -const streamContainerLogs = Effect.fnUntraced(function* ( - containerId: string, - retryDelay: Duration.Duration, -) { - const output = yield* Output; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - let sinceTimestamp: string | undefined; - let consecutiveReattaches = 0; - - const reattach = Effect.suspend(() => { - consecutiveReattaches += 1; - if (consecutiveReattaches > containerLogReattachCap) { - return Effect.fail( - new EdgeRuntimeLogStreamLostError({ - message: `lost the Edge Runtime log stream ${containerLogReattachCap} times; container ${containerId} is still running`, - containerId, - }), - ); - } - return Effect.sleep(retryDelay); - }); - - for (;;) { - const attempt = yield* attachToContainerLogsOnce(spawner, output, containerId, sinceTimestamp); - if (attempt.lastTimestamp !== undefined) { - if (attempt.lastTimestamp !== sinceTimestamp) consecutiveReattaches = 0; - sinceTimestamp = attempt.lastTimestamp; - } - - if (attempt.exitCode === 0) { - // `docker logs -f` exiting 0 doesn't necessarily mean the container stopped — the daemon - // can close the stream while it keeps running — so inspect the container to find out why. - const inspected = yield* inspectContainerState(spawner, containerId).pipe(Effect.result); - if (Result.isFailure(inspected)) { - if (isContainerNotFoundMessage(inspected.failure.message)) { - return { _tag: "containerGone" } satisfies ContainerLogsEndReason; - } - return yield* Effect.fail(inspected.failure); - } - const state = inspected.success; - if (state.running) { - yield* reattach; - continue; - } - if (externalTerminationExitCodes.has(state.exitCode)) { - return { - _tag: "supervisorTerminated", - exitCode: state.exitCode, - } satisfies ContainerLogsEndReason; - } - if (state.exitCode === 0) { - return { _tag: "containerExited" } satisfies ContainerLogsEndReason; - } - if (state.exitCode === 137) { - yield* reattach; - continue; - } - return yield* Effect.fail( - new EdgeRuntimeContainerCrashedError({ - message: `error running container ${containerId}: exit ${state.exitCode}`, - containerId, - exitCode: state.exitCode, - }), - ); - } - - // The `docker logs -f` process itself errored. A follow-up inspect distinguishes a container - // that no longer exists (end of session) from one still running (transient — re-attach) from - // anything else (a real, fatal stream failure), and sources `daemonDown` from docker's own - // inspect failure instead of the container's own stderr text. - const trimmedStderr = attempt.stderrText.trim(); - const inspected = yield* inspectContainerState(spawner, containerId).pipe(Effect.result); - if (Result.isFailure(inspected)) { - if (isContainerNotFoundMessage(inspected.failure.message)) { - return { _tag: "containerGone" } satisfies ContainerLogsEndReason; - } - return yield* Effect.fail( - new DockerLogsStreamError({ - message: - trimmedStderr.length > 0 - ? trimmedStderr - : `docker logs exited with ${attempt.exitCode}`, - containerId, - exitCode: attempt.exitCode, - stderr: trimmedStderr, - daemonDown: inspected.failure.daemonDown === true, - }), - ); - } - if (inspected.success.running) { - yield* reattach; - continue; - } - return yield* Effect.fail( - new DockerLogsStreamError({ - message: - trimmedStderr.length > 0 ? trimmedStderr : `docker logs exited with ${attempt.exitCode}`, - containerId, - exitCode: attempt.exitCode, - stderr: trimmedStderr, - daemonDown: false, - }), - ); - } -}); - -const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { - const dbId = localDockerId("db", projectId); - // A spawn failure (neither `docker` nor `podman` on PATH) must keep its - // cause: blanking stderr here would demote it to a bare "failed to inspect - // service" with no install guidance. - const result = yield* runChildProcess("docker", ["container", "inspect", dbId], { - stdout: "ignore", - stderr: "pipe", - }).pipe( - Effect.catch((cause) => - Effect.succeed({ exitCode: 1, stdout: "", stderr: describeContainerCliFailure(cause) }), - ), - ); - - if (result.exitCode === 0) { - return; - } - - if (result.stderr.includes("No such container") || result.stderr.includes("No such object")) { - return yield* Effect.fail( - new ServeLocalDbNotRunningError({ message: "supabase start is not running." }), - ); - } - - const message = - result.stderr.trim().length > 0 - ? `failed to inspect service: ${result.stderr.trim()}` - : "failed to inspect service"; - return yield* Effect.fail( - new ServeLocalDbInspectError({ message, daemonDown: isDockerDaemonUnreachable(result.stderr) }), - ); -}); - -const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: string) { - yield* runChildProcess("docker", ["container", "rm", "-f", "-v", containerId], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.ignore); -}); - // One step of Edge Runtime's create → cp → start bring-up. Only the cp step // passes a `messagePrefix`, since its raw stderr is uninterpretable alone. const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( @@ -1530,7 +954,11 @@ const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( stdin: opts.stdin, stdout: "pipe", stderr: "pipe", - }); + }).pipe( + Effect.mapError((cause) => + functionsServeError("failed to run Edge Runtime Docker step", cause), + ), + ); if (result.exitCode !== 0) { const detail = result.stderr.trim() || result.stdout.trim(); const message = @@ -1539,27 +967,14 @@ const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( : detail.length > 0 ? `${opts.messagePrefix}: ${detail}` : opts.messagePrefix; - return yield* Effect.fail(new Error(message)); + return yield* functionsServeError(message); } }); -const reloadKong = Effect.fnUntraced(function* (projectId: string) { - const output = yield* Output; - const kongId = localDockerId("kong", projectId); - // Needs the `--nginx-conf` flag pointing at the custom template - // `kong.service.ts` wrote, or reload re-renders from Kong's default - // template and drops the `email_templates` server (supabase/cli#6059). - const result = yield* runChildProcess( - "docker", - ["exec", kongId, "kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"], - { stdout: "ignore", stderr: "pipe" }, - ).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); - - if (result.exitCode !== 0) { - const suffix = result.stderr.trim().length > 0 ? ` ${result.stderr.trim()}` : ""; - yield* output.raw(`Warning: failed to reload Kong:${suffix}\n`, "stderr"); - } -}); +type ContainerLogsEndReason = + | { readonly _tag: "containerExited" } + | { readonly _tag: "supervisorTerminated"; readonly exitCode: number } + | { readonly _tag: "containerGone" }; const writeStoppedServingMessage = Effect.fnUntraced(function* () { const output = yield* Output; @@ -1645,6 +1060,7 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin projectEnvValues?: Readonly>, ) { const output = yield* Output; + const path = yield* Path.Path; const functionConfigs = yield* resolveServeFunctionConfigs( projectRoot, supabaseDir, @@ -1654,7 +1070,7 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin flagCwd, ); - const functionsDir = join(projectRoot, functionsDirName); + const functionsDir = path.join(projectRoot, functionsDirName); const binds = new Set(); const bitbucketCloneDirDefined = Option.isSome(yield* bitbucketCloneDir(projectEnvValues)); @@ -1665,24 +1081,23 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { - bitbucketCloneDirDefined, - additionalModuleRoots: [flagCwd], - skipMissingImportMapTargets: true, - onWarning: async (message) => { - bindWarnings.push(message); - }, - }), - )) { + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { + bitbucketCloneDirDefined, + additionalModuleRoots: [flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Effect.void; + }, + })) { binds.add(formatDockerBind(bind)); } const missingSourceWarning = bindWarnings.find((warning) => warning.includes("failed to read file:"), ); if (missingSourceWarning !== undefined) { - return yield* Effect.fail( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + return yield* functionsServeError( + missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), ); } } @@ -1693,19 +1108,20 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin /** * The reusable "bring up one Edge Runtime container" core, called both by - * standalone `functions serve` (via `startEdgeRuntime` below) and directly + * the start command and directly * by `start`'s own bring-up. * * Deliberately excludes config-loading (the caller resolves * {@link StartEdgeRuntimeContainerInput.config}/`authArtifacts` itself and * passes in already-resolved values), file-watching, and log streaming * (`serveFunctions`'s own loop still owns those for the standalone command). - * Also excludes the Kong reload, which only happens in `startEdgeRuntime` - * below, after this core succeeds. + * Also excludes the Kong reload, which is owned by the start command. */ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeContainer")( function* (input: StartEdgeRuntimeContainerInput) { const output = yield* Output; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; const projectId = input.config.projectId; const containerId = localDockerId("edge_runtime", projectId); const networkMode = input.networkId; @@ -1713,14 +1129,23 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // (wired into both `stop` and a failed-`start` rollback) reclaims this // same tree keyed by container name, so these secret env artifacts don't // leak on host disk indefinitely after the container is torn down. - const stagingDir = join(input.projectRoot, "supabase", ".temp", "start-secrets", containerId); + const stagingDir = path.join( + input.projectRoot, + "supabase", + ".temp", + "start-secrets", + containerId, + ); // A single directory-wide `rm` (not per-file cleanup closures) covers the // whole staging-write window below, including a mid-write failure // between two `writeDocker*` calls, not just the final docker steps. - const removeRuntimeArtifacts = Effect.tryPromise({ - try: () => rm(stagingDir, { recursive: true, force: true }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + const removeRuntimeArtifacts = fs + .remove(stagingDir, { recursive: true, force: true }) + .pipe( + Effect.mapError((cause) => + functionsServeError("failed to clean up Edge Runtime artifacts", cause), + ), + ); const bestEffortCleanupRuntimeArtifacts = removeRuntimeArtifacts.pipe( Effect.tapError((error) => output.warn(`Failed to clean up Edge Runtime artifacts: ${error.message}`), @@ -1737,7 +1162,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo input.flagCwd, ); - const functionsDir = join(input.projectRoot, functionsDirName); + const functionsDir = path.join(input.projectRoot, functionsDirName); const bitbucketCloneDirDefined = Option.isSome( yield* bitbucketCloneDir(input.projectEnvValues), ); @@ -1752,16 +1177,15 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, config, { - bitbucketCloneDirDefined, - additionalModuleRoots: [input.flagCwd], - skipMissingImportMapTargets: true, - onWarning: async (message) => { - bindWarnings.push(message); - }, - }), - )) { + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, config, { + bitbucketCloneDirDefined, + additionalModuleRoots: [input.flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Effect.void; + }, + })) { const key = formatDockerBind(bind); functionBinds.set(key, bind); if (!bind.externalScope) { @@ -1772,8 +1196,8 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo warning.includes("failed to read file:"), ); if (missingSourceWarning !== undefined) { - return yield* Effect.fail( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + return yield* functionsServeError( + missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), ); } for (const warning of bindWarnings) { @@ -1787,9 +1211,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo } const functionEnv = input.discoverFunctionEnvFiles && Option.isNone(input.envFile) - ? yield* parseFunctionEnvFile(join(functionsDir, config.slug, ".env")) + ? yield* parseFunctionEnvFile(path.join(functionsDir, config.slug, ".env")) : {}; functionsConfig[config.slug] = toFunctionContainerConfig( + path, input.projectRoot, config, functionEnv, @@ -1803,12 +1228,20 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // still exists through its covering parent. const binds = pruneRedundantDockerBinds(aggregatedBinds); - yield* ensureDockerNamedVolume( + const runtimeVolumePreparation = ensureDockerNamedVolume( edgeRuntimeCacheVolume(projectId).name, projectId, input.projectEnvValues, + ).pipe( + Effect.mapError((cause) => + functionsServeError("failed to prepare Edge Runtime volume", cause), + ), + ); + yield* runtimeVolumePreparation; + const networkPreparation = ensureDockerNetwork(networkMode, projectId).pipe( + Effect.mapError((cause) => functionsServeError("failed to prepare Docker network", cause)), ); - yield* ensureDockerNetwork(networkMode, projectId); + yield* networkPreparation; const env = [ ...(yield* parseCustomEnvFile( @@ -1826,7 +1259,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo `SUPABASE_INTERNAL_JWT_SECRET=${input.authArtifacts.jwtSecret}`, `SUPABASE_JWKS=${input.authArtifacts.jwks}`, `SUPABASE_INTERNAL_HOST_PORT=${input.config.apiPort}`, - `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${JSON.stringify(functionsConfig)}`, + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${encodeJson(functionsConfig)}`, ...(input.debug ? ["SUPABASE_INTERNAL_DEBUG=true"] : []), ]; if (input.inspectMode !== undefined) { @@ -1841,22 +1274,18 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return yield* Effect.gen(function* () { yield* Effect.try({ try: () => validateDockerMultilineEnvNames(multilineDockerEnv), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - const dockerEnvFile = yield* Effect.tryPromise({ - try: () => writeDockerEnvFile(singleLineDockerEnv, join(stagingDir, "env")), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => functionsServeError("invalid multiline environment variable name", cause), }); + const dockerEnvFile = yield* writeDockerEnvFile( + singleLineDockerEnv, + path.join(stagingDir, "env"), + ); const multilineEnvDir = "/root/.supabase/multiline-env"; - const dockerMultilineEnvScript = yield* Effect.tryPromise({ - try: () => - writeDockerMultilineEnvScript( - multilineDockerEnv, - multilineEnvDir, - join(stagingDir, "multiline-env"), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + const dockerMultilineEnvScript = yield* writeDockerMultilineEnvScript( + multilineDockerEnv, + multilineEnvDir, + path.join(stagingDir, "multiline-env"), + ); const labels = dockerProjectLabels(projectId); const serveMainFile = `${serveMainDir}/index.ts`; @@ -1869,14 +1298,17 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; - const serveMainTemplate = yield* Effect.promise(() => getFunctionsServeMainTemplate()); + const serveMainTemplate = yield* getFunctionsServeMainTemplate; // Streamed in via `docker cp` between create and start: embedding the template in the // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as // an empty directory on daemons that cannot see this host's filesystem (#6254, #4190). - const serveMainArchive = yield* Effect.tryPromise({ - try: () => containerArchiveBytes({ [serveMainFile]: serveMainTemplate }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + const serveMainArchive = yield* containerArchiveBytes({ + [serveMainFile]: serveMainTemplate, + }).pipe( + Effect.mapError((cause) => + functionsServeError("failed to prepare Edge Runtime bootstrap", cause), + ), + ); const containerProjectRoot = toDockerPath(input.projectRoot); const nofile = edgeRuntimeNofileUlimit(input.platform); if (nofile.clampWarning !== undefined) { @@ -1931,248 +1363,436 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return { containerId, cleanup: removeRuntimeArtifacts.pipe(Effect.orDie), - watchSpecs: yield* Effect.promise(() => buildWatchSpecs([...watchableBinds.values()])), + watchSpecs: yield* buildWatchSpecs([...watchableBinds.values()]), } satisfies StartedRuntime; }).pipe(Effect.onError(() => bestEffortCleanupRuntimeArtifacts)); }, ); -/** - * `SUPABASE_DB_URL` for standalone `functions serve`: always the `db` - * network alias with the fixed default password, since Deno can't resolve - * `_` in a container name. Not the same value `start`'s own bring-up uses — - * see {@link StartEdgeRuntimeContainerInput.dbUrl}'s doc comment. - */ -const defaultServeDbUrl = "postgresql://postgres:postgres@db:5432/postgres"; +const managedFunctionsConfig = ( + config: import("@supabase/stack/effect").StackConfig, + functions: ReadonlyArray, + inspectMode: FunctionsServeInspectMode | undefined, + inspectMain: boolean, + globalEnv: Readonly>, + functionEnv: Readonly>>>, +): EffectServiceConfig<"functions"> => { + const capability = config.capabilities?.functions; + const settings = capability?.enabled === false ? undefined : capability?.settings; + const edgeRuntime = settings?.edge_runtime; + const configuredSecrets = Object.fromEntries( + Object.entries(edgeRuntime?.secrets ?? {}).flatMap(([name, value]) => { + const plain = reveal(value); + return plain === undefined ? [] : [[name, plain] as const]; + }), + ); + const inspector = + inspectMode === undefined ? undefined : { enabled: true as const, port: "auto" as const }; + return { + enabled: true, + activation: "eager", + ...(capability !== undefined && capability.enabled !== false && capability.version !== undefined + ? { version: capability.version } + : {}), + settings: { + ...settings, + edge_runtime: { + ...edgeRuntime, + secrets: Object.fromEntries( + Object.entries({ ...configuredSecrets, ...globalEnv }).map(([name, value]) => [ + name, + Redacted.make(value), + ]), + ), + }, + functions: Object.fromEntries( + functions.map((entry) => [ + entry.slug, + { + enabled: entry.enabled, + verify_jwt: entry.verifyJwt, + import_map: entry.importMap, + entrypoint: entry.entrypoint, + static_files: [...entry.staticFiles], + env: Object.fromEntries( + Object.entries({ ...functionEnv[entry.slug], ...entry.env }).map(([key, value]) => [ + key, + Redacted.make(value), + ]), + ), + }, + ]), + ), + ...(inspectMode === undefined && !inspectMain + ? {} + : { inspector: { mode: inspectMode, main: inspectMain } }), + }, + ...(inspector === undefined ? {} : { endpoints: { inspector } }), + }; +}; -/** - * Resolves `functions serve`'s own config/secrets/image independently on - * every (re)start, then delegates the actual bring-up to - * {@link startEdgeRuntimeContainer}. Once that succeeds, this wrapper — and - * only this wrapper — reloads Kong so its routing table picks up the - * freshly (re)started container. - */ -const startEdgeRuntime = Effect.fnUntraced(function* (input: { - readonly flags: FunctionsServeFlags; - readonly dependencies: FunctionsServeDependencies; - readonly debug: boolean; - readonly networkId: Option.Option; - readonly inspectMode: FunctionsServeInspectMode | undefined; -}) { - const output = yield* Output; - // No docker precheck here: config resolution runs first, then - // `assertLocalDbRunning` below surfaces a down daemon as "failed to - // inspect service: ..." with the install hint as a suggestion. The - // remote-JWKS fetch is likewise held until after that assertion - // (`finalizeAuthArtifacts` below), so a down daemon never waits on - // external OIDC/JWKS requests first. - const resolved = yield* resolveServeConfig( - input.dependencies.projectRoot, - input.dependencies.projectIdOverride, - input.dependencies.goViperCompat, - input.dependencies.goConfigCompat, +const managedFunctionEnvironment = Effect.fnUntraced(function* ( + config: import("@supabase/stack/effect").StackConfig, + functions: ReadonlyArray, + flags: FunctionsServeFlags, + dependencies: FunctionsServeDependencies, +) { + const path = yield* Path.Path; + const capability = config.capabilities?.functions; + const settings = capability?.enabled === false ? undefined : capability?.settings; + const configuredSecrets = Object.fromEntries( + Object.entries(settings?.edge_runtime?.secrets ?? {}).flatMap(([name, value]) => { + const plain = reveal(value); + return plain === undefined ? [] : [[name, plain] as const]; + }), ); - const projectId = resolved.projectId; - const containerId = localDockerId("edge_runtime", projectId); - let ownsRuntime = false; - let startedRuntime: StartedRuntime | undefined; - return yield* Effect.gen(function* () { - // `SUPABASE_NETWORK_ID` is CLI-only, like `resolved.projectEnvValues` - // (`undefined` for library callers). - const networkMode = resolveDockerNetworkMode({ - explicit: Option.getOrUndefined(input.networkId), - envOverride: - resolved.projectEnvValues === undefined - ? undefined - : viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", resolved.projectEnvValues), - projectId, - }); - const localAuthArtifacts = yield* resolveLocalAuthArtifacts(resolved.auth, resolved.configPath); - const edgeRuntimeVersionOverride = yield* resolveEdgeRuntimeVersionPin( - input.dependencies.supabaseDir, - ); - const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - resolved.edgeRuntime.deno_version, - edgeRuntimeVersionOverride, - ); + const globalEntries = yield* parseCustomEnvFile( + flags.envFile, + dependencies.projectRoot, + dependencies.flagCwd, + configuredSecrets, + ); + const globalEnv = Object.fromEntries(globalEntries.map(splitEnvEntry)); + const functionEnv: Record>> = {}; + if (Option.isNone(flags.envFile)) { + const functionsDir = path.join(dependencies.projectRoot, functionsDirName); + for (const entry of functions) { + functionEnv[entry.slug] = { + ...globalEnv, + ...(yield* parseFunctionEnvFile(path.join(functionsDir, entry.slug, ".env"))), + }; + } + } + return { globalEnv, functionEnv }; +}); - yield* assertLocalDbRunning(projectId); - yield* bestEffortRemoveContainer(containerId); - - // Printed here, not in the shared `startEdgeRuntimeContainer` core, - // since `start`'s own bring-up (which calls that core directly) doesn't - // print it. - yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); - - // Finalized here, not inside `startEdgeRuntimeContainer`, to keep the - // shared core's caller-supplies-artifacts contract intact for `start`'s - // bring-up, which resolves its own JWKS. - const authArtifacts = yield* finalizeAuthArtifacts(localAuthArtifacts); - - // Resolved here, not earlier: an unreachable-daemon check on the image - // resolver would hijack the down-daemon message `assertLocalDbRunning` - // is responsible for. Known gap: parsing env-file/function config after - // this resolve means a broken `--env-file` now surfaces after a slow - // `docker pull` on cold cache instead of immediately — left open since - // fixing it risks `start`'s shared, more critical bring-up path. - const image = yield* resolveFunctionsDockerImage( - edgeRuntimeImage(edgeRuntimeVersion), - resolved.projectEnvValues, +const managedFunctionsWatchSpecs = Effect.fnUntraced(function* ( + resolved: ServeResolvedConfig, + flags: FunctionsServeFlags, + dependencies: FunctionsServeDependencies, +) { + const output = yield* Output; + const path = yield* Path.Path; + const functionConfigs = yield* resolveServeFunctionConfigs( + dependencies.projectRoot, + dependencies.supabaseDir, + resolved, + flags.importMap, + flags.noVerifyJwt, + dependencies.flagCwd, + ); + const functionsDir = path.join(dependencies.projectRoot, functionsDirName); + const binds: DockerBind[] = []; + const emittedScopeWarnings = new Set(); + const bitbucketCloneDirDefined = Option.isSome( + yield* bitbucketCloneDir(resolved.projectEnvValues), + ); + for (const config of functionConfigs) { + if (!config.enabled) continue; + const bindWarnings: string[] = []; + for (const bind of yield* buildDockerBinds( + resolved.projectId, + functionsDir, + functionsDir, + config, + { + bitbucketCloneDirDefined, + additionalModuleRoots: [dependencies.flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Effect.void; + }, + }, + )) { + if (!bind.externalScope) binds.push(bind); + } + const missingSourceWarning = bindWarnings.find((warning) => + warning.includes("failed to read file:"), ); + if (missingSourceWarning !== undefined) { + return yield* functionsServeError(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")); + } + for (const warning of bindWarnings) { + if ( + warning.startsWith("WARN: Mounting import map scope target") && + !emittedScopeWarnings.has(warning) + ) { + emittedScopeWarnings.add(warning); + yield* output.raw(warning, "stderr"); + } + } + } + return yield* buildWatchSpecs(binds); +}); - startedRuntime = yield* startEdgeRuntimeContainer({ - onContainerCreated: () => { - ownsRuntime = true; - }, - config: { - projectId, - apiPort: resolved.apiPort, - edgeRuntimePolicy: resolved.edgeRuntime.policy, - edgeRuntimeInspectorPort: resolved.edgeRuntime.inspector_port, - edgeRuntimeSecrets: resolved.edgeRuntime.secrets, - configDeclaredFunctions: resolved.configDeclaredFunctions, - configFunctions: resolved.configFunctions, - rawConfigFunctions: resolved.rawConfigFunctions, - }, - authArtifacts, - dbUrl: defaultServeDbUrl, - image, - projectRoot: input.dependencies.projectRoot, - supabaseDir: input.dependencies.supabaseDir, - flagCwd: input.dependencies.flagCwd, - platform: input.dependencies.platform, - debug: input.debug, - networkId: networkMode, - envFile: input.flags.envFile, - discoverFunctionEnvFiles: true, - importMap: input.flags.importMap, - noVerifyJwt: input.flags.noVerifyJwt, - inspectMode: input.inspectMode, - inspectMain: input.flags.inspectMain, - projectEnvValues: resolved.projectEnvValues, - }); +interface ManagedFunctionsRuntime { + readonly service: EffectServiceInstance<"functions">; + readonly watchSpecs: ReadonlyArray; + readonly observation: FunctionsObservation; +} - yield* reloadKong(projectId); +interface FunctionsObservation { + readonly exited: Deferred.Deferred; + readonly active: Ref.Ref; +} - return startedRuntime; - }).pipe( - // A failure after `startEdgeRuntimeContainer` returns (e.g. mid-`reloadKong`) - // escapes its own `Effect.onError`, so this wrapper also runs the - // returned runtime's staging-file cleanup, not just container removal — - // the shared core never removes the container it created. - Effect.onExit((exit) => - Exit.isFailure(exit) - ? Effect.all([ - ownsRuntime ? bestEffortRemoveContainer(containerId) : Effect.void, - startedRuntime === undefined ? Effect.void : startedRuntime.cleanup, - ]).pipe(Effect.asVoid) - : Effect.void, +const observeFunctions = Effect.fnUntraced(function* ( + service: EffectServiceInstance<"functions">, + output: typeof Output.Service, + observation: FunctionsObservation, +) { + const signalIfActive = Ref.get(observation.active).pipe( + Effect.flatMap((active) => + active ? Deferred.succeed(observation.exited, undefined) : Effect.void, + ), + ); + yield* Effect.forkScoped( + service.followLogs().pipe( + Stream.runForEach((entry) => + output.raw(entry.message, entry.stream === "stderr" ? "stderr" : "stdout"), + ), + Effect.ignoreCause, + Effect.ensuring(Deferred.succeed(observation.exited, undefined)), ), + { startImmediately: true }, + ); + yield* Effect.forkScoped( + service.followStatus.pipe( + Stream.runForEach((status) => + status.phase === "failed" || status.phase === "stopped" ? signalIfActive : Effect.void, + ), + Effect.ignoreCause, + Effect.ensuring(signalIfActive), + ), + { startImmediately: true }, ); }); +interface ResolvedManagedFunctions { + readonly resolved: ServeResolvedConfig; + readonly config: import("@supabase/stack/effect").StackConfig; + readonly functions: ReadonlyArray; + readonly environment: { + readonly globalEnv: Readonly>; + readonly functionEnv: Readonly>>>; + }; + readonly candidateConfig: EffectServiceConfig<"functions">; +} + /** - * Downgrades `self`'s failure to `onShutdown()`'s success when a shutdown signal arrives - * within `gracePeriod` of that failure — the Windows console-signal tie-break in - * {@link shutdownSignalGracePeriod}'s doc comment. + * Stack preparation takes the persisted stack view, while the service restart + * takes the service's concrete config. Keep the candidate conversion in one + * place so preparation and restart compare the same resolved function inputs. */ -const withShutdownGrace = ( - self: Effect.Effect, - shutdownRequested: Deferred.Deferred, - gracePeriod: Duration.Duration, - onShutdown: () => A, -): Effect.Effect => - self.pipe( - Effect.catch((error) => - Effect.raceFirst( - Deferred.await(shutdownRequested).pipe(Effect.as(onShutdown())), - Effect.sleep(gracePeriod).pipe(Effect.andThen(Effect.fail(error))), - ), +const managedFunctionsCandidateStackConfig = ( + config: import("@supabase/stack/effect").StackConfig, + candidate: EffectServiceConfig<"functions">, +): import("@supabase/stack/effect").StackConfig => { + const inspector = candidate.endpoints?.inspector; + const inspectorListener = + inspector === undefined + ? undefined + : inspector.enabled === false + ? { enabled: false as const } + : { + enabled: true as const, + ...(inspector.address === undefined ? {} : { address: inspector.address }), + ...(typeof inspector.port === "number" ? { port: inspector.port } : {}), + }; + return { + ...config, + capabilities: { + ...config.capabilities, + functions: { + enabled: true, + ...(candidate.version === undefined ? {} : { version: candidate.version }), + settings: candidate.settings, + }, + }, + ...(inspectorListener === undefined + ? {} + : { + listeners: { + ...config.listeners, + functionsInspector: inspectorListener, + }, + }), + }; +}; + +const resolveManagedFunctions = Effect.fnUntraced(function* ( + flags: FunctionsServeFlags, + dependencies: FunctionsServeDependencies, + inspectMode: FunctionsServeInspectMode | undefined, +) { + const resolved = yield* resolveServeConfig( + dependencies.projectRoot, + dependencies.projectIdOverride, + dependencies.goViperCompat, + dependencies.goConfigCompat, + ); + const config = yield* loadStackConfig(dependencies.projectRoot); + const functions = yield* resolveServeFunctionConfigs( + dependencies.projectRoot, + dependencies.supabaseDir, + resolved, + flags.importMap, + flags.noVerifyJwt, + dependencies.flagCwd, + ); + const environment = yield* managedFunctionEnvironment(config, functions, flags, dependencies); + return { + resolved, + config, + functions, + environment, + candidateConfig: managedFunctionsConfig( + config, + functions, + inspectMode, + flags.inspectMain, + environment.globalEnv, + environment.functionEnv, + ), + } satisfies ResolvedManagedFunctions; +}); + +const startManagedFunctions = Effect.fnUntraced(function* ( + flags: FunctionsServeFlags, + dependencies: FunctionsServeDependencies, + inspectMode: FunctionsServeInspectMode | undefined, + output: typeof Output.Service, +) { + const api = yield* StackApi; + const candidate = yield* resolveManagedFunctions(flags, dependencies, inspectMode); + const existing = yield* api.findStack({ projectRoot: dependencies.projectRoot }); + const stack = Option.isSome(existing) + ? yield* api.openStack(existing.value.id) + : yield* api.createStack({ + projectRoot: dependencies.projectRoot, + initialConfig: candidate.config, + }); + // A missing default is a destroyed registration, not a reason for a client + // to create a replacement under the same name. The supervisor owns default + // registration and reports the typed not-found failure to this caller. + const service = yield* stack.services.get({ name: "functions" }); + if (service.service !== "functions") { + return yield* functionsServeError("stack functions service has an unexpected kind"); + } + const descriptor = yield* service.describe; + const prepared = yield* stack.prepare({ + services: [service.id], + config: managedFunctionsCandidateStackConfig(candidate.config, candidate.candidateConfig), + }); + const preparedInstance = prepared.instances.find((instance) => instance.id === service.id); + if (preparedInstance === undefined) { + return yield* functionsServeError("stack prepare omitted the functions service"); + } + if ( + descriptor.effectiveConfigFingerprint === undefined || + preparedInstance.effectiveConfigFingerprint === undefined + ) { + return yield* functionsServeError("stack functions config fingerprint is unavailable"); + } + const observation: FunctionsObservation = { + exited: yield* Deferred.make(), + active: yield* Ref.make(false), + }; + yield* observeFunctions(service, output, observation); + if (preparedInstance.effectiveConfigFingerprint !== descriptor.effectiveConfigFingerprint) { + yield* service.restart({ config: candidate.candidateConfig }); + } else { + const status = yield* service.status; + if (status.phase !== "ready" && status.phase !== "starting") yield* service.start; + } + yield* Ref.set(observation.active, true); + return { + service, + watchSpecs: yield* managedFunctionsWatchSpecs(candidate.resolved, flags, dependencies), + observation, + } satisfies ManagedFunctionsRuntime; +}); + +const serveManagedFunctions = Effect.fnUntraced(function* ( + flags: FunctionsServeFlags, + dependencies: FunctionsServeDependencies, + inspectMode: FunctionsServeInspectMode | undefined, +) { + const output = yield* Output; + const processControl = yield* ProcessControl; + const shutdownRequested = yield* Deferred.make(); + yield* processControl + .awaitSignal() + .pipe( + Effect.andThen(Deferred.succeed(shutdownRequested, void 0)), + Effect.forkScoped({ startImmediately: true }), + ); + const startup = yield* Effect.raceFirst( + Deferred.await(shutdownRequested).pipe(Effect.as({ _tag: "shutdown" as const })), + startManagedFunctions(flags, dependencies, inspectMode, output).pipe( + Effect.map((runtime) => ({ _tag: "started" as const, runtime })), ), ); + if (startup._tag === "shutdown") { + yield* writeStoppedServingMessage(); + return; + } + let runtime = startup.runtime; + yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); + for (;;) { + const outcome = yield* Effect.raceFirst( + Deferred.await(shutdownRequested).pipe(Effect.as("shutdown" as const)), + Effect.raceFirst( + waitForRestartSignal(runtime.watchSpecs).pipe(Effect.as("restart" as const)), + Deferred.await(runtime.observation.exited).pipe(Effect.as("exited" as const)), + ), + ); + if (outcome === "shutdown") { + yield* writeStoppedServingMessage(); + return; + } + if (outcome === "exited") { + yield* writeContainerEndedMessage({ _tag: "containerGone" }); + return; + } + yield* Ref.set(runtime.observation.active, false); + const restarted = yield* Effect.raceFirst( + Deferred.await(shutdownRequested).pipe(Effect.as({ _tag: "shutdown" as const })), + Effect.gen(function* () { + const candidate = yield* resolveManagedFunctions(flags, dependencies, inspectMode); + yield* runtime.service.restart({ config: candidate.candidateConfig }); + yield* Ref.set(runtime.observation.active, true); + return { + _tag: "restarted" as const, + runtime: { + service: runtime.service, + watchSpecs: yield* managedFunctionsWatchSpecs(candidate.resolved, flags, dependencies), + observation: runtime.observation, + } satisfies ManagedFunctionsRuntime, + }; + }), + ); + if (restarted._tag === "shutdown") { + yield* writeStoppedServingMessage(); + return; + } + runtime = restarted.runtime; + } +}); export const serveFunctions = Effect.fn("functions.serve")(function* ( flags: FunctionsServeFlags, dependencies: FunctionsServeDependencies, ) { - const processControl = yield* ProcessControl; + yield* StackApi; const inspectMode = yield* Effect.try({ try: () => { const resolvedInspectMode = resolveFunctionsServeInspectMode(flags); buildFunctionsServeInspectArgs(resolvedInspectMode, flags.inspectMain); return resolvedInspectMode; }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => functionsServeError("invalid Functions serve inspection flags", cause), }); - const gracePeriod = dependencies.timers?.shutdownSignalGracePeriod ?? shutdownSignalGracePeriod; - const retryDelay = dependencies.timers?.dockerLogRetryDelay ?? dockerLogRetryDelay; - - const loop = Effect.gen(function* () { - // Hoisted to the loop's lifetime (not per-race) so a signal arriving - // between races isn't dropped while nothing is listening for it. - const shutdownRequested = yield* Deferred.make(); - yield* processControl - .awaitSignal() - .pipe( - Effect.andThen(Deferred.succeed(shutdownRequested, void 0)), - Effect.forkScoped({ startImmediately: true }), - ); - - for (;;) { - const startOutcome = yield* withShutdownGrace( - Effect.raceFirst( - Deferred.await(shutdownRequested).pipe(Effect.as("shutdown" as const)), - startEdgeRuntime({ - flags, - dependencies, - debug: dependencies.debug, - networkId: dependencies.networkId, - inspectMode, - }).pipe(Effect.map((started) => ({ _tag: "started" as const, started }))), - ), - shutdownRequested, - gracePeriod, - () => "shutdown" as const, - ); - - if (startOutcome === "shutdown") { - yield* writeStoppedServingMessage(); - return; - } - - const started = startOutcome.started; - - const outcome = yield* withShutdownGrace( - Effect.raceFirst( - Effect.raceFirst( - Deferred.await(shutdownRequested).pipe(Effect.as({ _tag: "shutdown" as const })), - waitForRestartSignal(started.watchSpecs).pipe(Effect.as({ _tag: "restart" as const })), - ), - streamContainerLogs(started.containerId, retryDelay).pipe( - Effect.map((reason) => ({ _tag: "exited" as const, reason })), - ), - ), - // raceFirst above already interrupted the restart listener, so only a shutdown - // signal — not a restart — can still downgrade this failure here. - shutdownRequested, - gracePeriod, - () => ({ _tag: "shutdown" as const }), - ).pipe( - Effect.ensuring( - bestEffortRemoveContainer(started.containerId).pipe(Effect.ensuring(started.cleanup)), - ), - ); - - if (outcome._tag === "shutdown") { - yield* writeStoppedServingMessage(); - return; - } - if (outcome._tag === "exited") { - yield* writeContainerEndedMessage(outcome.reason); - return; - } - } - }); - - yield* Effect.scoped(loop); + return yield* Effect.scoped(serveManagedFunctions(flags, dependencies, inspectMode)); }); diff --git a/apps/cli/src/shared/functions/serve.unit.test.ts b/apps/cli/src/shared/functions/serve.unit.test.ts index 21eb3dfea8..39df6bbf8e 100644 --- a/apps/cli/src/shared/functions/serve.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; import { buildServeEntrypointCommand } from "./serve.ts"; @@ -16,7 +17,7 @@ describe("buildServeEntrypointCommand", () => { }); it("keeps the spawned command short even with the real bundled template", async () => { - const bundled = await bundleServeMainTemplate(); + const bundled = await Effect.runPromise(bundleServeMainTemplate); const script = buildServeEntrypointCommand(["edge-runtime", "start"]); expect(bundled.length).toBeGreaterThan(20_000); expect(script.length).toBeLessThan(128); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 8cecb57445..2589407454 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -41,6 +41,7 @@ BranchesUpdateNetworkError BranchesUpdateUnexpectedStatusError CliConfigParseError CliConfigWriteError +CliEntrypointError CliProjectEnvParseError CliProjectHomeNotDirectoryError ComputeAlreadyConfiguredError @@ -254,11 +255,13 @@ FeedbackMessageTooLongError FeedbackNotFoundError FileWatcherError FunctionDeployCancelledError +FunctionDeployError FunctionDownloadNotFoundError FunctionImportNotDirectoryError FunctionNotFoundError FunctionsApiStatusError FunctionsApiTransportError +FunctionsDockerError FunctionsEnvNotSupportedError FunctionsListNetworkError FunctionsListUnexpectedStatusError @@ -266,6 +269,7 @@ FunctionsNewFileExistsError FunctionsNewInvalidSlugError FunctionsNewWorkdirError FunctionsNewWriteError +FunctionsServeError GenBearerJwtConfigParseError GenBearerJwtDecodeError GenBearerJwtKeyNotFoundError @@ -456,6 +460,7 @@ SeedMutuallyExclusiveFlagsError SeedWorkdirError ServeLocalDbInspectError ServeLocalDbNotRunningError +ServeMainBundleError ServiceVersionNotFoundError ServicesEnvNotSupportedError ShadowDbError diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 5b9226e19d..c4c3e0f1d3 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -10,6 +10,7 @@ import { InvalidProjectRootError, StackIdSchema, StackNotFoundError, + ServiceInstanceIdSchema, type CapabilityState, type EffectStack, type StackLifecycle, @@ -165,12 +166,14 @@ export function buildStorageStackApi( }, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: name === "database" ? ("eager" as const) : ("lazy" as const), state: name === "storage" ? storageState : ("ready" as const), error: name === "storage" ? options.storageError : undefined, })), artifacts: [], + instances: [], }), credentials: Effect.succeed({ database: { @@ -189,9 +192,16 @@ export function buildStorageStackApi( }), prepare: unusedFn, start: unusedFn, - stop: unused, - destroy: unused, - resetDatabase: unused, + services: { + create: () => unused, + get: () => unused, + list: unused, + }, + followStatus: Stream.empty, + sleep: () => unused, + restart: () => unused, + stop: () => unused, + destroy: () => unused, logs: unusedFn, followLogs: () => Stream.empty, }; diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 0f4bd8f084..4d2ad2de9b 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -14,17 +14,19 @@ unproven destroy leaves durable intent `destroying`. Public handles communicate with it through same-release Effect RPC and a small release-stable maintenance protocol for probe and stop. The package runs independently of the CLI and accepts normalized `StackConfig` values without reading `config.toml`. -CLI integration belongs to M5 in separate PRs, including Functions command wiring. -Future CLI handlers will call this facade without maintaining a second metadata or -PID-based liveness path. +CLI stack commands, Functions serving, and shadow-database consumers call the +Effect facade without maintaining a second metadata or PID-based liveness path. The public API has two deliberate entrypoints: Effect-native operations are available from `@supabase/stack/effect`, while the package root exposes the Promise handle and root Promise functions (`createStack`, `openStack`, `findStack`, `listStacks`, and `inspectStack`). Test callers use the public -`createTestStack` helper from `@supabase/stack/testing`; it owns a unique -temporary project root and exact-identity cleanup through `await using`, while -using the same managed state root as ordinary package callers. All +Effect-native `createTestStack` helper from `@supabase/stack/testing`; it owns a +unique temporary project root and removes that root after successful whole-stack +destruction. Tests compose acquisition and cleanup with `Effect.acquireUseRelease`, +or use its package-root Promise adapter with `await using`. The implementation +uses Effect directly in both cases, with Promise conversion confined to the root +facade. Test stacks use the same managed state root as ordinary package callers. All default callers therefore coordinate automatic ports through one registry; helper project roots and identities remain isolated. Temporary test stacks are excluded from listings scoped to another project root because their project roots @@ -73,12 +75,20 @@ network, socket, listener, or gateway. Status and retained logs are read from durable files only when owner metadata and the ownership lock are both absent. The same handle lazily launches a fresh Supervisor on its next start. -A Supervisor launched for a mutation also exits when that mutation cannot leave -running intent behind. In particular, a failed start before the running state is -committed releases its control endpoint and ownership lease after reporting the -failure. A successful start keeps the Supervisor alive; a sequential idempotent -start returns the current status without resetting lazy activation or publishing -a synthetic starting transition. +A Supervisor launched for a mutation owns its process and lease independently of +the launching handle. Interrupting that handle, or losing its RPC connection, +never sends a whole-stack maintenance stop; another client may already be using +the same owner. The launcher only cancels its readiness observation once the +detached child exists. The child reports startup failure and cleans up its own +pre-publication resources, while a launch whose caller disconnects before the +first connection may remain discoverable until a later explicit lifecycle +operation retires it. +The owner manages failed lifecycle work that cannot leave running intent behind: +a failed start before the running state is committed releases its control +endpoint and ownership lease after reporting the failure. A successful start +keeps the Supervisor alive; a sequential idempotent start returns the current +status without resetting lazy activation or publishing a synthetic starting +transition. If exact runtime cleanup cannot be proven, status remains `stopping` and the owner stays available for a retryable `stop()`; a cleaned stopped stack never diff --git a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md index f75180d5d5..acf5a41e3e 100644 --- a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md +++ b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md @@ -1,89 +1,81 @@ -# 0025. Ephemeral Postgres for schema tooling - -**Status**: proposed -**Date**: 2026-09-09 - -## Problem Statement - -`db diff`, `db pull`, `db schema declarative`, and `migration squash` provision a throwaway -shadow Postgres, snapshot its platform baseline as a PGDATA tar, and compare it to a target. -That path today always uses the legacy Docker local database: compose container IDs, platform -SQL templates, and `db.shadow_port`. - -The managed stack runtime (`@supabase/stack`) is a different Postgres: slim-artifact init plus -a fixed role/JWT/`_supabase` bootstrap, native host `PGDATA` or a named volume, and no extra -database API. `[experimental].stack` currently switches top-level `start`/`stop`. With the flag -on, schema commands still inspect `supabase_db_` and shadow against the legacy -baseline, so `--local` diffs are wrong or impossible. - -A second Postgres **instance** is required. `CREATE DATABASE` on the live cluster is not -equivalent: declarative sync needs two independent servers, and the cache is a full PGDATA -snapshot. - -## Domain language - -- **Schema init**: the one-shot that mutates Postgres for an enabled capability that already has - a prepare/migrate process, without starting that capability’s long-running process. Not - activation, and not the CLI overlay. The throwaway compile is `database` plus the requested - one-shot names (live: auth, storage, realtime; analytics and pooler only when those one-shots - run). It never includes studio, mail, or functions. CLI `--exclude` does not change this set. -- **Overlay**: CLI session SQL after schema init: webhooks (`pg_net`), API default grants, vault - upsert, and `roles.sql`. The CLI helper that runs schema init then overlay is not a fourth - concept. -- **Activation**: starting a capability’s long-running process and listeners. Not schema init. -- **Disabled capability**: exactly `{ enabled: false }`; the stack config schema rejects nested - pins (`version`, `settings`) on a disabled capability. Disable is not absence: schema-init can - turn a disabled cap back on, compiling it with default settings. -- **First create**: this start created the live project stack (`unconfigured` / no stack). Analog - of Compose’s fresh volume: schema init, Overlay, and user migrate-and-seed run once here. -- **Existing cluster**: a live project stack this start did not create (already-running or - start-from-existing-data). Analog of Compose’s existing volume: webhooks setup only. - -_Avoid_: treating `{ enabled: false }` as an empty object; “initialized PGDATA” as a setup -predicate; using stack `unconfigured` to mean “user migrations have not run.” +# 0025. Registered service instances for schema tooling -## Decision - -### (a) Public `EphemeralPostgres` on `@supabase/stack` - -The package exposes a scoped, Supervisor-free Postgres cluster API (`createEphemeralPostgres`) -on both the Effect and Promise facades. It is not a stack identity: it does not appear in -`listStacks` / `discoverStacks`, and it does not persist `state.json` under the managed stacks -root. - -The cluster uses the same catalog artifact/image and the same bootstrap as a real stack -database. Callers own migrations, `roles.sql`, declarative SQL, and cache keys. +**Status**: accepted +**Date**: 2026-09-16 -Handle operations: loopback URL; `stop` (process/container down, data retained); `start` (from -existing data); `exportPgData` only while stopped; destroy on scope close. +## Context -### (b) Snapshots are runtime-kind specific +Schema comparison needs independent PostgreSQL servers with isolated writable data. +Creating another database inside the primary server cannot provide independent +lifecycle, credentials, or physical snapshots. A separate shadow runtime would +duplicate process ownership, startup, bootstrap, and cleanup in the stack package. -Native Postgres runs as the host user. Container snapshots preserve image uids. A Docker tar must -not restore onto native, and the reverse is also refused. The cache key includes `runtime.kind` -(and engine). Native export is a host-tree tar of `PGDATA`; container export tars the volume -through the catalog Postgres image. +The stack package and its stored state are unreleased. There is no compatibility +requirement for their previous API or state representation. -### (c) `[experimental].stack` covers the db/migration family - -`SUPABASE_EXPERIMENTAL_STACK` / `[experimental].stack` select the stack backend for `db`, -`migration`, `test`, `gen`, `inspect`, and top-level `pull` as well as `start`/`stop`/`status`. -Flag off keeps the legacy Docker shadow and `supabase_db_*` local target. Linked / -`--db-url` targets stay URL/linked connections for engine and runtime selection -(`connType`); they do not switch the local engine. A `--db-url` whose host and port -match `config.toml` is still `isLocal` for dump's tool-container host rewrite. Top-level -`status` **is** aliased (`STACK_BACKEND_COMMANDS`). - -Shadow baseline for the stack backend is slim-init, stack bootstrap, schema init for the -platform trio (auth, storage, realtime), and the CLI overlay. Cache files use a distinct -`stack-shadow-baseline-*` namespace. Analytics and pooler stay off the shadow baseline. Schema -init never compiles studio, mail, or functions (those are not Postgres catalog one-shots). - -The stack backend requires the in-process pg-delta engine. Migra, pgAdmin, and -`--use-pg-schema` are rejected for every stack runtime because the **shadow is always** -`EphemeralPostgres`, including `--linked` / `--db-url`. +## Decision -### (d) Native dump, test, and squash clients +One stack owns a registry of service instances. Each instance has an immutable ID, +an optional unique name, typed configuration, concrete dependency IDs, and owned +runtime resources. Primary and shadow PostgreSQL use the same implementation. +The package has no separate ephemeral PostgreSQL factory or database-only +lifecycle interface. + +`stack.services` provides `create`, `get`, and `list`. Creation registers a stopped +instance and plans its endpoints without initializing data or starting a workload. +Every instance uses the same lifecycle and observation methods. Stack lifecycle +methods apply the same engine to an optional selection of instance IDs. + +Names are lookup metadata. Destroying an instance and creating another under the +same name produces a different ID; dependency references and old handles cannot +retarget it. Default registrations are seeded once. Destroyed defaults remain +absent when a stack is reopened or restarted. + +The supervisor owns admitted operations independently of requesting clients. +Per-instance admission protects lifecycle and storage operations, while unrelated +instances may start, restart, initialize, or snapshot concurrently. Shared state +commits update the current document and verify operation ownership. Slow process +work and archive I/O do not run under stack-wide state locks. + +Every runtime resource carries its concrete instance identity. Catalog recipe +identity does not identify a running process, container, volume, private binding, +or setup helper. Targeted cleanup removes only proven instance-owned resources; +unproven cleanup retains recovery evidence. + +## PostgreSQL initialization and snapshots + +PostgreSQL reconciles managed passwords, JWT material, and settings on every +start, including wake and restored data. JWT material belongs to the stack and +does not require a running Auth service. + +Creation-time database initialization selects typed catalog recipes. Their +resolved versions and inputs form a profile, and completion has a durable receipt +separate from the existence of PostgreSQL data. Catalog recipes target the exact +database instance and do not require their corresponding live services to run. +Project migrations, declarative schemas, roles, and overlays remain CLI work. +Database creation may reference another database's immutable ID to copy its +resolved initialization requirements within the stack. The new instance owns +its own initialization receipts and data. This lets CLI reset build a baseline +using the primary's catalog versions and secret inputs without exposing them. +Ordinary CLI shadows copy those requirements too, so repeated runs retain the +same resolved catalog inputs for cache lookup. A missing primary is an explicit +error; shadow creation does not recreate a destroyed default instance. + +The uniform `exportSnapshot` and `restoreSnapshot` methods initially support +PostgreSQL. Export requires stopped data. Restore requires a stopped instance +with empty owned storage and compatible initialization, runtime, and data format. +The instance remains fenced through validation, publication, and helper cleanup. +An interrupted requester does not abandon the supervisor's storage operation. + +Snapshots record resolved artifact and runtime metadata, PostgreSQL format, +initialization profile, provenance, and lineage. A restored clone preserves that +lineage while receiving a distinct instance ID and its own credentials. Native +and container snapshots are not interchangeable. Cache keys are CLI policy and +are not proof of shared database lineage. They combine resolved artifact, runtime, +bootstrap, and initialization identities with CLI overlay inputs. Endpoint ports +and instance paths do not affect bootstrap identity. + +## CLI integration `db dump`, `db test`, and `migration squash` talk to published loopback credentials for `--local`. On the stack backend, dump, squash, and `test db` always launch catalog `pg_dump` / @@ -95,94 +87,49 @@ Docker client against the published URL (`host.docker.internal`). The stack stay If Docker is missing on that path, the command fails and tells the user to install Docker Desktop. `db lint --local` does not launch a client binary: catalog Postgres ships `plpgsql_check`, so the lint transaction can `CREATE EXTENSION` on native and container stacks. - -### (e) Studio does not require analytics - -Compose runs Studio when `[analytics] enabled = false`. Stack compile allows that pairing so -bare `stack start` matches Compose. Studio’s capability and workload graphs do not list analytics -as a hard dependency; logs UI stays off when analytics is off. This is independent of schema -init, which never compiles Studio. - -### (f) Live start setup is Compose-faithful - -Compose keys “run full setup” on `volumeExists`. Stack has no compose volume, so the analog is -whether **this start created the stack identity** (first create / `unconfigured`). - -- **First create**: schema init, Overlay (webhooks, grants, vault, `roles.sql`), then - migrate-and-seed. `db start` and `stack start` share this. Do not report start success until - it completes. -- **Existing cluster**: webhooks setup only. No schema-init retry, no grants/vault/`roles.sql`, - no migrate-and-seed. -- If catalog or migrate-and-seed fails after the engine is already `running`, the command exits - non-zero and Postgres stays up. The next start is an existing cluster and does not retry. - Recover with `db reset`. Same stuck case as Compose after a failed fresh-volume setup. -- If the engine never reached `running`, lifecycle is written `unconfigured` before cleanup, so - first-create survives both proven and unproven cleanup. Already-written secrets are kept; - pass-through secrets may change while `unconfigured`. Leftover PGDATA/volume is not - auto-wiped. A later launch that fails because remnants remain names `stack destroy` as the - wipe. Cleanup only decides the in-process fence. - -### Default runtime and native-as-root - -Auto-selecting a **new** identity probes the Docker daemon (not only `docker --version`). A live -daemon persists Docker. A present client with a dead daemon persists **native** and prints a -notice that destroy-and-recreate (or a new `--stack` name) is required to get Docker later. -Persisted runtime never flips. Explicit `--runtime docker` still requires a live daemon. - -Native Postgres is refused when the process uid is 0 (`initdb` refuses root). There is no -uid-drop. Use `--runtime docker`. - -### Optional catalog downloads - -Live schema-init still fail-closes the platform trio (auth, storage, realtime) against the -enabled/full config. Analytics and pooler one-shots follow the start/excluded config, so -`--exclude analytics` and postgres-only `db start` skip those downloads. - -## Rationale - -Throwaway full stacks would pollute discovery, pull in a Supervisor, and still need a -pre-start PGDATA inject. Duplicating native spawn in the CLI would fork artifact and bootstrap -logic. A package-level cluster keeps one Postgres lifecycle for native and container while -leaving schema policy in the CLI. +The CLI chooses and retains a fresh shadow name before creation. After an uncertain +create response, it looks up that name and verifies the intended creation inputs +before treating the registration as owned. It does not blindly replay creation. +A creation-input digest on the uncertain result and the registration proves the +complete normalized request matches, including secret inputs without revealing +them. Missing or different evidence leaves the registration unclaimed. + +The cold flow creates and starts a registered database, applies CLI overlays, +optionally stops and exports a baseline, then starts it for migrations and +comparison. The cache flow restores a compatible baseline into a fresh stopped +instance before starting it. Cache fallback first proves cleanup of the failed +target, then creates a fresh instance. + +CLI finalization waits for an admitted snapshot operation to settle before +destroying its instance. Cleanup failure is reported with the retained instance; +it is not hidden as successful disposal. A crashed CLI may leave a discoverable +registration for explicit cleanup. + +Clients use planned managed SQL endpoints instead of choosing ports themselves. +All public SQL traffic traverses the stack TCP gateway. Private backend +connections are limited to runtime-managed dependency and setup work. Native +tool selection uses resolved artifact metadata and retains matching-major checks +without exposing runtime-owned data paths. + +The stack backend remains selected by the existing experimental CLI policy. +Linked and explicit database URLs keep their connection semantics. Existing CLI +behavior outside the stack backend does not require a second stack lifecycle. ## Consequences -### Positive - -- Native and Docker/Podman shadows share one API and the same slim baseline as `stack start`. -- Schema commands can target a running project stack through `credentials()` when the flag is on. - `credentials().database` is available whenever the database listener is assigned, including when - Auth is disabled. `credentials().api` is absent when Auth is off. Overlay and `--local` keep - calling `credentials()`. There is no second RPC, and the CLI does not read secret slots. -- `resetDatabase` wipes Postgres without destroying the stack identity, so `db reset --local` and declarative `--apply` stay on the stack backend. -- Live `db start` / `stack start` setup matches Compose: full setup on first create, webhooks only afterwards. -- Legacy Docker behavior is unchanged when the flag is off. -- Windows native stacks can dump and squash without PostgreSQL client tools on PATH. - -### Negative - -- Cache tars cannot be shared across native and container runtimes. -- Migra/pgAdmin remain unavailable on stack backends (shadow is always ephemeral). -- A failed first live setup after the engine is running is stuck until `db reset`, same as - Compose. A failed cold launch that never reached running retries first-create. -- Windows native dump/test/squash need a working Docker client even though Postgres itself is native. -- Native stacks as uid 0 cannot start; Docker (or a non-root user) is required. -- Auto-selected native after a dead Docker daemon is sticky until destroy or a new stack name. - -## Alternatives Considered - -1. **Database-only throwaway stacks** via `createStack`/`destroy`: extra Supervisor and - registry identity for a tooling cluster; cache restore still needs a data inject. -2. **CLI-owned spawn**: Docker shadows with the slim image, CLI-spawned native binary. Forks - catalog/bootstrap from the runtime package. -3. **`CREATE DATABASE` on the live cluster**: cannot snapshot independently or run two - declarative plan servers. - -## Related Decisions - -- ADR 0017: Simplified managed stack architecture - -## See Also - -- [`packages/stack/README.md`](../../packages/stack/README.md) -- [`apps/cli/docs/stack-commands.md`](../../apps/cli/docs/stack-commands.md) +- Primary and shadow databases share initialization, runtime, and cleanup behavior. +- Two shadows can coexist while Functions restarts independently of their work. +- Stable endpoint plans survive stop and sleep; external port conflicts fail + explicitly rather than relocating saved URLs. +- Explicit sleep retains started intent and gateway wake; stop fences wake and + retains data; destroy removes registration only after proven cleanup. +- Whole-stack operations include registered shadows, including those left by a + crashed CLI. Shadows have no automatic age-based cleanup. +- Previous unreleased stack APIs and stored formats are removed without adapters + or migrations. The new model retains safeguards for data created within it. + +## Related decisions + +- [Managed stack architecture](0017-simplified-managed-stack-architecture.md) +- [Stack package API](../../packages/stack/README.md) +- [CLI stack commands](../../apps/cli/docs/stack-commands.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 083f7fb36e..552feba7fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -65,7 +65,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0022 | [Config Diff Classification and Managed Surface](0022-config-diff-classification-and-managed-surface.md) | accepted | | 0023 | [Config Pull Write Strategy and Scope Resolution](0023-config-pull-write-strategy-and-scope-resolution.md) | accepted | | 0024 | [Top-Level `pull` Orchestration](0024-top-level-pull-orchestration.md) | accepted | -| 0025 | [Ephemeral Postgres for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | proposed | +| 0025 | [Registered Service Instances for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | accepted | ## Template diff --git a/packages/stack/README.md b/packages/stack/README.md index 405e2a4045..6d0edf1a42 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -1,132 +1,65 @@ # `@supabase/stack` -The local Supabase stack runtime. Its public API is a greenfield, -Effect-native managed runtime; implementation modules are private to the -package. - -The package runs independently of the CLI and accepts normalized `StackConfig` values; -it does not load `config.toml`. CLI command wiring, configuration translation, and -presentation belong to the M5 integration work in separate PRs. - -The supported entrypoints are: - -- `@supabase/stack` — Promise facade -- `@supabase/stack/effect` — Effect-native API -- `@supabase/stack/testing` — test helpers - -Stacks are managed identities: closing a handle does not stop a running stack. Creating or opening a -handle starts nothing, and a stopped stack retains no Supervisor, workload, container, network, or -listener. Status and retained logs remain available directly from durable state while stopped; a -later start on the same handle launches a fresh Supervisor. -With no configuration override, all capabilities and their companion workloads (including -imgproxy and Vector) are enabled, PostgreSQL is the only eager capability, and every other -capability is lazy. Starting the stack therefore launches only -PostgreSQL by default; capabilities configured as eager join its startup dependency closure. -The remaining lazy capabilities activate through the stack's listeners on demand for the current -running session. - -Lazy REST, Auth, Realtime, Studio, and pooler capabilities stop after 60 seconds without traffic -by default. Traffic means an active request or stream; idle HTTP keep-alive sockets do not keep a -service running, while open WebSocket or TCP connections do. Configure a different positive -timeout, or disable traffic stopping for a capability, with `idleTimeoutSeconds`: +The managed local Supabase runtime, with native and container backends. The package +accepts typed `StackConfig` inputs independently of the CLI. The CLI owns loading +`config.toml`, translating flags, and presenting results. -```ts -await stack.start({ - config: { - capabilities: { - rest: { idleTimeoutSeconds: 120 }, - realtime: { idleTimeoutSeconds: false }, - }, - }, -}); -``` +Supported entrypoints: -To change `idleTimeoutSeconds` on a running stack, call `stop()` and then `start()` with the updated -configuration. - -Stacks saved before idle stopping retain their previous policy: missing timeout values are read as -`false`. Restarting with the saved definition preserves that policy. To adopt the current defaults, -stop the stack and start it with the project configuration. Status can report changed effective -defaults even when the project file is unchanged; a stack still marked running must be stopped -before those defaults can be applied. - -Eager capabilities never auto-stop; an explicit timeout on an eager capability is ignored and its -effective timeout is `false`. PostgreSQL, Storage, Functions, Mail, and Analytics do not accept -idle timeout configuration. Studio and its `pg-meta` companion are stopped and started together. -Dependency protection keeps required dependencies available while a capability is running. -Stopping preserves listeners and data, and the next request wakes the lazy capability and restarts -its workloads. -Retirement cleanup is fail closed: if removal is unproven, the stack remains stopping and new -activation is rejected. An unproven cleanup marks participating capabilities as failed and fences -new activation across the stack. This is an operation-level result; the workload ledger tracks -resources still requiring removal. Unrelated healthy capabilities retain their observations. An -explicit stop retries the retained ledger. A failed committed destroy remains destroying and -accepts only a destroy retry; successful cleanup is required before the managed state is removed. -Status exposes the required recovery operation (`stop` or `destroy`) and its reason in `recovery`. -Participating capability failure states describe incomplete cleanup, including shared listener -cleanup; they do not imply that each workload process failed. - -The Effect API's `excludeStackCapabilities` helper disables requested optional capabilities and -their dependents in an in-memory config. Excluding `rest` also disables `studio`; excluding -`analytics` does not. The database remains required. The project config is unchanged, and runtime listeners are -created only for enabled capability routes. - -Native workloads have a two-minute readiness budget to allow cold starts to load shared libraries; -container workloads retain a 30-second budget, and PostgreSQL uses its configured `health_timeout`. -Each readiness probe returns immediately when its endpoint becomes healthy. - -Artifact preparation is controlled independently from capability activation through the optional -top-level `preparation` setting: +- `@supabase/stack`: Promise API. +- `@supabase/stack/effect`: Effect API with redacted secret inputs and outputs. +- `@supabase/stack/testing`: isolated test-stack helpers. -```ts -await stack.start({ config: { preparation: "on-demand" } }); -``` +The package and its managed state are unreleased. Previous APIs and state formats +have no compatibility contract. + +## Identities and instances + +A stack identity contains a service registry, shared security material, endpoint +plans, and retained data. Its identity derives from the canonical project root, +Git branch context, and stack name. Separate worktrees and named stacks are +independent. Moving a project resolves a new identity. -The default `"background"` mode prepares all enabled lazy artifacts after PostgreSQL has started, -without launching those services. A single Supervisor-owned background operation prepares the -finite selected workload set concurrently, streams downloads through hashing and decompression, -is canceled and awaited by `stop()` or `destroy()`, and keeps completed cache entries. -`"on-demand"` skips that background work for callers that want full lazy preparation. In either -mode, activating a lazy service prepares its requested dependency closure concurrently, while -explicit `stack.prepare(...)` remains available as a cache-only warmup. Eager capabilities remain -independent of this preparation policy. The setting is persisted with the stack definition and -survives `openStack()` and restart. Changing it for a running stack follows the existing -stop-before-change configuration rule. - -Running status includes an artifacts array for the current session. Each entry identifies a -workload and capability and reports `queued`, `preparing`, `downloading`, `ready`, or -`failed`; `failed` includes an error message and can be retried by activating the capability again. -`preparing` covers validation, verification, and extraction around the transfer. During stopping -or destroying, status may retain active preparation until teardown clears it. Once stopped, status -reports an empty array even when completed artifacts remain in the cache. - -The Functions inspector can be exposed through its own loopback listener. Set the Edge Runtime -mode and enable that listener together; the resulting `functionsInspector` endpoint forwards the -runtime's `/json/list` and WebSocket inspector paths. +`createStack` registers default instances from `initialConfig` without starting +workloads. Opening an existing stack preserves its registry and configuration. +Defaults are seeded once; destroying a default does not cause it to reappear. ```ts -await stack.start({ +import { createStack } from "@supabase/stack"; + +const stack = await createStack({ + projectRoot: process.cwd(), + initialConfig: { preparation: "on-demand" }, +}); + +const database = await stack.services.create({ + service: "database", + name: "schema-comparison", config: { - capabilities: { functions: { settings: { inspector: { mode: "run", main: true } } } }, - listeners: { functionsInspector: { enabled: true, address: "127.0.0.1", port: 9223 } }, + version: "17", + activation: "eager", + endpoints: { sql: { port: "auto" } }, }, + initialization: { catalog: { auth: {}, storage: {}, realtime: {} } }, }); -const inspector = (await stack.status()).endpoints.functionsInspector; + +await database.start(); +const credentials = await database.credentials(); +if (credentials === undefined) throw new Error("SQL endpoint is disabled"); +// Use credentials.url for SQL, then explicitly release the owned instance. +await database.destroy(); ``` -Native runtimes bind a private inspector port on loopback. Container runtimes bind port `9229` -inside the service and publish that private port to the configured loopback listener. Connect your -debugger through `inspector.url`; the workload's private inspector port stays local to the stack. +`stack.services` provides `create`, `get`, and `list`. `get({ id })` and +`get({ name })` only resolve an existing registration. Names are unique lookup +metadata; immutable IDs identify resources and dependency targets. Recreating a +name never revives its old handles or retargets dependents. -Explicit `stack.prepare(...)` accepts a synchronous `onProgress` callback for -caller-owned preparation. It receives the same phase values, including `ready` when an artifact is -available while its capability remains dormant. This transfer-local callback is not reconstructed by -a separate status request. +Dependent services bind typed dependency slots to existing instance IDs at +creation. Functions, PostgreSQL, and Mail have no service dependencies. Functions +can start while PostgreSQL and Auth are absent or stopped. -The package's end-to-end contract is exercised through the same public Stack API in native and -Docker modes. It begins from the PostgreSQL-only default, progressively activates every service with -realistic traffic, and verifies stop/start cycles, stable ports, and persistent data. The -CLI is not involved in these runtime tests. +## Lifecycle When `runtime` is omitted for a new stack, the package selects Docker when the Docker client is installed and its daemon is reachable, and native otherwise. An installed client with an @@ -136,11 +69,17 @@ new stack name. The Promise facade does not expose the notice. Native is refused when the process runs as uid 0. Existing stacks reuse their persisted runtime without probing; native, Docker, and Podman preferences remain explicit when supplied, and an explicit Docker runtime does not fall back. Podman is supported only on local Linux hosts. +Every instance exposes `describe`, `status`, `credentials`, `prepare`, `start`, +`sleep`, `stop`, `restart`, `destroy`, `logs`, `followLogs`, `followStatus`, +`exportSnapshot`, and `restoreSnapshot`. -Stack identity is the length-delimited SHA-256 tuple of the canonical project root, Git branch -context (or `ordinary-workspace` outside Git), and stack name. Separate worktree roots, branches, -projects in a monorepo, and named stacks therefore receive separate managed state. Identity -resolution is read-only; moving a project creates a new identity. +| Operation | Result | +| --------------------- | -------------------------------------------------------------------------- | +| `start()` | Make this instance and its prerequisite closure ready. | +| `sleep()` | Retain started intent and wake routes while stopping workloads. | +| `stop()` | Fence demand activation and stop workloads; retain configuration and data. | +| `restart({ config })` | Apply optional replacement runtime settings and make the instance ready. | +| `destroy()` | Remove exact owned runtime resources and data, then remove registration. | `createTestStack` gives each test stack a unique temporary project root and identity while sharing the managed state root used by ordinary package callers. It uses the same runtime selection as @@ -151,28 +90,168 @@ coordinate across all default callers. Helper project roots and identities remai temporary test stack is excluded from listings scoped to another project root but appears in an unfiltered package `listStacks()` result. A failed destroy retains the affected project root and managed state for recovery. +An explicit dependent start authorizes its prerequisite closure. Traffic-driven +activation cannot reverse a dependency's explicit stopped intent. Stop and restart +reject running or starting dependents outside the selection. Destroy also rejects +registered stopped dependents outside its selection. + +Restart replaces supplied runtime settings rather than merging nested settings. +Omitted passwords and endpoint bindings retain their saved values. Initialization +requirements and dependency IDs are creation-time inputs and cannot be replaced +by restart. -Callers can warm selected native artifacts or container images with `stack.prepare(...)` while a -stack is stopped or running; explicit preparation is cache-only and cancellation does not affect -completed entries. -Each capability may opt into eager activation in `StackConfig`; omitted settings keep every -non-PostgreSQL capability lazy. Prepared artifacts are not automatically pruned. `followLogs(...)` -provides filterable live entries through a stateless client-polled cursor. - -`resetDatabase()` wipes Postgres data only: identity, ports, secrets, logs, and storage volumes -stay. The database is started and bootstrapped before return. Applying migrations, declarative -schemas, and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the -`_realtime` schema owner, closed database role passwords, and JWT settings in one transaction; the -slim database artifact owns its initialization and migrations. - -`createEphemeralPostgres` is a scoped, Supervisor-free Postgres cluster for schema tooling. It uses -the same catalog artifact and bootstrap as a stack database, is not registered in `listStacks` / -`discoverStacks`, and destroys its data directory or volume when the Effect scope closes. The -Promise facade returns a handle with explicit `destroy()`. Callers own migrations and PGDATA -cache keys. `exportPgData` is valid only while the cluster is stopped; native and container snapshots -are not interchangeable. +Stack lifecycle methods accept an optional `{ services: [instanceId, ...] }`. +An explicit empty selection is a no-op. Selected start and restart make their +instances ready immediately; omitted selection applies whole-stack eager/lazy +policy. Whole start includes registered enabled dynamic instances as well as +defaults, while preserving unrelated already-ready services. Selected destroy +retains the stack identity; whole destroy removes it after proven cleanup. `runPostgresClient` prepares that same catalog pin and runs caller argv (`bash -c` dump scripts, `pg_prove`, …) without starting Postgres. Native prepends `artifact/bin` to `PATH`; container is a one-shot `docker|podman run --rm`. It is not an `EffectStack` method, so linked dump can prepare tools without a running stack. +PostgreSQL defaults to eager activation. Other services default to lazy +activation. Supported lazy services can retire after idle time; Functions and +PostgreSQL have no automatic idle timer. Manual sleep still requires a supported +wake route and rejects active traffic or protected dependency work. + +## Ownership and observation + +Handles are clients. Closing one, cancelling a request, or exiting the CLI does +not stop the shared runtime. A supervisor owns admitted lifecycle and snapshot +operations through settlement. Independent instances execute concurrently; +shared state transactions do not hold a lock across process startup or archive +I/O. + +`followStatus` observes instance transitions, including pending operations and +recovery. `followLogs` follows the selected instance across workload replacements. +The Promise API returns async iterables; the Effect API returns streams. Stop +preserves retained logs and planned endpoints. + +An owner that is retiring may reject a request before admission; the client waits +for its lease release and resolves a fresh owner once. An uncertain admitted +mutation is reported without replay. Callers creating resources should retain a +unique chosen name so they can reconcile an uncertain response through `get`. + +Cleanup must be proven before an instance is removed. Failures retain attributable +recovery evidence. Client crashes may leave discoverable instances that require +explicit cleanup; there is no implicit age-based collection. + +## Endpoints and security + +Public HTTP, WebSocket, and SQL traffic uses the managed gateway. Container +Functions code also receives a tracked SQL route rather than a raw database +alias. Private endpoints are reserved for runtime-managed dependencies, health +checks, and setup work. + +Creation plans stable client ports. A plan is not a bound socket or a readiness +promise. Binding an occupied saved port fails with a conflict instead of silently +changing the endpoint. Status distinguishes planned, listening, and unavailable +bindings. Individual lifecycle operations preserve unrelated listeners. + +Set `endpoints.sql.enabled` to `false` to disable a database's public SQL binding. +Its `credentials()` then returns `undefined`; managed private consumers can still +use the database. Root database credentials project only the designated default +database and are absent when that instance or its enabled SQL binding is absent. + +JWT signing material and expiry belong to stack security independently of Auth. +Each PostgreSQL instance owns its password. Descriptions redact secret settings; +credential methods are the explicit secret-bearing surface. + +Functions inspector settings select `run`, `wait`, or `brk`, with optional `main`. +The instance's `endpoints.inspector` controls the managed inspector listener. +Startup-control HTTP and WebSocket endpoints may become available before +application health so a debugger can release a waiting runtime. Ordinary +Functions routes remain gated until ready. + +`functions_root` is relative to the project root. Per-function entrypoints, +import maps, and static-file patterns are relative to that function's directory; +the shared import-map default is relative to `functions_root`. Explicitly +configured files may live outside the functions tree. Autodiscovery remains +contained within it. Container startup mounts source files read-only while +preserving their path relationships, including sibling imports. Restart resolves +the files again, so CLI watch restarts pick up source and configuration changes. + +## Initialization and snapshots + +PostgreSQL reconciles managed credentials and settings on every start, including +wake and restored data. Creation-time catalog requirements run before readiness, +independently of whether their corresponding live services are enabled. Durable +completion records are tied to the resolved initialization profile. Data existing +on disk alone is not evidence that catalog setup completed. + +`StackConfig.initialization.database` supplies the designated primary's initial +requirements. Dynamic databases use `services.create({ initialization })`. +Use `initialization: { from: database.id }` to copy another database's resolved +catalog requirements within the same stack. The copy retains its requirements +after the source is destroyed and does not copy its data or completion receipts. +Project migrations, roles, seeds, and CLI overlays are outside the runtime +lifecycle. + +Snapshot methods share the instance interface; PostgreSQL is the initial supported +service. Export requires stopped owned data and an absent destination. Restore +requires a stopped instance with empty storage and compatible runtime, PostgreSQL +format, and initialization profile. + +Database data has four durable states: `absent` means no owned data is present, `fresh` carries +the lineage of a successful new initialization, `restored` carries the validated snapshot +descriptor, and `incomplete` carries the operation ID when storage completeness is unknown. +Starting absent or incomplete data records incomplete before mutation and promotes it to fresh on +success. A successful restore records restored; once marked incomplete, a failed restore retains +that state unless the runtime proves the target empty, in which case it records absent. Existing fresh or restored +provenance is preserved across ordinary restarts. + +```ts +await database.stop(); +const snapshot = await database.exportSnapshot({ destination: "/tmp/baseline.tar" }); + +// clone is a separately registered stopped database with matching requirements. +await clone.restoreSnapshot({ source: "/tmp/baseline.tar" }); +await clone.start(); +``` + +The snapshot descriptor records actual artifact/runtime identity, format, +initialization profile, provenance, and lineage. Clones have separate writable +data and instance IDs while preserving the baseline's lineage. Starting a clone +reconciles its own configured credentials. Native and container snapshots are +not interchangeable. + +The target remains fenced during storage operations and recovery. Conflicting +commands fail instead of racing the archive. Publication never exposes a partial +snapshot. A caller finalizing a shadow waits for pending snapshot settlement +before destroying it, and reports unproven cleanup rather than hiding it. + +## Preparation and testing + +`prepare({ services?, config? })` prepares artifacts without changing saved +configuration, intent, listeners, or workloads. Candidate configuration previews +default instances; dynamic registrations retain their saved inputs. Startup +automatically prepares the artifacts it needs. Completed cache entries survive +operation cancellation. + +The Effect API uses reusable Effect values for no-argument operations, including +`stack.services.list`, and functions for operations with options. Stack lifecycle +methods are functions because they accept selections. `followStatus` is a Stream +value; `followLogs(query?)` returns a Stream. The Promise facade uses functions +throughout. + +Internal orchestration and CLI consumers use Effect directly. The package root +is the Promise facade for non-Effect consumers; internal code never calls it and +wraps its results back into Effects. Foreign Promise APIs are adapted at their +leaf boundaries. + +`createTestStack` from `@supabase/stack/testing` returns an Effect and owns a unique +project root while sharing normal managed port coordination. Its +`setupProject(projectRoot)` callback also returns an Effect. Supply an explicit +native or container runtime for reproducible tests, and use +`Effect.acquireUseRelease` with `stack.destroy()` for cleanup. Non-Effect tests +can import `createTestStack` from the package root and use `await using`; that +adapter belongs to the same Promise facade as the ordinary stack API. +Whole-stack destruction removes the test root after successful managed cleanup; +selected service destruction retains it. A failed destroy retains its root and +state for recovery. Real runtime tests exercise public handles, managed gateways, +snapshots, and inspector startup in native and Docker modes. + +See [the service-instance decision](../../docs/adr/0025-ephemeral-postgres-for-schema-tooling.md) +for the CLI shadow ownership and cache boundary. diff --git a/packages/stack/package.json b/packages/stack/package.json index c98ce1e480..a5f30e36b9 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./effect": "./src/effect.ts", "./testing": "./src/testing.ts", + "./internal/functions/files": "./src/functions/FunctionFiles.ts", "./internal/supervisor": "./src/internal/supervisor-process.ts" }, "scripts": { @@ -23,12 +24,14 @@ "@effect/platform-node": "catalog:", "@effect/sql-pg": "catalog:", "effect": "catalog:", - "jose": "^6.2.10" + "jose": "^6.2.10", + "tar-stream": "3.2.0" }, "devDependencies": { "@effect/vitest": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/tar-stream": "3.1.4", "@types/ws": "catalog:", "@vitest/coverage-v8": "catalog:", "esbuild": "^0.28.2", diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 97e19d60e2..f3d335725e 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -8,20 +8,23 @@ import { FileSystem, Option, Predicate, + Queue, Schema, Scope, Semaphore, + Stream, } from "effect"; import { NodeSocket, NodeSocketServer } from "@effect/platform-node"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- Effect FileSystem exposes stat but no no-follow lstat; this security check must reject symlinked control directories. import { lstat } from "node:fs/promises"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; -import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { RpcClientDefect, RpcClientError } from "effect/unstable/rpc/RpcClientError"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; import * as Socket from "effect/unstable/socket/Socket"; import * as SocketServer from "effect/unstable/socket/SocketServer"; import type { ControlEndpoint } from "../state/Ownership.ts"; +import { isStackError, type StackError } from "../public/Errors.ts"; import { decodeFrame, encodeFrame, @@ -61,12 +64,19 @@ export interface MaintenanceHandlers { readonly stop: Effect.Effect; } +/** Keeps the owner admitted from a validated RPC preface through its first request. */ +export interface RpcPrefaceLease { + readonly release: Effect.Effect; +} + export interface ControlServerOptions extends ControlIdentity { readonly endpoint: ControlEndpoint; readonly rpcRelease?: string; readonly maintenanceHandlers: MaintenanceHandlers; /** Re-evaluates owner shutdown after a lifecycle response or disconnect. */ readonly onShutdownReady?: Effect.Effect; + /** Acquires an owner admission witness before acknowledging an RPC preface. */ + readonly onRpcPreface?: () => Effect.Effect; readonly rpcHandlers: StackRpcHandlers; } @@ -83,6 +93,16 @@ const controlDirectory = (endpoint: ControlEndpoint): string => { return separator < 0 ? path : path.slice(0, separator); }; +const RpcMessageTagSchema = Schema.fromJsonString(Schema.Struct({ _tag: Schema.String })); + +const rpcMessageTag = (chunk: Uint8Array | string): Effect.Effect => { + const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + return Schema.decodeEffect(RpcMessageTagSchema)(text).pipe( + Effect.map(({ _tag }) => _tag), + Effect.orElseSucceed(() => undefined), + ); +}; + const controlServerError = (cause: unknown): SocketServer.SocketServerError => new SocketServer.SocketServerError({ reason: new SocketServer.SocketServerOpenError({ cause }), @@ -139,6 +159,25 @@ const isResponseConnectionFailure = (cause: Cause.Cause): bo Predicate.isTagged(error.reason, "SocketCloseError"), }); +const rpcPrefaceFailure = (error: StackError): JsonValue => ({ + kind: "rpc-retiring", + stackId: "stackId" in error && typeof error.stackId === "string" ? error.stackId : "", + ownerSessionId: + "ownerSessionId" in error && typeof error.ownerSessionId === "string" + ? error.ownerSessionId + : "", + error: { + tag: error._tag, + message: error.message, + ...(isStackError(error) && "stackId" in error && typeof error.stackId === "string" + ? { stackId: error.stackId } + : {}), + ...(isStackError(error) && "ownerSessionId" in error && typeof error.ownerSessionId === "string" + ? { ownerSessionId: error.ownerSessionId } + : {}), + }, +}); + /** Wrap one accepted socket. This is the only reader for the connection. */ const demuxSocket = ( socket: Socket.Socket, @@ -151,6 +190,10 @@ const demuxSocket = ( chunk: Uint8Array | string | Socket.CloseEvent, ) => Effect.Effect; let connectionWriter: Writer | undefined; + let phase: "preface" | "maintenance" | "rpc" = "preface"; + let firstRpcRequestSeen = false; + let rpcPrefaceLease: RpcPrefaceLease | undefined; + let releaseRpcPreface: (notify: boolean) => Effect.Effect = () => Effect.void; const runRaw = ( handler: (_: Uint8Array) => Effect.Effect | void, @@ -163,8 +206,20 @@ const demuxSocket = ( const decoder = new FrameDecoder(); const prefaceReady = yield* Deferred.make(); let preface = new Uint8Array(0); - let phase: "preface" | "maintenance" | "rpc" = "preface"; let closed = false; + phase = "preface"; + firstRpcRequestSeen = false; + rpcPrefaceLease = undefined; + + releaseRpcPreface = (notify: boolean) => + Effect.suspend(() => { + const lease = rpcPrefaceLease; + rpcPrefaceLease = undefined; + if (lease === undefined) return Effect.void; + return lease.release.pipe( + Effect.andThen(notify ? (options.onShutdownReady ?? Effect.void) : Effect.void), + ); + }); const markPrefaceReady = Deferred.succeed(prefaceReady, undefined).pipe(Effect.asVoid); const close = Effect.suspend(() => { @@ -176,6 +231,10 @@ const demuxSocket = ( Effect.flatMap((write) => write(new Socket.CloseEvent(1000))), ), ), + // A client may disconnect before sending its first RPC frame. Release the + // preface witness as soon as the close is flushed so owner retirement does not + // wait for socket reader cleanup. + Effect.andThen(releaseRpcPreface(true)), ); }); @@ -314,6 +373,11 @@ const demuxSocket = ( ); } } else { + const tag = yield* rpcMessageTag(frame.slice(4)); + if (tag === "Request") { + if (!firstRpcRequestSeen) yield* markPrefaceReady; + firstRpcRequestSeen = true; + } const returned = handler(frame.slice(4)); if (Effect.isEffect(returned)) yield* returned; } @@ -371,7 +435,35 @@ const demuxSocket = ( yield* close; return; } - if (phase === "rpc") yield* markPrefaceReady; + if (phase === "rpc") { + if (options.onRpcPreface !== undefined) { + const admitted = yield* Effect.exit(options.onRpcPreface()); + if (Exit.isFailure(admitted)) { + const error = Cause.squash(admitted.cause); + yield* sendJson( + isStackError(error) + ? rpcPrefaceFailure(error) + : { + kind: "rpc-retiring", + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + error: { + tag: "StackStateInvalidError", + message: "RPC admission failed", + }, + }, + ); + yield* close; + return; + } + rpcPrefaceLease = admitted.value; + } + yield* sendJson({ + kind: "rpc-ready", + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }); + } const remainder = combined.slice(decoded.value.consumed); if (remainder.byteLength > 0) yield* processFrames(remainder); return; @@ -395,7 +487,9 @@ const demuxSocket = ( }), ), ); - yield* socket.runRaw(processChunk, { onOpen }); + yield* socket + .runRaw(processChunk, { onOpen }) + .pipe(Effect.ensuring(releaseRpcPreface(true))); yield* Fiber.interrupt(prefaceDeadline); }), ).pipe( @@ -416,17 +510,24 @@ const demuxSocket = ( }), ); } - return Socket.isCloseEvent(chunk) - ? write(chunk) - : encodeRawFrame(chunk).pipe( - Effect.mapError( - (error) => - new Socket.SocketError({ - reason: new Socket.SocketWriteError({ cause: new Error(error.message) }), - }), - ), - Effect.flatMap(write), - ); + if (Socket.isCloseEvent(chunk)) return write(chunk); + return encodeRawFrame(chunk).pipe( + Effect.mapError( + (error) => + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause: new Error(error.message) }), + }), + ), + Effect.flatMap(write), + Effect.andThen(rpcMessageTag(chunk)), + Effect.flatMap((tag) => { + const response = + phase === "rpc" && + firstRpcRequestSeen && + (tag === "Exit" || tag === "Chunk" || tag === "Defect"); + return response ? releaseRpcPreface(false) : Effect.void; + }), + ); }), }); }; @@ -500,7 +601,6 @@ export const startControlServer = ( Effect.provideService(SocketServer.SocketServer, server), Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); - const isCompletionRequest = (tag: string): boolean => tag === "start" || tag === "destroy"; const completionRequests = new Set(); const startShutdown = (completion: Effect.Effect) => Effect.uninterruptible( @@ -510,9 +610,22 @@ export const startControlServer = ( ...protocol, run: (handler) => protocol.run((clientId, request) => { - if (Predicate.isTagged(request, "Request") && isCompletionRequest(request.tag)) - completionRequests.add(`${clientId}:${String(request.id)}`); - return handler(clientId, request); + if (!Predicate.isTagged(request, "Request")) return handler(clientId, request); + const key = `${clientId}:${String(request.id)}`; + completionRequests.add(key); + return handler(clientId, request).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + const disconnected = + !(yield* protocol.clientIds).has(clientId) || + (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)); + if (!completionRequests.has(key) || !disconnected) return; + completionRequests.delete(key); + if (options.onShutdownReady !== undefined) + yield* startShutdown(options.onShutdownReady); + }), + ), + ); }), send: (clientId, response, transferables) => Predicate.isTagged(response, "Exit") && @@ -544,23 +657,21 @@ export const startControlServer = ( } satisfies ControlServer; }); -/** Client-side framed socket; writer emits the RPC preface exactly once. */ +/** Client-side framed socket backed by the already-admitted control connection. */ const makeControlRpcSocket = ( - socket: Socket.Socket, - options: { - readonly rpcRelease?: string; - readonly stackId: string; - readonly ownerSessionId: string; - }, + incoming: Queue.Dequeue, + write: ( + chunk: Uint8Array | string | Socket.CloseEvent, + ) => Effect.Effect, ): Socket.Socket => { - let prefaced = false; return Socket.make({ runRaw: (handler, options) => Effect.scoped( Effect.gen(function* () { const decoder = new FrameDecoder(); - yield* socket.runRaw( - (chunk) => + yield* options?.onOpen ?? Effect.void; + yield* Stream.fromQueue(incoming).pipe( + Stream.runForEach((chunk) => decoder.push(toBytes(chunk), RPC_MAX_FRAME_BYTES).pipe( Effect.mapError( (error) => @@ -577,46 +688,49 @@ const makeControlRpcSocket = ( ).pipe(Effect.asVoid), ), ), - options, - ); - }), - ), - writer: Effect.map( - socket.writer, - (write) => (chunk: Uint8Array | string | Socket.CloseEvent) => - Effect.gen(function* () { - if (Socket.isCloseEvent(chunk)) { - yield* write(chunk); - return; - } - const encodedFrame = yield* encodeRawFrame(chunk).pipe( - Effect.mapError( - (error) => - new Socket.SocketError({ - reason: new Socket.SocketWriteError({ cause: new Error(error.message) }), - }), ), ); - if (!prefaced) { - prefaced = true; - const preface = encodePreface({ - kind: "rpc", - release: options.rpcRelease ?? STACK_RPC_RELEASE, - stackId: options.stackId, - ownerSessionId: options.ownerSessionId, - }); - const combined = new Uint8Array(preface.byteLength + encodedFrame.byteLength); - combined.set(preface); - combined.set(encodedFrame, preface.byteLength); - yield* write(combined); - return; - } - yield* write(encodedFrame); }), + ), + writer: Effect.succeed((chunk: Uint8Array | string | Socket.CloseEvent) => + Effect.gen(function* () { + if (Socket.isCloseEvent(chunk)) { + yield* write(chunk); + return; + } + const encodedFrame = yield* encodeRawFrame(chunk).pipe( + Effect.mapError( + (error) => + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause: new Error(error.message) }), + }), + ), + ); + yield* write(encodedFrame); + }), ), }); }; +const isRpcAdmissionAck = ( + value: JsonValue, + expected: { readonly stackId: string; readonly ownerSessionId: string }, +): value is JsonValue & { + readonly kind: "rpc-ready" | "rpc-retiring"; + readonly stackId: string; + readonly ownerSessionId: string; +} => { + if (!isJsonRecord(value)) return false; + return ( + (value.kind === "rpc-ready" || value.kind === "rpc-retiring") && + value.stackId === expected.stackId && + value.ownerSessionId === expected.ownerSessionId + ); +}; + +const isJsonRecord = (value: JsonValue): value is { readonly [key: string]: JsonValue } => + typeof value === "object" && value !== null && !Array.isArray(value); + export interface ControlClientOptions extends ControlIdentity { readonly rpcRelease?: string; } @@ -624,8 +738,6 @@ export interface ControlClientOptions extends ControlIdentity { export interface ControlClient { readonly probe: Effect.Effect; readonly stop: Effect.Effect; - /** Connects with an RPC preface and completes when the owner closes the socket. */ - readonly awaitClose: (onOpen?: Effect.Effect) => Effect.Effect; readonly rpc: Effect.Effect; } @@ -742,48 +854,137 @@ export const makeControlClient = ( return { probe, stop, - awaitClose: (onOpen = Effect.void) => - Effect.scoped( - Effect.gen(function* () { - const socket = yield* NodeSocket.makeNet({ - path: endpointPath(endpoint), - openTimeout: MAINTENANCE_REQUEST_DEADLINE_MS, - }); - const write = yield* socket.writer; - const prefaceFailure = yield* Deferred.make(); - const read = socket.runRaw(() => Effect.void, { - onOpen: write( - encodePreface({ - kind: "rpc", - release: options.rpcRelease ?? STACK_RPC_RELEASE, - stackId: options.stackId, - ownerSessionId: options.ownerSessionId, - }), - ).pipe( - Effect.matchEffect({ - onFailure: (error) => Deferred.fail(prefaceFailure, error).pipe(Effect.asVoid), - onSuccess: () => onOpen, - }), - ), - }); - return yield* Effect.raceFirst(read, Deferred.await(prefaceFailure)).pipe( - Effect.catchFilter( - Socket.SocketCloseError.filterClean((code) => code === 1000), - () => Effect.void, - ), - ); - }), - ), rpc: Effect.gen(function* () { const socket = yield* NodeSocket.makeNet({ path: endpointPath(endpoint), openTimeout: MAINTENANCE_REQUEST_DEADLINE_MS, }); - const controlSocket = makeControlRpcSocket(socket, { - rpcRelease: options.rpcRelease, + const incoming = yield* Queue.unbounded(); + const opened = yield* Deferred.make(); + const write = yield* socket.writer; + const frameDecoder = new FrameDecoder(); + let awaitingAdmission = true; + const enqueue = (chunk: Uint8Array | string): Effect.Effect => + Effect.gen(function* () { + const frames = yield* frameDecoder.push(toBytes(chunk), RPC_MAX_FRAME_BYTES).pipe( + Effect.matchEffect({ + onFailure: (error) => + Deferred.fail( + opened, + new RpcClientError({ + reason: new RpcClientDefect({ + message: `Invalid RPC admission response: ${error.message}`, + cause: error, + }), + }), + ).pipe(Effect.as([])), + onSuccess: Effect.succeed, + }), + ); + for (const frame of frames) { + if (awaitingAdmission) { + awaitingAdmission = false; + const decoded = yield* Effect.exit(decodeFrame(frame)); + if (Exit.isFailure(decoded)) { + yield* Deferred.fail( + opened, + new RpcClientError({ + reason: new RpcClientDefect({ + message: "RPC admission acknowledgement is invalid", + cause: Cause.squash(decoded.cause), + }), + }), + ); + return; + } + if ( + !isRpcAdmissionAck(decoded.value, { + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }) + ) { + yield* Deferred.fail( + opened, + new RpcClientError({ + reason: new RpcClientDefect({ + message: "RPC admission acknowledgement is missing", + cause: decoded.value, + }), + }), + ); + return; + } + if (decoded.value.kind === "rpc-retiring") { + yield* Deferred.fail( + opened, + new RpcClientError({ + reason: new RpcClientDefect({ + message: "Stack owner is retiring before RPC admission", + cause: decoded.value, + }), + }), + ); + return; + } + yield* Deferred.succeed(opened, undefined); + continue; + } + yield* Queue.offer(incoming, frame).pipe(Effect.asVoid); + } + }); + const preface = encodePreface({ + kind: "rpc", + release: options.rpcRelease ?? STACK_RPC_RELEASE, stackId: options.stackId, ownerSessionId: options.ownerSessionId, }); + const open = write(preface).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Deferred.failCause( + opened, + Cause.map(cause, (error) => new RpcClientError({ reason: error.reason })), + ).pipe(Effect.asVoid), + onSuccess: () => Effect.void, + }), + ); + const reader: Effect.Effect = socket + .runRaw(enqueue, { + onOpen: open, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Deferred.failCause( + opened, + Cause.map(cause, (error) => new RpcClientError({ reason: error.reason })), + ).pipe(Effect.andThen(Queue.failCause(incoming, cause)), Effect.asVoid), + onSuccess: () => + Deferred.fail( + opened, + new RpcClientError({ + reason: new RpcClientDefect({ + message: "Control connection closed before RPC admission", + cause: new Error("Control socket closed before admission"), + }), + }), + ).pipe( + Effect.asVoid, + Effect.andThen( + Queue.fail( + incoming, + new Socket.SocketError({ + reason: new Socket.SocketCloseError({ code: 1000 }), + }), + ), + ), + Effect.asVoid, + ), + }), + ); + yield* Effect.forkScoped(reader); + yield* Deferred.await(opened); + const controlSocket = makeControlRpcSocket(incoming, write); const protocol = yield* RpcClient.makeProtocolSocket().pipe( Effect.provideService(Socket.Socket, controlSocket), Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts index 795d120de5..8445e16f24 100644 --- a/packages/stack/src/control/MaintenanceProtocol.ts +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -77,6 +77,8 @@ export const MaintenanceResponseSchema = Schema.Union([ tag: MaintenanceErrorCodeSchema, message: Schema.String, stackErrorTag: Schema.optionalKey(MaintenanceStackErrorTagSchema), + stackId: Schema.optionalKey(StackIdSchema), + ownerSessionId: Schema.optionalKey(OwnerSessionIdSchema), }), }), ]); diff --git a/packages/stack/src/control/ServiceProtocol.ts b/packages/stack/src/control/ServiceProtocol.ts new file mode 100644 index 0000000000..472a8aef8f --- /dev/null +++ b/packages/stack/src/control/ServiceProtocol.ts @@ -0,0 +1,96 @@ +import { Schema } from "effect"; +import { + ServiceInstanceIdSchema, + ServiceKindSchema, + SnapshotDescriptorSchema, +} from "../public/Service.ts"; +import { + ApiCredentialsSchema, + DatabaseCredentialsSchema, + EmptyServiceCredentialsSchema, + StorageCredentialsSchema, +} from "../public/Credentials.ts"; +import { NetworkPortSchema, ServiceStatusSchema } from "../public/Status.ts"; + +const ServiceEndpointSchema = Schema.Struct({ + address: Schema.String, + port: NetworkPortSchema, + url: Schema.String, + protocol: Schema.optionalKey(Schema.Literals(["http", "tcp"] as const)), + enabled: Schema.optionalKey(Schema.Boolean), +}); + +const ServiceDataSchema = Schema.Union([ + Schema.Struct({ origin: Schema.Literal("absent") }), + Schema.Struct({ origin: Schema.Literal("fresh"), lineageId: Schema.String }), + Schema.Struct({ origin: Schema.Literal("restored"), snapshot: SnapshotDescriptorSchema }), + Schema.Struct({ origin: Schema.Literal("incomplete"), operationId: Schema.String }), +]); + +const ServiceInitializationSchema = Schema.Struct({ + profileId: Schema.String, + recipes: Schema.Array( + Schema.Struct({ + service: ServiceKindSchema, + recipeId: Schema.String, + artifactIdentity: Schema.String, + completed: Schema.Boolean, + }), + ), +}); + +/** Wire representation of a materialized registered service instance. */ +export const ServiceDescriptorSchema = Schema.Struct({ + id: ServiceInstanceIdSchema, + service: ServiceKindSchema, + name: Schema.optionalKey(Schema.String), + enabled: Schema.Boolean, + config: Schema.Struct({ + enabled: Schema.Boolean, + activation: Schema.Literals(["eager", "lazy"] as const), + idleTimeoutSeconds: Schema.Union([Schema.Literal(false), Schema.Finite]), + version: Schema.String, + settings: Schema.Record(Schema.String, Schema.Unknown), + }), + dependencies: Schema.Record(Schema.String, ServiceInstanceIdSchema), + snapshotSupport: Schema.Literals(["supported", "unsupported"] as const), + endpoints: Schema.Record(Schema.String, ServiceEndpointSchema), + artifactIdentity: Schema.optionalKey(Schema.String), + runtimeIdentity: Schema.optionalKey(Schema.String), + effectiveConfigFingerprint: Schema.optionalKey(Schema.String), + initializationProfileId: Schema.optionalKey(Schema.NullOr(Schema.String)), + bootstrapRecipeId: Schema.optionalKey(Schema.String), + bootstrapInputsId: Schema.optionalKey(Schema.String), + creationInputsId: Schema.optionalKey(Schema.String), + initialization: Schema.optionalKey(ServiceInitializationSchema), + data: ServiceDataSchema, +}); + +export const ServiceDescriptorListSchema = Schema.Array(ServiceDescriptorSchema); + +export const PrepareResultSchema = Schema.Struct({ + instances: Schema.Array( + Schema.Struct({ + id: ServiceInstanceIdSchema, + service: ServiceKindSchema, + artifacts: Schema.Array( + Schema.Struct({ + identity: Schema.String, + outcome: Schema.Literals(["cached", "downloaded", "pulled"] as const), + }), + ), + effectiveConfigFingerprint: Schema.optionalKey(Schema.String), + }), + ), +}); + +/** Service credentials are optional for services without a credential projection. */ +export const ServiceCredentialsSchema = Schema.Union([ + Schema.Undefined, + DatabaseCredentialsSchema, + ApiCredentialsSchema, + StorageCredentialsSchema, + EmptyServiceCredentialsSchema, +]); + +export { ServiceStatusSchema, SnapshotDescriptorSchema }; diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index 97ea96a9d0..65a8dae660 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -3,38 +3,186 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { EffectStackCredentialsSchema } from "../public/Credentials.ts"; -import { StackConfigSchema } from "../public/Config.ts"; -import { LogQuerySchema, StackLogBatchSchema } from "../public/Logs.ts"; -import { StackStatusSchema } from "../public/Status.ts"; +import { StackRestartPayloadSchema } from "../public/Config.ts"; +import { LogQuerySchema, ServiceLogQuerySchema, StackLogBatchSchema } from "../public/Logs.ts"; +import { StackRecoverySchema, StackStatusSchema } from "../public/Status.ts"; +import { StackIdSchema } from "../public/StackId.ts"; import { STACK_ERROR_TAGS } from "../public/Errors.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import { + EffectCreateServiceOptionsSchema, + ServiceRestartPayloadSchema, +} from "../public/Service.ts"; +import { + PrepareResultSchema, + ServiceCredentialsSchema, + ServiceDescriptorListSchema, + ServiceDescriptorSchema, + ServiceStatusSchema, + SnapshotDescriptorSchema, +} from "./ServiceProtocol.ts"; + +const LifecycleOutcomeSchema = Schema.Struct({ + requested: Schema.Array(Schema.String), + affected: Schema.Array(Schema.String), + succeeded: Schema.Array(Schema.String), + failed: Schema.Array(Schema.String), + statuses: Schema.optionalKey(Schema.Array(ServiceStatusSchema)), + recovery: Schema.optionalKey(StackRecoverySchema), + removed: Schema.optionalKey(Schema.Array(Schema.String)), + retained: Schema.optionalKey(Schema.Array(Schema.String)), +}); /** Pinned release identifier used to detect incompatible live owners. */ -export const STACK_RPC_RELEASE = "stack-rpc-v1@0.2.0" as const; +export const STACK_RPC_RELEASE = "stack-rpc-v1@0.3.0" as const; const StackRpcErrorTagSchema = Schema.Literals([...STACK_ERROR_TAGS] as const); const StackRpcErrorSchema = Schema.Struct({ tag: StackRpcErrorTagSchema, message: Schema.String, + stackId: Schema.optionalKey(StackIdSchema), + instanceId: Schema.optionalKey(Schema.String), + ownerSessionId: Schema.optionalKey(Schema.String), + operationId: Schema.optionalKey(Schema.String), + expectedCreationInputsId: Schema.optionalKey(Schema.String), + mutation: Schema.optionalKey( + Schema.Literals([ + "create", + "restore", + "start", + "sleep", + "stop", + "restart", + "destroy", + "exportSnapshot", + ] as const), + ), + outcome: Schema.optionalKey(LifecycleOutcomeSchema), }); export type StackRpcError = Schema.Schema.Type; const StackRpc = { + servicesCreate: Rpc.make("servicesCreate", { + payload: EffectCreateServiceOptionsSchema, + success: ServiceDescriptorSchema, + error: StackRpcErrorSchema, + }), + servicesGet: Rpc.make("servicesGet", { + payload: Schema.Union([ + Schema.Struct({ id: ServiceInstanceIdSchema }), + Schema.Struct({ name: Schema.String.check(Schema.isNonEmpty()) }), + ]), + success: ServiceDescriptorSchema, + error: StackRpcErrorSchema, + }), + servicesList: Rpc.make("servicesList", { + success: ServiceDescriptorListSchema, + error: StackRpcErrorSchema, + }), + serviceStatus: Rpc.make("serviceStatus", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: ServiceStatusSchema, + error: StackRpcErrorSchema, + }), + serviceFollowStatus: Rpc.make("serviceFollowStatus", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: ServiceStatusSchema, + error: StackRpcErrorSchema, + stream: true, + }), + serviceStart: Rpc.make("serviceStart", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: ServiceStatusSchema, + error: StackRpcErrorSchema, + }), + serviceSleep: Rpc.make("serviceSleep", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: ServiceStatusSchema, + error: StackRpcErrorSchema, + }), + serviceStop: Rpc.make("serviceStop", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: ServiceStatusSchema, + error: StackRpcErrorSchema, + }), + serviceDestroy: Rpc.make("serviceDestroy", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: Schema.Void, + error: StackRpcErrorSchema, + }), + servicePrepare: Rpc.make("servicePrepare", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: PrepareResultSchema, + error: StackRpcErrorSchema, + }), + serviceRestart: Rpc.make("serviceRestart", { + payload: ServiceRestartPayloadSchema, + success: ServiceStatusSchema, + error: StackRpcErrorSchema, + }), + serviceCredentials: Rpc.make("serviceCredentials", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema }), + success: ServiceCredentialsSchema, + error: StackRpcErrorSchema, + }), + serviceLogs: Rpc.make("serviceLogs", { + payload: Schema.Struct({ + id: ServiceInstanceIdSchema, + query: Schema.optionalKey(ServiceLogQuerySchema), + }), + success: StackLogBatchSchema, + error: StackRpcErrorSchema, + }), + serviceExportSnapshot: Rpc.make("serviceExportSnapshot", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema, destination: Schema.String }), + success: SnapshotDescriptorSchema, + error: StackRpcErrorSchema, + }), + serviceRestoreSnapshot: Rpc.make("serviceRestoreSnapshot", { + payload: Schema.Struct({ id: ServiceInstanceIdSchema, source: Schema.String }), + success: SnapshotDescriptorSchema, + error: StackRpcErrorSchema, + }), status: Rpc.make("status", { success: StackStatusSchema, error: StackRpcErrorSchema }), + followStatus: Rpc.make("followStatus", { + success: StackStatusSchema, + error: StackRpcErrorSchema, + stream: true, + }), credentials: Rpc.make("credentials", { success: EffectStackCredentialsSchema, error: StackRpcErrorSchema, }), start: Rpc.make("start", { - payload: Schema.Struct({ config: Schema.optionalKey(StackConfigSchema) }), + payload: Schema.Struct({ + services: Schema.optionalKey(Schema.Array(ServiceInstanceIdSchema)), + }), + success: StackStatusSchema, + error: StackRpcErrorSchema, + }), + sleep: Rpc.make("sleep", { + payload: Schema.Struct({ services: Schema.optionalKey(Schema.Array(ServiceInstanceIdSchema)) }), success: StackStatusSchema, error: StackRpcErrorSchema, }), - destroy: Rpc.make("destroy", { success: Schema.Void, error: StackRpcErrorSchema }), - resetDatabase: Rpc.make("resetDatabase", { + stop: Rpc.make("stop", { + payload: Schema.Struct({ services: Schema.optionalKey(Schema.Array(ServiceInstanceIdSchema)) }), success: StackStatusSchema, error: StackRpcErrorSchema, }), + restart: Rpc.make("restart", { + payload: StackRestartPayloadSchema, + success: StackStatusSchema, + error: StackRpcErrorSchema, + }), + destroy: Rpc.make("destroy", { + payload: Schema.Struct({ + services: Schema.optionalKey(Schema.Array(ServiceInstanceIdSchema)), + }), + success: Schema.Void, + error: StackRpcErrorSchema, + }), logs: Rpc.make("logs", { payload: LogQuerySchema, success: StackLogBatchSchema, @@ -43,11 +191,29 @@ const StackRpc = { } as const; export const StackRpcGroup = RpcGroup.make( + StackRpc.servicesCreate, + StackRpc.servicesGet, + StackRpc.servicesList, + StackRpc.serviceStatus, + StackRpc.serviceFollowStatus, + StackRpc.serviceStart, + StackRpc.serviceSleep, + StackRpc.serviceStop, + StackRpc.serviceDestroy, + StackRpc.servicePrepare, + StackRpc.serviceRestart, + StackRpc.serviceCredentials, + StackRpc.serviceLogs, + StackRpc.serviceExportSnapshot, + StackRpc.serviceRestoreSnapshot, StackRpc.status, + StackRpc.followStatus, StackRpc.credentials, StackRpc.start, + StackRpc.stop, + StackRpc.sleep, + StackRpc.restart, StackRpc.destroy, - StackRpc.resetDatabase, StackRpc.logs, ); type StackRpcDefinitions = RpcGroup.Rpcs; diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index fe59588d88..1886ce23b7 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -1,4 +1,4 @@ -import { NodeServices, NodeSocket } from "@effect/platform-node"; +import { NodeServices, NodeSocket, NodeSocketServer } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, @@ -11,16 +11,21 @@ import { Option, Path, PlatformError, + Predicate, Redacted, Ref, Scope, + Stream, } from "effect"; import * as TestClock from "effect/testing/TestClock"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import * as Socket from "effect/unstable/socket/Socket"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import type { StackStatus } from "../public/Status.ts"; +import type { StackStatus, ServiceStatus } from "../public/Status.ts"; +import type { ServiceDescriptor } from "../public/Service.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import type { ControlEndpoint } from "../state/Ownership.ts"; import { makeControlClient, @@ -43,11 +48,13 @@ import { MaintenanceProtocolError, } from "./MaintenanceProtocol.ts"; import { STACK_RPC_RELEASE, type StackRpcError, type StackRpcHandlers } from "./StackRpc.ts"; +import { unconfiguredServiceRpcHandlers } from "./test-helpers.ts"; interface ServerOverrides { readonly rpcHandlers?: Partial; readonly maintenanceHandlers?: Partial; readonly onShutdownReady?: Effect.Effect; + readonly onRpcPreface?: NonNullable; } interface ServerSetup { @@ -124,14 +131,18 @@ const withServer = ( endpoints: {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: "eager", state: "stopped", })), artifacts: [], + instances: [], }; const defaultRpcHandlers: StackRpcHandlers = { + ...unconfiguredServiceRpcHandlers, status: () => Effect.succeed(status), + followStatus: () => Stream.never, credentials: () => Effect.succeed({ database: { @@ -146,8 +157,10 @@ const withServer = ( }, }), start: () => Effect.succeed(status), + sleep: () => Effect.succeed(status), + stop: () => Effect.succeed(status), + restart: () => Effect.succeed(status), destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(status), logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; const defaultMaintenanceHandlers: MaintenanceHandlers = { @@ -180,6 +193,7 @@ const withServer = ( maintenanceHandlers: { ...defaultMaintenanceHandlers, ...custom.maintenanceHandlers }, rpcHandlers: { ...defaultRpcHandlers, ...custom.rpcHandlers }, onShutdownReady: custom.onShutdownReady, + ...(custom.onRpcPreface === undefined ? {} : { onRpcPreface: custom.onRpcPreface }), }; yield* startControlServer(options); const rebind = Effect.scoped(startControlServer(options)).pipe( @@ -225,6 +239,7 @@ const sendRawAndReadFrame = ( const sendRawSequenceAndReadFrame = ( endpoint: ControlEndpoint, chunks: ReadonlyArray, + skipRpcAdmissionAck = false, ): Effect.Effect => Effect.gen(function* () { const socket = yield* NodeSocket.makeNet({ @@ -233,12 +248,17 @@ const sendRawSequenceAndReadFrame = ( const write = yield* socket.writer; const decoder = new FrameDecoder(); const response = yield* Deferred.make(); + let awaitingRpcAdmissionAck = skipRpcAdmissionAck; const read = socket .runRaw((chunk) => decoder.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk).pipe( Effect.flatMap((frames) => Effect.forEach(frames, (frame) => - Deferred.succeed(response, frame).pipe(Effect.asVoid), + awaitingRpcAdmissionAck + ? Effect.sync(() => { + awaitingRpcAdmissionAck = false; + }) + : Deferred.succeed(response, frame).pipe(Effect.asVoid), ), ), Effect.asVoid, @@ -279,6 +299,74 @@ const makeDestroyRequestFrame = (): Effect.Effect { + it.live("waits for the admission acknowledgement after the preface write", () => + withServer( + ({ + endpoint, + stackId, + ownerSessionId, + completionStarted, + completionRelease, + }): Effect.Effect => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpcFiber = yield* Effect.forkChild(client.rpc); + const resolved = yield* Deferred.make(); + yield* Effect.forkChild( + Fiber.join(rpcFiber).pipe(Effect.andThen(Deferred.succeed(resolved, undefined))), + ); + yield* Deferred.await(completionStarted); + expect(Option.isNone(yield* Deferred.poll(resolved))).toBe(true); + yield* Deferred.succeed(completionRelease, undefined); + yield* Deferred.await(resolved); + }), + ({ completionStarted, completionRelease }) => ({ + onRpcPreface: () => + Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.map(() => ({ release: Effect.void })), + ), + }), + ), + ); + + it.live("fails RPC acquisition when the owner closes gracefully before admission", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-control-" }); + const endpoint: ControlEndpoint = { kind: "unix", path: path.join(root, "control.sock") }; + const server = yield* NodeSocketServer.make({ path: endpoint.path }); + yield* Effect.forkScoped( + server.run((socket) => + socket + .runRaw(() => + Effect.gen(function* () { + const write = yield* socket.writer; + yield* write(new Socket.CloseEvent(1000)); + }), + ) + .pipe(Effect.ignore), + ), + ); + const result = yield* Effect.exit( + makeControlClient(endpoint, { + stackId: deriveStackId(testIdentity).toString(), + ownerSessionId: "00000000-0000-4000-8000-000000000000", + }).rpc.pipe(Effect.timeout("1 second")), + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect( + Option.isSome(failure) && Predicate.isTagged(failure.value, "RpcClientError"), + ).toBe(true); + } + }), + ), + ); + it.live("rejects a symlinked control directory", () => Effect.gen(function* () { const result = yield* withServer(() => Effect.void, undefined, { @@ -417,6 +505,7 @@ describe("control transport", () => { }), ({ completion }) => ({ rpcHandlers: { + ...unconfiguredServiceRpcHandlers, start: () => Effect.fail({ tag: "StackPreparationError", @@ -434,7 +523,7 @@ describe("control transport", () => { Effect.gen(function* () { const client = makeControlClient(endpoint, { stackId, ownerSessionId }); const rpc = yield* client.rpc; - yield* rpc.destroy(undefined); + yield* rpc.destroy({}); yield* Deferred.await(completion).pipe( Effect.timeoutOrElse({ duration: 5_500, @@ -462,9 +551,11 @@ describe("control transport", () => { ownerSessionId, }); const frame = yield* makeDestroyRequestFrame(); - const response = yield* sendRawSequenceAndReadFrame(endpoint, [ - concatBytes(preface, frame), - ]); + const response = yield* sendRawSequenceAndReadFrame( + endpoint, + [concatBytes(preface, frame)], + true, + ); expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); yield* Deferred.await(completionStarted).pipe( Effect.timeoutOrElse({ @@ -509,9 +600,11 @@ describe("control transport", () => { ownerSessionId, }); const frame = yield* makeDestroyRequestFrame(); - const response = yield* sendRawSequenceAndReadFrame(endpoint, [ - concatBytes(preface, frame), - ]); + const response = yield* sendRawSequenceAndReadFrame( + endpoint, + [concatBytes(preface, frame)], + true, + ); expect(response).toMatchObject({ _tag: "Exit", requestId: "1", @@ -526,6 +619,7 @@ describe("control transport", () => { }), ({ completion }) => ({ rpcHandlers: { + ...unconfiguredServiceRpcHandlers, destroy: () => Effect.die(new Error("injected destroy defect")), }, onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), @@ -547,6 +641,93 @@ describe("control transport", () => { ), ); + it.live("round-trips a status observation stream", () => + withServer( + ({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + const observed = yield* Stream.runCollect(rpc.followStatus(undefined)); + expect(observed.map(({ lifecycle }) => lifecycle)).toEqual(["stopped", "running"]); + }), + ({ status }) => ({ + rpcHandlers: { + ...unconfiguredServiceRpcHandlers, + followStatus: () => + Stream.fromIterable([ + status, + { ...status, lifecycle: "running", desiredLifecycle: "running" }, + ]), + }, + }), + ), + ); + + it.live("round-trips registered service lifecycle operations", () => + withServer( + ({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + const created = yield* rpc.servicesCreate({ + service: "database", + name: "shadow", + config: { enabled: true }, + }); + expect(created).toMatchObject({ service: "database", name: "shadow" }); + const started = yield* rpc.serviceStart({ id: "database-shadow" as never }); + expect(started).toMatchObject({ phase: "ready", intent: "started" }); + const stopped = yield* rpc.serviceStop({ id: "database-shadow" as never }); + expect(stopped).toMatchObject({ phase: "stopped", intent: "stopped" }); + yield* rpc.serviceDestroy({ id: "database-shadow" as never }); + }), + () => { + const shadowId = ServiceInstanceIdSchema.make("database-shadow"); + const descriptor = { + id: shadowId, + service: "database", + name: "shadow", + enabled: true, + config: { + enabled: true, + activation: "eager", + idleTimeoutSeconds: false, + version: "test", + settings: {}, + }, + dependencies: {}, + snapshotSupport: "supported", + endpoints: {}, + data: { origin: "absent" }, + } satisfies ServiceDescriptor<"database">; + const status = (intent: "started" | "stopped", phase: "ready" | "stopped") => + ({ + id: shadowId, + service: "database", + name: "shadow", + enabled: true, + intent, + phase, + activation: "eager", + endpoints: [], + }) satisfies ServiceStatus; + return { + rpcHandlers: { + servicesCreate: () => Effect.succeed(descriptor), + servicesGet: () => Effect.succeed(descriptor), + servicesList: () => Effect.succeed([descriptor]), + serviceStatus: () => Effect.succeed(status("stopped", "stopped")), + serviceFollowStatus: () => Stream.fromIterable([status("stopped", "stopped")]), + serviceStart: () => Effect.succeed(status("started", "ready")), + serviceSleep: () => Effect.succeed(status("started", "stopped")), + serviceStop: () => Effect.succeed(status("stopped", "stopped")), + serviceDestroy: () => Effect.void, + }, + }; + }, + ), + ); + it.live("round-trips artifact preparation state alongside dormant capabilities", () => withServer( ({ endpoint, stackId, ownerSessionId }) => @@ -556,7 +737,12 @@ describe("control transport", () => { const observed = yield* rpc.status(undefined); expect(observed.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); expect(observed.artifacts).toEqual([ - { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + { + workloadId: "rest:rest", + instanceId: "rest-instance", + capability: "rest", + state: "downloading", + }, ]); expect(observed.recovery).toEqual({ operation: "stop", @@ -565,6 +751,7 @@ describe("control transport", () => { }), ({ status }) => ({ rpcHandlers: { + ...unconfiguredServiceRpcHandlers, status: () => Effect.succeed({ ...status, @@ -573,7 +760,14 @@ describe("control transport", () => { capabilities: status.capabilities.map((capability) => capability.name === "rest" ? { ...capability, state: "dormant" } : capability, ), - artifacts: [{ workloadId: "rest:rest", capability: "rest", state: "downloading" }], + artifacts: [ + { + workloadId: "rest:rest", + instanceId: ServiceInstanceIdSchema.make("rest-instance"), + capability: "rest", + state: "downloading", + }, + ], recovery: { operation: "stop", message: "injected cleanup diagnostic" }, }), }, @@ -593,6 +787,7 @@ describe("control transport", () => { }), () => ({ rpcHandlers: { + ...unconfiguredServiceRpcHandlers, logs: () => Effect.succeed({ entries: [ diff --git a/packages/stack/src/control/test-helpers.ts b/packages/stack/src/control/test-helpers.ts new file mode 100644 index 0000000000..298342709f --- /dev/null +++ b/packages/stack/src/control/test-helpers.ts @@ -0,0 +1,67 @@ +import { Effect, Stream } from "effect"; +import type { StackRpcError, StackRpcHandlers } from "./StackRpc.ts"; + +const unavailable: StackRpcError = { + tag: "StackStateInvalidError", + message: "Service RPC is not configured in this transport fixture", +}; + +/** Completes unconfigured stack RPCs while focused tests replace the operation under test. */ +export const unconfiguredStackRpcHandlers: Pick< + StackRpcHandlers, + | "status" + | "followStatus" + | "credentials" + | "start" + | "sleep" + | "stop" + | "restart" + | "destroy" + | "logs" +> = { + status: () => Effect.fail(unavailable), + followStatus: () => Stream.fail(unavailable), + credentials: () => Effect.fail(unavailable), + start: () => Effect.fail(unavailable), + sleep: () => Effect.fail(unavailable), + stop: () => Effect.fail(unavailable), + restart: () => Effect.fail(unavailable), + destroy: () => Effect.void, + logs: () => Effect.fail(unavailable), +}; + +/** Completes unconfigured service RPCs while focused tests replace the operation under test. */ +export const unconfiguredServiceRpcHandlers: Pick< + StackRpcHandlers, + | "servicesCreate" + | "servicesGet" + | "servicesList" + | "serviceStatus" + | "serviceFollowStatus" + | "serviceStart" + | "serviceSleep" + | "serviceStop" + | "serviceDestroy" + | "servicePrepare" + | "serviceRestart" + | "serviceCredentials" + | "serviceLogs" + | "serviceExportSnapshot" + | "serviceRestoreSnapshot" +> = { + servicesCreate: () => Effect.fail(unavailable), + servicesGet: () => Effect.fail(unavailable), + servicesList: () => Effect.fail(unavailable), + serviceStatus: () => Effect.fail(unavailable), + serviceFollowStatus: () => Stream.fail(unavailable), + serviceStart: () => Effect.fail(unavailable), + serviceSleep: () => Effect.fail(unavailable), + serviceStop: () => Effect.fail(unavailable), + serviceDestroy: () => Effect.fail(unavailable), + servicePrepare: () => Effect.fail(unavailable), + serviceRestart: () => Effect.fail(unavailable), + serviceCredentials: () => Effect.fail(unavailable), + serviceLogs: () => Effect.fail(unavailable), + serviceExportSnapshot: () => Effect.fail(unavailable), + serviceRestoreSnapshot: () => Effect.fail(unavailable), +}; diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index d3c1b77e35..220f0f747b 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -1,4 +1,4 @@ -import { NodeServices } from "@effect/platform-node"; +import { NodeRuntime, NodeServices } from "@effect/platform-node"; import { Crypto, Data, Effect, FileSystem, Path, Schema } from "effect"; // Node's fd3 readiness channel has no FileSystem abstraction, so it's used directly here. // oxlint-disable-next-line effecttsgo/node-builtin-import -- readiness uses inherited fd3 directly, which has no FileSystem service abstraction. @@ -24,6 +24,7 @@ import { openSupervisorBootstrapLog } from "../supervisor/BootstrapLog.ts"; class SupervisorReadinessError extends Data.TaggedError("SupervisorReadinessError")<{ readonly message: string; readonly cause?: unknown; + readonly phase: "encode" | "write"; }> {} interface ReadinessState { @@ -60,19 +61,28 @@ const writeReadiness = ( new SupervisorReadinessError({ message: "Unable to encode supervisor readiness", cause, + phase: "encode", }), ), ); yield* Effect.try({ try: () => { - NodeFs.writeSync(3, `${encoded}\n`, undefined, "utf8"); - NodeFs.closeSync(3); - readiness.written = true; + try { + NodeFs.writeSync(3, `${encoded}\n`, undefined, "utf8"); + readiness.written = true; + } finally { + try { + NodeFs.closeSync(3); + } catch { + // The launcher may have already closed the readiness descriptor. + } + } }, catch: (cause) => new SupervisorReadinessError({ message: "Unable to write supervisor readiness", cause, + phase: "write", }), }); }); @@ -123,9 +133,14 @@ const runSupervisor = (args: SupervisorArgs, readiness: ReadinessState) => maintenanceHandlers: supervisor.maintenanceHandlers, rpcHandlers: supervisor.rpcHandlers, onShutdownReady: supervisor.shutdownIfIdle, + onRpcPreface: () => supervisor.acquireRpcPreface, }); yield* publishOwnership(lease); - yield* writeReadiness({ ok: true, stackId: args.stackId, ownerSessionId }, readiness); + yield* writeReadiness({ ok: true, stackId: args.stackId, ownerSessionId }, readiness).pipe( + Effect.catchTag("SupervisorReadinessError", (error) => + error.phase === "write" ? Effect.void : Effect.fail(error), + ), + ); yield* supervisor.shutdown; }), ).pipe(Effect.provide(NodeServices.layer)); @@ -133,10 +148,10 @@ const runSupervisor = (args: SupervisorArgs, readiness: ReadinessState) => const parseSupervisorArgs = (argv: ReadonlyArray) => Schema.decodeEffect(Schema.fromJsonString(SupervisorArgsSchema))(argv[0] ?? "{}"); -export const runSupervisorProcess = (argv: ReadonlyArray): Promise => { - const readiness = { written: false } satisfies ReadinessState; - return Effect.runPromise( - parseSupervisorArgs(argv).pipe( +export const runSupervisorProcess = (argv: ReadonlyArray): Effect.Effect => + Effect.suspend(() => { + const readiness = { written: false } satisfies ReadinessState; + return parseSupervisorArgs(argv).pipe( Effect.tap((args) => Effect.sync(() => { const log = openSupervisorBootstrapLog(args.stateRoot, args.stackId); @@ -158,10 +173,12 @@ export const runSupervisorProcess = (argv: ReadonlyArray): Promise }), ), Effect.flatMap((args) => runSupervisor(args, readiness)), - ), - ).catch((error) => reportSupervisorFailure(error, readiness)); -}; + Effect.catch((error) => Effect.sync(() => reportSupervisorFailure(error, readiness))), + ); + }); if (import.meta.main) { - await runSupervisorProcess(process.argv.slice(2)); + NodeRuntime.runMain(runSupervisorProcess(process.argv.slice(2)), { + disableErrorReporting: true, + }); } diff --git a/packages/stack/src/functions/FunctionFiles.ts b/packages/stack/src/functions/FunctionFiles.ts new file mode 100644 index 0000000000..e2abf84892 --- /dev/null +++ b/packages/stack/src/functions/FunctionFiles.ts @@ -0,0 +1,601 @@ +import { Data, Effect, FileSystem, Path, Predicate, Result, Schema } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +const windowsAbsolutePath = /^[A-Za-z]:\//u; +const importPathPattern = + /(?:import|export)\s+(?:type\s+)?(?:\{[^{}]+\}|.*?)\s*(?:from)?\s*['"](.*?)['"]|import\(\s*['"](.*?)['"]\)/giu; + +export interface FunctionFilesInput { + readonly projectRoot: string; + readonly sourceRoot: string; + readonly entrypoint: string; + readonly importMap: string; + readonly staticFiles: ReadonlyArray; + readonly additionalModuleRoots?: ReadonlyArray; + readonly skipMissingImportMapTargets?: boolean; +} + +export interface FunctionFile { + readonly hostPath: string; + /** The lexical path requested by the import map, which can differ from hostPath for symlinks. */ + readonly targetPath: string; + readonly kind: "file" | "directory"; + readonly externalScope: boolean; +} + +export interface FunctionFilesPlan { + readonly files: ReadonlyArray; + readonly warnings: ReadonlyArray; + /** Canonical roots that bound ordinary source and import-map traversal. */ + readonly allowedRoots: ReadonlyArray; +} + +export class FunctionFilesError extends Data.TaggedError("FunctionFilesError")<{ + readonly message: string; + readonly reason: "import-not-directory" | "filesystem" | "parse" | "cycle"; + readonly pathname?: string; + readonly cause?: unknown; + readonly fsReason?: "not-found" | "not-directory"; +}> {} + +type Fs = FileSystem.FileSystem; +type P = Path.Path; +type FileCallback = ( + pathname: string, + contents: Uint8Array, +) => Effect.Effect; +type WarningCallback = (message: string) => Effect.Effect; + +const slash = (pathname: string) => pathname.replaceAll("\\", "/"); +const isDenoConfigFile = (pathname: string) => { + const name = pathname.slice(pathname.lastIndexOf("/") + 1).toLowerCase(); + return name === "deno.json" || name === "deno.jsonc"; +}; +const contained = (path: P, root: string, candidate: string) => { + const relativePath = path.relative(path.resolve(root), path.resolve(candidate)); + return ( + relativePath === "" || + (!path.isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`)) + ); +}; +const containedInAny = (path: P, roots: ReadonlyArray, candidate: string) => + roots.some((root) => contained(path, root, candidate)); +const isNotFound = (error: unknown) => + error instanceof FunctionFilesError && error.fsReason === "not-found"; +const isNotDirectory = (error: unknown) => + error instanceof FunctionFilesError && error.fsReason === "not-directory"; + +const stripJsonComments = (contents: string) => { + const src = contents.replace(/^\uFEFF/u, ""); + const out: string[] = []; + let pendingComma = -1; + let index = 0; + while (index < src.length) { + const char = src.charAt(index); + if (char === '"') { + pendingComma = -1; + out.push(char); + index += 1; + while (index < src.length) { + const next = src.charAt(index++); + out.push(next); + if (next === "\\" && index < src.length) out.push(src.charAt(index++)); + else if (next === '"') break; + } + continue; + } + if (char === "/" && src.charAt(index + 1) === "/") { + index += 2; + while (index < src.length && src.charAt(index) !== "\n") index += 1; + continue; + } + if (char === "/" && src.charAt(index + 1) === "*") { + index += 2; + while (index < src.length && !(src.charAt(index) === "*" && src.charAt(index + 1) === "/")) + index += 1; + index += 2; + continue; + } + if (char === ",") { + pendingComma = out.length; + out.push(char); + } else if (char === "}" || char === "]") { + if (pendingComma >= 0) { + out[pendingComma] = ""; + pendingComma = -1; + } + out.push(char); + } else { + out.push(char); + if (!" \t\n\r".includes(char)) pendingComma = -1; + } + index += 1; + } + return out.join(""); +}; +const resolveImportTarget = (path: P, jsonPath: string, target: string) => { + if (target.startsWith("/")) return target; + try { + if (new URL(target).protocol.length > 0) return target; + } catch { + // Relative path. + } + const resolved = slash(path.join(path.dirname(jsonPath), target)); + const normalized = + resolved.startsWith("/") || + windowsAbsolutePath.test(resolved) || + resolved.startsWith("./") || + resolved.startsWith("../") + ? resolved + : `./${resolved}`; + return target.endsWith("/") && !normalized.endsWith("/") ? `${normalized}/` : normalized; +}; +const isRemote = (target: string) => { + if (target.startsWith("/") || windowsAbsolutePath.test(target)) return false; + try { + return new URL(target).protocol.length > 0; + } catch { + return false; + } +}; +const readStringMap = (input: unknown, fieldName: string): Record => { + if (input === undefined) return {}; + if (typeof input !== "object" || input === null || Array.isArray(input)) + throw new Error(`failed to parse import map: expected ${fieldName} to be an object`); + const result: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (typeof value !== "string") + throw new Error(`failed to parse import map: expected ${fieldName}.${key} to be a string`); + result[key] = value; + } + return result; +}; + +class ImportMapFile { + constructor( + readonly imports: Record = {}, + readonly scopes: Record> = {}, + readonly importMapReference = "", + ) {} + static fromUnknown(input: unknown) { + const value = + typeof input === "object" && input !== null ? (input as Record) : {}; + const scopes: Record> = {}; + const rawScopes = value["scopes"]; + if (rawScopes !== undefined) { + if (typeof rawScopes !== "object" || rawScopes === null || Array.isArray(rawScopes)) + throw new Error("failed to parse import map: expected scopes to be an object"); + for (const [key, scope] of Object.entries(rawScopes)) + scopes[key] = readStringMap(scope, `scopes.${key}`); + } + return new ImportMapFile( + readStringMap(value["imports"], "imports"), + scopes, + typeof value["importMap"] === "string" ? value["importMap"] : "", + ); + } + get isReference() { + return ( + Object.keys(this.imports).length === 0 && + Object.keys(this.scopes).length === 0 && + this.importMapReference.length > 0 + ); + } + resolve(path: P, jsonPath: string) { + return new ImportMapFile( + Object.fromEntries( + Object.entries(this.imports).map(([key, value]) => [ + key, + resolveImportTarget(path, jsonPath, value), + ]), + ), + Object.fromEntries( + Object.entries(this.scopes).map(([key, scope]) => [ + resolveImportTarget(path, jsonPath, key), + Object.fromEntries( + Object.entries(scope).map(([name, value]) => [ + name, + resolveImportTarget(path, jsonPath, value), + ]), + ), + ]), + ), + this.importMapReference, + ); + } +} +const hasErrnoCode = (value: unknown): value is { readonly code?: unknown } => + typeof value === "object" && value !== null && "code" in value; + +const mapFsError = (pathname: string, cause: unknown) => { + const fsReason = + cause instanceof PlatformError.PlatformError && + cause.reason instanceof PlatformError.SystemError + ? Predicate.isTagged(cause.reason, "NotFound") + ? "not-found" + : hasErrnoCode(cause.reason.cause) && cause.reason.cause.code === "ENOTDIR" + ? "not-directory" + : undefined + : undefined; + return new FunctionFilesError({ + message: `failed to access file: ${pathname}`, + reason: "filesystem", + pathname, + cause, + ...(fsReason === undefined ? {} : { fsReason }), + }); +}; +const realPath = (fs: Fs, pathname: string) => + fs.realPath(pathname).pipe(Effect.mapError((cause) => mapFsError(pathname, cause))); +const fileStat = (fs: Fs, pathname: string) => + fs.stat(pathname).pipe(Effect.mapError((cause) => mapFsError(pathname, cause))); +const readDirectory = (fs: Fs, pathname: string) => + fs + .readDirectory(pathname, { recursive: true }) + .pipe(Effect.mapError((cause) => mapFsError(pathname, cause))); +const readBytes = (fs: Fs, pathname: string) => + fs.readFile(pathname).pipe(Effect.mapError((cause) => mapFsError(pathname, cause))); +const loadImportMap = ( + fs: Fs, + path: P, + pathname: string, + onRead: FileCallback | undefined, + seen: ReadonlySet, +): Effect.Effect => + Effect.gen(function* () { + const resolvedPath = path.resolve(pathname); + if (seen.has(resolvedPath)) + return yield* new FunctionFilesError({ + message: `cyclic import map reference: ${pathname}`, + reason: "cycle", + pathname, + }); + const contents = yield* readBytes(fs, pathname); + if (onRead !== undefined) yield* onRead(pathname, contents); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + stripJsonComments(new TextDecoder().decode(contents)), + ).pipe( + Effect.mapError( + (cause) => + new FunctionFilesError({ + message: `failed to parse import map: ${pathname}`, + reason: "parse", + pathname, + cause, + }), + ), + ); + const importMap = yield* Effect.try({ + try: () => ImportMapFile.fromUnknown(parsed).resolve(path, slash(pathname)), + catch: (cause) => + new FunctionFilesError({ + message: `failed to parse import map: ${pathname}`, + reason: "parse", + pathname, + cause, + }), + }); + const nextSeen = new Set(seen).add(resolvedPath); + return isDenoConfigFile(pathname) && importMap.isReference + ? yield* loadImportMap( + fs, + path, + path.join(path.dirname(pathname), importMap.importMapReference), + onRead, + nextSeen, + ) + : importMap; + }); +const substitute = (mappings: Readonly>, specifier: string) => { + let match: [string, string] | undefined; + for (const entry of Object.entries(mappings)) { + const [prefix, value] = entry; + if (prefix.length === 0) continue; + if (prefix.endsWith("/")) { + if (!value.endsWith("/") || !specifier.startsWith(prefix)) continue; + } else if (specifier !== prefix) continue; + if (match === undefined || prefix.length > match[0].length) match = entry; + } + return match === undefined ? undefined : match[1] + specifier.slice(match[0].length); +}; +const resolveSpecifier = (map: ImportMapFile, current: string, specifier: string) => { + let resolved = specifier; + let substituted = false; + let scoped: Readonly> | undefined; + let length = -1; + for (const [name, value] of Object.entries(map.scopes)) + if ( + (name === current || (name.endsWith("/") && current.startsWith(name))) && + name.length > length + ) { + scoped = value; + length = name.length; + } + const scopedResolved = scoped === undefined ? undefined : substitute(scoped, resolved); + if (scopedResolved !== undefined) { + resolved = scopedResolved; + substituted = true; + } + if (!substituted) { + const globalResolved = substitute(map.imports, resolved); + if (globalResolved !== undefined) { + resolved = globalResolved; + substituted = true; + } + } + return { path: resolved, substituted }; +}; + +const walkImports = ( + fs: Fs, + path: P, + map: ImportMapFile, + source: string, + roots: ReadonlyArray, + displayRoot: string, + onFile: FileCallback, + onWarning: WarningCallback, +): Effect.Effect => + Effect.gen(function* () { + type Loaded = + | { readonly _tag: "loaded"; readonly contents: Uint8Array } + | { readonly _tag: "outside" }; + const seen = new Set(); + const queue = [slash(source)]; + while (queue.length > 0) { + const current = queue.pop(); + if (current === undefined || seen.has(current)) continue; + seen.add(current); + const loaded = yield* realPath(fs, path.resolve(current)).pipe( + Effect.flatMap((currentPath) => + containedInAny(path, roots, currentPath) + ? readBytes(fs, currentPath).pipe( + Effect.map((contents): Loaded => ({ _tag: "loaded", contents })), + ) + : Effect.succeed({ _tag: "outside" }), + ), + Effect.result, + ); + if (Result.isFailure(loaded)) { + const error = loaded.failure; + if (isNotFound(error)) { + yield* onWarning( + `WARN: failed to read file: open ${slash(path.relative(displayRoot, current))}: no such file or directory\n`, + ); + continue; + } + if (isNotDirectory(error)) + return yield* new FunctionFilesError({ + message: `failed to read file: open ${slash(path.relative(displayRoot, current))}: not a directory`, + reason: "import-not-directory", + pathname: current, + cause: error, + }); + return yield* error; + } + if (loaded.success._tag === "outside") { + yield* onWarning(`WARN: Skipping import path outside source root: ${current}\n`); + continue; + } + const { contents } = loaded.success; + yield* onFile(current, contents); + importPathPattern.lastIndex = 0; + for (const match of new TextDecoder().decode(contents).matchAll(importPathPattern)) { + const raw = match[1] ?? match[2]; + if (raw === undefined) continue; + let { path: modulePath, substituted } = resolveSpecifier(map, slash(current), raw.trim()); + modulePath = slash(modulePath); + if (!modulePath.slice(modulePath.lastIndexOf("/") + 1).includes(".")) continue; + if ( + !modulePath.startsWith("./") && + !modulePath.startsWith("../") && + !modulePath.startsWith("/") && + !windowsAbsolutePath.test(modulePath) + ) + continue; + if (!substituted && (modulePath.startsWith("./") || modulePath.startsWith("../"))) + modulePath = slash(path.join(path.dirname(current), modulePath)); + const resolvedModule = path.resolve(modulePath); + const containmentPath = yield* realPath(fs, resolvedModule).pipe( + Effect.orElseSucceed(() => resolvedModule), + ); + if (!containedInAny(path, roots, containmentPath)) { + yield* onWarning(`WARN: Skipping import path outside source root: ${modulePath}\n`); + continue; + } + queue.push(slash(resolvedModule)); + } + } + }); + +const hasGlob = (pattern: string) => + pattern.includes("*") || pattern.includes("?") || pattern.includes("["); +const globBase = (path: P, pattern: string) => { + const normalized = slash(pattern); + if (!hasGlob(normalized)) return path.dirname(normalized); + const stable: string[] = []; + for (const part of normalized.split("/")) { + if (part.includes("*") || part.includes("?") || part.includes("[")) break; + stable.push(part); + } + return stable.length === 0 ? "." : stable.join("/"); +}; +const globRegexp = (pattern: string) => { + let source = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + if (char === undefined) continue; + const next = pattern[index + 1]; + if (char === "*" && next === "*") { + source += ".*"; + index += 1; + } else if (char === "*") source += "[^/]*"; + else if (char === "?") source += "[^/]"; + else source += char.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&"); + } + return new RegExp(`${source}$`, "u"); +}; +const expandStatic = ( + fs: Fs, + path: P, + pattern: string, +): Effect.Effect, FunctionFilesError> => + Effect.gen(function* () { + if (!hasGlob(pattern)) { + yield* fileStat(fs, pattern); + return [pattern]; + } + const candidates = yield* readDirectory(fs, globBase(path, pattern)); + const matcher = globRegexp(slash(path.resolve(pattern))); + const matches = candidates + .map((candidate) => path.resolve(globBase(path, pattern), candidate)) + .filter((candidate) => matcher.test(slash(candidate))); + if (matches.length === 0) + return yield* new FunctionFilesError({ + message: `no files matched pattern: ${pattern}`, + reason: "filesystem", + pathname: pattern, + }); + return matches; + }); + +const plan = ( + input: FunctionFilesInput, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* realPath(fs, path.resolve(input.projectRoot)); + const sourceRoot = yield* realPath(fs, path.resolve(input.sourceRoot)); + const additionalRoots = yield* Effect.forEach( + input.additionalModuleRoots ?? [], + (root) => realPath(fs, path.resolve(root)).pipe(Effect.orElseSucceed(() => undefined)), + { concurrency: 4 }, + ); + const moduleRoots = [ + sourceRoot, + ...additionalRoots.filter((root): root is string => root !== undefined), + ]; + const importMapRoots = [sourceRoot]; + if (input.importMap.length > 0) { + const mapPath = yield* realPath(fs, path.resolve(input.importMap)); + if (!contained(path, sourceRoot, mapPath)) importMapRoots.push(path.dirname(mapPath)); + } + const files: FunctionFile[] = []; + const warnings: string[] = []; + const addImportMapFile = (pathname: string, _contents: Uint8Array) => + Effect.gen(function* () { + const canonicalPath = yield* realPath(fs, path.resolve(pathname)); + if (!contained(path, sourceRoot, canonicalPath)) { + const root = path.dirname(canonicalPath); + if (!importMapRoots.includes(root)) importMapRoots.push(root); + } + yield* add(importMapRoots, pathname).pipe(Effect.asVoid); + }); + const add = ( + roots: ReadonlyArray, + pathname: string, + externalScope = false, + targetPath = pathname, + ): Effect.Effect< + { readonly hostPath: string; readonly contained: boolean }, + FunctionFilesError + > => + Effect.gen(function* () { + const hostPath = yield* realPath(fs, path.resolve(pathname)); + if (!containedInAny(path, roots, hostPath)) return { hostPath, contained: false }; + const kind = (yield* fileStat(fs, pathname)).type === "Directory" ? "directory" : "file"; + files.push({ hostPath, targetPath: slash(targetPath), kind, externalScope }); + return { hostPath, contained: true }; + }); + const map = + input.importMap.length > 0 + ? yield* loadImportMap(fs, path, input.importMap, addImportMapFile, new Set()) + : new ImportMapFile(); + yield* walkImports( + fs, + path, + map, + input.entrypoint, + moduleRoots, + input.sourceRoot, + (pathname, _contents) => add(moduleRoots, pathname).pipe(Effect.asVoid), + (message) => + Effect.sync(() => { + warnings.push(message); + }), + ); + for (const [target, isScopeTarget] of [ + ...Object.values(map.imports).map((target) => [target, false] as const), + ...Object.values(map.scopes).flatMap((scope) => + Object.values(scope).map((target) => [target, true] as const), + ), + ]) { + if (isRemote(target)) continue; + yield* Effect.gen(function* () { + const result = yield* add(importMapRoots, target); + const info = yield* fileStat(fs, target); + if (!result.contained && isScopeTarget) { + files.push({ + hostPath: result.hostPath, + targetPath: slash(target), + kind: info.type === "Directory" ? "directory" : "file", + externalScope: true, + }); + warnings.push( + `WARN: Mounting import map scope target outside the project root: ${result.hostPath}\n`, + ); + } + if (info.type === "Directory" || !result.contained) return; + yield* walkImports( + fs, + path, + map, + target, + importMapRoots, + input.sourceRoot, + (pathname, _contents) => add(importMapRoots, pathname).pipe(Effect.asVoid), + (message) => + Effect.sync(() => { + warnings.push(message); + }), + ); + }).pipe( + Effect.catch((error) => { + if (isNotDirectory(error)) + return Effect.sync(() => { + warnings.push( + `WARN: Skipping import map target that is not a directory: ${target}\n`, + ); + }); + if (input.skipMissingImportMapTargets === true && isNotFound(error)) + return Effect.sync(() => { + warnings.push(`WARN: Skipping missing import map target: ${target}\n`); + }); + return Effect.fail(error); + }), + ); + } + for (const pattern of input.staticFiles) { + const matches = yield* expandStatic(fs, path, pattern).pipe( + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + for (const pathname of matches) { + if ((yield* fileStat(fs, pathname)).type === "Directory") + return yield* new FunctionFilesError({ + message: `file path is a directory: ${pathname}`, + reason: "filesystem", + pathname, + }); + yield* add([sourceRoot], pathname).pipe(Effect.asVoid); + } + } + return { files, warnings, allowedRoots: [...new Set(importMapRoots)] }; + }); + +/** Discovers function files while requiring the caller's platform FileSystem and Path services. */ +export const planFunctionFiles = ( + input: FunctionFilesInput, +): Effect.Effect => plan(input); diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index b8da240a65..6ec59d2cf2 100644 --- a/packages/stack/src/functions/FunctionsBootstrap.ts +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -1,11 +1,13 @@ import { Crypto, Effect, FileSystem, Path, PlatformError } from "effect"; import { StackPreparationError } from "../public/Errors.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; +import { resolveServiceInstancePaths, resolveStackPaths } from "../state/Paths.ts"; import type { StackId } from "../public/StackId.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; export interface FunctionsBootstrapOwner { /** Publishes the stack-owned Edge Runtime main service for the current session. */ readonly write: (input: { + readonly instanceId: ServiceInstanceId; readonly content: string; }) => Effect.Effect; /** Removes only this stack's functions bootstrap root. */ @@ -41,28 +43,40 @@ export const makeFunctionsBootstrapOwner = ( const stackPaths = yield* resolveStackPaths(options).pipe( Effect.mapError((cause) => failure("Unable to resolve functions bootstrap path", { cause })), ); - const root = path.join(stackPaths.runtime, "functions"); + const root = path.join(stackPaths.runtime, "instances"); const write = (input: { + readonly instanceId: ServiceInstanceId; readonly content: string; }): Effect.Effect => { if (input.content.includes("\u0000")) return Effect.fail(failure("Functions bootstrap contains an invalid character")); - const target = path.join(root, "index.ts"); return Effect.gen(function* () { + const instancePaths = yield* resolveServiceInstancePaths(stackPaths, input.instanceId).pipe( + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => + failure("Unable to resolve functions bootstrap path", { cause }), + ), + ); + const instanceRoot = path.join(instancePaths.runtime, "functions"); + const target = path.join(instanceRoot, "index.ts"); const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => failure("Unable to allocate functions bootstrap file", { cause }), ), ); - const temporary = path.join(root, `.index.ts.${token}.tmp`); + const temporary = path.join(instanceRoot, `.index.ts.${token}.tmp`); return yield* Effect.gen(function* () { yield* mapFs( - root, + instanceRoot, "create functions bootstrap directory", - fs.makeDirectory(root, { recursive: true, mode: 0o700 }), + fs.makeDirectory(instanceRoot, { recursive: true, mode: 0o700 }), + ); + yield* mapFs( + instanceRoot, + "secure functions bootstrap directory", + fs.chmod(instanceRoot, 0o700), ); - yield* mapFs(root, "secure functions bootstrap directory", fs.chmod(root, 0o700)); yield* Effect.scoped( Effect.gen(function* () { const file = yield* mapFs( diff --git a/packages/stack/src/functions/function-files.integration.test.ts b/packages/stack/src/functions/function-files.integration.test.ts new file mode 100644 index 0000000000..7189ee5311 --- /dev/null +++ b/packages/stack/src/functions/function-files.integration.test.ts @@ -0,0 +1,101 @@ +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { describe, expect, it } from "@effect/vitest"; +import { FunctionFilesError, planFunctionFiles } from "./FunctionFiles.ts"; + +const withFixture = ( + use: (services: { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly root: string; + }) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryRoot = yield* fs.makeTempDirectory({ prefix: "function-files-" }); + const root = yield* fs.realPath(temporaryRoot); + return yield* Effect.acquireUseRelease( + Effect.succeed(root), + (fixtureRoot) => use({ fs, path, root: fixtureRoot }), + (fixtureRoot) => fs.remove(fixtureRoot, { recursive: true, force: true }).pipe(Effect.ignore), + ); + }); + +describe("planFunctionFiles", () => { + it.effect("discovers entrypoint imports, import-map targets, and static files", () => + withFixture(({ fs, path, root }) => + Effect.gen(function* () { + const projectRoot = path.join(root, "project"); + const functionDir = path.join(projectRoot, "supabase", "functions", "hello"); + const sharedDir = path.join(projectRoot, "shared"); + yield* fs.makeDirectory(path.join(functionDir, "public"), { recursive: true }); + yield* fs.makeDirectory(sharedDir, { recursive: true }); + const entrypoint = path.join(functionDir, "index.ts"); + const sibling = path.join(functionDir, "sibling.ts"); + const importMap = path.join(functionDir, "deno.json"); + const shared = path.join(sharedDir, "mod.ts"); + const staticFile = path.join(functionDir, "public", "hello.txt"); + yield* fs.writeFileString(entrypoint, 'import "./sibling.ts"; import "@shared/mod.ts";\n'); + yield* fs.writeFileString(sibling, "export const sibling = true;\n"); + yield* fs.writeFileString(shared, "export const shared = true;\n"); + yield* fs.writeFileString(staticFile, "hello\n"); + yield* fs.writeFileString(importMap, '{"imports":{"@shared/":"../../../shared/"}}'); + const plan = yield* planFunctionFiles({ + projectRoot, + sourceRoot: projectRoot, + entrypoint, + importMap, + staticFiles: [path.join(functionDir, "public", "*.txt")], + }); + const paths = plan.files.map((file) => file.hostPath); + expect(paths).toEqual( + expect.arrayContaining([entrypoint, importMap, sibling, shared, staticFile]), + ); + expect(plan.files.some((file) => file.hostPath === shared && file.externalScope)).toBe( + false, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("mounts an external scope file without following its imports", () => + withFixture(({ fs, path, root }) => + Effect.gen(function* () { + const projectRoot = path.join(root, "project"); + const functionDir = path.join(projectRoot, "supabase", "functions", "hello"); + const externalDir = path.join(root, "external"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.makeDirectory(externalDir, { recursive: true }); + const entrypoint = path.join(functionDir, "index.ts"); + const importMap = path.join(functionDir, "deno.json"); + const external = path.join(externalDir, "mod.ts"); + const externalDependency = path.join(externalDir, "dependency.ts"); + yield* fs.writeFileString(entrypoint, "Deno.serve(() => new Response('ok'));\n"); + yield* fs.writeFileString(external, 'export { dependency } from "./dependency.ts";\n'); + yield* fs.writeFileString(externalDependency, "export const dependency = true;\n"); + yield* fs.writeFileString( + importMap, + '{"scopes":{"./":{"@external":"../../../../external/mod.ts"}}}', + ); + const plan = yield* planFunctionFiles({ + projectRoot, + sourceRoot: projectRoot, + entrypoint, + importMap, + staticFiles: [], + }); + expect(plan.files.find((file) => file.hostPath === external)).toMatchObject({ + targetPath: external, + externalScope: true, + kind: "file", + }); + expect(plan.files.some((file) => file.hostPath === externalDependency)).toBe(false); + expect(plan.warnings).toContain( + `WARN: Mounting import map scope target outside the project root: ${external}\n`, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/functions/functions-bootstrap.integration.test.ts b/packages/stack/src/functions/functions-bootstrap.integration.test.ts index c1886a8d39..39e2879c4b 100644 --- a/packages/stack/src/functions/functions-bootstrap.integration.test.ts +++ b/packages/stack/src/functions/functions-bootstrap.integration.test.ts @@ -2,6 +2,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; import { StackIdSchema } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import { makeFunctionsBootstrapOwner } from "./FunctionsBootstrap.ts"; const setupBootstrapOwner = (prefix: string) => @@ -9,17 +10,22 @@ const setupBootstrapOwner = (prefix: string) => const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix }); const stackId = StackIdSchema.make("a".repeat(64)); + const instanceId = ServiceInstanceIdSchema.make("primary"); const owner = yield* makeFunctionsBootstrapOwner({ stateRoot: root, stackId }); - return { fs, root, stackId, owner }; + return { fs, root, stackId, instanceId, owner }; }); describe("functions bootstrap owner", () => { it.live("publishes a private Functions bootstrap file with restrictive modes", () => Effect.gen(function* () { const path = yield* Path.Path; - const { fs, root, stackId, owner } = yield* setupBootstrapOwner("stack-functions-bootstrap-"); - const target = yield* owner.write({ content: "export default 1" }); - expect(target).toContain(path.join(root, stackId, "runtime", "functions", "index.ts")); + const { fs, root, stackId, instanceId, owner } = yield* setupBootstrapOwner( + "stack-functions-bootstrap-", + ); + const target = yield* owner.write({ instanceId, content: "export default 1" }); + expect(target).toContain( + path.join(root, stackId, "runtime", "instances", "primary", "functions", "index.ts"), + ); expect((yield* fs.stat(path.dirname(target))).mode! & 0o777).toBe(0o700); expect((yield* fs.stat(target)).mode! & 0o777).toBe(0o600); expect(yield* fs.readFileString(target)).toBe("export default 1"); @@ -29,10 +35,10 @@ describe("functions bootstrap owner", () => { it.live("cleans the Functions bootstrap file and directory", () => Effect.gen(function* () { const path = yield* Path.Path; - const { fs, root, stackId, owner } = yield* setupBootstrapOwner( + const { fs, root, stackId, instanceId, owner } = yield* setupBootstrapOwner( "stack-functions-bootstrap-cleanup-", ); - const target = yield* owner.write({ content: "export default 1" }); + const target = yield* owner.write({ instanceId, content: "export default 1" }); expect(yield* fs.exists(target)).toBe(true); @@ -45,15 +51,17 @@ describe("functions bootstrap owner", () => { it.live("recreates readable Functions bootstrap content after cleanup", () => Effect.gen(function* () { - const { fs, owner } = yield* setupBootstrapOwner("stack-functions-bootstrap-recreate-"); - const first = yield* owner.write({ content: "export default 1" }); + const { fs, instanceId, owner } = yield* setupBootstrapOwner( + "stack-functions-bootstrap-recreate-", + ); + const first = yield* owner.write({ instanceId, content: "export default 1" }); expect(yield* fs.exists(first)).toBe(true); yield* owner.cleanupAll; expect(yield* fs.exists(first)).toBe(false); - const recreated = yield* owner.write({ content: "export default 2" }); + const recreated = yield* owner.write({ instanceId, content: "export default 2" }); expect(yield* fs.readFileString(recreated)).toBe("export default 2"); }).pipe(Effect.provide(NodeServices.layer)), @@ -71,16 +79,19 @@ describe("functions bootstrap owner", () => { yield* fs.makeDirectory(canonicalRoot); yield* fs.symlink(canonicalRoot, configuredRoot); const stackIdValue = StackIdSchema.make("b".repeat(64)); + const instanceId = ServiceInstanceIdSchema.make("primary"); const owner = yield* makeFunctionsBootstrapOwner({ stateRoot: configuredRoot, stackId: stackIdValue, }); - const target = yield* owner.write({ content: "export default 2" }); + const target = yield* owner.write({ instanceId, content: "export default 2" }); const expected = path.join( yield* fs.realPath(canonicalRoot), stackIdValue, "runtime", + "instances", + "primary", "functions", "index.ts", ); diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index fc574fdd3d..b21d1839db 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -1,27 +1,39 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Effect FileSystem exposes stat but no no-follow lstat; the resolver fixture must verify symlink rejection. -import { lstat } from "node:fs/promises"; import { NodeServices } from "@effect/platform-node"; -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Path, PlatformError } from "effect"; import { describe, expect, it } from "@effect/vitest"; import { createWorkerServicePathResolver, packageJsonContainedFor, + resolveFunctionConfigs, resolveFunctionConfig, type FunctionFileSystem, FunctionFileSystemError, } from "./serve-main-resolver.ts"; const makeNodeFileSystem = (fs: FileSystem.FileSystem): FunctionFileSystem => ({ lstat: (path) => - Effect.tryPromise({ - try: () => lstat(path), - catch: (cause) => new FunctionFileSystemError({ cause }), - }).pipe( - Effect.map((info) => ({ - isDirectory: info.isDirectory(), - isFile: info.isFile(), - isSymbolicLink: info.isSymbolicLink(), - })), - ), + Effect.gen(function* () { + const info = yield* fs.stat(path); + const isSymbolicLink = yield* fs.readLink(path).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => { + if ( + error.reason instanceof PlatformError.SystemError && + error.reason._tag === "Unknown" && + typeof error.reason.cause === "object" && + error.reason.cause !== null && + "code" in error.reason.cause && + error.reason.cause.code === "EINVAL" + ) + return Effect.succeed(false); + return Effect.fail(error); + }), + ); + return { + isDirectory: info.type === "Directory", + isFile: info.type === "File", + isSymbolicLink, + }; + }).pipe(Effect.mapError((cause) => new FunctionFileSystemError({ cause }))), realPath: (path) => fs.realPath(path).pipe(Effect.mapError((cause) => new FunctionFileSystemError({ cause }))), readDirectory: (path) => @@ -49,6 +61,36 @@ describe("Edge Runtime worker service paths", () => { }); }); describe("Edge Runtime request-time function resolver", () => { + it.live("resolves mapless and jsonc functions while omitting disabled missing functions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-resolver-maps-" }); + const mapless = path.join(root, "mapless"); + const jsonc = path.join(root, "jsonc"); + yield* fs.makeDirectory(mapless, { recursive: true }); + yield* fs.makeDirectory(jsonc, { recursive: true }); + yield* fs.writeFileString(path.join(mapless, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(jsonc, "index.ts"), "export default 2"); + yield* fs.writeFileString(path.join(jsonc, "deno.jsonc"), "{ /* comment */ }"); + const canonicalRoot = yield* fs.realPath(root); + const resolved = yield* resolveFunctionConfigs({ + root, + overrides: { $default: { verifyJWT: false }, disabled: { enabled: false } }, + fs: nodeFileSystem, + }); + expect(resolved.map(({ slug }) => slug).sort()).toEqual(["jsonc", "mapless"]); + expect(resolved.find(({ slug }) => slug === "mapless")?.config).toMatchObject({ + importMapPath: "", + verifyJWT: false, + }); + expect(resolved.find(({ slug }) => slug === "jsonc")?.config.importMapPath).toBe( + path.join(canonicalRoot, "jsonc", "deno.jsonc"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("resolves current filesystem paths for create/delete", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -91,6 +133,69 @@ describe("Edge Runtime request-time function resolver", () => { ).toBeUndefined(); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("accepts explicitly configured files outside the functions root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const project = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-external-", + }); + const root = path.join(project, "supabase", "functions"); + const hello = path.join(root, "hello"); + const external = path.join(project, "functions-extra"); + yield* fs.makeDirectory(hello, { recursive: true }); + yield* fs.makeDirectory(external, { recursive: true }); + yield* fs.writeFileString(path.join(external, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(external, "deno.json"), "{}"); + yield* fs.writeFileString(path.join(external, "asset.txt"), "asset"); + const canonicalProject = yield* fs.realPath(project); + const config = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { + hello: { + entrypointPath: "../../../functions-extra/index.ts", + importMapPath: "../../../functions-extra/deno.json", + staticFiles: ["../../../functions-extra/asset.txt"], + }, + }, + fs: nodeFileSystem, + }); + expect(config).toMatchObject({ + entrypointPath: path.join(canonicalProject, "functions-extra", "index.ts"), + importMapPath: path.join(canonicalProject, "functions-extra", "deno.json"), + staticFiles: [path.join(canonicalProject, "functions-extra", "asset.txt")], + }); + const configOnly = yield* resolveFunctionConfig({ + root, + slug: "config-only", + overrides: { + "config-only": { + entrypointPath: "../../../functions-extra/index.ts", + }, + }, + fs: nodeFileSystem, + }); + expect(configOnly?.entrypointPath).toBe( + path.join(canonicalProject, "functions-extra", "index.ts"), + ); + const globalExternal = yield* resolveFunctionConfig({ + root, + slug: "global-external", + overrides: { + $default: { importMapRoot: "../../functions-extra/deno.json" }, + "global-external": { + entrypointPath: "../../../functions-extra/index.ts", + }, + }, + fs: nodeFileSystem, + }); + expect(globalExternal?.importMapPath).toBe( + path.join(canonicalProject, "functions-extra", "deno.json"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.live("discovers a contained package.json for a function without an import map", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -138,10 +243,10 @@ describe("Edge Runtime request-time function resolver", () => { overrides: { hello: { enabled: true, - verify_jwt: false, - import_map: "", - entrypoint: "", - static_files: [], + verifyJWT: false, + importMapPath: "", + entrypointPath: "", + staticFiles: [], env: {}, }, }, @@ -159,11 +264,10 @@ describe("Edge Runtime request-time function resolver", () => { overrides: { hello: { enabled: true, - verify_jwt: false, - import_map: "", + verifyJWT: false, + importMapPath: "", entrypointPath: "custom.ts", - entrypoint: "index.ts", - static_files: [], + staticFiles: [], env: {}, }, }, @@ -214,12 +318,12 @@ describe("Edge Runtime request-time function resolver", () => { yield* fs.writeFileString(path.join(created, "index.ts"), "export default 2"); yield* fs.writeFileString(path.join(root, "shared-deno.json"), "{}"); const defaults = { - verify_jwt: false, - import_map_root: "shared-deno.json", + verifyJWT: false, + importMapRoot: "shared-deno.json", }; const overrides = { $default: defaults, - hello: { verify_jwt: true }, + hello: { verifyJWT: true }, }; expect( yield* resolveFunctionConfig({ root, slug: "created", overrides, fs: nodeFileSystem }), @@ -254,8 +358,8 @@ describe("Edge Runtime request-time function resolver", () => { root, slug: "hello", overrides: { - $default: { import_map_root: "shared-deno.json" }, - hello: { import_map: "custom-deno.json" }, + $default: { importMapRoot: "shared-deno.json" }, + hello: { importMapPath: "custom-deno.json" }, }, fs: nodeFileSystem, }), @@ -338,7 +442,7 @@ describe("Edge Runtime request-time function resolver", () => { yield* resolveFunctionConfig({ root, slug: "safe", - overrides: { safe: { entrypoint: "../outside.ts" } }, + overrides: { safe: { entrypointPath: "../outside.ts" } }, fs: nodeFileSystem, }), ).toBeUndefined(); @@ -382,7 +486,7 @@ describe("Edge Runtime request-time function resolver", () => { yield* resolveFunctionConfig({ root, slug: "hello", - overrides: { hello: { static_files: ["public/*.txt"] } }, + overrides: { hello: { staticFiles: ["public/*.txt"] } }, fs: nodeFileSystem, }), ).toMatchObject({ staticFiles: [path.join(canonicalRoot, "hello", "public", "*.txt")] }); @@ -392,7 +496,7 @@ describe("Edge Runtime request-time function resolver", () => { yield* resolveFunctionConfig({ root, slug: "hello", - overrides: { hello: { static_files: ["public/*.txt"] } }, + overrides: { hello: { staticFiles: ["public/*.txt"] } }, fs: nodeFileSystem, }), ).toBeUndefined(); diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index da1806c083..7ed5f28e8e 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -1,19 +1,14 @@ import { Data, Effect } from "effect"; import { dirname, join } from "./serve-main-deps.ts"; -interface FunctionOverride { +export interface FunctionOverride { readonly enabled?: boolean; readonly verifyJWT?: boolean; - readonly verify_jwt?: boolean; readonly entrypointPath?: string; - readonly entrypoint?: string; readonly importMapPath?: string; - readonly import_map?: string; /** Reserved `$default` field: path relative to the shared functions root. */ readonly importMapRoot?: string; - readonly import_map_root?: string; readonly staticFiles?: ReadonlyArray; - readonly static_files?: ReadonlyArray; readonly env?: Readonly>; } @@ -28,6 +23,11 @@ export interface FunctionConfig { readonly env?: Readonly>; } +export interface ResolvedFunctionConfig { + readonly slug: string; + readonly config: FunctionConfig; +} + interface FunctionFileInfo { readonly isDirectory: boolean; readonly isFile: boolean; @@ -72,6 +72,12 @@ const safeRealPath = ( Effect.orElseSucceed(() => false), ); +const existingRealPath = (fs: FunctionFileSystem, candidate: string): Effect.Effect => + Effect.all([fs.realPath(candidate), optionalInfo(fs, candidate)], { concurrency: 2 }).pipe( + Effect.map(([resolved, info]) => resolved.startsWith("/") && info !== undefined), + Effect.orElseSucceed(() => false), + ); + const rejectSymlinkDescendants = ( fs: FunctionFileSystem, root: string, @@ -125,24 +131,32 @@ export const resolveFunctionConfig = (options: { const rawEntrypoint = override?.entrypointPath && override.entrypointPath.length > 0 ? override.entrypointPath - : override.entrypoint && override.entrypoint.length > 0 - ? override.entrypoint - : "index.ts"; - if (!rawEntrypoint.startsWith("/")) { + : "index.ts"; + const configuredEntrypoint = functionOverride?.entrypointPath !== undefined; + if (!rawEntrypoint.startsWith("/") && !configuredEntrypoint) { const directoryInfo = yield* optionalInfo(fs, functionDirectory); if (directoryInfo === undefined || !directoryInfo.isDirectory) return undefined; if (!(yield* safeRealPath(fs, canonicalRoot, functionDirectory))) return undefined; } const entrypointPath = relativePath(functionDirectory, rawEntrypoint); - if (!(yield* safeRealPath(fs, canonicalRoot, entrypointPath))) return undefined; + if ( + configuredEntrypoint + ? !(yield* existingRealPath(fs, entrypointPath)) + : !(yield* safeRealPath(fs, canonicalRoot, entrypointPath)) + ) + return undefined; const entrypointInfo = yield* optionalInfo(fs, entrypointPath); - if (entrypointInfo === undefined || !entrypointInfo.isFile || entrypointInfo.isSymbolicLink) + if ( + entrypointInfo === undefined || + !entrypointInfo.isFile || + (!configuredEntrypoint && entrypointInfo.isSymbolicLink) + ) return undefined; // Per-function import maps are relative to that function's directory; the reserved global // default is root-relative, so one shared map is reused by every slug. - const functionImportMap = functionOverride?.importMapPath ?? functionOverride?.import_map; - const globalImportMap = globalDefaults?.importMapRoot ?? globalDefaults?.import_map_root; + const functionImportMap = functionOverride?.importMapPath; + const globalImportMap = globalDefaults?.importMapRoot; let importMapPath = functionImportMap !== undefined ? relativePath(functionDirectory, functionImportMap) @@ -150,9 +164,16 @@ export const resolveFunctionConfig = (options: { ? relativePath(canonicalRoot, globalImportMap) : relativePath(functionDirectory, ""); if (importMapPath.length > 0) { - if (!(yield* safeRealPath(fs, canonicalRoot, importMapPath))) return undefined; + const configuredImportMap = functionImportMap !== undefined || globalImportMap !== undefined; + if ( + configuredImportMap + ? !(yield* existingRealPath(fs, importMapPath)) + : !(yield* safeRealPath(fs, canonicalRoot, importMapPath)) + ) + return undefined; const info = yield* optionalInfo(fs, importMapPath); - if (info === undefined || !info.isFile || info.isSymbolicLink) return undefined; + if (info === undefined || !info.isFile || (!configuredImportMap && info.isSymbolicLink)) + return undefined; } else { for (const candidate of ["deno.json", "deno.jsonc"]) { const path = join(functionDirectory, candidate); @@ -170,23 +191,32 @@ export const resolveFunctionConfig = (options: { } } - const staticFiles = (override.staticFiles ?? override.static_files ?? []).map((pattern) => + const staticFiles = (override.staticFiles ?? []).map((pattern) => relativePath(functionDirectory, pattern), ); + const configuredStaticFiles = functionOverride?.staticFiles !== undefined; for (const pattern of staticFiles) { - if (!contained(canonicalRoot, pattern)) return undefined; + if (!configuredStaticFiles && !contained(canonicalRoot, pattern)) return undefined; const wildcardIndex = pattern.search(globPattern); const prefix = wildcardIndex < 0 ? pattern : pattern.slice(0, wildcardIndex); const searchRoot = wildcardIndex < 0 ? dirname(pattern) : prefix.slice(0, Math.max(0, prefix.lastIndexOf("/"))) || canonicalRoot; - if (!(yield* rejectSymlinkDescendants(fs, canonicalRoot, searchRoot))) return undefined; + const staticGuardRoot = + configuredStaticFiles && !contained(canonicalRoot, searchRoot) + ? yield* fs.realPath(searchRoot).pipe(Effect.orElseSucceed(() => "")) + : canonicalRoot; + if ( + staticGuardRoot.length === 0 || + !(yield* rejectSymlinkDescendants(fs, staticGuardRoot, searchRoot)) + ) + return undefined; if (!globPattern.test(pattern)) { const info = yield* optionalInfo(fs, pattern); if ( info !== undefined && - (!(yield* safeRealPath(fs, canonicalRoot, pattern)) || info.isSymbolicLink) + (!(yield* safeRealPath(fs, staticGuardRoot, pattern)) || info.isSymbolicLink) ) return undefined; } @@ -196,11 +226,35 @@ export const resolveFunctionConfig = (options: { entrypointPath, importMapPath, staticFiles, - verifyJWT: override.verifyJWT ?? override.verify_jwt ?? true, + verifyJWT: override.verifyJWT ?? true, env: override.env, }; }); +/** Resolves configured and discovered functions against the current filesystem tree. */ +export const resolveFunctionConfigs = (options: { + readonly root: string; + readonly overrides: FunctionOverrides; + readonly fs: FunctionFileSystem; +}): Effect.Effect> => + Effect.gen(function* () { + const discovered = yield* options.fs + .readDirectory(options.root) + .pipe( + Effect.catchTag("FunctionFileSystemError", () => Effect.succeed>([])), + ); + const slugs = new Set([ + ...discovered, + ...Object.keys(options.overrides).filter((slug) => slug !== "$default"), + ]); + const result: ResolvedFunctionConfig[] = []; + for (const slug of slugs) { + const config = yield* resolveFunctionConfig({ ...options, slug }); + if (config !== undefined) result.push({ slug, config }); + } + return result; + }); + const packageJsonPathFor = (config: FunctionConfig): string => join(dirname(config.entrypointPath), "package.json"); diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index f305d98fe9..7de88f66c7 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -1,15 +1,4 @@ -import { - Cause, - Config, - ConfigProvider, - Console, - Data, - Effect, - Exit, - Option, - Schema, - Stream, -} from "effect"; +import { Config, ConfigProvider, Console, Data, Effect, Option, Schema, Stream } from "effect"; interface DenoErrorConstructors { readonly InvalidWorkerCreation?: abstract new (...args: never[]) => Error; @@ -142,15 +131,10 @@ interface AuthFailure { const FunctionOverrideSchema = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), verifyJWT: Schema.optionalKey(Schema.Boolean), - verify_jwt: Schema.optionalKey(Schema.Boolean), entrypointPath: Schema.optionalKey(Schema.String), - entrypoint: Schema.optionalKey(Schema.String), importMapPath: Schema.optionalKey(Schema.String), - import_map: Schema.optionalKey(Schema.String), importMapRoot: Schema.optionalKey(Schema.String), - import_map_root: Schema.optionalKey(Schema.String), staticFiles: Schema.optionalKey(Schema.Array(Schema.String)), - static_files: Schema.optionalKey(Schema.Array(Schema.String)), env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); const FunctionOverridesSchema = Schema.Record(Schema.String, FunctionOverrideSchema); @@ -358,82 +342,91 @@ export function prepareUserRequest(request: Request): Request { return forwarded; } +const requestEffect = (request: Request) => + Effect.gen(function* () { + const { pathname } = new URL(request.url); + if (pathname === "/_internal/health") return getResponse({ message: "ok" }, STATUS_CODE.OK); + if (pathname === "/_internal/metric") + return Response.json(yield* foreign(() => EdgeRuntime.getRuntimeMetrics())); + const functionName = pathname.split("/")[1]; + if (!functionName) return getResponse("Function not found", STATUS_CODE.NotFound); + const config = yield* functionConfig(functionName); + if (!config) return getResponse("Function not found", STATUS_CODE.NotFound); + if (request.method !== "OPTIONS" && config.verifyJWT) { + const token = getAuthToken(request); + if (typeof token !== "string") return getAuthErrorResponse(token); + const authFailure = yield* verifyHybridJWT(JWT_SECRET, token); + if (Option.isSome(authFailure)) return getAuthErrorResponse(authFailure.value); + } + const envVarsObj: Record = { + ...Deno.env.toObject(), + ...Object.fromEntries( + Object.entries(config.env ?? {}).filter(([name]) => !name.startsWith("SUPABASE_")), + ), + SUPABASE_FUNCTION_SLUG: functionName, + }; + if (SUPABASE_PUBLISHABLE_KEY) + envVarsObj.SUPABASE_PUBLISHABLE_KEYS = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown), + )({ default: SUPABASE_PUBLISHABLE_KEY }); + if (SUPABASE_SECRET_KEY) + envVarsObj.SUPABASE_SECRET_KEYS = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown), + )({ default: SUPABASE_SECRET_KEY }); + const envVars = Object.entries(envVarsObj).filter( + ([name]) => !EXCLUDED_ENVS.has(name) && !name.startsWith("SUPABASE_INTERNAL_"), + ); + const noNpm = !(yield* shouldUsePackageJsonDiscovery(config)); + const workerRequest = Effect.gen(function* () { + const worker = yield* foreign(() => + EdgeRuntime.userWorkers.create({ + servicePath: workerServicePath(functionName, config), + memoryLimitMb: 256, + workerTimeoutMs: Number.isFinite(WALLCLOCK_LIMIT_SEC) + ? WALLCLOCK_LIMIT_SEC * 1000 + : 400_000, + noModuleCache: true, + noNpm, + importMapPath: config.importMapPath, + envVars, + forceCreate: true, + customModuleRoot: "", + cpuTimeSoftLimitMs: 1000, + cpuTimeHardLimitMs: 2000, + decoratorType: "tc39", + maybeEntrypoint: toFileUrl(config.entrypointPath).href, + context: { useReadSyncFileAPI: true }, + staticPatterns: config.staticFiles, + }), + ); + return yield* foreign(() => worker.fetch(prepareUserRequest(request))); + }); + return yield* workerRequest.pipe( + Effect.catchTag("BootstrapOperationError", ({ cause }) => + Console.error("[functions] worker error", cause).pipe( + Effect.andThen(Effect.succeed(getWorkerErrorResponse(cause))), + ), + ), + ); + }); + +class RequestCancelled extends Data.TaggedError("RequestCancelled") {} + +const requestCancellation = (request: Request) => + Effect.callback((resume) => { + const abort = () => resume(Effect.fail(new RequestCancelled())); + if (request.signal.aborted) abort(); + else request.signal.addEventListener("abort", abort, { once: true }); + return Effect.sync(() => request.signal.removeEventListener("abort", abort)); + }); + +const handleRequest = (request: Request) => + Effect.raceFirst(requestCancellation(request), requestEffect(request)).pipe( + Effect.catchTag("RequestCancelled", () => Effect.succeed(new Response(null, { status: 499 }))), + ); + Deno.serve({ - handler: (request: Request) => - Effect.runPromiseExit( - Effect.gen(function* () { - const { pathname } = new URL(request.url); - if (pathname === "/_internal/health") return getResponse({ message: "ok" }, STATUS_CODE.OK); - if (pathname === "/_internal/metric") - return Response.json(yield* foreign(() => EdgeRuntime.getRuntimeMetrics())); - const functionName = pathname.split("/")[1]; - if (!functionName) return getResponse("Function not found", STATUS_CODE.NotFound); - const config = yield* functionConfig(functionName); - if (!config) return getResponse("Function not found", STATUS_CODE.NotFound); - if (request.method !== "OPTIONS" && config.verifyJWT) { - const token = getAuthToken(request); - if (typeof token !== "string") return getAuthErrorResponse(token); - const authFailure = yield* verifyHybridJWT(JWT_SECRET, token); - if (Option.isSome(authFailure)) return getAuthErrorResponse(authFailure.value); - } - const envVarsObj: Record = { - ...Deno.env.toObject(), - ...Object.fromEntries( - Object.entries(config.env ?? {}).filter(([name]) => !name.startsWith("SUPABASE_")), - ), - SUPABASE_FUNCTION_SLUG: functionName, - }; - if (SUPABASE_PUBLISHABLE_KEY) - envVarsObj.SUPABASE_PUBLISHABLE_KEYS = yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Unknown), - )({ default: SUPABASE_PUBLISHABLE_KEY }); - if (SUPABASE_SECRET_KEY) - envVarsObj.SUPABASE_SECRET_KEYS = yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Unknown), - )({ default: SUPABASE_SECRET_KEY }); - const envVars = Object.entries(envVarsObj).filter( - ([name]) => !EXCLUDED_ENVS.has(name) && !name.startsWith("SUPABASE_INTERNAL_"), - ); - const noNpm = !(yield* shouldUsePackageJsonDiscovery(config)); - const workerRequest = Effect.gen(function* () { - const worker = yield* foreign(() => - EdgeRuntime.userWorkers.create({ - servicePath: workerServicePath(functionName, config), - memoryLimitMb: 256, - workerTimeoutMs: Number.isFinite(WALLCLOCK_LIMIT_SEC) - ? WALLCLOCK_LIMIT_SEC * 1000 - : 400_000, - noModuleCache: true, - noNpm, - importMapPath: config.importMapPath, - envVars, - forceCreate: true, - customModuleRoot: "", - cpuTimeSoftLimitMs: 1000, - cpuTimeHardLimitMs: 2000, - decoratorType: "tc39", - maybeEntrypoint: toFileUrl(config.entrypointPath).href, - context: { useReadSyncFileAPI: true }, - staticPatterns: config.staticFiles, - }), - ); - return yield* foreign(() => worker.fetch(prepareUserRequest(request))); - }); - return yield* workerRequest.pipe( - Effect.catchTag("BootstrapOperationError", ({ cause }) => - Console.error("[functions] worker error", cause).pipe( - Effect.andThen(Effect.succeed(getWorkerErrorResponse(cause))), - ), - ), - ); - }), - { signal: request.signal }, - ).then((exit) => { - if (Exit.isSuccess(exit)) return exit.value; - if (request.signal.aborted && Cause.hasInterruptsOnly(exit.cause)) - return new Response(null, { status: 499 }); - throw Cause.squash(exit.cause); - }), + handler: (request: Request) => Effect.runPromise(handleRequest(request)), onListen: () => { const names = Object.keys(configured); const examples = names diff --git a/packages/stack/src/gateway/ActivityTracker.ts b/packages/stack/src/gateway/ActivityTracker.ts index b88524ab0a..a5f3e730e7 100644 --- a/packages/stack/src/gateway/ActivityTracker.ts +++ b/packages/stack/src/gateway/ActivityTracker.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; import type { CapabilityName } from "../public/Capability.ts"; +import { GatewayActivationError } from "../public/Errors.ts"; /** Tracks gateway work that must keep a lazy capability running. */ export interface GatewayActivity { readonly track: ( capability: CapabilityName, effect: Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; } export interface GatewayActivityCallbacks { diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index 3932d35db8..368bd6c6c0 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -2,6 +2,7 @@ import { Data, Effect, Exit } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; import { makeHttpGateway, type HttpGateway, type HttpGatewayOptions } from "./HttpGateway.ts"; import { makeTcpGateway, type TcpGateway, type TcpGatewayOptions } from "./TcpGateway.ts"; import type { GatewayActivity } from "./ActivityTracker.ts"; @@ -14,6 +15,8 @@ export interface BackendEndpoint { export interface ActivationResult { readonly capability: CapabilityName; + /** Concrete instance selected for this activation when a service has multiple registrations. */ + readonly instanceId?: ServiceInstanceId; readonly endpoint: BackendEndpoint; } @@ -54,6 +57,8 @@ export type GatewayHeaderTransform = ( export interface GatewayProxyRoute { readonly capability: CapabilityName; + /** Concrete service instance selected for this route, when applicable. */ + readonly instanceId?: ServiceInstanceId; /** Workload binding selected when a capability exposes multiple endpoints. */ readonly binding?: string; readonly match: (request: GatewayRouteRequest) => boolean; @@ -87,15 +92,15 @@ export type GatewayRoute = GatewayProxyRoute | GatewayLocalRoute; export const isGatewayProxyRoute = (route: GatewayRoute): route is GatewayProxyRoute => route.capability !== undefined; -type GatewayHttpKey = PortField | "api:internal"; +type GatewayHttpKey = string; export interface StackGateway { - readonly http: ReadonlyMap; - readonly tcp: ReadonlyMap; + readonly http: ReadonlyMap; + readonly tcp: ReadonlyMap; readonly close: Effect.Effect; } -export interface HttpGatewayListenerOptions { +interface HttpGatewayListenerOptions { readonly field: PortField; /** Optional internal map key when multiple listeners serve one logical field. */ readonly key?: GatewayHttpKey; @@ -104,6 +109,7 @@ export interface HttpGatewayListenerOptions { interface TcpGatewayListenerOptions { readonly field: PortField; + readonly key?: string; readonly options: Omit; } @@ -123,7 +129,7 @@ export const makeGateway = ( ): Effect.Effect => Effect.gen(function* () { const http = new Map(); - const tcp = new Map(); + const tcp = new Map(); const closeValues = (values: Iterable<{ readonly close: Effect.Effect }>) => Effect.forEach(values, (gateway) => gateway.close.pipe(Effect.exit), { concurrency: "unbounded", @@ -151,10 +157,11 @@ export const makeGateway = ( http.set(key, acquired.value); } for (const entry of options.tcp ?? []) { - if (tcp.has(entry.field) || http.has(entry.field)) { + const key = entry.key ?? entry.field; + if (tcp.has(key) || http.has(key)) { yield* closeValues([...http.values(), ...tcp.values()]); return yield* new GatewayActivationError({ - message: `Duplicate gateway listener ${entry.field}`, + message: `Duplicate gateway listener ${key}`, }); } const acquired = yield* Effect.exit( @@ -168,7 +175,7 @@ export const makeGateway = ( yield* closeValues([...http.values(), ...tcp.values()]); return yield* Effect.failCause(acquired.cause); } - tcp.set(entry.field, acquired.value); + tcp.set(key, acquired.value); } const closeOperation = closeValues([...http.values(), ...tcp.values()]); const close = yield* Effect.cached(closeOperation); diff --git a/packages/stack/src/gateway/RouteCatalog.ts b/packages/stack/src/gateway/RouteCatalog.ts index 6d06609471..484e50ba0d 100644 --- a/packages/stack/src/gateway/RouteCatalog.ts +++ b/packages/stack/src/gateway/RouteCatalog.ts @@ -187,7 +187,7 @@ const realtimeWebsocketRoute = (material?: GatewayApiMaterial): GatewayProxyRout const apiRoutes = ( capability: CapabilityName, material?: GatewayApiMaterial, -): ReadonlyArray => { +): ReadonlyArray => { switch (capability) { case "rest": return [ @@ -247,13 +247,16 @@ const apiRoutes = ( ]; case "functions": return [ - prefixRoute( - capability, - "/functions/v1", - stripPrefix("/functions/v1"), - undefined, - material === undefined ? undefined : authorizationTransform(material, "sb-api-key"), - ), + { + ...prefixRoute( + capability, + "/functions/v1", + stripPrefix("/functions/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "sb-api-key"), + ), + binding: "primary", + }, ]; case "analytics": return [ @@ -290,20 +293,24 @@ export const routeCatalogFor = ( target.set(field, current); }; for (const route of plan.routes) { + const withInstance = (candidate: GatewayProxyRoute): GatewayProxyRoute => ({ + ...candidate, + instanceId: route.instanceId, + }); if (route.listener === "api" && route.protocol === "http") - append(http, route.listener, apiRoutes(route.capability, material)); + append(http, route.listener, apiRoutes(route.capability, material).map(withInstance)); else if (route.listener === "studio" && route.protocol === "http") - append(http, route.listener, [directRoute(route.capability)]); + append(http, route.listener, [withInstance(directRoute(route.capability))]); else if (route.listener === "mailUi" && route.protocol === "http") - append(http, route.listener, [directRoute(route.capability, "ui")]); + append(http, route.listener, [withInstance(directRoute(route.capability, "ui"))]); else if (route.listener === "functionsInspector" && route.protocol === "http") - append(http, route.listener, [directRoute(route.capability, "inspector")]); + append(http, route.listener, [withInstance(directRoute(route.capability, "inspector"))]); else if (route.listener === "database" && route.protocol === "tcp") - append(tcp, route.listener, [directRoute(route.capability, "primary")]); + append(tcp, route.listener, [withInstance(directRoute(route.capability, "primary"))]); else if (route.listener === "pooler" && route.protocol === "tcp") - append(tcp, route.listener, [directRoute(route.capability, "primary")]); + append(tcp, route.listener, [withInstance(directRoute(route.capability, "primary"))]); else if (route.listener === "smtp" && route.protocol === "tcp") - append(tcp, route.listener, [directRoute(route.capability, "smtp")]); + append(tcp, route.listener, [withInstance(directRoute(route.capability, "smtp"))]); else if (route.listener === "pop3" && route.protocol === "tcp") append(tcp, route.listener, [directRoute(route.capability, "pop3")]); } diff --git a/packages/stack/src/gateway/route-catalog.integration.test.ts b/packages/stack/src/gateway/route-catalog.integration.test.ts index c223015dea..25a103e07c 100644 --- a/packages/stack/src/gateway/route-catalog.integration.test.ts +++ b/packages/stack/src/gateway/route-catalog.integration.test.ts @@ -1,7 +1,8 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { compileStack } from "../model/Compiler.ts"; +import { Effect, Path } from "effect"; +import { compileStack, seedServiceRegistry } from "../model/Compiler.ts"; +import { createExecutionPlan } from "../model/ExecutionPlan.ts"; import { routeCatalogFor, type GatewayRouteCatalog } from "./RouteCatalog.ts"; const material = { @@ -18,7 +19,18 @@ const catalogFixture = () => runtime: { kind: "native" }, config: { capabilities: { pooler: { enabled: true } } }, }); - return routeCatalogFor(compiled.executionPlan, material); + const seeded = yield* seedServiceRegistry( + compiled.definition, + { + projectRoot: "/tmp/route-catalog", + runtime: { kind: "native" }, + path: yield* Path.Path, + }, + compiled.sourceConfig, + compiled.secrets, + ); + const plan = yield* createExecutionPlan({ kind: "native" }, seeded.registry); + return routeCatalogFor(plan, material); }); const routeFor = ( diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index a0ffbc0e41..9a59d3c0da 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,26 +1,30 @@ export { createStack, + createTestStack, openStack, findStack, listStacks, discoverStacks, inspectStack, - createEphemeralPostgres, } from "./public/PromiseStack.ts"; export type { PromiseStack, PromiseStackConfig, + PromiseCreateStackOptions, + PromiseCreateTestStackOptions, + PromiseTestStack, PromiseInspectStackOptions, PromiseStartStackOptions, PromisePrepareStackOptions, - PromiseCreateEphemeralPostgresOptions, - PromiseEphemeralPostgres, + PromiseOpenStackOptions, + PromiseServiceSelection, + PromiseServiceConfigUpdate, + PromiseRestartStackOptions, CreateStackOptions, FindStackOptions, ListStacksOptions, StackDiscoveryIssue, StackDiscoveryResult, - PreparedCapability, } from "./public/PromiseStack.ts"; export type { CapabilityName, @@ -33,9 +37,35 @@ export type { StackRecovery, ArtifactPreparationState, ArtifactPreparationStatus, + InstanceArtifactPreparationStatus, + ServiceLogQuery, StackDescriptor, StackInspection, } from "./public/index.ts"; +export type { + AnyServiceDescriptor, + AnyEffectServiceInstance, + AnyServiceInstance, + CatalogRecipeInput, + CreateServiceOptions, + EffectCreateServiceOptions, + EffectServiceCollection, + EffectServiceInstance, + PrepareResult, + ServiceCollection, + ServiceConfig, + ServiceConfigMap, + ServiceCredentials, + ServiceDependencies, + ServiceDescriptor, + ServiceInitialization, + ServiceInstance, + ServiceKind, + ServiceRef, + ServiceSettings, + SnapshotDescriptor, +} from "./public/Service.ts"; +export type { ServiceInstanceId } from "./public/ServiceInstanceId.ts"; export { StackIdSchema, isStackId } from "./public/StackId.ts"; export type { StackId } from "./public/StackId.ts"; export { StackRuntimeSchema, RuntimeEngineSchema } from "./public/Runtime.ts"; @@ -46,6 +76,7 @@ export { CapabilityVersionsSchema, ArtifactPreparationStateSchema, ArtifactPreparationStatusSchema, + InstanceArtifactPreparationStatusSchema, } from "./public/Status.ts"; export { CapabilityNameSchema, @@ -57,6 +88,7 @@ export type { PreparationMode } from "./public/Config.ts"; export { LogCursorSchema, LogQuerySchema, + ServiceLogQuerySchema, StackLogBatchSchema, StackLogEntrySchema, } from "./public/Logs.ts"; diff --git a/packages/stack/src/internal/supervisor-process.ts b/packages/stack/src/internal/supervisor-process.ts index ea04f26e40..d6ef656f79 100644 --- a/packages/stack/src/internal/supervisor-process.ts +++ b/packages/stack/src/internal/supervisor-process.ts @@ -2,6 +2,21 @@ import { NATIVE_PROCESS_DISPATCH_SENTINEL, SUPERVISOR_DISPATCH_SENTINEL, } from "./dispatch-markers.ts"; +import { Data, Effect } from "effect"; + +class ProcessDispatchError extends Data.TaggedError("ProcessDispatchError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const importError = (cause: unknown) => + new ProcessDispatchError({ + message: "Unable to load the dispatched stack process entrypoint", + cause, + }); + +const dispatchError = (cause: unknown) => + new ProcessDispatchError({ message: "The dispatched stack process failed", cause }); export { NATIVE_PROCESS_DISPATCH_SENTINEL, @@ -13,21 +28,35 @@ export { * Returns true when the argv is the supervisor child dispatch, otherwise the * caller should continue with its normal command entrypoint. */ -export const runSupervisorProcessIfDispatched = (argv: ReadonlyArray): Promise => { - if (argv[0] !== SUPERVISOR_DISPATCH_SENTINEL) return Promise.resolve(false); - return import("../entrypoints/supervisor-node.ts") - .then(({ runSupervisorProcess }) => runSupervisorProcess(argv.slice(1))) - .then(() => true); -}; +export const runSupervisorProcessIfDispatched = ( + argv: ReadonlyArray, +): Effect.Effect => + argv[0] !== SUPERVISOR_DISPATCH_SENTINEL + ? Effect.succeed(false) + : Effect.tryPromise({ + try: () => import("../entrypoints/supervisor-node.ts"), + catch: importError, + }).pipe( + Effect.flatMap(({ runSupervisorProcess }) => runSupervisorProcess(argv.slice(1))), + Effect.as(true), + ); /** * Runs the embedded native launcher when a compiled CLI receives its private * dispatch marker. Returns false for ordinary CLI argv so callers can continue * with their normal command entrypoint. */ -export const runNativeProcessIfDispatched = (argv: ReadonlyArray): Promise => { - if (argv[0] !== NATIVE_PROCESS_DISPATCH_SENTINEL) return Promise.resolve(false); - return import("../runtime/native-launcher.ts") - .then(({ runNativeLauncher }) => runNativeLauncher()) - .then(() => true); -}; +export const runNativeProcessIfDispatched = ( + argv: ReadonlyArray, +): Effect.Effect => + argv[0] !== NATIVE_PROCESS_DISPATCH_SENTINEL + ? Effect.succeed(false) + : Effect.tryPromise({ + try: () => import("../runtime/native-launcher.ts"), + catch: importError, + }).pipe( + Effect.flatMap(({ runNativeLauncher }) => + Effect.try({ try: runNativeLauncher, catch: dispatchError }), + ), + Effect.as(true), + ); diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 9bebe92eb2..0d03821141 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Path, Redacted, Schema } from "effect"; +import { Crypto, Duration, Effect, Path, PlatformError, Redacted, Schema } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { StackConfigSchema, type StackConfig, type PreparationMode } from "../public/Config.ts"; import type { JwtSigning } from "../public/Config.ts"; @@ -28,8 +28,23 @@ import { type MaterializedCapability, } from "./ExecutionPlan.ts"; import type { CapabilityModule, MaterializedSettings } from "./CapabilityModule.ts"; + +export { createExecutionPlan } from "./ExecutionPlan.ts"; +import { + PersistedServiceInstanceSchema, + PersistedServiceRegistrySchema, + ServiceInitializationInputsSchema, + type PersistedServiceEndpoints, + type PersistedServiceInstance, + type PersistedServiceRegistry, + type ServiceResourceIdentity, + type ServiceInitializationInputs, +} from "./ServiceRegistry.ts"; +import type { AnyEffectCreateServiceOptions, AnyEffectServiceConfig } from "../public/Service.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import { base64UrlEncode } from "../state/SecretStore.ts"; import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; -import { AUTH_JWT_SECRET_SLOT, DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; interface SecretSlot { readonly slot: string; @@ -42,9 +57,11 @@ export interface StackDefinition { readonly security: Readonly<{ readonly jwt: Readonly<{ readonly issuer: string | null; + readonly expirySeconds: number; readonly signing: MaterializedJwtSigning; }>; }>; + readonly initialization?: StackConfig["initialization"]; } type MaterializedJwtSigning = @@ -66,13 +83,12 @@ export interface SecretSlotInput { readonly generator?: SecretGenerator; } -/** Internal credentials are not user settings but still need durable managed slots. */ -const INTERNAL_MANAGED_SECRET_SLOTS = [DATABASE_INTERNAL_PASSWORD_SLOT] as const; - export interface CompiledStack { readonly definition: StackDefinition; + /** Raw validated input retained only for first registry seeding. */ + readonly sourceConfig: StackConfig; readonly secrets: ReadonlyArray; - readonly executionPlan: ExecutionPlan; + readonly executionPlan?: ExecutionPlan; } export interface PreviousCompilation { @@ -83,6 +99,8 @@ export interface CompileStackInput { readonly projectRoot: string; readonly runtime: StackRuntime; readonly config?: StackConfig; + /** Initialized service registry supplying immutable runtime instance identities. */ + readonly registry?: PersistedServiceRegistry; } const isRecord = (value: unknown): value is Record => @@ -91,7 +109,7 @@ const isRecord = (value: unknown): value is Record => !Array.isArray(value) && !Redacted.isRedacted(value); -const canonical = (value: unknown): string => { +export const canonical = (value: unknown): string => { if (Redacted.isRedacted(value)) return '{"$secret":true}'; if (value === undefined) return "null"; if (value === null || typeof value !== "object") return JSON.stringify(value); @@ -204,22 +222,24 @@ const attachAuthSecretGenerators = ( for (let index = 0; index < slots.length; index++) { const entry = slots[index]; if (entry === undefined || entry.policy !== "managed") continue; - const generator = - entry.slot === "secret:auth.settings.publishable_key" - ? ({ kind: "publishable-key" } satisfies SecretGenerator) - : entry.slot === "secret:auth.settings.secret_key" - ? ({ kind: "secret-key" } satisfies SecretGenerator) - : entry.slot === AUTH_JWT_SECRET_SLOT - ? ({ kind: "jwt-secret" } satisfies SecretGenerator) - : entry.slot === "secret:auth.settings.anon_key" - ? ({ kind: "jwt-token", role: "anon", signing: jwtSigning } satisfies SecretGenerator) - : entry.slot === "secret:auth.settings.service_role_key" - ? ({ - kind: "jwt-token", - role: "service_role", - signing: jwtSigning, - } satisfies SecretGenerator) - : undefined; + const isSetting = (path: string): boolean => + entry.slot === `secret:${path}` || + entry.slot.endsWith(`.${path.slice(path.indexOf(".") + 1)}`); + const generator = isSetting("auth.settings.publishable_key") + ? ({ kind: "publishable-key" } satisfies SecretGenerator) + : isSetting("auth.settings.secret_key") + ? ({ kind: "secret-key" } satisfies SecretGenerator) + : entry.slot === AUTH_JWT_SECRET_SLOT || isSetting("auth.settings.jwt_secret") + ? ({ kind: "jwt-secret" } satisfies SecretGenerator) + : isSetting("auth.settings.anon_key") + ? ({ kind: "jwt-token", role: "anon", signing: jwtSigning } satisfies SecretGenerator) + : isSetting("auth.settings.service_role_key") + ? ({ + kind: "jwt-token", + role: "service_role", + signing: jwtSigning, + } satisfies SecretGenerator) + : undefined; if (generator !== undefined) slots[index] = { ...entry, generator }; } }; @@ -238,7 +258,11 @@ const attachManagedRandomSecretGenerators = (slots: SecretSlotInput[]): void => for (let index = 0; index < slots.length; index++) { const entry = slots[index]; if (entry === undefined || entry.policy !== "managed") continue; - const generator = managedRandomBase64urlGenerators[entry.slot]; + const generator = + managedRandomBase64urlGenerators[entry.slot] ?? + Object.entries(managedRandomBase64urlGenerators).find(([path]) => + entry.slot.endsWith(`.${path.slice(path.lastIndexOf(".") + 1)}`), + )?.[1]; if (generator !== undefined) slots[index] = { ...entry, generator }; } }; @@ -265,11 +289,13 @@ const ensureManagedSlots = ( module: CapabilityModule, enabled: boolean, slots: SecretSlotInput[], + slotPrefix: string = module.name, ): MaterializedSettings => { if (!enabled || module.managedSecretSlots.length === 0) return settings; const result = settings; for (const path of module.managedSecretSlots) { - const slot = `secret:${path}`; + const suffix = path.startsWith(`${module.name}.`) ? path.slice(module.name.length + 1) : path; + const slot = `secret:${slotPrefix}.${suffix}`; const existing = slots.find((candidate) => candidate.slot === slot); if (existing === undefined) slots.push({ slot, policy: "managed" }); setMaterializedPath(result, path.split(".").slice(2), { slot }); @@ -484,6 +510,14 @@ const enabledSettings = ( raw: unknown; } => { const defaultIdleTimeout = CAPABILITY_MODULES[name].defaultIdleTimeoutSeconds ?? false; + if (name === "database" && extract(raw, "enabled") === false) + return { + enabled: false, + activation: "eager", + idleTimeoutSeconds: false, + settings: CAPABILITY_MODULES.database.defaultSettings, + raw, + }; if (name === "database") return { enabled: true, @@ -544,6 +578,7 @@ const materializeCapability = ( slots: SecretSlotInput[], normalizeFunctions: boolean, previousVersion?: string, + slotPrefix: string = module.name, ): Effect.Effect< MaterializedCapability, InvalidStackConfigError | StackVersionUnsupportedError, @@ -558,8 +593,14 @@ const materializeCapability = ( return Effect.gen(function* () { const normalizedSettings = yield* normalized; const merged = materializeAbsence(normalizedSettings); - const slotted = slotsFor(merged, `${module.name}.settings`, slots, module.secretPolicy); - const completeSettings = ensureManagedSlots(slotted, module, selected.enabled, slots); + const slotted = slotsFor(merged, `${slotPrefix}.settings`, slots, module.secretPolicy); + const completeSettings = ensureManagedSlots( + slotted, + module, + selected.enabled, + slots, + slotPrefix, + ); const version = yield* releaseFor(module, selected.raw, previousVersion); return { enabled: selected.enabled, @@ -571,18 +612,997 @@ const materializeCapability = ( }); }; -const planForDefinition = ( +/** Materializes one service leaf and namespaces every generated secret slot by instance ID. */ +const compileServiceCapability = ( + module: CapabilityModule, + raw: unknown, + projectRoot: string, + path: Path.Path, + instanceId: string, + slots: SecretSlotInput[] = [], + previousVersion?: string, +): Effect.Effect< + MaterializedCapability, + InvalidStackConfigError | StackVersionUnsupportedError, + Path.Path +> => + materializeCapability( + module, + raw, + projectRoot, + path, + slots, + module.name === "functions", + previousVersion, + instanceId, + ); + +export interface CompiledServiceInstance { + readonly id: ServiceInstanceId; + readonly service: CapabilityName; + readonly name?: string; + readonly intent: "stopped"; + readonly config: MaterializedCapabilities[CapabilityName]; + readonly endpoints: PersistedServiceEndpoints[CapabilityName]; + readonly dependencies: Readonly>; + readonly resources: ServiceResourceIdentity; + readonly revisions: { readonly config: 0; readonly intent: 0 }; + readonly pendingOperation: null; + readonly initializationInputs: ServiceInitializationInputs | null; + readonly data: { readonly origin: "absent" }; + readonly artifactIdentity?: string; + readonly runtimeIdentity?: string; + readonly bootstrapRecipeId?: string; + readonly bootstrapInputsId?: string; + readonly creationInputsId?: string; + readonly passwordSecretRef?: string; + readonly secretSlots: ReadonlyArray; + /** Fully validated candidate ready for registry registration. */ + readonly instance: PersistedServiceInstance; +} + +const initializationModules = { + auth: AuthModule, + storage: StorageModule, + realtime: RealtimeModule, + analytics: AnalyticsModule, + pooler: PoolerModule, +} as const; + +type CatalogService = keyof typeof initializationModules; + +const digestId = ( + crypto: Crypto.Crypto, + prefix: string, + value: unknown, +): Effect.Effect => + crypto + .digest("SHA-256", new TextEncoder().encode(canonical(value))) + .pipe(Effect.map((digest) => `${prefix}:${base64UrlEncode(digest)}`)); + +const profileValue = (value: unknown, slots: ReadonlyArray): unknown => { + if (isRecord(value) && typeof value.slot === "string") { + const source = slots.find((slot) => slot.slot === value.slot); + return { + secret: source?.value === undefined ? null : Redacted.value(source.value), + }; + } + if (Array.isArray(value)) return value.map((item) => profileValue(item, slots)); + if (isRecord(value)) + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, profileValue(item, slots)]), + ); + return value; +}; + +export const resolvedStateValue = ( + value: unknown, + secrets: Readonly>, +): unknown => { + if (isRecord(value) && typeof value.slot === "string") return secrets[value.slot]?.value ?? null; + if (Array.isArray(value)) return value.map((item) => resolvedStateValue(item, secrets)); + if (isRecord(value)) + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + key === "passwordSecretRef" && typeof item === "string" + ? (secrets[item]?.value ?? null) + : resolvedStateValue(item, secrets), + ]), + ); + return value; +}; + +const creationInputValue = (value: unknown): unknown => { + if (Redacted.isRedacted(value)) return Redacted.value(value); + if (Array.isArray(value)) return value.map(creationInputValue); + if (isRecord(value)) + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, creationInputValue(item)]), + ); + return value; +}; + +/** Computes the client/server identity of a normalized dynamic service creation request. */ +export const fingerprintCreationInputs = ( + options: AnyEffectCreateServiceOptions, +): Effect.Effect => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return yield* digestId(crypto, "creation", creationInputValue(options)); + }); + +/** Computes a semantic bootstrap fingerprint after managed secrets are persisted. */ +export const fingerprintBootstrapInputs = ( + instance: Pick< + PersistedServiceInstance, + "service" | "config" | "initializationInputs" | "bootstrapRecipeId" + >, + security: { + readonly jwt: { + readonly issuer: string | null; + readonly expirySeconds: number; + readonly signing: + | null + | { readonly kind: "symmetric"; readonly secret: { readonly slot: string } } + | { readonly kind: "jwks-file"; readonly path: string }; + }; + }, + secrets: Readonly>, +): Effect.Effect => + instance.bootstrapRecipeId === undefined + ? Effect.map(Effect.void, () => undefined) + : Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return yield* digestId(crypto, "bootstrap", { + service: instance.service, + version: instance.config.version, + settings: resolvedStateValue(instance.config.settings, secrets), + password: + "passwordSecretRef" in instance.config && + typeof instance.config.passwordSecretRef === "string" + ? (secrets[instance.config.passwordSecretRef]?.value ?? null) + : null, + initialization: resolvedStateValue(instance.initializationInputs, secrets), + jwt: { + issuer: security.jwt.issuer, + expirySeconds: security.jwt.expirySeconds, + signing: resolvedStateValue(security.jwt.signing, secrets), + }, + }); + }); + +/** Computes the public configuration identity, including resolved secrets and endpoint intent. */ +export const fingerprintEffectiveConfig = ( + instance: Pick, + security: { + readonly jwt: { + readonly issuer: string | null; + readonly expirySeconds: number; + readonly signing: + | null + | { readonly kind: "symmetric"; readonly secret: { readonly slot: string } } + | { readonly kind: "jwks-file"; readonly path: string }; + }; + }, + secrets: Readonly>, +): Effect.Effect => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest( + "SHA-256", + new TextEncoder().encode( + canonical({ + service: instance.service, + config: resolvedStateValue(instance.config, secrets), + initialization: resolvedStateValue(instance.initializationInputs, secrets), + jwt: { + issuer: security.jwt.issuer, + expirySeconds: security.jwt.expirySeconds, + signing: resolvedStateValue(security.jwt.signing, secrets), + }, + }), + ), + ); + return Array.from(digest) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + }); + +const initializationInputsFor = ( + id: ServiceInstanceId, + initialization: unknown, + context: { + readonly projectRoot: string; + readonly path: Path.Path; + readonly registry?: PersistedServiceRegistry; + }, + slots: SecretSlotInput[], +): Effect.Effect< + ServiceInitializationInputs | null, + InvalidStackConfigError | StackVersionUnsupportedError | PlatformError.PlatformError, + Path.Path | Crypto.Crypto +> => { + if (initialization === undefined || initialization === null) return Effect.succeed(null); + if (!isRecord(initialization)) + return Effect.fail( + new InvalidStackConfigError({ message: "Service initialization must be an object" }), + ); + if ("from" in initialization) { + const sourceId = initialization.from; + if (typeof sourceId !== "string") + return Effect.fail( + new InvalidStackConfigError({ + message: "Service initialization source must be an instance ID", + }), + ); + const source = context.registry?.instances.find((instance) => instance.id === sourceId); + if (source === undefined || source.service !== "database") + return Effect.fail( + new InvalidStackConfigError({ + message: `Service initialization source ${sourceId} must reference an existing database instance`, + }), + ); + return Effect.succeed(source.initializationInputs); + } + const catalog = initialization.catalog; + if (catalog === undefined || catalog === null) return Effect.succeed(null); + if (!isRecord(catalog)) + return Effect.fail( + new InvalidStackConfigError({ message: "Service initialization catalog must be an object" }), + ); + const unknownService = Object.keys(catalog).find( + (service) => !(service in initializationModules), + ); + if (unknownService !== undefined) + return Effect.fail( + new InvalidStackConfigError({ + message: `Unsupported service initialization catalog entry: ${unknownService}`, + }), + ); + if (Object.keys(catalog).length === 0) return Effect.succeed(null); + return Effect.gen(function* () { + const resolved: Record = {}; + for (const service of Object.keys(initializationModules) as ReadonlyArray) { + const input = catalog[service]; + if (input === undefined) continue; + const normalized = yield* (() => { + const raw = { enabled: true, ...input }; + switch (service) { + case "auth": + return compileServiceCapability( + AuthModule, + raw, + context.projectRoot, + context.path, + id, + slots, + ); + case "storage": + return compileServiceCapability( + StorageModule, + raw, + context.projectRoot, + context.path, + id, + slots, + ); + case "realtime": + return compileServiceCapability( + RealtimeModule, + raw, + context.projectRoot, + context.path, + id, + slots, + ); + case "analytics": + return compileServiceCapability( + AnalyticsModule, + raw, + context.projectRoot, + context.path, + id, + slots, + ); + case "pooler": + return compileServiceCapability( + PoolerModule, + raw, + context.projectRoot, + context.path, + id, + slots, + ); + } + })(); + if (service === "auth") { + const authJwtSecret = extract(normalized.settings, "jwt_secret"); + if (isRecord(authJwtSecret) && typeof authJwtSecret.slot === "string") { + const slotIndex = slots.findIndex((entry) => entry.slot === authJwtSecret.slot); + const existing = slots[slotIndex]; + const canonicalIndex = slots.findIndex((entry) => entry.slot === AUTH_JWT_SECRET_SLOT); + const canonicalSlot = slots[canonicalIndex]; + if ( + existing?.value !== undefined && + canonicalSlot?.value !== undefined && + Redacted.value(existing.value) !== Redacted.value(canonicalSlot.value) + ) + return yield* new InvalidStackConfigError({ + message: "Service initialization JWT secret must match stack signing secret", + }); + if (existing !== undefined && canonicalIndex < 0) + slots[slotIndex] = { ...existing, slot: AUTH_JWT_SECRET_SLOT }; + else if (existing !== undefined && canonicalIndex !== slotIndex) + slots.splice(slotIndex, 1); + setMaterializedPath(normalized.settings, ["jwt_secret"], { + slot: AUTH_JWT_SECRET_SLOT, + }); + } + } + resolved[service] = { version: normalized.version, settings: normalized.settings }; + } + const crypto = yield* Crypto.Crypto; + const profileId = yield* digestId(crypto, "profile", profileValue(resolved, slots)); + const profile = yield* Schema.decodeEffect(ServiceInitializationInputsSchema)({ + profileId, + catalog: resolved, + }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + message: `Invalid service initialization requirements: ${String(error)}`, + cause: error, + }), + ), + ); + return profile; + }); +}; + +const endpointValue = (config: AnyEffectServiceConfig): unknown => + "endpoints" in config ? config.endpoints : undefined; + +const mergeEndpointIntents = ( + previous: PersistedServiceEndpoints[CapabilityName], + replacement: unknown, +): unknown => { + if (replacement === undefined || replacement === null) return previous; + if (!isRecord(replacement)) return previous; + const result: Record = {}; + for (const [key, value] of Object.entries(previous)) result[key] = value; + for (const [key, value] of Object.entries(replacement)) result[key] = value; + return result; +}; + +const serviceDependencyKinds: Readonly>> = { + database: [], + rest: ["database"], + auth: ["database"], + realtime: ["database"], + storage: ["database"], + functions: [], + studio: ["database", "rest", "analytics"], + mail: [], + analytics: ["database"], + pooler: ["database"], +}; + +const serviceArtifactIdentity = ( + service: CapabilityName, + version: string, + runtime: StackRuntime, +): string | undefined => { + const release = CAPABILITY_MODULES[service].releases[version]; + const workload = release?.workloads[0]; + if (workload === undefined) return undefined; + return runtime.kind === "container" + ? `container:${workload.artifacts.container.image}` + : `native:${workload.artifacts.native.release}`; +}; + +const serviceRuntimeIdentity = ( + service: CapabilityName, + version: string, runtime: StackRuntime, +): string | undefined => { + const workload = CAPABILITY_MODULES[service].releases[version]?.workloads[0]; + return workload === undefined + ? undefined + : runtime.kind === "container" + ? `container:${service}:${version}` + : `native:${service}:${version}`; +}; + +const completeCompiledInstance = ( + options: { + readonly service: CapabilityName; + readonly name?: string; + readonly dependencies?: Readonly>; + }, + id: ServiceInstanceId, + config: MaterializedCapabilities[CapabilityName], + endpoints: unknown, + slots: ReadonlyArray, + initializationInputs: ServiceInitializationInputs | null, + artifactIdentity: string | undefined, + bootstrapRecipeId: string | undefined, + bootstrapInputsId: string | undefined, + passwordSecretRef: string | undefined, + runtimeIdentity: string | undefined, +): Effect.Effect => { + const dependencies = + "dependencies" in options && options.dependencies !== undefined ? options.dependencies : {}; + const candidate = { + id, + service: options.service, + ...(options.name === undefined ? {} : { name: options.name }), + intent: "stopped" as const, + config: { + ...config, + endpoints, + ...(passwordSecretRef === undefined ? {} : { passwordSecretRef }), + }, + dependencies, + resources: {}, + revisions: { config: 0, intent: 0 }, + pendingOperation: null, + initialization: null, + initializationInputs, + data: { origin: "absent" as const }, + ...(artifactIdentity === undefined ? {} : { artifactIdentity }), + ...(runtimeIdentity === undefined ? {} : { runtimeIdentity }), + ...(bootstrapRecipeId === undefined ? {} : { bootstrapRecipeId }), + ...(bootstrapInputsId === undefined ? {} : { bootstrapInputsId }), + }; + return Schema.decodeUnknownEffect(PersistedServiceInstanceSchema)(candidate).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + message: `Compiled ${options.service} instance failed validation: ${String(error)}`, + cause: error, + }), + ), + Effect.map((instance) => ({ + id, + service: options.service, + name: options.name, + intent: "stopped" as const, + config: instance.config, + endpoints: instance.config.endpoints, + dependencies: instance.dependencies, + resources: instance.resources, + revisions: { config: 0, intent: 0 } as const, + pendingOperation: null, + initializationInputs: instance.initializationInputs, + data: { origin: "absent" as const }, + ...(instance.artifactIdentity === undefined + ? {} + : { artifactIdentity: instance.artifactIdentity }), + ...(instance.runtimeIdentity === undefined + ? {} + : { runtimeIdentity: instance.runtimeIdentity }), + ...(instance.bootstrapRecipeId === undefined + ? {} + : { bootstrapRecipeId: instance.bootstrapRecipeId }), + ...(instance.bootstrapInputsId === undefined + ? {} + : { bootstrapInputsId: instance.bootstrapInputsId }), + ...(passwordSecretRef === undefined ? {} : { passwordSecretRef }), + secretSlots: slots, + instance, + })), + ); +}; + +const compileService = ( + options: { + readonly service: CapabilityName; + readonly name?: string; + readonly config: unknown; + readonly dependencies?: Readonly>; + readonly initialization?: unknown; + /** Restart recompilation retains the persisted password instead of creating a new slot. */ + readonly createPassword?: boolean; + }, + id: ServiceInstanceId, + context: { + readonly projectRoot: string; + readonly path: Path.Path; + readonly previousVersion?: string; + readonly registry?: PersistedServiceRegistry; + readonly runtime: StackRuntime; + }, + slots: SecretSlotInput[], + endpoints: unknown, +): Effect.Effect< + CompiledServiceInstance, + InvalidStackConfigError | StackVersionUnsupportedError | PlatformError.PlatformError, + Path.Path | Crypto.Crypto +> => { + const module = CAPABILITY_MODULES[options.service]; + const rawPassword = extract(options.config, "password"); + const password = + options.service !== "database" + ? undefined + : Redacted.isRedacted(rawPassword) + ? rawPassword + : typeof rawPassword === "string" + ? Redacted.make(rawPassword) + : undefined; + const passwordSlot = `secret:${id}:password`; + return Effect.gen(function* () { + if ( + options.service === "database" && + options.createPassword !== false && + !slots.some((entry) => entry.slot === passwordSlot) + ) + slots.push({ + slot: passwordSlot, + policy: "managed", + ...(password === undefined + ? { generator: { kind: "random-base64url", bytes: 32 } satisfies SecretGenerator } + : { value: password }), + }); + const config = yield* (() => { + switch (options.service) { + case "database": + return compileServiceCapability( + DatabaseModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "rest": + return compileServiceCapability( + RestModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "auth": + return compileServiceCapability( + AuthModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "realtime": + return compileServiceCapability( + RealtimeModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "storage": + return compileServiceCapability( + StorageModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "functions": + return compileServiceCapability( + FunctionsModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "studio": + return compileServiceCapability( + StudioModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "mail": + return compileServiceCapability( + MailModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "analytics": + return compileServiceCapability( + AnalyticsModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + case "pooler": + return compileServiceCapability( + PoolerModule, + options.config, + context.projectRoot, + context.path, + id, + slots, + context.previousVersion, + ); + } + })(); + const initializationInputs = yield* initializationInputsFor( + id, + options.initialization, + context, + slots, + ); + attachAuthSecretGenerators(slots, context.projectRoot, undefined); + attachManagedRandomSecretGenerators(slots); + const artifactIdentity = serviceArtifactIdentity( + options.service, + config.version, + context.runtime, + ); + const runtimeIdentity = serviceRuntimeIdentity( + options.service, + config.version, + context.runtime, + ); + const bootstrap = module.releases[config.version]?.workloads.find( + (workload) => workload.bootstrap !== undefined, + ); + const bootstrapRecipeId = + bootstrap === undefined ? undefined : `${options.service}:${bootstrap.name}`; + return yield* completeCompiledInstance( + options, + id, + config, + endpoints === undefined ? {} : endpoints, + slots, + initializationInputs, + artifactIdentity, + bootstrapRecipeId, + undefined, + options.service === "database" && (password !== undefined || options.createPassword !== false) + ? passwordSlot + : undefined, + runtimeIdentity, + ); + }); +}; + +/** Compiles one typed Effect service option after allocating its immutable identity. */ +export const compileServiceInstance = ( + options: AnyEffectCreateServiceOptions, + context: { + readonly projectRoot: string; + readonly path: Path.Path; + readonly previousVersion?: string; + readonly instanceId?: ServiceInstanceId; + readonly registry?: PersistedServiceRegistry; + readonly runtime: StackRuntime; + }, +): Effect.Effect< + CompiledServiceInstance, + InvalidStackConfigError | StackVersionUnsupportedError | PlatformError.PlatformError, + Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const id = + context.instanceId ?? + ServiceInstanceIdSchema.make(yield* (yield* Crypto.Crypto).randomUUIDv4); + const slots: SecretSlotInput[] = []; + const compiled = yield* compileService( + options, + id, + context, + slots, + endpointValue(options.config), + ); + const creationInputsId = yield* fingerprintCreationInputs(options); + return { + ...compiled, + creationInputsId, + instance: { ...compiled.instance, creationInputsId }, + }; + }); + +/** Recompiles one instance in place, retaining omitted endpoints, password and initialization. */ +export const compileServiceRestart = ( + previous: CompiledServiceInstance | PersistedServiceInstance, + config: AnyEffectServiceConfig | undefined, + context: { + readonly projectRoot: string; + readonly path: Path.Path; + readonly runtime: StackRuntime; + }, +): Effect.Effect< + CompiledServiceInstance, + InvalidStackConfigError | StackVersionUnsupportedError | PlatformError.PlatformError, + Path.Path | Crypto.Crypto +> => { + const previousInstance = "instance" in previous ? previous.instance : previous; + const previousEndpoints = "instance" in previous ? previous.endpoints : previous.config.endpoints; + const previousPasswordSecretRef = + "instance" in previous + ? previous.passwordSecretRef + : extract(previous.config, "passwordSecretRef"); + if (config === undefined) + return Effect.fail( + new InvalidStackConfigError({ message: "Restart configuration is required" }), + ); + const slots: SecretSlotInput[] = []; + const rawPassword = extract(config, "password"); + const suppliedPassword = + previous.service === "database" && Redacted.isRedacted(rawPassword) ? rawPassword : undefined; + if (suppliedPassword !== undefined) + slots.push({ + slot: `secret:${previous.id}:password`, + policy: "managed", + value: suppliedPassword, + }); + const replacementEndpoints = mergeEndpointIntents(previousEndpoints, endpointValue(config)); + const compileOptions = { + service: previous.service, + name: previous.name, + config, + dependencies: previous.dependencies, + initialization: undefined, + createPassword: false, + }; + return compileService( + compileOptions, + previous.id, + { + ...context, + previousVersion: previous.config.version, + }, + slots, + replacementEndpoints, + ).pipe( + Effect.flatMap((next) => { + const retainedPassword = + suppliedPassword === undefined && typeof previousPasswordSecretRef === "string" + ? previousPasswordSecretRef + : next.passwordSecretRef; + const nextConfig = + retainedPassword === undefined + ? next.instance.config + : { ...next.instance.config, passwordSecretRef: retainedPassword }; + return Schema.decodeUnknownEffect(PersistedServiceInstanceSchema)({ + ...next.instance, + config: nextConfig, + initializationInputs: previousInstance.initializationInputs, + initialization: previousInstance.initialization, + }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + message: `Restarted service instance failed validation: ${String(error)}`, + cause: error, + }), + ), + Effect.map((instance) => ({ + ...next, + ...(previousInstance.creationInputsId === undefined + ? {} + : { creationInputsId: previousInstance.creationInputsId }), + config: instance.config, + passwordSecretRef: retainedPassword, + initializationInputs: previous.initializationInputs, + instance: + previousInstance.creationInputsId === undefined + ? instance + : { ...instance, creationInputsId: previousInstance.creationInputsId }, + })), + ); + }), + ); +}; + +export interface SeededServiceRegistry { + readonly registry: PersistedServiceRegistry; + readonly secretSlots: ReadonlyArray; +} + +const mergeSecretSlots = ( + shared: ReadonlyArray, + instances: ReadonlyArray, +): Effect.Effect, InvalidStackConfigError> => { + const merged = new Map(); + for (const entry of [...shared, ...instances]) { + const existing = merged.get(entry.slot); + if (existing === undefined) { + merged.set(entry.slot, entry); + continue; + } + if (existing.policy !== entry.policy) + return Effect.fail( + new InvalidStackConfigError({ + message: `Secret slot ${entry.slot} has conflicting policies during service seeding`, + }), + ); + if ( + existing.value !== undefined && + entry.value !== undefined && + Redacted.value(existing.value) !== Redacted.value(entry.value) + ) + return Effect.fail( + new InvalidStackConfigError({ + message: `Secret slot ${entry.slot} has conflicting values during service seeding`, + }), + ); + merged.set(entry.slot, { + ...existing, + ...(existing.value === undefined && entry.value !== undefined ? { value: entry.value } : {}), + ...(existing.generator === undefined && entry.generator !== undefined + ? { generator: entry.generator } + : {}), + }); + } + return Effect.succeed([...merged.values()]); +}; + +const seedEndpoint = (definition: StackDefinition, service: CapabilityName): unknown => { + const listeners = definition.listeners; + const endpoint = ( + listener: MaterializedListener | undefined, + ): OptionalEndpointIntentLike | undefined => + listener === undefined + ? undefined + : listener.enabled + ? { + address: listener.address, + port: listener.port === "automatic" ? "auto" : listener.port, + } + : { enabled: false }; + switch (service) { + case "database": + return { sql: endpoint(listeners.database) }; + case "functions": + return { inspector: endpoint(listeners.functionsInspector) }; + case "studio": + return { studio: endpoint(listeners.studio) }; + case "mail": + return { + smtp: endpoint(listeners.smtp), + pop3: endpoint(listeners.pop3), + mailUi: endpoint(listeners.mailUi), + }; + case "pooler": + return { pooler: endpoint(listeners.pooler) }; + default: + return {}; + } +}; + +type OptionalEndpointIntentLike = + | { + readonly enabled?: true; + readonly address?: string; + readonly port?: "auto" | number; + } + | { readonly enabled: false }; + +/** Seeds every default through the same leaf compiler used by dynamic creation. */ +export const seedServiceRegistry = ( definition: StackDefinition, -): Effect.Effect => - createExecutionPlan(runtime, definition.capabilities); + context: { + readonly projectRoot: string; + readonly path: Path.Path; + readonly runtime: StackRuntime; + }, + sourceConfig: StackConfig, + sharedSecretSlots: ReadonlyArray, +): Effect.Effect< + SeededServiceRegistry, + InvalidStackConfigError | StackVersionUnsupportedError | PlatformError.PlatformError, + Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const ids = new Map(); + for (const service of Object.keys(CAPABILITY_MODULES) as ReadonlyArray) + ids.set(service, ServiceInstanceIdSchema.make(yield* crypto.randomUUIDv4)); + const compiled: CompiledServiceInstance[] = []; + for (const service of Object.keys(CAPABILITY_MODULES) as ReadonlyArray) { + const id = ids.get(service); + if (id === undefined) + return yield* new InvalidStackConfigError({ message: `Missing generated ${service} ID` }); + const sourceCapability = sourceConfig.capabilities?.[service]; + const dependencies: Record = {}; + for (const dependency of serviceDependencyKinds[service]) { + const dependencyId = ids.get(dependency); + if (dependencyId === undefined) + return yield* new InvalidStackConfigError({ + message: `Missing generated ${dependency} ID for ${service}`, + }); + dependencies[dependency] = dependencyId; + } + const initialization = + service === "database" ? sourceConfig.initialization?.database : undefined; + const sourceSettings = extract(sourceCapability, "settings"); + const rawSettings = isRecord(sourceSettings) ? sourceSettings : undefined; + const functionsRoot = extract(rawSettings, "functions_root"); + const normalizedSettings = + rawSettings !== undefined && service === "functions" && typeof functionsRoot === "string" + ? { + ...rawSettings, + functions_root: context.path.isAbsolute(functionsRoot) + ? context.path.relative(context.projectRoot, functionsRoot) + : functionsRoot, + } + : rawSettings; + compiled.push( + yield* compileService( + { + service, + name: service, + config: { + ...sourceCapability, + settings: normalizedSettings, + endpoints: seedEndpoint(definition, service), + }, + dependencies, + initialization, + }, + id, + context, + [], + seedEndpoint(definition, service), + ), + ); + } + const registry = yield* Schema.decodeEffect(PersistedServiceRegistrySchema)({ + initialized: true, + instances: compiled.map(({ instance }) => instance), + defaultInstanceIds: Object.fromEntries(compiled.map((entry) => [entry.service, entry.id])), + }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + message: `Seeded service registry failed validation: ${String(error)}`, + cause: error, + }), + ), + ); + const secretSlots = yield* mergeSecretSlots( + sharedSecretSlots, + compiled.flatMap((entry) => entry.secretSlots), + ); + return { registry, secretSlots }; + }); -/** Rebuilds the private execution plan from a persisted, fully materialized definition. */ -export const rebuildExecutionPlan = ( +const planForDefinition = ( runtime: StackRuntime, definition: StackDefinition, + registry: PersistedServiceRegistry | undefined, ): Effect.Effect => - planForDefinition(runtime, definition); + registry === undefined + ? Effect.fail( + new InvalidStackConfigError({ + message: "An initialized service registry is required to build an execution plan", + }), + ) + : createExecutionPlan(runtime, registry); export const compileStack = ( input: CompileStackInput, @@ -602,7 +1622,6 @@ export const compileStack = ( const jwtSecret = yield* canonicalJwtSecret(config); const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; const slots: SecretSlotInput[] = []; - for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); const databaseResult = yield* materializeCapability( DatabaseModule, extract(rawCapabilities, "database"), @@ -630,6 +1649,8 @@ export const compileStack = ( false, previous?.definition.capabilities.auth.version, ); + // The shared API listener uses these keys even when the Auth workload is disabled. + ensureManagedSlots(authResult.settings, AuthModule, true, slots); const thirdParty = resolveThirdPartyIssuer(authResult.settings); if (!thirdParty.ok) return yield* new InvalidStackConfigError({ @@ -723,12 +1744,21 @@ export const compileStack = ( functionsInspector: materializeListener(rawListeners.functionsInspector, false), } satisfies Record; const rawJwt = config.security?.jwt; + const configuredAuthExpiry = extract(authResult.settings, "jwt_expiry"); + const expirySeconds = + rawJwt?.expirySeconds ?? + (typeof configuredAuthExpiry === "number" ? configuredAuthExpiry : 3_600); + if (!Number.isSafeInteger(expirySeconds) || expirySeconds <= 0) + return yield* new InvalidStackConfigError({ + message: "JWT expiry must be a finite positive integer", + }); ensureCanonicalJwtSlot(slots, jwtSecret); attachAuthSecretGenerators(slots, input.projectRoot, rawJwt?.signing); attachManagedRandomSecretGenerators(slots); const security = { jwt: { issuer: rawJwt?.issuer ?? null, + expirySeconds, signing: rawJwt?.signing?.kind === "jwks-file" ? slotsFor(rawJwt.signing, "security.jwt.signing", slots) @@ -740,13 +1770,15 @@ export const compileStack = ( capabilities, listeners, security, + ...(config.initialization === undefined ? {} : { initialization: config.initialization }), }; - const executionPlan = yield* planForDefinition(input.runtime, definition); - return { definition, secrets: slots, executionPlan }; + const executionPlan = + input.registry === undefined + ? undefined + : yield* planForDefinition(input.runtime, definition, input.registry); + return { definition, sourceConfig: config, secrets: slots, executionPlan }; }); -export const canonicalize = canonical; - /** Compares two complete materialized definitions by their canonical schema representation. */ export const sameDefinition = (left: StackDefinition, right: StackDefinition): boolean => canonical(left) === canonical(right); diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 4c94ffc051..4e2a6a2ecb 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -1,26 +1,14 @@ +import { Effect } from "effect"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; import type { StackRuntime } from "../public/Runtime.ts"; -import { - AuthModule, - DatabaseModule, - FunctionsModule, - MailModule, - PoolerModule, - RealtimeModule, - RestModule, - StorageModule, - StudioModule, - AnalyticsModule, -} from "./capabilities/index.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; import type { WorkloadSpec, NativeArtifact, ContainerArtifact, MaterializedSettings, } from "./CapabilityModule.ts"; -import { Effect } from "effect"; -import { InvalidStackConfigError } from "../public/Errors.ts"; import type { AnalyticsSettings } from "./capabilities/analytics.ts"; import type { AuthSettings } from "./capabilities/auth.ts"; import type { DatabaseSettings } from "./capabilities/database.ts"; @@ -31,6 +19,20 @@ import type { RealtimeSettings } from "./capabilities/realtime.ts"; import type { RestSettings } from "./capabilities/rest.ts"; import type { StorageSettings } from "./capabilities/storage.ts"; import type { StudioSettings } from "./capabilities/studio.ts"; +import type { PersistedServiceInstance, PersistedServiceRegistry } from "./ServiceRegistry.ts"; +import { InvalidStackConfigError } from "../public/Errors.ts"; +import { + AuthModule, + DatabaseModule, + FunctionsModule, + MailModule, + PoolerModule, + RealtimeModule, + RestModule, + StorageModule, + StudioModule, + AnalyticsModule, +} from "./capabilities/index.ts"; export const CAPABILITY_MODULES = { database: DatabaseModule, @@ -47,6 +49,9 @@ export const CAPABILITY_MODULES = { export interface PlannedWorkload { readonly id: string; + /** Runtime identity; recipeId remains stable across service instances. */ + readonly instanceId: ServiceInstanceId; + readonly recipeId: string; readonly capability: CapabilityName; readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies: ReadonlyArray; @@ -60,12 +65,13 @@ export interface PlannedWorkload { export interface ExecutionPlan { readonly runtime: StackRuntime; - /** Materialized lazy/eager policy consumed by the Supervisor gateway seam. */ - readonly activation: Readonly<{ [Name in CapabilityName]: "eager" | "lazy" }>; - readonly startOrder: ReadonlyArray; - readonly dependencies: Readonly<{ [Name in CapabilityName]: ReadonlyArray }>; + /** Activation policy keyed by the immutable service instance ID. */ + readonly activation: Readonly>; + readonly startOrder: ReadonlyArray; + readonly dependencies: Readonly>>; readonly routes: ReadonlyArray< Readonly<{ + readonly instanceId: ServiceInstanceId; readonly capability: CapabilityName; readonly listener: PortField; readonly protocol: "http" | "tcp"; @@ -74,239 +80,267 @@ export interface ExecutionPlan { readonly workloads: ReadonlyArray; } -export interface MaterializedCapability { - readonly enabled: boolean; - readonly activation: "eager" | "lazy"; - readonly idleTimeoutSeconds: number | false; - readonly version: string; - readonly settings: MaterializedSettings; -} - -export interface MaterializedCapabilities { - readonly database: MaterializedCapability; - readonly rest: MaterializedCapability; - readonly auth: MaterializedCapability; - readonly realtime: MaterializedCapability; - readonly storage: MaterializedCapability; - readonly functions: MaterializedCapability; - readonly studio: MaterializedCapability; - readonly mail: MaterializedCapability; - readonly analytics: MaterializedCapability; - readonly pooler: MaterializedCapability; -} - -/** Return the requested capabilities and every transitive dependency. */ +/** Returns the requested service instances and every transitive dependency. */ export const dependencyClosure = ( plan: ExecutionPlan, - roots: Iterable, -): Set => { - const closure = new Set(); - const visit = (name: CapabilityName): void => { - if (closure.has(name)) return; - closure.add(name); - for (const dependency of plan.dependencies[name]) visit(dependency); + roots: Iterable, +): Set => { + const closure = new Set(); + const visit = (id: ServiceInstanceId): void => { + if (closure.has(id)) return; + closure.add(id); + for (const dependency of plan.dependencies[id] ?? []) visit(dependency); }; for (const root of roots) visit(root); return closure; }; -export const eagerCapabilities = (plan: ExecutionPlan): Set => { - return dependencyClosure( - plan, - CAPABILITY_NAMES.filter((name) => plan.activation[name] === "eager"), - ); -}; - export const activeExecutionPlan = ( plan: ExecutionPlan, - active: ReadonlySet, + active: ReadonlySet, ): ExecutionPlan => ({ ...plan, - workloads: plan.workloads.filter((workload) => active.has(workload.capability)), - startOrder: plan.startOrder.filter((name) => active.has(name)), + workloads: plan.workloads.filter((workload) => active.has(workload.instanceId)), + startOrder: plan.startOrder.filter((id) => active.has(id)), }); -const selectedWorkloads = ( - name: CapabilityName, +const selectedWorkloadsForInstance = ( + instance: PersistedServiceInstance, modules: typeof CAPABILITY_MODULES, - capabilities: MaterializedCapabilities, workloads: ReadonlyArray, ): ReadonlyArray => { - switch (name) { + switch (instance.service) { case "database": - return ( - modules.database.selectWorkloads?.(capabilities.database.settings, workloads) ?? workloads - ); + return modules.database.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "rest": - return modules.rest.selectWorkloads?.(capabilities.rest.settings, workloads) ?? workloads; + return modules.rest.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "auth": - return modules.auth.selectWorkloads?.(capabilities.auth.settings, workloads) ?? workloads; + return modules.auth.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "realtime": - return ( - modules.realtime.selectWorkloads?.(capabilities.realtime.settings, workloads) ?? workloads - ); + return modules.realtime.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "storage": - return ( - modules.storage.selectWorkloads?.(capabilities.storage.settings, workloads) ?? workloads - ); + return modules.storage.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "functions": - return ( - modules.functions.selectWorkloads?.(capabilities.functions.settings, workloads) ?? workloads - ); + return modules.functions.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "studio": - return modules.studio.selectWorkloads?.(capabilities.studio.settings, workloads) ?? workloads; + return modules.studio.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "mail": - return modules.mail.selectWorkloads?.(capabilities.mail.settings, workloads) ?? workloads; + return modules.mail.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "analytics": - return ( - modules.analytics.selectWorkloads?.(capabilities.analytics.settings, workloads) ?? workloads - ); + return modules.analytics.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; case "pooler": - return modules.pooler.selectWorkloads?.(capabilities.pooler.settings, workloads) ?? workloads; + return modules.pooler.selectWorkloads?.(instance.config.settings, workloads) ?? workloads; } }; +const hasOwnedRuntime = (instance: PersistedServiceInstance): boolean => + instance.config.enabled || + Object.keys(instance.resources).length > 0 || + instance.data.origin !== "absent" || + instance.pendingOperation !== null; + +const dependencyKinds = (service: CapabilityName): ReadonlyArray => + CAPABILITY_MODULES[service].dependencies; + +const dependencyId = ( + instance: PersistedServiceInstance, + kind: CapabilityName, +): ServiceInstanceId | undefined => + Object.entries(instance.dependencies).find(([name]) => name === kind)?.[1]; + +const missingInstance = (instance: PersistedServiceInstance, dependency: CapabilityName) => + new InvalidStackConfigError({ + message: `${instance.service} instance ${instance.id} requires a registered ${dependency} instance`, + capability: instance.service, + dependency, + }); + +/** Builds a plan directly from the initialized registry; every identity is registry-owned. */ export const createExecutionPlan = ( runtime: StackRuntime, - capabilities: MaterializedCapabilities, + registry: PersistedServiceRegistry, modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, + selection?: ReadonlySet, ): Effect.Effect => { - const dependencyMap = { - database: modules.database.dependencies, - rest: modules.rest.dependencies, - auth: modules.auth.dependencies, - realtime: modules.realtime.dependencies, - storage: modules.storage.dependencies, - functions: modules.functions.dependencies, - studio: modules.studio.dependencies, - mail: modules.mail.dependencies, - analytics: modules.analytics.dependencies, - pooler: modules.pooler.dependencies, - } satisfies { [Name in CapabilityName]: ReadonlyArray }; - for (const name of CAPABILITY_NAMES) { - if (!capabilities[name].enabled) continue; - for (const dependency of dependencyMap[name]) { - if (!capabilities[dependency].enabled) { - return Effect.fail( - new InvalidStackConfigError({ - message: `${name} requires disabled capability ${dependency}`, - capability: name, - dependency, - }), - ); + const byId = new Map(registry.instances.map((instance) => [instance.id, instance])); + const instances = registry.instances; + const registeredIds = new Set(instances.map((instance) => instance.id)); + const required = new Set(); + const collectRequired = (id: ServiceInstanceId): void => { + if (required.has(id)) return; + required.add(id); + const instance = byId.get(id); + if (instance !== undefined) + for (const dependency of Object.values(instance.dependencies)) collectRequired(dependency); + }; + if (selection === undefined) for (const instance of instances) collectRequired(instance.id); + else for (const id of selection) collectRequired(id); + const dependencies: Record> = {}; + + for (const instance of instances) { + const ids: ServiceInstanceId[] = []; + for (const kind of dependencyKinds(instance.service)) { + const id = dependencyId(instance, kind); + if (id === undefined) { + if (required.has(instance.id)) return Effect.fail(missingInstance(instance, kind)); + continue; + } + const dependency = byId.get(id); + if (dependency === undefined || dependency.service !== kind) { + if (required.has(instance.id)) return Effect.fail(missingInstance(instance, kind)); + continue; } + ids.push(id); } + dependencies[instance.id] = ids; } - const start: CapabilityName[] = []; - const visited = new Set(); - const visitingCapabilities = new Set(); - let capabilityGraphError: InvalidStackConfigError | undefined; - const visit = (name: CapabilityName): void => { - if (visitingCapabilities.has(name)) { - capabilityGraphError = new InvalidStackConfigError({ - message: `Capability dependency cycle detected at ${name}`, - capability: name, + + const startOrder: ServiceInstanceId[] = []; + const visited = new Set(); + const visiting = new Set(); + let serviceGraphError: InvalidStackConfigError | undefined; + const visit = (id: ServiceInstanceId): void => { + if (serviceGraphError !== undefined) return; + if (visited.has(id)) return; + if (visiting.has(id)) { + const instance = byId.get(id); + serviceGraphError = new InvalidStackConfigError({ + message: `Service dependency cycle detected at ${id}`, + capability: instance?.service, }); return; } - if (visited.has(name) || !capabilities[name].enabled) return; - visitingCapabilities.add(name); - visited.add(name); - for (const dependency of dependencyMap[name]) { - if (!Object.hasOwn(modules, dependency)) { - capabilityGraphError = new InvalidStackConfigError({ - message: `Unknown capability dependency ${dependency}`, - capability: name, - dependency, - }); - continue; - } - visit(dependency); - } - visitingCapabilities.delete(name); - start.push(name); + visiting.add(id); + for (const dependency of dependencies[id] ?? []) visit(dependency); + visiting.delete(id); + visited.add(id); + startOrder.push(id); }; - for (const name of CAPABILITY_NAMES) visit(name); - if (capabilityGraphError !== undefined) return Effect.fail(capabilityGraphError); - const routes = CAPABILITY_NAMES.flatMap((name) => - capabilities[name].enabled - ? modules[name].routes.map((route) => ({ capability: name, ...route })) - : [], - ); - const declaredWorkloads: PlannedWorkload[] = []; - for (const name of CAPABILITY_NAMES) { - if (!capabilities[name].enabled) continue; - const release = modules[name].releases[capabilities[name].version]; + if (selection === undefined) for (const instance of instances) visit(instance.id); + else for (const id of selection) visit(id); + if (serviceGraphError !== undefined) return Effect.fail(serviceGraphError); + + const activation: Record = {}; + const routableInstances = instances.filter(hasOwnedRuntime); + const ownedRuntimeById = new Set(routableInstances.map((instance) => instance.id)); + const routes = routableInstances.flatMap((instance) => { + activation[instance.id] = instance.config.activation; + return modules[instance.service].routes.map((route) => ({ + instanceId: instance.id, + capability: instance.service, + ...route, + })); + }); + const declared: PlannedWorkload[] = []; + for (const instance of instances.filter(hasOwnedRuntime)) { + const release = modules[instance.service].releases[instance.config.version]; if (release === undefined) return Effect.fail( new InvalidStackConfigError({ - message: `Missing ${name} release ${capabilities[name].version}`, - capability: name, - version: capabilities[name].version, + message: `Missing ${instance.service} release ${instance.config.version}`, + capability: instance.service, + version: instance.config.version, }), ); - const selectedEntries = selectedWorkloads(name, modules, capabilities, release.workloads); - for (const entry of selectedEntries) { - const id = `${name}:${entry.name}`; - declaredWorkloads.push({ - id, - capability: name, + for (const entry of selectedWorkloadsForInstance(instance, modules, release.workloads)) { + const recipeId = `${instance.service}:${entry.name}`; + const dependenciesForWorkload: string[] = []; + for (const dependency of entry.dependencies) { + const separator = dependency.indexOf(":"); + const candidate = separator < 0 ? instance.service : dependency.slice(0, separator); + const kind = CAPABILITY_NAMES.find((name) => name === candidate); + if (kind === undefined) + return Effect.fail( + new InvalidStackConfigError({ + message: `Unknown workload dependency capability ${candidate}`, + capability: instance.service, + workload: dependency, + }), + ); + const recipe = separator < 0 ? dependency : dependency.slice(separator + 1); + const targetId = kind === instance.service ? instance.id : dependencyId(instance, kind); + if (targetId === undefined || !registeredIds.has(targetId)) { + if (required.has(instance.id)) return Effect.fail(missingInstance(instance, kind)); + continue; + } + // A stopped or disabled prerequisite remains in the service graph. Its + // absent workload is handled by activation preflight, so the neutral + // plan can still be used for unrelated teardown and inspection. + if (!ownedRuntimeById.has(targetId)) continue; + dependenciesForWorkload.push(`${targetId}:${recipe}`); + } + declared.push({ + id: `${instance.id}:${entry.name}`, + instanceId: instance.id, + recipeId, + capability: instance.service, ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), - dependencies: entry.dependencies, + dependencies: dependenciesForWorkload, readiness: entry.readiness, artifacts: entry.artifacts, selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, }); } } - const byId = new Map(declaredWorkloads.map((entry) => [entry.id, entry])); - const workloadOrder: typeof declaredWorkloads = []; - const visiting = new Set(); + const byWorkload = new Map(declared.map((entry) => [entry.id, entry])); + const workloadOrder: PlannedWorkload[] = []; + const visitingWorkloads = new Set(); const visitedWorkloads = new Set(); - let graphError: InvalidStackConfigError | undefined; + let workloadGraphError: InvalidStackConfigError | undefined; const visitWorkload = (id: string): void => { + if (workloadGraphError !== undefined) return; if (visitedWorkloads.has(id)) return; - if (visiting.has(id)) { - graphError = new InvalidStackConfigError({ + if (visitingWorkloads.has(id)) { + workloadGraphError = new InvalidStackConfigError({ message: `Workload dependency cycle detected at ${id}`, workload: id, }); return; } - const entry = byId.get(id); + const entry = byWorkload.get(id); if (entry === undefined) { - graphError = new InvalidStackConfigError({ + workloadGraphError = new InvalidStackConfigError({ message: `Missing private workload dependency ${id}`, workload: id, }); return; } - visiting.add(id); + visitingWorkloads.add(id); for (const dependency of entry.dependencies) visitWorkload(dependency); - visiting.delete(id); + visitingWorkloads.delete(id); visitedWorkloads.add(id); workloadOrder.push(entry); }; - for (const entry of declaredWorkloads) visitWorkload(entry.id); - if (graphError !== undefined) return Effect.fail(graphError); - const activation = { - database: capabilities.database.activation, - rest: capabilities.rest.activation, - auth: capabilities.auth.activation, - realtime: capabilities.realtime.activation, - storage: capabilities.storage.activation, - functions: capabilities.functions.activation, - studio: capabilities.studio.activation, - mail: capabilities.mail.activation, - analytics: capabilities.analytics.activation, - pooler: capabilities.pooler.activation, - } satisfies { [Name in CapabilityName]: "eager" | "lazy" }; + const workloadRoots = + selection === undefined ? declared : declared.filter((entry) => required.has(entry.instanceId)); + for (const entry of workloadRoots) visitWorkload(entry.id); + if (workloadGraphError !== undefined) return Effect.fail(workloadGraphError); return Effect.succeed({ runtime, activation, - startOrder: start, - dependencies: dependencyMap, + startOrder, + dependencies, routes, workloads: workloadOrder, }); }; + +export interface MaterializedCapability { + readonly enabled: boolean; + readonly activation: "eager" | "lazy"; + readonly idleTimeoutSeconds: number | false; + readonly version: string; + readonly settings: MaterializedSettings; +} +export interface MaterializedCapabilities { + readonly database: MaterializedCapability; + readonly rest: MaterializedCapability; + readonly auth: MaterializedCapability; + readonly realtime: MaterializedCapability; + readonly storage: MaterializedCapability; + readonly functions: MaterializedCapability; + readonly studio: MaterializedCapability; + readonly mail: MaterializedCapability; + readonly analytics: MaterializedCapability; + readonly pooler: MaterializedCapability; +} diff --git a/packages/stack/src/model/PostgresRelease.ts b/packages/stack/src/model/PostgresRelease.ts new file mode 100644 index 0000000000..02ae7700da --- /dev/null +++ b/packages/stack/src/model/PostgresRelease.ts @@ -0,0 +1,34 @@ +import { Effect } from "effect"; +import { StackVersionUnsupportedError } from "../public/Errors.ts"; +import { catalogEntryFor, catalogReleaseFor } from "./WorkloadCatalog.ts"; + +export interface PostgresRelease { + readonly version: string; + readonly image: string; +} + +/** Resolves an exact or major PostgreSQL catalog release for client tooling. */ +export const resolvePostgresRelease = ( + version?: string, +): Effect.Effect => { + const entry = catalogEntryFor("database:database"); + const requested = version ?? entry.defaultVersion; + const exact = catalogReleaseFor("database:database", requested); + const major = requested.split(".")[0]; + const majorVersion = + major === undefined + ? undefined + : Object.keys(entry.releases).find((candidate) => candidate.split(".")[0] === major); + const selected = + exact ?? + (majorVersion === undefined ? undefined : catalogReleaseFor("database:database", majorVersion)); + if (selected === undefined) + return Effect.fail( + new StackVersionUnsupportedError({ + message: `Unsupported PostgreSQL version ${requested}`, + version: requested, + capability: "database", + }), + ); + return Effect.succeed({ version: selected.version, image: selected.containerImage }); +}; diff --git a/packages/stack/src/model/ServiceRegistry.ts b/packages/stack/src/model/ServiceRegistry.ts new file mode 100644 index 0000000000..257cce28ed --- /dev/null +++ b/packages/stack/src/model/ServiceRegistry.ts @@ -0,0 +1,431 @@ +import { Effect, Schema, SchemaGetter } from "effect"; +import type { MaterializedCapabilities } from "./ExecutionPlan.ts"; +import { validateMaterializedSettingsByName } from "../state/MaterializedSettingsValidation.ts"; +import { + ServiceInstanceIdSchema, + ServiceKindSchema, + SERVICE_KINDS, + type ServiceKind, + OptionalEndpointIntentSchema, + SnapshotDescriptorSchema, +} from "../public/Service.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import { + ServiceDependencyError, + ServiceNameConflictError, + ServiceNotFoundError, +} from "../public/Errors.ts"; +import { ActivationModeSchema } from "../public/Capability.ts"; + +const ServiceResourceIdentitySchema = Schema.Struct({ + runtime: Schema.optionalKey(Schema.String), + storage: Schema.optionalKey(Schema.String), + alias: Schema.optionalKey(Schema.String), +}); +export type ServiceResourceIdentity = Schema.Schema.Type; + +const InitializationRecipeSchema = Schema.Struct({ + service: ServiceKindSchema, + recipeId: Schema.String.check(Schema.isNonEmpty()), + artifactIdentity: Schema.String, + completed: Schema.Boolean, +}); +const ServiceInitializationEvidenceSchema = Schema.Struct({ + profileId: Schema.String, + recipes: Schema.Array(InitializationRecipeSchema), +}); +export type ServiceInitializationEvidence = Schema.Schema.Type< + typeof ServiceInitializationEvidenceSchema +>; + +const ServiceDataStateSchema = Schema.Union([ + Schema.Struct({ origin: Schema.Literal("absent") }), + Schema.Struct({ origin: Schema.Literal("fresh"), lineageId: Schema.String }), + Schema.Struct({ origin: Schema.Literal("restored"), snapshot: SnapshotDescriptorSchema }), + Schema.Struct({ origin: Schema.Literal("incomplete"), operationId: Schema.String }), +]); +type ServiceDataState = Schema.Schema.Type; + +const ServiceRevisionsSchema = Schema.Struct({ + config: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))), + intent: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))), +}); + +/** Durable operation journal; public status intentionally exposes only id and kind. */ +const PersistedPendingOperationSchema = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty()), + kind: Schema.Literals([ + "start", + "sleep", + "stop", + "restart", + "destroy", + "exportSnapshot", + "restoreSnapshot", + ] as const), + generation: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))), + ownerSessionId: Schema.String.check(Schema.isNonEmpty()), + phase: Schema.Literals(["admitted", "running", "settling", "cleanup", "complete"] as const), + stagingPath: Schema.optionalKey(Schema.String), + outputPath: Schema.optionalKey(Schema.String), + helperId: Schema.optionalKey(Schema.String), + sourceInstanceId: Schema.optionalKey(ServiceInstanceIdSchema), +}); +export type PersistedPendingOperation = Schema.Schema.Type; + +type MaterializedSettingsFor = MaterializedCapabilities[K]["settings"]; +type Endpoint = Schema.Schema.Type; +export type PersistedServiceEndpoints = { + database: { readonly sql?: Endpoint }; + rest: Record; + auth: Record; + realtime: Record; + storage: Record; + functions: { readonly inspector?: Endpoint }; + studio: { readonly studio?: Endpoint }; + mail: { readonly smtp?: Endpoint; readonly pop3?: Endpoint; readonly mailUi?: Endpoint }; + analytics: Record; + pooler: { readonly pooler?: Endpoint }; +}; + +type PersistedServiceConfigMap = { + [K in ServiceKind]: { + readonly enabled: boolean; + readonly activation: Schema.Schema.Type; + readonly idleTimeoutSeconds: K extends + | "database" + | "storage" + | "functions" + | "mail" + | "analytics" + ? false + : number | false; + readonly version: string; + readonly settings: MaterializedSettingsFor; + readonly endpoints: PersistedServiceEndpoints[K]; + readonly passwordSecretRef?: K extends "database" ? string : never; + }; +}; +type PersistedServiceConfig = PersistedServiceConfigMap[K]; +const materializedSettingsSchema = (service: K) => + Schema.declareConstructor>()( + [], + () => (input, _ast, options) => validateMaterializedSettingsByName(service, input, options), + ); + +const endpointSchema = >(fields: Fields) => + Schema.Struct(fields); +const endpointSchemas = { + database: endpointSchema({ sql: Schema.optionalKey(OptionalEndpointIntentSchema) }), + rest: endpointSchema({}), + auth: endpointSchema({}), + realtime: endpointSchema({}), + storage: endpointSchema({}), + functions: endpointSchema({ + inspector: Schema.optionalKey(OptionalEndpointIntentSchema), + }), + studio: endpointSchema({ studio: Schema.optionalKey(OptionalEndpointIntentSchema) }), + mail: endpointSchema({ + smtp: Schema.optionalKey(OptionalEndpointIntentSchema), + pop3: Schema.optionalKey(OptionalEndpointIntentSchema), + mailUi: Schema.optionalKey(OptionalEndpointIntentSchema), + }), + analytics: endpointSchema({}), + pooler: endpointSchema({ pooler: Schema.optionalKey(OptionalEndpointIntentSchema) }), +} satisfies { readonly [K in ServiceKind]: Schema.Top }; + +const configSchema = (service: K, idle: I) => + Schema.Struct({ + enabled: Schema.Boolean, + activation: ActivationModeSchema, + idleTimeoutSeconds: idle, + version: Schema.String.check(Schema.isNonEmpty()), + settings: materializedSettingsSchema(service), + endpoints: endpointSchemas[service], + }); + +const retirableIdle = Schema.Union([ + Schema.Literal(false), + Schema.Finite.check(Schema.isGreaterThan(0)), +]); +const configSchemas = { + database: Schema.Struct({ + enabled: Schema.Boolean, + activation: ActivationModeSchema, + idleTimeoutSeconds: Schema.Literal(false), + version: Schema.String.check(Schema.isNonEmpty()), + settings: materializedSettingsSchema("database"), + endpoints: endpointSchemas.database, + passwordSecretRef: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + }), + rest: configSchema("rest", retirableIdle), + auth: configSchema("auth", retirableIdle), + realtime: configSchema("realtime", retirableIdle), + storage: configSchema("storage", Schema.Literal(false)), + functions: configSchema("functions", Schema.Literal(false)), + studio: configSchema("studio", retirableIdle), + mail: configSchema("mail", Schema.Literal(false)), + analytics: configSchema("analytics", Schema.Literal(false)), + pooler: configSchema("pooler", retirableIdle), +} satisfies { readonly [K in ServiceKind]: Schema.Top }; +const dependencySchemas = { + database: Schema.Struct({}), + rest: Schema.Struct({ database: ServiceInstanceIdSchema }), + auth: Schema.Struct({ database: ServiceInstanceIdSchema }), + realtime: Schema.Struct({ database: ServiceInstanceIdSchema }), + storage: Schema.Struct({ database: ServiceInstanceIdSchema }), + functions: Schema.Struct({}), + studio: Schema.Struct({ + database: ServiceInstanceIdSchema, + rest: ServiceInstanceIdSchema, + analytics: ServiceInstanceIdSchema, + }), + mail: Schema.Struct({}), + analytics: Schema.Struct({ database: ServiceInstanceIdSchema }), + pooler: Schema.Struct({ database: ServiceInstanceIdSchema }), +} satisfies { readonly [K in ServiceKind]: Schema.Top }; + +const catalogInputSchema = (service: K) => + Schema.Struct({ + version: Schema.String.check(Schema.isNonEmpty()), + settings: materializedSettingsSchema(service), + }); +export const ServiceInitializationInputsSchema = Schema.Struct({ + profileId: Schema.String.check(Schema.isNonEmpty()), + catalog: Schema.Struct({ + auth: Schema.optionalKey(catalogInputSchema("auth")), + storage: Schema.optionalKey(catalogInputSchema("storage")), + realtime: Schema.optionalKey(catalogInputSchema("realtime")), + analytics: Schema.optionalKey(catalogInputSchema("analytics")), + pooler: Schema.optionalKey(catalogInputSchema("pooler")), + }), +}); +export type ServiceInitializationInputs = Schema.Schema.Type< + typeof ServiceInitializationInputsSchema +>; + +type PersistedServiceInstanceBase = { + readonly id: ServiceInstanceId; + readonly name?: string; + readonly intent: "started" | "stopped"; + readonly dependencies: Readonly>; + readonly resources: ServiceResourceIdentity; + readonly revisions: Schema.Schema.Type; + readonly pendingOperation: PersistedPendingOperation | null; + readonly initialization: ServiceInitializationEvidence | null; + readonly initializationInputs: ServiceInitializationInputs | null; + readonly data: ServiceDataState; + readonly artifactIdentity?: string; + readonly runtimeIdentity?: string; + readonly bootstrapRecipeId?: string; + readonly bootstrapInputsId?: string; + readonly creationInputsId?: string; +}; +export type PersistedServiceInstanceFor = { + [K in ServiceKind]: Omit & { + readonly service: K; + readonly config: PersistedServiceConfig; + }; +}[K]; +const instanceSchema = (service: K) => + Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal(service), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + intent: Schema.Literals(["stopped", "started"] as const), + config: configSchemas[service], + dependencies: dependencySchemas[service], + resources: ServiceResourceIdentitySchema, + revisions: ServiceRevisionsSchema, + pendingOperation: Schema.NullOr(PersistedPendingOperationSchema), + initialization: Schema.NullOr(ServiceInitializationEvidenceSchema), + initializationInputs: Schema.NullOr(ServiceInitializationInputsSchema), + data: ServiceDataStateSchema, + artifactIdentity: Schema.optionalKey(Schema.String), + runtimeIdentity: Schema.optionalKey(Schema.String), + bootstrapRecipeId: Schema.optionalKey(Schema.String), + bootstrapInputsId: Schema.optionalKey(Schema.String), + creationInputsId: Schema.optionalKey(Schema.String), + }); + +export const PersistedServiceInstanceSchema = Schema.Union([ + instanceSchema("database"), + instanceSchema("rest"), + instanceSchema("auth"), + instanceSchema("realtime"), + instanceSchema("storage"), + instanceSchema("functions"), + instanceSchema("studio"), + instanceSchema("mail"), + instanceSchema("analytics"), + instanceSchema("pooler"), +]); + +export type PersistedServiceInstance = Schema.Schema.Type; + +const uniqueInstances = Schema.Array(PersistedServiceInstanceSchema).pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((instances) => { + const ids = instances.map(({ id }) => id); + const names = instances.flatMap(({ name }) => (name === undefined ? [] : [name])); + return Effect.succeed( + new Set(ids).size === ids.length && new Set(names).size === names.length + ? undefined + : "Service instance IDs and names must be unique", + ); + }), + encode: SchemaGetter.passthrough(), + }), +); + +const registryShape = Schema.Struct({ + initialized: Schema.Boolean, + instances: uniqueInstances, + defaultInstanceIds: Schema.Record(Schema.String, ServiceInstanceIdSchema), +}); +export const PersistedServiceRegistrySchema = registryShape.pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((registry) => { + for (const [kind, id] of Object.entries(registry.defaultInstanceIds)) { + if (!SERVICE_KINDS.some((candidate) => candidate === kind)) + return Effect.succeed(`Unknown default service kind ${kind}`); + const instance = registry.instances.find((entry) => entry.id === id); + if (instance === undefined || instance.service !== kind) + return Effect.succeed(`Default service ${kind} references an invalid instance ${id}`); + } + return Effect.succeed(true); + }), + encode: SchemaGetter.passthrough(), + }), +); +export type PersistedServiceRegistry = Schema.Schema.Type; + +export const emptyServiceRegistry = (): PersistedServiceRegistry => ({ + initialized: true, + instances: [], + defaultInstanceIds: {}, +}); + +const dependencyKinds: Readonly>> = { + database: [], + rest: ["database"], + auth: ["database"], + realtime: ["database"], + storage: ["database"], + functions: [], + studio: ["database", "rest", "analytics"], + mail: [], + analytics: ["database"], + pooler: ["database"], +}; + +const instanceNotFound = (id: ServiceInstanceId): ServiceNotFoundError => + new ServiceNotFoundError({ message: `Service instance ${id} was not found`, instanceId: id }); + +const validateServiceDependencies = ( + registry: PersistedServiceRegistry, + instance: { + readonly service: ServiceKind; + readonly dependencies: Readonly>; + }, +): Effect.Effect => { + const byId = new Map(registry.instances.map((entry) => [entry.id, entry])); + for (const kind of dependencyKinds[instance.service]) { + const id = instance.dependencies[kind]; + if (id === undefined) + return Effect.fail( + new ServiceDependencyError({ + message: `${instance.service} requires a ${kind} dependency`, + service: instance.service, + dependency: kind, + }), + ); + const dependency = byId.get(id); + if (dependency === undefined) + return Effect.fail( + new ServiceDependencyError({ + message: `${instance.service} references missing dependency ${id}`, + service: instance.service, + dependency: kind, + }), + ); + if (dependency.service !== kind) + return Effect.fail( + new ServiceDependencyError({ + message: `${instance.service} dependency ${id} is ${dependency.service}, expected ${kind}`, + service: instance.service, + dependency: kind, + }), + ); + } + return Effect.void; +}; + +const hasDependencyCycle = ( + registry: PersistedServiceRegistry, + candidate: PersistedServiceInstance, +): boolean => { + const byId = new Map(registry.instances.map((entry) => [entry.id, entry])); + byId.set(candidate.id, candidate); + const visiting = new Set(); + const visited = new Set(); + const visit = (id: ServiceInstanceId): boolean => { + if (visiting.has(id)) return true; + if (visited.has(id)) return false; + visiting.add(id); + const entry = byId.get(id); + if (entry !== undefined) + for (const dependency of Object.values(entry.dependencies)) + if (visit(dependency)) return true; + visiting.delete(id); + visited.add(id); + return false; + }; + return visit(candidate.id); +}; + +export const registerServiceInstance = ( + registry: PersistedServiceRegistry, + instance: PersistedServiceInstance, +): Effect.Effect => { + if (registry.instances.some(({ id }) => id === instance.id)) + return Effect.fail( + new ServiceNameConflictError({ message: `Service instance ${instance.id} already exists` }), + ); + if (instance.name !== undefined && registry.instances.some(({ name }) => name === instance.name)) + return Effect.fail( + new ServiceNameConflictError({ message: `Service name ${instance.name} already exists` }), + ); + return validateServiceDependencies(registry, instance).pipe( + Effect.flatMap(() => + hasDependencyCycle(registry, instance) + ? Effect.fail(new ServiceDependencyError({ message: "Service dependency cycle detected" })) + : Effect.succeed({ ...registry, instances: [...registry.instances, instance] }), + ), + ); +}; + +export const removeServiceInstance = ( + registry: PersistedServiceRegistry, + id: ServiceInstanceId, +): Effect.Effect => { + if (!registry.instances.some((entry) => entry.id === id)) + return Effect.fail(instanceNotFound(id)); + const dependent = registry.instances.find((entry) => + Object.values(entry.dependencies).includes(id), + ); + if (dependent !== undefined) + return Effect.fail( + new ServiceDependencyError({ + message: `Cannot remove ${id}; ${dependent.id} depends on it`, + dependency: id, + }), + ); + return Effect.succeed({ + ...registry, + instances: registry.instances.filter((entry) => entry.id !== id), + defaultInstanceIds: Object.fromEntries( + Object.entries(registry.defaultInstanceIds).filter(([, instanceId]) => instanceId !== id), + ), + }); +}; diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index f778fa8a0d..da2e200aff 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -196,10 +196,6 @@ export const catalogReleaseFor = ( return containerImage === undefined ? undefined : { version: selected, containerImage }; }; -/** Resolves the container alias for a catalog workload identity. */ -export const containerAliasFor = (workloadId: WorkloadId): string => - workloadCatalog[workloadId].containerAlias; - const artifactFor = ( entry: WorkloadCatalogEntry, release: WorkloadCatalogRelease, @@ -227,17 +223,17 @@ const artifactFor = ( /** Effect-native resolver used by preparation/runtime boundaries. */ export const resolveNativeArtifactForWorkload = ( - workload: Pick, + workload: Pick, platform: { readonly os: string; readonly arch: string } = { os: process.platform, arch: process.arch, }, ): Effect.Effect => { - const entry = catalogEntryFor(workload.id); + const entry = catalogEntryFor(workload.recipeId); if (entry === undefined) return Effect.fail( new StackPreparationError({ - message: `Unknown workload catalog entry: ${workload.id}`, + message: `Unknown workload catalog entry: ${workload.recipeId}`, workload: workload.id, }), ); @@ -250,7 +246,7 @@ export const resolveNativeArtifactForWorkload = ( platform: `${platform.os}/${platform.arch}`, }), ); - const release = catalogReleaseFor(workload.id, workload.artifacts.native.release); + const release = catalogReleaseFor(workload.recipeId, workload.artifacts.native.release); if (release === undefined) return Effect.fail( new StackPreparationError({ diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 5b1d472f0b..4e9ef4aa63 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -60,11 +60,11 @@ export const FunctionsModule: CapabilityModule = { defaultEnabled: true, defaultActivation: "lazy", defaultVersion: version, - dependencies: ["database"], + dependencies: [], releases: { [version]: release(version, [ workload("edge-runtime", "functions", { - dependencies: ["database:database"], + dependencies: [], readiness: { portField: "functionsInspector" }, }), ]), @@ -82,11 +82,11 @@ export const FunctionsModule: CapabilityModule = { name, { enabled: fn.enabled ?? true, - verify_jwt: fn.verify_jwt ?? true, - import_map: fn.import_map ?? "", - entrypoint: fn.entrypoint ?? "", - static_files: fn.static_files ?? [], env: fn.env ?? {}, + ...(fn.verify_jwt === undefined ? {} : { verify_jwt: fn.verify_jwt }), + ...(fn.import_map === undefined ? {} : { import_map: fn.import_map }), + ...(fn.entrypoint === undefined ? {} : { entrypoint: fn.entrypoint }), + ...(fn.static_files === undefined ? {} : { static_files: fn.static_files }), }, ]), ), diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 9c52193f21..f3cc011254 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -8,3 +8,13 @@ export { RestModule, RestSettingsSchema } from "./rest.ts"; export { parseFileSize, StorageModule, StorageSettingsSchema } from "./storage.ts"; export { StudioModule, StudioSettingsSchema } from "./studio.ts"; export { AnalyticsModule, AnalyticsSettingsSchema } from "./analytics.ts"; +export type { AuthSettings } from "./auth.ts"; +export type { DatabaseSettings } from "./database.ts"; +export type { FunctionsSettings } from "./functions.ts"; +export type { MailSettings } from "./mail.ts"; +export type { PoolerSettings } from "./pooler.ts"; +export type { RealtimeSettings } from "./realtime.ts"; +export type { RestSettings } from "./rest.ts"; +export type { StorageSettings } from "./storage.ts"; +export type { StudioSettings } from "./studio.ts"; +export type { AnalyticsSettings } from "./analytics.ts"; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 65fd8b2232..8201009546 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -1,7 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; -import { compileStack } from "./Compiler.ts"; +import { Cause, Effect, Exit, Option, Path } from "effect"; +import { compileStack, seedServiceRegistry, createExecutionPlan } from "./Compiler.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { workload } from "./CapabilityModule.ts"; import { @@ -16,17 +16,25 @@ import { StackPreparationError } from "../public/Errors.ts"; const databaseCatalog = catalogEntryFor("database:database"); const defaultDatabaseMajor = databaseCatalog.defaultVersion.split(".")[0]; -const compile = (config: Parameters[0]["config"]) => - compileStack({ projectRoot: "/tmp/catalog-project", runtime: { kind: "native" }, config }).pipe( - Effect.provide(NodeServices.layer), - ); +const compile = ( + config: Parameters[0]["config"], + runtime: Parameters[0]["runtime"] = { kind: "native" }, +) => + Effect.gen(function* () { + const context = { projectRoot: "/tmp/catalog-project", runtime, path: yield* Path.Path }; + const compiled = yield* compileStack({ ...context, config }); + const seeded = yield* seedServiceRegistry( + compiled.definition, + context, + config ?? {}, + compiled.secrets, + ); + const executionPlan = yield* createExecutionPlan(runtime, seeded.registry); + return { ...compiled, registry: seeded.registry, executionPlan }; + }).pipe(Effect.provide(NodeServices.layer)); const compileContainer = (config: Parameters[0]["config"]) => - compileStack({ - projectRoot: "/tmp/catalog-project", - runtime: { kind: "container", engine: "docker" }, - config, - }).pipe(Effect.provide(NodeServices.layer)); + compile(config, { kind: "container", engine: "docker" }); const expectWorkload = (value: T | undefined, description: string): T => { expect(value, description).toBeDefined(); @@ -100,7 +108,7 @@ describe("complete workload catalog", () => { const capability = defaults.definition.capabilities[name]; if (!capability.enabled) continue; const workload = - defaults.executionPlan.workloads.find((entry) => entry.id === `${name}:${name}`) ?? + defaults.executionPlan.workloads.find((entry) => entry.recipeId === `${name}:${name}`) ?? defaults.executionPlan.workloads.find((entry) => entry.capability === name); const selected = expectWorkload(workload, `${name} workload`); expect(selected.artifacts.native.release).toBe(capability.version); @@ -110,7 +118,7 @@ describe("complete workload catalog", () => { capabilities: { database: { version: defaultDatabaseMajor } }, }); const database = databaseAlias.executionPlan.workloads.find( - (entry) => entry.id === "database:database", + (entry) => entry.recipeId === "database:database", ); const selected = expectWorkload(database, "database workload"); expect(databaseAlias.definition.capabilities.database.version).toBe( @@ -152,7 +160,7 @@ describe("complete workload catalog", () => { for (const id of ["studio:pgmeta", "analytics:vector"] as const) { const catalog = WORKLOAD_CATALOG[id]; const selectedCatalog = expectWorkload(catalog, `${id} catalog entry`); - const workload = result.executionPlan.workloads.find((entry) => entry.id === id); + const workload = result.executionPlan.workloads.find((entry) => entry.recipeId === id); const selectedWorkload = expectWorkload(workload, `${id} workload`); expect(selectedWorkload.artifacts.native.release).toBe(selectedCatalog.defaultVersion); const artifact = yield* resolveNativeArtifactForWorkload(selectedWorkload, { @@ -172,16 +180,27 @@ describe("complete workload catalog", () => { it.live("enables companion workloads by default and honors explicit disablement", () => Effect.gen(function* () { const defaults = yield* compile({}); - expect(defaults.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy")).toBe( - true, - ); - expect(defaults.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( - true, - ); - expect(defaults.executionPlan.workloads).toEqual( + expect( + defaults.executionPlan.workloads.some(({ recipeId }) => recipeId === "storage:imgproxy"), + ).toBe(true); + expect( + defaults.executionPlan.workloads.some(({ recipeId }) => recipeId === "analytics:vector"), + ).toBe(true); + expect( + defaults.executionPlan.workloads.map((entry) => ({ + recipeId: entry.recipeId, + dependencies: entry.dependencies.map( + (id) => + expectWorkload( + defaults.executionPlan.workloads.find((dependency) => dependency.id === id), + "workload dependency", + ).recipeId, + ), + })), + ).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "storage:storage", + recipeId: "storage:storage", dependencies: ["database:database", "storage:imgproxy"], }), ]), @@ -193,25 +212,36 @@ describe("complete workload catalog", () => { analytics: { settings: {} }, }, }); - expect(enabled.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy")).toBe( - true, - ); - expect(enabled.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( - true, - ); - expect(enabled.executionPlan.workloads).toEqual( + expect( + enabled.executionPlan.workloads.some(({ recipeId }) => recipeId === "storage:imgproxy"), + ).toBe(true); + expect( + enabled.executionPlan.workloads.some(({ recipeId }) => recipeId === "analytics:vector"), + ).toBe(true); + expect( + enabled.executionPlan.workloads.map((entry) => ({ + recipeId: entry.recipeId, + dependencies: entry.dependencies.map( + (id) => + expectWorkload( + enabled.executionPlan.workloads.find((dependency) => dependency.id === id), + "workload dependency", + ).recipeId, + ), + })), + ).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "storage:storage", + recipeId: "storage:storage", dependencies: ["database:database", "storage:imgproxy"], }), - expect.objectContaining({ id: "storage:imgproxy", dependencies: [] }), + expect.objectContaining({ recipeId: "storage:imgproxy", dependencies: [] }), expect.objectContaining({ - id: "analytics:analytics", + recipeId: "analytics:analytics", dependencies: ["database:database"], }), expect.objectContaining({ - id: "analytics:vector", + recipeId: "analytics:vector", dependencies: ["analytics:analytics"], }), ]), @@ -224,12 +254,12 @@ describe("complete workload catalog", () => { studio: { enabled: false }, }, }); - expect(disabled.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy")).toBe( - false, - ); - expect(disabled.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( - false, - ); + expect( + disabled.executionPlan.workloads.some(({ recipeId }) => recipeId === "storage:imgproxy"), + ).toBe(false); + expect( + disabled.executionPlan.workloads.some(({ recipeId }) => recipeId === "analytics:vector"), + ).toBe(false); }), ); @@ -242,7 +272,10 @@ describe("complete workload catalog", () => { }, }); const images = new Map( - result.executionPlan.workloads.map((entry) => [entry.id, entry.artifacts.container.image]), + result.executionPlan.workloads.map((entry) => [ + entry.recipeId, + entry.artifacts.container.image, + ]), ); expect(images.get("mail:mail")).toBe(catalogReleaseFor("mail:mail")?.containerImage); expect(images.get("storage:imgproxy")).toBe( @@ -260,7 +293,7 @@ describe("complete workload catalog", () => { capabilities: { database: { version: defaultDatabaseMajor } }, }); const database = compiled.executionPlan.workloads.find( - ({ id }) => id === "database:database", + ({ recipeId }) => recipeId === "database:database", ); const artifact = yield* resolveNativeArtifactForWorkload( expectWorkload(database, "database workload"), @@ -280,7 +313,7 @@ describe("complete workload catalog", () => { Effect.gen(function* () { const compiled = yield* compile({}); const database = compiled.executionPlan.workloads.find( - ({ id }) => id === "database:database", + ({ recipeId }) => recipeId === "database:database", ); const selected = expectWorkload(database, "database workload"); const unsupported = { @@ -307,7 +340,7 @@ describe("complete workload catalog", () => { return Effect.gen(function* () { const compiled = yield* compile({}); const database = compiled.executionPlan.workloads.find( - ({ id }) => id === "database:database", + ({ recipeId }) => recipeId === "database:database", ); const failed = yield* resolveNativeArtifactForWorkload( expectWorkload(database, "database workload"), diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 9bda95a4f0..94d18356c4 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -1,8 +1,16 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, Option, Redacted, Path, Schema } from "effect"; +import { EffectCreateServiceOptionsSchema } from "../public/Service.ts"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; -import { canonicalize, compileStack, rebuildExecutionPlan, sameDefinition } from "./Compiler.ts"; +import { + canonical, + compileStack, + createExecutionPlan, + fingerprintCreationInputs, + seedServiceRegistry, + sameDefinition, +} from "./Compiler.ts"; import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { DEFAULT_DATABASE_HEALTH_TIMEOUT } from "./capabilities/database.ts"; import { catalogEntryFor } from "./WorkloadCatalog.ts"; @@ -14,14 +22,68 @@ const compile = ( runtime: Parameters[0]["runtime"] = { kind: "native" }, previous?: Parameters[1], ) => - compileStack({ projectRoot: "/tmp/supabase-project", runtime, config }, previous).pipe( - Effect.provide(layer), - ); + Effect.gen(function* () { + const context = { projectRoot: "/tmp/supabase-project", runtime, path: yield* Path.Path }; + const compiled = yield* compileStack({ ...context, config }, previous); + const seeded = yield* seedServiceRegistry( + compiled.definition, + context, + config ?? {}, + compiled.secrets, + ); + const executionPlan = yield* createExecutionPlan(runtime, seeded.registry); + return { ...compiled, registry: seeded.registry, executionPlan }; + }).pipe(Effect.provide(layer)); const failureOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("closed capability compiler", () => { + it.live( + "keeps creation proof stable across the RPC codec while distinguishing secret inputs", + () => + Effect.gen(function* () { + const request = { + service: "database" as const, + name: "retained-client-name", + config: { + password: Redacted.make("requested-password"), + endpoints: { sql: { port: "auto" as const } }, + }, + }; + const codec = Schema.fromJsonString(Schema.toCodecJson(EffectCreateServiceOptionsSchema)); + const wire = yield* Schema.encodeEffect(codec)(request); + const received = yield* Schema.decodeEffect(codec)(wire); + expect(yield* fingerprintCreationInputs(request)).toBe( + yield* fingerprintCreationInputs(received), + ); + expect( + yield* fingerprintCreationInputs({ + ...request, + config: { ...request.config, password: Redacted.make("different-password") }, + }), + ).not.toBe(yield* fingerprintCreationInputs(request)); + }).pipe(Effect.provide(layer)), + ); + + it.live("materializes shared API credentials without Auth or database workloads", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { + database: { enabled: false }, + auth: { enabled: false }, + rest: { enabled: false }, + functions: { enabled: true }, + }, + }); + const slots = new Set(result.secrets.map((entry) => entry.slot)); + expect(slots.has("secret:auth.settings.publishable_key")).toBe(true); + expect(slots.has("secret:auth.settings.secret_key")).toBe(true); + expect(slots.has("secret:auth.settings.anon_key")).toBe(true); + expect(slots.has("secret:auth.settings.service_role_key")).toBe(true); + }), + ); + it.live("compiles every optional exclusion and closes Studio dependents", () => Effect.gen(function* () { for (const name of [ @@ -56,11 +118,13 @@ describe("closed capability compiler", () => { }); expect(analyticsOff.definition.capabilities.studio.enabled).toBe(true); expect(analyticsOff.definition.capabilities.analytics.enabled).toBe(false); - expect(analyticsOff.executionPlan.workloads.some(({ id }) => id === "studio:studio")).toBe( - true, - ); expect( - analyticsOff.executionPlan.workloads.some(({ id }) => id === "analytics:analytics"), + analyticsOff.executionPlan.workloads.some(({ recipeId }) => recipeId === "studio:studio"), + ).toBe(true); + expect( + analyticsOff.executionPlan.workloads.some( + ({ recipeId }) => recipeId === "analytics:analytics", + ), ).toBe(false); }), ); @@ -110,7 +174,14 @@ describe("closed capability compiler", () => { Effect.gen(function* () { const result = yield* compile({}); expect(result.definition.preparation).toBe("background"); - expect(result.executionPlan.activation).toEqual({ + expect( + Object.fromEntries( + result.registry.instances.map((instance) => [ + instance.service, + result.executionPlan.activation[instance.id], + ]), + ), + ).toEqual({ database: "eager", rest: "lazy", auth: "lazy", @@ -242,15 +313,14 @@ describe("closed capability compiler", () => { expect(result.definition.capabilities.auth.settings).toMatchObject({ site_url: "https://example.test", }); - expect(canonicalize(result.definition)).not.toContain("secret-value"); - expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); + expect(canonical(result.definition)).not.toContain("secret-value"); + expect(canonical(result.executionPlan)).not.toContain("secret-value"); const supplied = result.secrets.find( (entry) => entry.slot === "secret:auth.settings.secret_key", ); expect(supplied?.policy).toBe("managed"); expect(Redacted.isRedacted(supplied?.value)).toBe(true); for (const slot of [ - "secret:database.internal.password", "secret:auth.settings.publishable_key", "secret:auth.settings.jwt_secret", "secret:auth.settings.anon_key", @@ -258,7 +328,7 @@ describe("closed capability compiler", () => { ]) { expect(result.secrets.find((entry) => entry.slot === slot)?.policy).toBe("managed"); } - expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); + expect(canonical(result.executionPlan)).not.toContain("secret-value"); }), ); @@ -577,10 +647,11 @@ describe("closed capability compiler", () => { }, }, }).pipe(Effect.exit); - expect(failureOf(invalidEncryption)).toBeInstanceOf(InvalidStackConfigError); - expect(failureOf(invalidEncryption)?.setting).toBe( - "capabilities.pooler.settings.encryption_key", - ); + const error = failureOf(invalidEncryption); + expect(error).toBeInstanceOf(InvalidStackConfigError); + if (!(error instanceof InvalidStackConfigError)) + throw new Error("Expected invalid encryption key"); + expect(error.setting).toBe("capabilities.pooler.settings.encryption_key"); const invalidSecretBase = yield* compile({ capabilities: { @@ -606,27 +677,6 @@ describe("closed capability compiler", () => { }), ); - it.live("persists and reuses the managed database password slot", () => - Effect.gen(function* () { - const first = yield* compile({}); - const initial = first.secrets.filter( - (entry) => entry.slot === "secret:database.internal.password", - ); - expect(initial).toHaveLength(1); - const second = yield* compile( - {}, - { kind: "native" }, - { - definition: first.definition, - }, - ); - expect( - second.secrets.filter((entry) => entry.slot === "secret:database.internal.password"), - ).toHaveLength(1); - expect(second.secrets[0]?.slot).toBe(initial[0]?.slot); - }), - ); - it.live("rejects unknown fields at the public compiler boundary", () => Effect.gen(function* () { const exit = yield* compile({ @@ -710,7 +760,7 @@ describe("closed capability compiler", () => { const result = yield* compile({}); const database = result.executionPlan.workloads.filter((w) => w.capability === "database"); expect(database).toHaveLength(1); - expect(database[0]?.id).toBe("database:database"); + expect(database[0]?.recipeId).toBe("database:database"); expect(database[0]?.bootstrap).toBe("database"); }), ); @@ -724,15 +774,6 @@ describe("closed capability compiler", () => { }), ); - it.live("reports dependency closure errors", () => - Effect.gen(function* () { - const result = yield* compile({ - capabilities: { rest: { enabled: false }, studio: { enabled: true } }, - }).pipe(Effect.exit); - expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); - }), - ); - it.live("resolves the supported database major to its supported release", () => Effect.gen(function* () { for (const [release, image] of Object.entries( @@ -742,7 +783,7 @@ describe("closed capability compiler", () => { const result = yield* compile({ capabilities: { database: { version: major } } }); expect(result.definition.capabilities.database.version).toBe(release); expect( - result.executionPlan.workloads.find((w) => w.id === "database:database")?.artifacts, + result.executionPlan.workloads.find((w) => w.recipeId === "database:database")?.artifacts, ).toEqual({ native: { kind: "native", release }, container: { @@ -850,20 +891,16 @@ describe("closed capability compiler", () => { }), ); - it.live("validates persisted capability closure before rebuilding its plan", () => + it.live("rejects a plan whose concrete dependency registration is missing", () => Effect.gen(function* () { const first = yield* compile({}); - const persisted = { - ...first.definition, - capabilities: { - ...first.definition.capabilities, - rest: { ...first.definition.capabilities.rest, enabled: false }, + const result = yield* createExecutionPlan( + { kind: "native" }, + { + ...first.registry, + instances: first.registry.instances.filter((instance) => instance.service !== "rest"), }, - }; - const result = yield* rebuildExecutionPlan({ kind: "native" }, persisted).pipe( - Effect.provide(layer), - Effect.exit, - ); + ).pipe(Effect.exit); expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); }), ); @@ -888,9 +925,6 @@ describe("closed capability compiler", () => { hello: { enabled: true, verify_jwt: false, - import_map: "", - entrypoint: "", - static_files: [], env: {}, }, }); diff --git a/packages/stack/src/model/postgres-release.unit.test.ts b/packages/stack/src/model/postgres-release.unit.test.ts new file mode 100644 index 0000000000..c8240fe75d --- /dev/null +++ b/packages/stack/src/model/postgres-release.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { catalogEntryFor } from "./WorkloadCatalog.ts"; +import { resolvePostgresRelease } from "./PostgresRelease.ts"; +import { StackVersionUnsupportedError } from "../public/Errors.ts"; + +describe("resolvePostgresRelease", () => { + it.effect("resolves the catalog default and a major selector", () => + Effect.gen(function* () { + const entry = catalogEntryFor("database:database"); + const fallback = yield* resolvePostgresRelease(); + expect(fallback.version).toBe(entry.defaultVersion); + expect(fallback.image.length).toBeGreaterThan(0); + + const major = entry.defaultVersion.split(".")[0]; + expect(major).toBeDefined(); + if (major === undefined) return; + const selected = yield* resolvePostgresRelease(major); + expect(selected).toEqual(fallback); + }), + ); + + it.effect("maps a superseded exact pin to the current catalog release of that major", () => + Effect.gen(function* () { + const major = catalogEntryFor("database:database").defaultVersion.split(".")[0]; + expect(major).toBeDefined(); + if (major === undefined) return; + const selected = yield* resolvePostgresRelease(`${major}.0.0.1`); + expect(selected.version).toBe(catalogEntryFor("database:database").defaultVersion); + }), + ); + + it.effect("fails for an unknown PostgreSQL version", () => + Effect.gen(function* () { + const exit = yield* resolvePostgresRelease("99").pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(StackVersionUnsupportedError); + }), + ); +}); diff --git a/packages/stack/src/model/service-instance-compiler.integration.test.ts b/packages/stack/src/model/service-instance-compiler.integration.test.ts new file mode 100644 index 0000000000..82e5d46c47 --- /dev/null +++ b/packages/stack/src/model/service-instance-compiler.integration.test.ts @@ -0,0 +1,298 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Path, Redacted } from "effect"; +import { + compileServiceInstance, + compileServiceRestart, + compileStack, + seedServiceRegistry, +} from "./Compiler.ts"; +import { createExecutionPlan } from "./ExecutionPlan.ts"; +import { emptyServiceRegistry, registerServiceInstance } from "./ServiceRegistry.ts"; +import { AUTH_JWT_SECRET_SLOT, resolveSecrets } from "../state/SecretStore.ts"; + +const layer = NodeServices.layer; +const context = ( + runtime: + | { readonly kind: "native" } + | { readonly kind: "container"; readonly engine: "docker" | "podman" }, +) => + Effect.gen(function* () { + return { projectRoot: "/tmp/supabase-project", path: yield* Path.Path, runtime }; + }); + +describe("service instance compiler", () => { + it.live("allocates independent IDs and instance-scoped secret slots", () => + Effect.gen(function* () { + const runtime = { kind: "native" } as const; + const first = yield* compileServiceInstance( + { + service: "database", + name: "first", + config: { password: Redacted.make("first-password"), settings: {} }, + }, + yield* context(runtime), + ); + const second = yield* compileServiceInstance( + { + service: "database", + name: "second", + config: { password: Redacted.make("second-password"), settings: {} }, + }, + yield* context(runtime), + ); + + expect(first.id).not.toBe(second.id); + expect(first.secretSlots.map(({ slot }) => slot)).toEqual([`secret:${first.id}:password`]); + expect(second.secretSlots.map(({ slot }) => slot)).toEqual([`secret:${second.id}:password`]); + }).pipe(Effect.provide(layer)), + ); + + it.live("resolves equal creation profiles independently of instance identity", () => + Effect.gen(function* () { + const runtime = { kind: "native" } as const; + const initialization = { + catalog: { auth: { settings: { jwt_secret: Redacted.make("same-secret") } } }, + }; + const first = yield* compileServiceInstance( + { service: "database", config: { settings: {} }, initialization }, + yield* context(runtime), + ); + const second = yield* compileServiceInstance( + { service: "database", config: { settings: {} }, initialization }, + yield* context(runtime), + ); + const changed = yield* compileServiceInstance( + { + service: "database", + config: { + settings: {}, + }, + initialization: { + catalog: { auth: { settings: { jwt_secret: Redacted.make("changed-secret") } } }, + }, + }, + yield* context(runtime), + ); + + expect(first.initializationInputs?.profileId).toBe(second.initializationInputs?.profileId); + expect(first.initializationInputs?.profileId).not.toBe( + changed.initializationInputs?.profileId, + ); + }).pipe(Effect.provide(layer)), + ); + + it.live("retains omitted restart inputs and applies explicit replacements", () => + Effect.gen(function* () { + const runtime = { kind: "native" } as const; + const first = yield* compileServiceInstance( + { + service: "database", + config: { + password: Redacted.make("old-password"), + endpoints: { sql: { port: 5432 } }, + settings: {}, + }, + }, + yield* context(runtime), + ); + const retained = yield* compileServiceRestart( + first, + { settings: {} }, + yield* context(runtime), + ); + const replaced = yield* compileServiceRestart( + first, + { + password: Redacted.make("new-password"), + endpoints: { sql: { port: 6543 } }, + settings: {}, + }, + yield* context(runtime), + ); + + expect(retained.id).toBe(first.id); + if (retained.instance.service !== "database") throw new Error("restart changed service kind"); + if (retained.instance.config.endpoints.sql?.enabled === false) + throw new Error("retained endpoint became disabled"); + expect(retained.instance.config.endpoints.sql?.port).toBe(5432); + expect(retained.passwordSecretRef).toBe(first.passwordSecretRef); + if (replaced.instance.service !== "database") throw new Error("restart changed service kind"); + if (replaced.instance.config.endpoints.sql?.enabled === false) + throw new Error("replaced endpoint became disabled"); + expect(replaced.instance.config.endpoints.sql?.port).toBe(6543); + expect(replaced.passwordSecretRef).toBe(`secret:${first.id}:password`); + expect(replaced.secretSlots[0]?.slot).toBe(`secret:${first.id}:password`); + }).pipe(Effect.provide(layer)), + ); + + it.live("generates one database password for seeded and dynamic instances", () => + Effect.gen(function* () { + const runtime = { kind: "native" } as const; + const contextValue = yield* context(runtime); + const dynamic = yield* compileServiceInstance( + { service: "database", config: { settings: {} } }, + contextValue, + ); + const dynamicSlot = `secret:${dynamic.id}:password`; + expect(dynamic.passwordSecretRef).toBe(dynamicSlot); + expect(dynamic.secretSlots).toEqual([ + { + slot: dynamicSlot, + policy: "managed", + generator: { kind: "random-base64url", bytes: 32 }, + }, + ]); + const resolvedDynamic = yield* resolveSecrets( + { declarations: dynamic.secretSlots }, + undefined, + "unconfigured", + ); + const restarted = yield* compileServiceRestart(dynamic, { settings: {} }, contextValue); + const resolvedRestart = yield* resolveSecrets( + { declarations: restarted.secretSlots }, + resolvedDynamic.persisted, + "stopped", + ); + expect(resolvedRestart.persisted[dynamicSlot]?.value).toBe( + resolvedDynamic.persisted[dynamicSlot]?.value, + ); + + const compiled = yield* compileStack({ + projectRoot: "/tmp/supabase-project", + runtime, + config: {}, + }); + const path = yield* Path.Path; + const seeded = yield* seedServiceRegistry( + compiled.definition, + { projectRoot: "/tmp/supabase-project", path, runtime }, + compiled.sourceConfig, + compiled.secrets, + ); + const database = seeded.registry.instances.find((entry) => entry.service === "database"); + if (database === undefined) throw new Error("missing seeded database"); + const seededSlot = `secret:${database.id}:password`; + expect(database.config.passwordSecretRef).toBe(seededSlot); + expect(seeded.secretSlots.some(({ slot }) => slot === seededSlot)).toBe(true); + const resolvedSeed = yield* resolveSecrets( + { declarations: seeded.secretSlots }, + undefined, + "unconfigured", + ); + expect(resolvedSeed.persisted[seededSlot]?.value).toBeTypeOf("string"); + const seededRestart = yield* compileServiceRestart(database, { settings: {} }, contextValue); + const resolvedSeedRestart = yield* resolveSecrets( + { declarations: seededRestart.secretSlots }, + resolvedSeed.persisted, + "stopped", + ); + expect(resolvedSeedRestart.persisted[seededSlot]?.value).toBe( + resolvedSeed.persisted[seededSlot]?.value, + ); + }).pipe(Effect.provide(layer)), + ); + + it.live("declares generated Realtime catalog secrets when the service itself is disabled", () => + Effect.gen(function* () { + const compiled = yield* compileServiceInstance( + { + service: "database", + config: { settings: {} }, + initialization: { catalog: { realtime: {} } }, + }, + yield* context({ kind: "native" }), + ); + const realtimeSlots = compiled.secretSlots.filter( + (slot) => slot.slot.endsWith(".db_enc_key") || slot.slot.endsWith(".secret_key_base"), + ); + expect(realtimeSlots).toHaveLength(2); + expect(realtimeSlots.every((slot) => slot.generator?.kind === "random-base64url")).toBe(true); + }).pipe(Effect.provide(layer)), + ); + + it.live("seeds defaults once and plans two database instances plus independent functions", () => + Effect.gen(function* () { + const runtime = { kind: "native" } as const; + const path = yield* Path.Path; + const sourceConfig = { + capabilities: { + database: { enabled: false }, + functions: { + enabled: true, + settings: { edge_runtime: { secrets: { FOO: Redacted.make("bar") } } }, + }, + }, + listeners: { database: { port: 5432 } }, + }; + const compiled = yield* compileStack({ + projectRoot: "/tmp/supabase-project", + runtime, + config: sourceConfig, + }); + const seeded = yield* seedServiceRegistry( + compiled.definition, + { projectRoot: "/tmp/supabase-project", path, runtime }, + compiled.sourceConfig, + compiled.secrets, + ); + const database = seeded.registry.instances.find( + (instance) => instance.service === "database", + ); + const functions = seeded.registry.instances.find( + (instance) => instance.service === "functions", + ); + if (database === undefined || functions === undefined) + throw new Error("missing seeded instances"); + const first = yield* compileServiceInstance( + { service: "database", config: { settings: {} } }, + yield* context(runtime), + ); + const second = yield* compileServiceInstance( + { service: "database", config: { settings: {} } }, + yield* context(runtime), + ); + const withFirst = yield* registerServiceInstance(seeded.registry, first.instance); + const withBoth = yield* registerServiceInstance(withFirst, second.instance); + const plan = yield* createExecutionPlan(runtime, withBoth); + + expect(database.config.enabled).toBe(false); + expect(functions.config.enabled).toBe(true); + if (functions.service !== "functions") throw new Error("wrong functions instance"); + const functionSecret = functions.config.settings.edge_runtime?.secrets?.FOO; + if (functionSecret === undefined || typeof functionSecret !== "object") + throw new Error("missing configured functions secret"); + expect(functionSecret.slot.startsWith(`secret:${functions.id}.`)).toBe(true); + expect(seeded.secretSlots.some(({ slot }) => slot === AUTH_JWT_SECRET_SLOT)).toBe(true); + expect(plan.workloads.filter(({ instanceId }) => instanceId === first.id)).toHaveLength(1); + expect(plan.workloads.filter(({ instanceId }) => instanceId === second.id)).toHaveLength(1); + expect(plan.workloads.some(({ instanceId }) => instanceId === functions.id)).toBe(true); + expect(plan.workloads.some(({ instanceId }) => instanceId === database.id)).toBe(false); + }).pipe(Effect.provide(layer)), + ); + + it.live("allows selected planning around unrelated disabled dependencies", () => + Effect.gen(function* () { + const runtime = { kind: "native" } as const; + const database = yield* compileServiceInstance( + { service: "database", config: { enabled: false, settings: {} } }, + yield* context(runtime), + ); + const functions = yield* compileServiceInstance( + { service: "functions", config: { settings: {} } }, + yield* context(runtime), + ); + const registry = yield* registerServiceInstance( + yield* registerServiceInstance(emptyServiceRegistry(), database.instance), + functions.instance, + ); + const plan = yield* createExecutionPlan( + runtime, + registry, + undefined, + new Set([functions.id]), + ); + expect(plan.workloads.every(({ instanceId }) => instanceId === functions.id)).toBe(true); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index eac0dee437..2ff6a7dab5 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -38,6 +38,10 @@ export interface PreparedWorkloadArtifact { } export type RuntimeArtifactPreparationProgress = ArtifactPreparationStatus; +export type RuntimeArtifactInput = Pick< + PlannedWorkload, + "id" | "recipeId" | "capability" | "artifacts" | "selected" +>; type RuntimeArtifactPreparationProgressListener = ( progress: RuntimeArtifactPreparationProgress, ) => void; @@ -45,7 +49,7 @@ type RuntimeArtifactPreparationProgressListener = ( export interface RuntimeArtifactPreparer { readonly prepare: ( runtime: StackRuntime, - workload: PlannedWorkload, + workload: RuntimeArtifactInput, onProgress?: RuntimeArtifactPreparationProgressListener, ) => Effect.Effect; } @@ -86,7 +90,7 @@ export const makeRuntimeArtifactPreparer = ( ): RuntimeArtifactPreparer => { const prepare = ( runtime: StackRuntime, - workload: PlannedWorkload, + workload: RuntimeArtifactInput, onProgress?: RuntimeArtifactPreparationProgressListener, ): Effect.Effect => Effect.suspend(() => { @@ -204,7 +208,7 @@ export const makeRuntimeArtifactPreparer = ( }; const nativeResult = ( - workload: PlannedWorkload, + workload: RuntimeArtifactInput, artifact: NativeWorkloadArtifact, prepared: PreparedArtifact, ): PreparedWorkloadArtifact => ({ diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 471ac1a399..e6ec643d4d 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -22,12 +22,15 @@ import { import { ContainerEngineError, StackPreparationError } from "../public/Errors.ts"; import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; const databaseRelease = catalogReleaseFor("database:database"); if (databaseRelease === undefined) throw new Error("Missing default database release"); const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload => ({ id: "database:database", + instanceId: ServiceInstanceIdSchema.make("primary"), + recipeId: "database:database", capability: "database", dependencies: [], readiness: {}, @@ -101,6 +104,7 @@ const containerEngine = ( removeVolume: () => Effect.void, createContainer: (_spec: ContainerContainerSpec) => Effect.die("unused"), copyToContainer: () => Effect.die("unused"), + execContainer: () => Effect.die("unused"), startContainer: () => Effect.void, waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, diff --git a/packages/stack/src/public/Capability.ts b/packages/stack/src/public/Capability.ts index 9037b5f18a..7e0b52beae 100644 --- a/packages/stack/src/public/Capability.ts +++ b/packages/stack/src/public/Capability.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; export const CAPABILITY_NAMES = [ "database", @@ -31,6 +32,7 @@ export const ActivationModeSchema = Schema.Literals(["eager", "lazy"] as const); export type ActivationMode = Schema.Schema.Type; export const CapabilityStatusSchema = Schema.Struct({ + id: ServiceInstanceIdSchema, name: CapabilityNameSchema, activation: ActivationModeSchema, state: CapabilityStateSchema, diff --git a/packages/stack/src/public/Config.ts b/packages/stack/src/public/Config.ts index aa163dfc80..08439e704f 100644 --- a/packages/stack/src/public/Config.ts +++ b/packages/stack/src/public/Config.ts @@ -19,6 +19,8 @@ import { import { PoolerSettingsSchema, type PoolerSettings } from "../model/capabilities/pooler.ts"; import { ActivationModeSchema, type ActivationMode } from "./Capability.ts"; import { NetworkPortSchema, PORT_FIELDS, type PortField } from "./Status.ts"; +import { EffectDatabaseInitializationSchema, ServiceRestartPayloadSchema } from "./Service.ts"; +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; export const PreparationModeSchema = Schema.Literals(["background", "on-demand"] as const); type PreparationMode = Schema.Schema.Type; @@ -57,13 +59,17 @@ const retirableCapability = (settings: S) => }), ]); -export const DatabaseCapabilityConfigSchema = Schema.Struct({ - // PostgreSQL accepts an exact catalog release or a major selector such as - // "15"/"17". Major selectors are resolved to a concrete catalog release by - // the compiler, preserving a compatible previous pin when one exists. - version: Schema.optionalKey(Schema.String), - settings: Schema.optionalKey(DatabaseSettingsSchema), -}); +export const DatabaseCapabilityConfigSchema = Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + // PostgreSQL accepts an exact catalog release or a major selector such as + // "15"/"17". Major selectors are resolved to a concrete catalog release by + // the compiler, preserving a compatible previous pin when one exists. + enabled: Schema.optionalKey(Schema.Literal(true)), + version: Schema.optionalKey(Schema.String), + settings: Schema.optionalKey(DatabaseSettingsSchema), + }), +]); export const StackCapabilitiesConfigSchema = Schema.Struct({ database: Schema.optionalKey(DatabaseCapabilityConfigSchema), rest: Schema.optionalKey(retirableCapability(RestSettingsSchema)), @@ -98,6 +104,7 @@ export const StackSecurityConfigSchema = Schema.Struct({ jwt: Schema.optionalKey( Schema.Struct({ issuer: Schema.optionalKey(Schema.String), + expirySeconds: Schema.optionalKey(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)))), signing: Schema.optionalKey(JwtSigningSchema), }), ), @@ -108,10 +115,31 @@ export const StackConfigSchema = Schema.Struct({ capabilities: Schema.optionalKey(StackCapabilitiesConfigSchema), listeners: Schema.optionalKey(StackListenersConfigSchema), security: Schema.optionalKey(StackSecurityConfigSchema), + initialization: Schema.optionalKey( + Schema.Struct({ database: Schema.optionalKey(EffectDatabaseInitializationSchema) }), + ), }); export type StackConfig = Schema.Schema.Type; export type StackCapabilitiesConfig = Schema.Schema.Type; export type StackSecurityConfig = Schema.Schema.Type; + +/** Stack restart accepts either a whole-stack candidate or selected instance updates. */ +export const StackRestartPayloadSchema = Schema.Union( + [ + Schema.Struct({ + config: Schema.optionalKey(StackConfigSchema), + services: Schema.optionalKey(Schema.Never), + updates: Schema.optionalKey(Schema.Never), + }), + Schema.Struct({ + services: Schema.Array(ServiceInstanceIdSchema), + updates: Schema.optionalKey(Schema.Array(ServiceRestartPayloadSchema)), + config: Schema.optionalKey(Schema.Never), + }), + ], + { mode: "oneOf" }, +); +export type StackRestartPayload = Schema.Schema.Type; export { DatabaseSettingsSchema, RestSettingsSchema, diff --git a/packages/stack/src/public/Credentials.ts b/packages/stack/src/public/Credentials.ts index 8b78a3e631..e7df53e0c5 100644 --- a/packages/stack/src/public/Credentials.ts +++ b/packages/stack/src/public/Credentials.ts @@ -1,54 +1,58 @@ import { Schema } from "effect"; -import type * as Redacted from "effect/Redacted"; -const EffectDatabaseCredentialsSchema = Schema.Struct({ - url: Schema.RedactedFromValue(Schema.String), - password: Schema.RedactedFromValue(Schema.String), +export const DatabaseCredentialsSchema = Schema.Struct({ + url: Schema.String, + password: Schema.String, }); +export type DatabaseCredentials = Schema.Schema.Type; -const EffectApiCredentialsSchema = Schema.Struct({ +export const ApiCredentialsSchema = Schema.Struct({ publishableKey: Schema.String, - secretKey: Schema.RedactedFromValue(Schema.String), + secretKey: Schema.String, anonJwt: Schema.String, - serviceRoleJwt: Schema.RedactedFromValue(Schema.String), + serviceRoleJwt: Schema.String, }); +export type ApiCredentials = Schema.Schema.Type; -const EffectStorageCredentialsSchema = Schema.Struct({ +export const StorageCredentialsSchema = Schema.Struct({ endpoint: Schema.String, region: Schema.String, accessKeyId: Schema.String, - secretAccessKey: Schema.RedactedFromValue(Schema.String), + secretAccessKey: Schema.String, }); +export type StorageCredentials = Schema.Schema.Type; + +export const EmptyServiceCredentialsSchema = Schema.Struct({ kind: Schema.Literal("none") }); +export type EmptyServiceCredentials = Schema.Schema.Type; export const EffectStackCredentialsSchema = Schema.Struct({ - database: EffectDatabaseCredentialsSchema, - api: Schema.optionalKey(EffectApiCredentialsSchema), - storage: Schema.optionalKey(EffectStorageCredentialsSchema), + database: Schema.optionalKey( + Schema.Struct({ + url: Schema.Redacted(Schema.String), + password: Schema.Redacted(Schema.String), + }), + ), + api: Schema.optionalKey( + Schema.Struct({ + publishableKey: Schema.String, + secretKey: Schema.Redacted(Schema.String), + anonJwt: Schema.String, + serviceRoleJwt: Schema.Redacted(Schema.String), + }), + ), + storage: Schema.optionalKey( + Schema.Struct({ + endpoint: Schema.String, + region: Schema.String, + accessKeyId: Schema.String, + secretAccessKey: Schema.Redacted(Schema.String), + }), + ), }); -export interface EffectStackCredentials { - readonly database: { - readonly url: Redacted.Redacted; - readonly password: Redacted.Redacted; - }; - readonly api?: { - readonly publishableKey: string; - readonly secretKey: Redacted.Redacted; - readonly anonJwt: string; - readonly serviceRoleJwt: Redacted.Redacted; - }; - readonly storage?: { - readonly endpoint: string; - readonly region: string; - readonly accessKeyId: string; - readonly secretAccessKey: Redacted.Redacted; - }; -} +export type EffectStackCredentials = Schema.Schema.Type; export const PromiseStackCredentialsSchema = Schema.Struct({ - database: Schema.Struct({ - url: Schema.String, - password: Schema.String, - }), + database: Schema.optionalKey(Schema.Struct({ url: Schema.String, password: Schema.String })), api: Schema.optionalKey( Schema.Struct({ publishableKey: Schema.String, diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 96c8f477ea..0677271af8 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -2,11 +2,10 @@ import { Cause, Context, Crypto, - Deferred, + Data, Effect, Exit, FileSystem, - Fiber, Match, Option, Path, @@ -25,13 +24,24 @@ import type { StackIdentity } from "../identity/Identity.ts"; import { resolveStackIdentity, deriveStackId } from "../identity/Identity.ts"; import { compileStack, - rebuildExecutionPlan, - sameDefinition, + fingerprintCreationInputs, + fingerprintBootstrapInputs, + fingerprintEffectiveConfig, + createExecutionPlan, + canonical, + resolvedStateValue, + seedServiceRegistry, type SecretSlotInput, + type SeededServiceRegistry, type StackDefinition, } from "../model/Compiler.ts"; -import { dependencyClosure, type ExecutionPlan } from "../model/ExecutionPlan.ts"; -import type { PersistedStackState } from "../state/StackState.ts"; +import { makeProductionRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import { dependencyClosure } from "../model/ExecutionPlan.ts"; +import { STACK_STATE_FORMAT, type PersistedStackState } from "../state/StackState.ts"; +import { + PersistedServiceRegistrySchema, + type PersistedServiceRegistry, +} from "../model/ServiceRegistry.ts"; import { toPersistedIdentity } from "../state/StackState.ts"; import { isMissingStateRemnantError, @@ -40,16 +50,35 @@ import { type StackStateStore, } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; -import { StackIdSchema, type StackId } from "./StackId.ts"; +import { AUTH_JWT_SECRET_SLOT, resolveSecrets } from "../state/SecretStore.ts"; +import { plannedInstancePorts } from "../supervisor/InstanceEngine.ts"; +import { isStackId, StackIdSchema, type StackId } from "./StackId.ts"; import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; import type { StackConfig } from "./Config.ts"; +import type { ServiceInstanceId } from "./ServiceInstanceId.ts"; +import type { + AnyEffectServiceInstance, + AnyServiceDescriptor, + EffectServiceCollection, + EffectServiceInstance, + ServiceDescriptor, + ServiceKind, + SnapshotDescriptor, + PrepareResult, + ServiceCredentials, +} from "./Service.ts"; +import { + EffectCreateServiceOptionsSchema, + SnapshotDescriptorSchema, + ServiceRestartPayloadSchema, +} from "./Service.ts"; import { type ArtifactPreparationStatus, type StackStatus, type StackDescriptor, type StackInspection, + type StackRecovery, } from "./Status.ts"; -import { CAPABILITY_NAMES, type CapabilityName } from "./Capability.ts"; import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import type { EffectStackCredentials } from "./Credentials.ts"; import { @@ -61,6 +90,8 @@ import { StackNotFoundError, StackNotRunningError, StackOwnershipConflictError, + OwnerRetiringError, + UncertainOperationError, StackRuntimeMismatchError, StackLifecycleConflictError, StackPreparationError, @@ -71,12 +102,17 @@ import { StackRuntimeError, StackCleanupError, ContainerEngineError, - EphemeralPostgresError, - RequiresActivatedProcessError, StackStateInvalidError, StackStateFormatUnsupportedError, StackUpgradeRequiredError, StackMustBeStoppedError, + ServiceNotFoundError, + ServiceNameConflictError, + ServiceDependencyError, + InitializationMismatchError, + UnsupportedSnapshotError, + NoSnapshotDataError, + SnapshotTargetInvalidError, PortAllocationError, PortUnavailableError, GatewayActivationError, @@ -92,9 +128,9 @@ import { type StackStopError, type StackLogsError, type DestroyStackError, - type ResetDatabaseError, type StackError, type StackErrorTag, + type LifecycleOutcome, isStackError, isStackErrorTag, PREPARE_STACK_ERROR_TAGS, @@ -104,11 +140,12 @@ import { STACK_STOP_ERROR_TAGS, STACK_LOGS_ERROR_TAGS, DESTROY_STACK_ERROR_TAGS, - RESET_DATABASE_ERROR_TAGS, + CREATE_STACK_ERROR_TAGS, } from "./Errors.ts"; import { ownerLockExists, readOwnerMetadata, + waitForOwnerRelease, type OwnerMetadata, type StackRuntimeEnvironmentValue, } from "../state/Ownership.ts"; @@ -130,21 +167,26 @@ import { NATIVE_ROOT_UNSUPPORTED_MESSAGE, type ContainerEngineResolverShape, } from "../runtime/ContainerEngineResolver.ts"; -import { formatStopTimeoutMessage } from "../runtime/Diagnostics.ts"; -import { statusForSnapshot } from "../supervisor/StatusProjection.ts"; -import type { SupervisorSnapshot } from "../supervisor/SupervisorState.ts"; +import { statusForPersistedState } from "../supervisor/StatusProjection.ts"; import { EMPTY_LOG_CURSOR, readRetainedLogs, selectLogBatch } from "../supervisor/LogStore.ts"; -import { - makeProductionRuntimeArtifactPreparer, - type PreparedWorkloadArtifact, -} from "../preparation/RuntimeArtifacts.ts"; export interface StartStackOptions { - readonly config?: StackConfig; + readonly services?: ReadonlyArray; +} +export interface ServiceSelection { + readonly services?: ReadonlyArray; } +export type ServiceConfigUpdate = import("./Service.ts").ServiceRestartPayload; +export type RestartStackOptions = + | { readonly services?: never; readonly config?: StackConfig } + | { + readonly services: ReadonlyArray; + readonly updates?: ReadonlyArray; + readonly config?: never; + }; export interface PrepareStackOptions { readonly config?: StackConfig; - readonly capabilities?: ReadonlyArray; + readonly services?: ReadonlyArray; /** Synchronous progress observer for this caller-owned preparation. */ readonly onProgress?: (status: ArtifactPreparationStatus) => void; } @@ -152,6 +194,10 @@ export interface CreateStackOptions { readonly projectRoot: string; readonly name?: string; readonly runtime?: StackRuntimePreference; + readonly initialConfig: StackConfig; +} +export interface OpenStackOptions { + readonly initialConfig?: StackConfig; } export interface FindStackOptions { readonly projectRoot: string; @@ -164,27 +210,34 @@ export interface ListStacksOptions { export interface InspectStackOptions { readonly config?: StackConfig; } -export interface PreparedCapability { - readonly capability: CapabilityName; - readonly version: string; - readonly outcome: "cached" | "downloaded" | "pulled"; +interface PrepareStackInstance { + readonly id: ServiceInstanceId; + readonly service: ServiceKind; + readonly artifacts: ReadonlyArray<{ + readonly identity: string; + readonly outcome: "cached" | "downloaded" | "pulled"; + }>; + readonly effectiveConfigFingerprint?: string; } export interface PrepareStackResult { - readonly capabilities: ReadonlyArray; + readonly instances: ReadonlyArray; } export interface EffectStack { readonly id: StackId; + readonly services: EffectServiceCollection; readonly status: Effect.Effect; + readonly followStatus: Stream.Stream; readonly credentials: Effect.Effect; readonly prepare: ( options?: PrepareStackOptions, ) => Effect.Effect; readonly start: (options?: StartStackOptions) => Effect.Effect; - readonly stop: Effect.Effect; - readonly destroy: Effect.Effect; - readonly resetDatabase: Effect.Effect; + readonly sleep: (options?: ServiceSelection) => Effect.Effect; + readonly stop: (options?: ServiceSelection) => Effect.Effect; + readonly restart: (options?: RestartStackOptions) => Effect.Effect; + readonly destroy: (options?: ServiceSelection) => Effect.Effect; readonly logs: (query?: LogQuery) => Effect.Effect; readonly followLogs: (query?: LogQuery) => Stream.Stream; /** Present when auto-select persisted native because the Docker daemon was down. */ @@ -200,7 +253,9 @@ const descriptor = (state: PersistedStackState, id: StackId): StackDescriptor => name: state.identity.stackName, branchContext: state.identity.branchContext, runtime: state.runtime, - desiredLifecycle: state.desiredLifecycle, + desiredLifecycle: state.registry.instances.some((instance) => instance.intent === "started") + ? "running" + : "stopped", }); const environment = () => @@ -210,8 +265,82 @@ const environment = () => ), ); -const isCapabilityName = (value: unknown): value is CapabilityName => - typeof value === "string" && CAPABILITY_NAMES.some((name) => name === value); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isLifecycleOutcome = (value: unknown): value is LifecycleOutcome => + isRecord(value) && + ["requested", "affected", "succeeded", "failed"].every((key) => Array.isArray(value[key])); + +const isStackRecovery = (value: unknown): value is StackRecovery => + isRecord(value) && + (value.operation === "stop" || value.operation === "destroy") && + typeof value.message === "string"; + +const isServiceDescriptor = (value: unknown): value is AnyServiceDescriptor => + isRecord(value) && + typeof value.id === "string" && + typeof value.service === "string" && + (value.name === undefined || typeof value.name === "string") && + typeof value.enabled === "boolean" && + isRecord(value.config) && + isRecord(value.dependencies) && + isRecord(value.endpoints); + +const isServiceDescriptorFor = + (service: K) => + (value: unknown): value is ServiceDescriptor => + isServiceDescriptor(value) && value.service === service; + +const isServiceStatus = (value: unknown): value is import("./Status.ts").ServiceStatus => + isRecord(value) && + typeof value.id === "string" && + typeof value.service === "string" && + (value.name === undefined || typeof value.name === "string") && + typeof value.enabled === "boolean" && + (value.intent === "started" || value.intent === "stopped") && + typeof value.phase === "string" && + typeof value.activation === "string" && + Array.isArray(value.endpoints); + +const isStackStatus = (value: unknown): value is StackStatus => + isRecord(value) && + typeof value.id === "string" && + typeof value.lifecycle === "string" && + typeof value.desiredLifecycle === "string" && + isRecord(value.runtime) && + Array.isArray(value.capabilities) && + Array.isArray(value.instances) && + isRecord(value.endpoints); + +const isServiceCredentials = ( + service: K, + value: unknown, +): value is ServiceCredentials => { + if (value === undefined) return service === "database"; + if (!isRecord(value)) return false; + if (service === "database") + return typeof value.url === "string" && typeof value.password === "string"; + if (service === "functions" || service === "storage") return true; + return value.kind === "none"; +}; + +const isServiceDescriptorList = (value: unknown): value is ReadonlyArray => + Array.isArray(value) && value.every(isServiceDescriptor); + +const isPrepareResult = (value: unknown): value is PrepareResult => + isRecord(value) && + Array.isArray(value.instances) && + value.instances.every( + (entry) => + isRecord(entry) && + typeof entry.id === "string" && + typeof entry.service === "string" && + Array.isArray(entry.artifacts), + ); + +const isStackLogBatch = (value: unknown): value is StackLogBatch => + isRecord(value) && Array.isArray(value.entries) && isRecord(value.cursor); type ControlError = | StackRpcError @@ -220,13 +349,37 @@ type ControlError = | MaintenanceProtocolError | StackError; -const stackErrorFactories = { +type MutationContext = { + readonly mutation: UncertainOperationError["mutation"]; + readonly instanceId?: string; + readonly expectedCreationInputsId?: string; +}; + +class PreAdmissionOwnerLoss extends Data.TaggedError("PreAdmissionOwnerLoss")<{ + readonly ownerSessionId: string; + readonly cause: RpcClientError; +}> {} + +const isPreAdmissionOwnerLoss = (value: unknown): value is PreAdmissionOwnerLoss => + isRecord(value) && + value._tag === "PreAdmissionOwnerLoss" && + typeof value.ownerSessionId === "string" && + Predicate.isTagged(value.cause, "RpcClientError"); + +const stackErrorFactories: Partial StackError>> = { InvalidStackIdentityError: (message: string) => new InvalidStackIdentityError({ message }), InvalidProjectRootError: (message: string) => new InvalidProjectRootError({ message }), InvalidStackConfigError: (message: string) => new InvalidStackConfigError({ message }), StackVersionUnsupportedError: (message: string) => new StackVersionUnsupportedError({ message }), StackNotFoundError: (message: string) => new StackNotFoundError({ message }), StackOwnershipConflictError: (message: string) => new StackOwnershipConflictError({ message }), + ServiceNotFoundError: (message: string) => new ServiceNotFoundError({ message }), + ServiceNameConflictError: (message: string) => new ServiceNameConflictError({ message }), + ServiceDependencyError: (message: string) => new ServiceDependencyError({ message }), + InitializationMismatchError: (message: string) => new InitializationMismatchError({ message }), + UnsupportedSnapshotError: (message: string) => new UnsupportedSnapshotError({ message }), + NoSnapshotDataError: (message: string) => new NoSnapshotDataError({ message }), + SnapshotTargetInvalidError: (message: string) => new SnapshotTargetInvalidError({ message }), StackRuntimeMismatchError: (message: string) => new StackRuntimeMismatchError({ message }), StackNotRunningError: (message: string) => new StackNotRunningError({ message }), StackMustBeStoppedError: (message: string) => new StackMustBeStoppedError({ message }), @@ -249,23 +402,51 @@ const stackErrorFactories = { StackCleanupError: (message: string) => new StackCleanupError({ message }), ContainerEngineError: (message: string) => new ContainerEngineError({ message }), StackDestructionError: (message: string) => new StackDestructionError({ message }), - EphemeralPostgresError: (message: string) => new EphemeralPostgresError({ message }), - RequiresActivatedProcessError: (message: string) => - new RequiresActivatedProcessError({ message, capability: "unknown" }), PostgresClientError: (message: string) => new PostgresClientError({ message }), -} satisfies Record StackError>; +}; const isOwnerUnreachable = (error: unknown): boolean => Predicate.isTagged(error, "RpcClientError") || Predicate.isTagged(error, "SocketError") || - isMaintenanceTransportFailure(error); + Predicate.isTagged(error, "SocketOpenError") || + Predicate.isTagged(error, "SocketCloseError") || + isMaintenanceTransportFailure(error) || + (isRecord(error) && "reason" in error && isOwnerUnreachable(error.reason)); -const errorForRpc = (error: ControlError): StackError => { +const isUncertainMutation = (value: unknown): value is UncertainOperationError["mutation"] => + typeof value === "string" && + ["create", "restore", "start", "sleep", "stop", "restart", "destroy", "exportSnapshot"].some( + (mutation) => mutation === value, + ); + +const isOwnerRetiringControlError = (value: unknown): value is StackRpcError => + isRecord(value) && value.tag === "OwnerRetiringError" && typeof value.ownerSessionId === "string"; + +const errorForRpc = ( + error: ControlError, + context: { + readonly stackId?: StackId; + readonly mutation?: UncertainOperationError["mutation"]; + readonly instanceId?: string; + readonly expectedCreationInputsId?: string; + } = {}, +): StackError => { if (isStackError(error)) return error; - if (isOwnerUnreachable(error)) + if (isOwnerUnreachable(error)) { + if (context.stackId !== undefined && context.mutation !== undefined) + return new UncertainOperationError({ + message: `The ${context.mutation} response was lost after dispatch: ${error.message}`, + stackId: context.stackId, + mutation: context.mutation, + ...(context.instanceId === undefined ? {} : { instanceId: context.instanceId }), + ...(context.expectedCreationInputsId === undefined + ? {} + : { expectedCreationInputsId: context.expectedCreationInputsId }), + }); return new StackOwnershipConflictError({ message: `Stack owner is unreachable: ${error.message}`, }); + } if ( typeof error === "object" && error !== null && @@ -274,7 +455,77 @@ const errorForRpc = (error: ControlError): StackError => { typeof error.tag === "string" && typeof error.message === "string" ) { - if (isStackErrorTag(error.tag)) return stackErrorFactories[error.tag](error.message); + if ( + error.tag === "UncertainOperationError" && + "stackId" in error && + typeof error.stackId === "string" && + isStackId(error.stackId) && + "mutation" in error && + isUncertainMutation(error.mutation) + ) { + return new UncertainOperationError({ + message: error.message, + stackId: error.stackId, + mutation: error.mutation, + ...(typeof error.instanceId === "string" ? { instanceId: error.instanceId } : {}), + ...(typeof error === "object" && + error !== null && + "operationId" in error && + typeof error.operationId === "string" + ? { operationId: error.operationId } + : {}), + ...(typeof error === "object" && + error !== null && + "expectedCreationInputsId" in error && + typeof error.expectedCreationInputsId === "string" + ? { expectedCreationInputsId: error.expectedCreationInputsId } + : {}), + ...(context.expectedCreationInputsId === undefined || + ("expectedCreationInputsId" in error && typeof error.expectedCreationInputsId === "string") + ? {} + : { expectedCreationInputsId: context.expectedCreationInputsId }), + }); + } + if ( + error.tag === "OwnerRetiringError" && + "stackId" in error && + "ownerSessionId" in error && + typeof error.stackId === "string" && + isStackId(error.stackId) && + typeof error.ownerSessionId === "string" + ) + return new OwnerRetiringError({ + message: error.message, + stackId: error.stackId, + ownerSessionId: error.ownerSessionId, + }); + if ( + error.tag === "StackLifecycleConflictError" && + ("instanceId" in error || "outcome" in error || "recovery" in error) + ) + return new StackLifecycleConflictError({ + message: error.message, + ...(typeof error.stackId === "string" && isStackId(error.stackId) + ? { stackId: error.stackId } + : {}), + ...(typeof error.instanceId === "string" ? { instanceId: error.instanceId } : {}), + ...("outcome" in error && isLifecycleOutcome(error.outcome) + ? { outcome: error.outcome } + : {}), + ...("recovery" in error && isStackRecovery(error.recovery) + ? { recovery: error.recovery } + : {}), + }); + if ( + error.tag === "StackDestructionError" && + "outcome" in error && + isLifecycleOutcome(error.outcome) + ) + return new StackDestructionError({ message: error.message, outcome: error.outcome }); + if (isStackErrorTag(error.tag)) { + const factory = stackErrorFactories[error.tag]; + if (factory !== undefined) return factory(error.message); + } return new StackStateInvalidError({ message: error.message }); } return new StackStateInvalidError({ message: error.message }); @@ -314,12 +565,13 @@ const logsError = (error: ControlError): StackLogsError => narrowError(error, STACK_LOGS_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); const destroyError = (error: ControlError): DestroyStackError => narrowError(error, DESTROY_STACK_ERROR_TAGS, (message) => new StackDestructionError({ message })); -const resetDatabaseError = (error: ControlError): ResetDatabaseError => - narrowError( - error, - RESET_DATABASE_ERROR_TAGS, - (message) => new StackStateInvalidError({ message }), - ); +const createError = (error: unknown): CreateStackError => + isStackError(error) && isNarrowError(error, CREATE_STACK_ERROR_TAGS) + ? error + : new StackStateInvalidError({ + message: error instanceof Error ? error.message : String(error), + cause: error, + }); /** Internal control-transport seam used by public lifecycle integration tests. */ export interface HandleDependencies { @@ -329,14 +581,15 @@ export interface HandleDependencies { readonly readOfflineState: Effect.Effect, StackError>; readonly readPersistedState: Effect.Effect, StackError>; readonly readLogs: (query?: LogQuery) => Effect.Effect; - readonly waitForRelease: Effect.Effect; + readonly waitForRelease: (ownerSessionId?: string) => Effect.Effect; readonly prepare: ( options?: PrepareStackOptions, ) => Effect.Effect; + readonly fingerprintCreationInputs?: (options: unknown) => Effect.Effect; } /** @internal Owner metadata together with whether this handle launched the owner. */ -export interface OwnerResolution { +interface OwnerResolution { readonly owner: OwnerMetadata; readonly launched: boolean; } @@ -344,11 +597,15 @@ export interface OwnerResolution { export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Effect => Effect.sync(() => { const isStoppedState = (state: PersistedStackState): boolean => - state.desiredLifecycle === "stopped" || state.desiredLifecycle === "unconfigured"; + state.registry.instances.every( + (instance) => instance.intent === "stopped" && instance.pendingOperation === null, + ); const stackNotFound = () => new StackNotFoundError({ message: "Stack state was not found" }); + const serviceSelectionPayload = (selection: ServiceSelection | undefined) => + selection?.services === undefined ? {} : { services: selection.services }; type ResolvedClient = { readonly client: ReturnType; - readonly resolution: OwnerResolution; + readonly ownerSessionId: string; }; const resolveClient = ( launch: boolean, @@ -366,211 +623,126 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff }), ) : Effect.succeed({ - resolution: resolution.value, client: makeControlClient(resolution.value.owner.endpoint, { stackId: id, ownerSessionId: resolution.value.owner.ownerSessionId, rpcRelease: protocol === "rpc" ? STACK_RPC_RELEASE : resolution.value.owner.rpcRelease, }), + ownerSessionId: resolution.value.owner.ownerSessionId, }) : Effect.fail( new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), ), ), ); - const stopExactOwner = (owner: OwnerMetadata): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const client = makeControlClient(owner.endpoint, { - stackId: id, - ownerSessionId: owner.ownerSessionId, - rpcRelease: owner.rpcRelease, - }); - const stop = yield* Effect.exit(client.stop); - if ( - Exit.isSuccess(stop) && - !stop.value.ok && - stop.value.error.tag === "operation-failed" && - stop.value.error.stackErrorTag === "StackLifecycleConflictError" - ) { - return; - } - const release = yield* Effect.exit(options.waitForRelease); - let cause: Cause.Cause = Cause.empty; - if (Exit.isFailure(stop)) { - cause = Cause.combine( - cause, - Cause.fail( - new StackCleanupError({ - message: "Unable to stop freshly launched Supervisor", - cause: stop.cause, - }), - ), - ); - } else if (!stop.value.ok) { - cause = Cause.combine( - cause, - Cause.fail( - new StackCleanupError({ - message: stop.value.error.message, - cause: stop.value.error, - }), - ), - ); - } - if (Exit.isFailure(release)) { - cause = Cause.combine( - cause, - Cause.fail( - new StackCleanupError({ - message: "Freshly launched Supervisor did not release ownership", - cause: release.cause, - }), - ), - ); - } - if (cause.reasons.length > 0) return yield* Effect.failCause(cause); - }), - ); - const shouldCleanupFreshOwner = (result: Exit.Exit): boolean => { - if (Exit.isSuccess(result)) return false; - if (Cause.hasInterruptsOnly(result.cause)) return true; - const failure = Cause.findErrorOption(result.cause); - return Option.isSome(failure) && Predicate.isTagged(failure.value, "RpcClientError"); - }; - const cleanupLaunchedOwner = ( - resolution: OwnerResolution, - result: Exit.Exit, - ): Effect.Effect => - resolution.launched && shouldCleanupFreshOwner(result) - ? Effect.uninterruptible( - stopExactOwner(resolution.owner).pipe( - Effect.catchCause((cleanupCause) => - Effect.fail( - new StackCleanupError({ - message: "Unable to clean up freshly launched Supervisor", - cause: Cause.combine( - Exit.isFailure(result) ? result.cause : Cause.empty, - cleanupCause, - ), - }), - ), - ), - ), - ) - : Effect.void; const invoke = ( call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, launch = false, + mutation?: MutationContext, ): Effect.Effect => { - const rpcCall: Effect.Effect = resolveClient( - launch, - ).pipe( - Effect.flatMap(({ client, resolution }) => - Effect.scoped(client.rpc.pipe(Effect.flatMap(call))).pipe( - Effect.onExit((result) => - launch ? cleanupLaunchedOwner(resolution, result) : Effect.void, + const rawAttempt = (): Effect.Effect => + resolveClient(launch).pipe( + Effect.flatMap(({ client, ownerSessionId }) => { + // Opening the RPC channel precedes handler admission. A lost owner here is safe to + // retry once; errors after `call` starts may represent an already committed mutation. + return client.rpc.pipe( + Effect.catchIf( + (error): error is RpcClientError => isOwnerUnreachable(error), + (cause) => + Effect.fail( + new PreAdmissionOwnerLoss({ + ownerSessionId, + cause, + }), + ), + ), + Effect.flatMap((rpc) => Effect.suspend(() => call(rpc))), + Effect.scoped, + ); + }), + ); + // Retirement is reported before admission. Wait for that owner to release, then + // resolve a fresh owner once; an admitted or ambiguous operation is never replayed. + return rawAttempt().pipe( + Effect.catchIf( + (error): error is StackRpcError | PreAdmissionOwnerLoss => + isOwnerRetiringControlError(error) || isPreAdmissionOwnerLoss(error), + (error) => + options.waitForRelease(error.ownerSessionId).pipe( + Effect.mapError((waitError): ControlError => waitError), + Effect.andThen(rawAttempt()), ), + ), + Effect.mapError((error) => + mapError( + isPreAdmissionOwnerLoss(error) + ? new StackOwnershipConflictError({ + message: `Stack owner ${error.ownerSessionId} became unreachable before admission`, + cause: error.cause, + }) + : mutation === undefined + ? error + : errorForRpc(error, { stackId: id, ...mutation }), ), ), ); - const mapped: Effect.Effect = rpcCall.pipe(Effect.mapError(mapError)); - return mapped; }; const destroyAndAwaitOwner: Effect.Effect = resolveClient(true).pipe( Effect.mapError(destroyError), - Effect.flatMap(({ client, resolution }) => - Effect.gen(function* () { - const ownerConnected = yield* Deferred.make(); - const ownerWatch = client.awaitClose( - Deferred.succeed(ownerConnected, undefined).pipe(Effect.asVoid), - ); - const ownerFiber = yield* Effect.forkChild(ownerWatch, { startImmediately: true }); - const ownerReady = Deferred.await(ownerConnected).pipe( - Effect.raceFirst( - Fiber.join(ownerFiber).pipe( - Effect.flatMap(() => - Effect.fail( - new StackDestructionError({ - message: "Unable to observe Supervisor control connection", - }), - ), - ), - Effect.mapError( - (cause) => - new StackDestructionError({ - message: "Unable to observe Supervisor control connection", - cause, - }), - ), - ), + Effect.flatMap(({ client, ownerSessionId }) => + Effect.scoped( + client.rpc.pipe( + Effect.mapError( + (cause: RpcClientError) => + new PreAdmissionOwnerLoss({ + ownerSessionId, + cause, + }), ), - ); - const destroyAttempt = ownerReady.pipe( - Effect.andThen( - Effect.scoped(client.rpc.pipe(Effect.flatMap((rpc) => rpc.destroy(undefined)))), + Effect.flatMap((rpc) => rpc.destroy({})), + ), + ).pipe( + Effect.mapError((error) => + destroyError( + isPreAdmissionOwnerLoss(error) + ? new StackOwnershipConflictError({ + message: `Stack owner ${error.ownerSessionId} became unreachable before admission`, + cause: error.cause, + }) + : errorForRpc(error, { stackId: id, mutation: "destroy" }), ), - Effect.onExit((attempt) => cleanupLaunchedOwner(resolution, attempt)), - Effect.mapError(destroyError), - ); - const result = yield* Effect.exit( - destroyAttempt.pipe( - // The owner closes its control server only after all workload cleanup has completed. - // Await the exact preface-only socket instead of decoding a terminal RPC stream Exit. - Effect.andThen( - Fiber.join(ownerFiber).pipe( - Effect.mapError( - (cause) => - new StackDestructionError({ - message: "Unable to observe Supervisor shutdown completion", - cause, - }), - ), - ), + ), + Effect.andThen( + options.waitForRelease(ownerSessionId).pipe( + Effect.mapError( + (error) => + new StackDestructionError({ + message: error.message, + cause: error, + }), ), ), - ); - if (Exit.isFailure(result)) { - yield* Fiber.interrupt(ownerFiber); - } - return yield* result; - }), - ), - ); - const destroy: Effect.Effect = Effect.suspend(() => - options.readPersistedState.pipe( - Effect.mapError(destroyError), - Effect.flatMap((state) => - Option.isNone(state) ? Effect.fail(stackNotFound()) : destroyAndAwaitOwner, + ), ), ), ); - const resetDatabase: Effect.Effect = Effect.suspend( - (): Effect.Effect => - invoke((rpc) => rpc.resetDatabase(undefined), resetDatabaseError).pipe( - Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { - const offline: Effect.Effect = options.readOfflineState.pipe( - Effect.mapError(resetDatabaseError), - Effect.flatMap((state): Effect.Effect => - Option.isNone(state) - ? Effect.fail(stackNotFound()) - : isStoppedState(state.value) - ? Effect.fail( - new StackNotRunningError({ - stackId: id, - message: "Stack is not running", - }), - ) - : Effect.fail(ownershipError), + const destroy = (selection?: ServiceSelection): Effect.Effect => + selection?.services !== undefined && selection.services.length === 0 + ? Effect.void + : selection?.services === undefined + ? Effect.suspend(() => + options.readPersistedState.pipe( + Effect.mapError(destroyError), + Effect.flatMap((state) => + Option.isNone(state) ? Effect.fail(stackNotFound()) : destroyAndAwaitOwner, + ), ), - Effect.catchTag("StackOwnershipConflictError", () => Effect.fail(ownershipError)), - ); - return offline; - }), - ), - ); + ) + : invoke((rpc) => rpc.destroy(serviceSelectionPayload(selection)), destroyError, true, { + mutation: "destroy", + }); const status: Effect.Effect = Effect.suspend( (): Effect.Effect => { const rpcStatus = invoke((rpc) => rpc.status(undefined), statusError); @@ -581,13 +753,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff Effect.flatMap((state): Effect.Effect => { if (Option.isNone(state)) return Effect.fail(stackNotFound()); if (isStoppedState(state.value)) { - const fallback: SupervisorSnapshot = { - stack: { _tag: "stopped", session: "initialized" }, - sessionId: Symbol("offline-status"), - plan: undefined, - capabilities: new Map(), - }; - return statusForSnapshot(id, state.value, { _tag: "unavailable" }, fallback); + return statusForPersistedState(id, state.value); } return Effect.fail( new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), @@ -601,7 +767,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff ); const credentials: Effect.Effect = Effect.suspend((): Effect.Effect => - invoke((rpc) => rpc.credentials(undefined), credentialsError).pipe( + invoke((rpc) => rpc.credentials(undefined), credentialsError, true).pipe( Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { const offline: Effect.Effect = options.readOfflineState.pipe( @@ -625,19 +791,14 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff ), ); const start = (startOptions?: StartStackOptions) => { - return invoke( - (rpc) => - startOptions?.config === undefined - ? rpc.start({}) - : rpc.start({ config: startOptions.config }), - startError, - true, - ).pipe( + return invoke((rpc) => rpc.start(serviceSelectionPayload(startOptions)), startError, true, { + mutation: "start", + }).pipe( Effect.tapError(() => options.readPersistedState.pipe( Effect.flatMap((state) => Option.isSome(state) && isStoppedState(state.value) - ? options.waitForRelease.pipe(Effect.ignore) + ? options.waitForRelease().pipe(Effect.ignore) : Effect.void, ), Effect.ignore, @@ -649,87 +810,305 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff isNarrowError(error, STACK_LOGS_ERROR_TAGS) ? error : new StackStateInvalidError({ message: error.message, cause: error }); - const stopOwner = (owner: ReturnType) => - Effect.gen(function* () { - // Subscribe to the owner control connection before sending stop so a - // fast shutdown cannot race the close witness. - const closeFiber = yield* Effect.forkChild(owner.awaitClose(), { startImmediately: true }); - const response = yield* owner.stop.pipe(Effect.exit); - if (Exit.isFailure(response)) { - yield* Fiber.interrupt(closeFiber); - return yield* Effect.failCause(response.cause); - } - if (!response.value.ok) { - yield* Fiber.interrupt(closeFiber); - if ( - response.value.error.tag === "operation-failed" && - response.value.error.stackErrorTag !== undefined && - isStackErrorTag(response.value.error.stackErrorTag) - ) { - return yield* stackErrorFactories[response.value.error.stackErrorTag]( - response.value.error.message, - ); - } - return yield* new StackLifecycleConflictError({ message: response.value.error.message }); - } - yield* Fiber.join(closeFiber).pipe(Effect.ignore); - yield* options.waitForRelease; - }).pipe( - Effect.mapError(stopError), - Effect.timeoutOrElse({ - duration: "60 seconds", - orElse: () => - status.pipe( - Effect.map((value) => - value.capabilities - .filter( - (capability) => - capability.state === "ready" || - capability.state === "starting" || - capability.state === "stopping", - ) - .map((capability) => capability.name), - ), - Effect.timeoutOrElse({ - duration: "2 seconds", - orElse: () => Effect.succeed>([]), - }), - Effect.orElseSucceed(() => [] as ReadonlyArray), - Effect.flatMap((running) => - Effect.fail( - new StackLifecycleConflictError({ - message: formatStopTimeoutMessage(running), - }), - ), - ), + const sleep = (selection?: ServiceSelection) => + invoke((rpc) => rpc.sleep(serviceSelectionPayload(selection)), startError, true, { + mutation: "sleep", + }); + const stop = (selection?: ServiceSelection) => + invoke((rpc) => rpc.stop(serviceSelectionPayload(selection)), stopError, true, { + mutation: "stop", + }); + const restart = (restartOptions?: RestartStackOptions) => + invoke( + (rpc) => rpc.restart(restartOptions === undefined ? {} : restartOptions), + startError, + true, + { mutation: "restart" }, + ); + const prepare = ( + prepareOptions?: PrepareStackOptions, + ): Effect.Effect => options.prepare(prepareOptions); + const decodeService = ( + value: unknown, + predicate: (value: unknown) => value is A, + label: string, + ): Effect.Effect => + predicate(value) + ? Effect.succeed(value) + : Effect.fail(new StackStateInvalidError({ message: `Invalid ${label} response` })); + const serviceCall = ( + call: (rpc: StackRpcClient) => Effect.Effect, + mutation?: MutationContext, + ): Effect.Effect => + invoke(call, (error) => errorForRpc(error, { stackId: id, ...mutation }), true, mutation); + const serviceStream = ( + call: (rpc: StackRpcClient) => Stream.Stream, + ): Stream.Stream => + Stream.unwrap( + resolveClient(true).pipe( + Effect.flatMap(({ client }) => + client.rpc.pipe( + Effect.map((rpc) => call(rpc).pipe(Stream.mapError(errorForRpc))), + Effect.mapError(errorForRpc), ), - }), + ), + Effect.mapError(errorForRpc), + ), ); - const launchAndStop = resolveClient(true, "maintenance").pipe( - Effect.mapError(stopError), - Effect.flatMap(({ client }) => stopOwner(client)), - ); - const stop: Effect.Effect = Effect.suspend(() => - resolveClient(false, "maintenance").pipe( - Effect.mapError(stopError), - Effect.flatMap(({ client }) => stopOwner(client)), - Effect.catchTag("StackOwnershipConflictError", () => - options.readOfflineState.pipe( - Effect.mapError(stopError), - Effect.flatMap((state) => - Option.isSome(state) && isStoppedState(state.value) ? Effect.void : launchAndStop, + const serviceStatus = (instanceId: ServiceInstanceId) => + serviceCall((rpc) => rpc.serviceStatus({ id: instanceId })).pipe( + Effect.flatMap((value) => decodeService(value, isServiceStatus, "service status")), + ); + const serviceDescribe = (instanceId: ServiceInstanceId) => + serviceCall((rpc) => rpc.servicesGet({ id: instanceId })).pipe( + Effect.flatMap((value) => decodeService(value, isServiceDescriptor, "service descriptor")), + ); + const serviceCredentials = ( + service: K, + instanceId: ServiceInstanceId, + ): Effect.Effect, StackError> => + serviceCall((rpc) => rpc.serviceCredentials({ id: instanceId })).pipe( + Effect.flatMap((value) => + isServiceCredentials(service, value) + ? Effect.succeed(value) + : Effect.fail(new StackStateInvalidError({ message: "Invalid service credentials" })), + ), + ); + const serviceHandle = ( + initial: ServiceDescriptor, + ): EffectServiceInstance => ({ + id: initial.id, + service: initial.service, + name: initial.name, + describe: serviceDescribe(initial.id).pipe( + Effect.flatMap((value) => + decodeService(value, isServiceDescriptorFor(initial.service), "service descriptor"), + ), + ), + status: serviceStatus(initial.id), + credentials: serviceCredentials(initial.service, initial.id), + prepare: serviceCall((rpc) => rpc.servicePrepare({ id: initial.id })).pipe( + Effect.flatMap((value) => decodeService(value, isPrepareResult, "prepare result")), + ), + start: serviceCall((rpc) => rpc.serviceStart({ id: initial.id }), { + mutation: "start", + instanceId: initial.id, + }).pipe(Effect.flatMap((value) => decodeService(value, isServiceStatus, "service status"))), + sleep: serviceCall((rpc) => rpc.serviceSleep({ id: initial.id }), { + mutation: "sleep", + instanceId: initial.id, + }).pipe(Effect.flatMap((value) => decodeService(value, isServiceStatus, "service status"))), + stop: serviceCall((rpc) => rpc.serviceStop({ id: initial.id }), { + mutation: "stop", + instanceId: initial.id, + }).pipe(Effect.flatMap((value) => decodeService(value, isServiceStatus, "service status"))), + restart: (restartOptions) => + Effect.gen(function* () { + const payload = yield* Schema.decodeUnknownEffect(ServiceRestartPayloadSchema)({ + id: initial.id, + service: initial.service, + ...(restartOptions?.config === undefined ? {} : { config: restartOptions.config }), + }).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + message: `Invalid service restart request: ${String(error)}`, + cause: error, + }), + ), + ); + return yield* serviceCall((rpc) => rpc.serviceRestart(payload), { + mutation: "restart", + instanceId: initial.id, + }).pipe( + Effect.flatMap((value) => decodeService(value, isServiceStatus, "service status")), + ); + }), + destroy: serviceCall((rpc) => rpc.serviceDestroy({ id: initial.id }), { + mutation: "destroy", + instanceId: initial.id, + }).pipe(Effect.asVoid), + exportSnapshot: (snapshotOptions) => + serviceCall( + (rpc) => + rpc.serviceExportSnapshot({ id: initial.id, destination: snapshotOptions.destination }), + { mutation: "exportSnapshot", instanceId: initial.id }, + ).pipe( + Effect.flatMap((value) => + decodeService( + value, + (entry): entry is SnapshotDescriptor => Schema.is(SnapshotDescriptorSchema)(entry), + "snapshot descriptor", ), - // Ownership artifacts that block the offline fast path may be - // stale. Let ensureSupervisor arbitrate the lease; a live owner - // remains protected and returns a typed conflict. - Effect.catchTag("StackOwnershipConflictError", () => launchAndStop), ), ), + restoreSnapshot: (snapshotOptions) => + serviceCall( + (rpc) => rpc.serviceRestoreSnapshot({ id: initial.id, source: snapshotOptions.source }), + { mutation: "restore", instanceId: initial.id }, + ).pipe( + Effect.flatMap((value) => + decodeService( + value, + (entry): entry is SnapshotDescriptor => Schema.is(SnapshotDescriptorSchema)(entry), + "snapshot descriptor", + ), + ), + ), + logs: (query) => + serviceCall((rpc) => + rpc.serviceLogs({ id: initial.id, ...(query === undefined ? {} : { query }) }), + ).pipe(Effect.flatMap((value) => decodeService(value, isStackLogBatch, "service logs"))), + followLogs: (query) => + Stream.paginate({ cursor: query?.cursor, first: true }, ({ cursor, first }) => { + const { cursor: _initialCursor, tail: _tail, ...baseQuery } = query ?? {}; + const request = serviceCall((rpc) => + rpc.serviceLogs({ + id: initial.id, + query: { + ...baseQuery, + ...(first && query?.tail !== undefined ? { tail: query.tail } : {}), + ...(cursor === undefined || cursor.opaque === EMPTY_LOG_CURSOR.opaque + ? {} + : { cursor }), + }, + }), + ).pipe(Effect.flatMap((value) => decodeService(value, isStackLogBatch, "service logs"))); + const delayed = first + ? request + : Effect.schedule(Effect.void, Schedule.duration("100 millis")).pipe( + Effect.andThen(request), + ); + return delayed.pipe( + Effect.map( + (batch) => + [ + batch.entries, + batch.running + ? Option.some({ cursor: batch.cursor, first: false }) + : Option.none(), + ] as const, + ), + ); + }), + followStatus: serviceStream((rpc) => rpc.serviceFollowStatus({ id: initial.id })).pipe( + Stream.mapEffect((value) => decodeService(value, isServiceStatus, "service status")), ), - ); - const prepare = ( - prepareOptions?: PrepareStackOptions, - ): Effect.Effect => options.prepare(prepareOptions); + }); + const serviceHandleFor = (value: AnyServiceDescriptor): AnyEffectServiceInstance => { + switch (value.service) { + case "database": + return serviceHandle(value); + case "rest": + return serviceHandle(value); + case "auth": + return serviceHandle(value); + case "realtime": + return serviceHandle(value); + case "storage": + return serviceHandle(value); + case "functions": + return serviceHandle(value); + case "studio": + return serviceHandle(value); + case "mail": + return serviceHandle(value); + case "analytics": + return serviceHandle(value); + case "pooler": + return serviceHandle(value); + } + }; + const services: EffectServiceCollection = { + create: (serviceOptions) => + Effect.gen(function* () { + const decoded = yield* Schema.decodeUnknownEffect(EffectCreateServiceOptionsSchema)( + serviceOptions, + ).pipe( + Effect.mapError( + (cause) => + new InvalidStackConfigError({ + message: `Invalid service creation options: ${String(cause)}`, + cause, + }), + ), + ); + const expectedCreationInputsId = + options.fingerprintCreationInputs === undefined + ? undefined + : yield* options.fingerprintCreationInputs(decoded); + const value = yield* (() => { + switch (decoded.service) { + case "database": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "rest": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "auth": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "realtime": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "storage": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "functions": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "studio": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "mail": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "analytics": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + case "pooler": + return serviceCall((rpc) => rpc.servicesCreate(decoded), { + mutation: "create", + expectedCreationInputsId, + }); + } + })(); + const descriptor = yield* decodeService( + value, + isServiceDescriptorFor(serviceOptions.service), + "service descriptor", + ); + return serviceHandle(descriptor); + }), + get: (ref) => + serviceCall((rpc) => rpc.servicesGet(ref)).pipe( + Effect.flatMap((value) => + decodeService(value, isServiceDescriptor, "service descriptor"), + ), + Effect.map(serviceHandleFor), + ), + list: serviceCall((rpc) => rpc.servicesList()).pipe( + Effect.flatMap((value) => decodeService(value, isServiceDescriptorList, "service list")), + ), + }; const logs = (query?: LogQuery): Effect.Effect => invoke((rpc) => rpc.logs(query ?? {}), logsError).pipe( Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { @@ -756,13 +1135,19 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff ); return { id, + services, status, + followStatus: serviceStream((rpc) => rpc.followStatus(undefined)).pipe( + Stream.mapEffect((value) => decodeService(value, isStackStatus, "stack status")), + Stream.mapError(statusError), + ), credentials, prepare, start, + sleep, stop, + restart, destroy, - resetDatabase, logs, followLogs: (query) => Stream.paginate({ cursor: query?.cursor, first: true }, ({ cursor, first }) => { @@ -795,15 +1180,126 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff } satisfies EffectStack; }); -const stateInitial = (identity: StackIdentity, runtime: StackRuntime): PersistedStackState => ({ - format: "supabase-stack-state-v1", - identity: toPersistedIdentity(identity), - runtime, - desiredLifecycle: "unconfigured", - ports: [], - privatePorts: [], - secrets: {}, -}); +const stateInitial = ( + identity: StackIdentity, + runtime: StackRuntime, + seeded?: { + readonly definition: StackDefinition; + readonly services: SeededServiceRegistry; + }, + secrets: PersistedStackState["secrets"] = {}, +): PersistedStackState => { + const api = seeded?.definition.listeners.api; + const security = seeded?.definition.security; + const signing = security?.jwt.signing; + const persistedSecurity = { + jwt: { + issuer: security?.jwt.issuer ?? null, + expirySeconds: security?.jwt.expirySeconds ?? 3_600, + signing: + signing === null || signing === undefined + ? { kind: "symmetric" as const, secret: { slot: AUTH_JWT_SECRET_SLOT } } + : signing, + }, + }; + return { + format: STACK_STATE_FORMAT, + identity: toPersistedIdentity(identity), + runtime, + preparation: seeded?.definition.preparation ?? "background", + security: persistedSecurity, + listeners: + api === undefined + ? {} + : { + api: api.enabled + ? { + enabled: true, + address: api.address, + ...(typeof api.port === "number" ? { port: api.port } : {}), + } + : { enabled: false }, + }, + registry: seeded?.services.registry ?? { + initialized: true, + instances: [], + defaultInstanceIds: {}, + }, + ports: [], + privatePorts: [], + secrets, + }; +}; + +const listenerEndpoint = ( + listener: StackDefinition["listeners"][keyof StackDefinition["listeners"]], +): + | { readonly address: string; readonly port: "auto" | number } + | { + readonly enabled: false; + } => + listener.enabled + ? { + address: listener.address, + port: listener.port === "automatic" ? "auto" : listener.port, + } + : { enabled: false }; + +const candidateEndpoints = ( + definition: StackDefinition, + service: ServiceKind, +): Readonly> => { + switch (service) { + case "database": + return { sql: listenerEndpoint(definition.listeners.database) }; + case "functions": + return { inspector: listenerEndpoint(definition.listeners.functionsInspector) }; + case "studio": + return { studio: listenerEndpoint(definition.listeners.studio) }; + case "mail": + return { + smtp: listenerEndpoint(definition.listeners.smtp), + pop3: listenerEndpoint(definition.listeners.pop3), + mailUi: listenerEndpoint(definition.listeners.mailUi), + }; + case "pooler": + return { pooler: listenerEndpoint(definition.listeners.pooler) }; + default: + return {}; + } +}; + +/** Applies candidate defaults to their persisted identities while retaining dynamic services. */ +const prospectiveRegistry = ( + state: PersistedStackState, + definition: StackDefinition, +): Effect.Effect => + Schema.decodeUnknownEffect(PersistedServiceRegistrySchema)({ + ...state.registry, + instances: state.registry.instances.map((instance) => { + if (state.registry.defaultInstanceIds[instance.service] !== instance.id) return instance; + const capability = definition.capabilities[instance.service]; + return { + ...instance, + config: { + ...instance.config, + ...capability, + endpoints: { + ...instance.config.endpoints, + ...candidateEndpoints(definition, instance.service), + }, + }, + }; + }), + }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + message: `Candidate service registry failed validation: ${String(error)}`, + cause: error, + }), + ), + ); type ChildProcessSpawnerValue = Context.Service.Shape< typeof ChildProcessSpawner.ChildProcessSpawner @@ -894,55 +1390,65 @@ const handleDependencies = (options: { stackId: options.id, message: "Stack state is missing", }); - let definition: StackDefinition; - let plan: ExecutionPlan; - if (prepareOptions?.config === undefined && state.definition !== undefined) { - definition = state.definition; - plan = yield* rebuildExecutionPlan(state.runtime, definition); - } else { - const compiled = yield* compileStack( - { - projectRoot: state.identity.projectRoot, - runtime: state.runtime, - config: prepareOptions?.config, - }, - state.definition === undefined ? undefined : { definition: state.definition }, - ).pipe(Effect.provideService(Path.Path, options.path)); - definition = compiled.definition; - plan = compiled.executionPlan; - } - const selected = new Set(); - if (prepareOptions?.capabilities === undefined) { - for (const name of CAPABILITY_NAMES) - if (definition.capabilities[name].enabled) selected.add(name); - } else { - const requested: CapabilityName[] = []; - for (const name of prepareOptions.capabilities) { - if (!isCapabilityName(name)) - return yield* new StackPreparationError({ - stackId: options.id, - capability: String(name), - message: `Unknown capability ${String(name)}`, - }); - requested.push(name); - } - for (const name of dependencyClosure(plan, requested)) { - if (!definition.capabilities[name].enabled) - return yield* new InvalidStackConfigError({ - stackId: options.id, - capability: name, - message: `Capability ${name} is disabled`, - }); - selected.add(name); - } + + const requested = + prepareOptions?.services === undefined + ? state.registry.instances + .filter((instance) => instance.config.enabled) + .map((instance) => instance.id) + : [...prepareOptions.services]; + const uniqueRequested = [...new Set(requested)]; + for (const id of uniqueRequested) { + const instance = state.registry.instances.find((entry) => entry.id === id); + if (instance === undefined) + return yield* new ServiceNotFoundError({ + instanceId: id, + message: `Service instance ${id} was not found`, + }); + if (!instance.config.enabled) + return yield* new InvalidStackConfigError({ + stackId: options.id, + capability: instance.service, + message: `Service instance ${id} is disabled`, + }); } - const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); + if (uniqueRequested.length === 0) return { instances: [] }; + + // Compile candidate settings and secret declarations without writing state. The + // persisted registry remains the authority for identities and dynamic services. + const candidate = + prepareOptions?.config === undefined + ? undefined + : yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config: prepareOptions.config, + registry: state.registry, + }).pipe( + Effect.provideService(Path.Path, options.path), + Effect.mapError(directPrepareError), + ); + const candidateRegistry = + candidate === undefined + ? state.registry + : yield* prospectiveRegistry(state, candidate.definition).pipe( + Effect.mapError(directPrepareError), + ); + const plan = yield* createExecutionPlan( + state.runtime, + candidateRegistry, + undefined, + new Set(uniqueRequested), + ).pipe(Effect.mapError(directPrepareError)); + const selected = dependencyClosure(plan, uniqueRequested); + const workloads = plan.workloads.filter((workload) => selected.has(workload.instanceId)); for (const workload of workloads) prepareOptions?.onProgress?.({ workloadId: workload.id, capability: workload.capability, state: "queued", }); + const preparer = yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.environment.stateRoot, runtime: state.runtime, @@ -957,32 +1463,66 @@ const handleDependencies = (options: { Effect.provideService(Path.Path, options.path), Effect.provideService(Crypto.Crypto, options.crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + Effect.mapError(directPrepareError), ); const artifacts = yield* Effect.forEach( workloads, (workload) => preparer.prepare(state.runtime, workload, prepareOptions?.onProgress), { concurrency: "unbounded" }, - ); - const byCapability = new Map>(); + ).pipe(Effect.mapError(directPrepareError)); + const artifactsByInstance = new Map(); for (const artifact of artifacts) { - const existing = byCapability.get(artifact.capability) ?? []; - byCapability.set(artifact.capability, [...existing, artifact]); + const workload = workloads.find((entry) => entry.id === artifact.workloadId); + if (workload === undefined) continue; + const current = artifactsByInstance.get(workload.instanceId) ?? []; + artifactsByInstance.set(workload.instanceId, [...current, artifact]); } - return { - capabilities: plan.startOrder - .filter((name) => selected.has(name)) - .map((name): PreparedCapability => { - const outcome: PreparedCapability["outcome"] = - state.runtime.kind === "native" - ? byCapability.get(name)?.some((entry) => entry.outcome === "downloaded") - ? "downloaded" - : "cached" - : byCapability.get(name)?.some((entry) => entry.outcome === "pulled") - ? "pulled" - : "cached"; - return { capability: name, version: definition.capabilities[name].version, outcome }; - }), - }; + const candidateSecrets = + candidate === undefined + ? state.secrets + : Object.fromEntries([ + ...Object.entries(state.secrets), + ...candidate.secrets.flatMap((slot) => + slot.value === undefined + ? [] + : [ + [ + slot.slot, + { policy: slot.policy, value: String(Redacted.value(slot.value)) }, + ] as const, + ], + ), + ]); + const preparedInstances: PrepareStackInstance[] = []; + for (const id of uniqueRequested) { + const instance = candidateRegistry.instances.find((entry) => entry.id === id); + if (instance === undefined) continue; + const instanceArtifacts = artifactsByInstance.get(id) ?? []; + preparedInstances.push({ + id, + service: instance.service, + artifacts: instanceArtifacts.map((artifact) => ({ + identity: `${artifact.capability}:${artifact.version}`, + outcome: artifact.outcome, + })), + effectiveConfigFingerprint: yield* fingerprintEffectiveConfig( + instance, + candidate?.definition.security ?? state.security, + candidateSecrets, + ).pipe( + Effect.provideService(Crypto.Crypto, options.crypto), + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.id, + message: "Unable to fingerprint prepared service configuration", + cause: error, + }), + ), + ), + }); + } + return { instances: preparedInstances }; }), ).pipe(Effect.mapError(directPrepareError)); const readLogs = (query?: LogQuery) => @@ -1014,25 +1554,46 @@ const handleDependencies = (options: { : new StackStateInvalidError({ message: error.message, cause: error }), ), ); - const waitForRelease = Effect.gen(function* () { - if ( - (yield* readOwnerMetadata(options.environment.stateRoot, options.id, options.environment)) !== - undefined - ) - return yield* new StackOwnershipConflictError({ - message: "Supervisor is still shutting down", - }); - if (yield* ownerLockExists(options.environment.stateRoot, options.id)) - return yield* new StackOwnershipConflictError({ - message: "Supervisor ownership lease is still held", - }); - }).pipe( - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), - Effect.mapError((error) => new StackOwnershipConflictError({ message: error.message })), - Effect.provideService(FileSystem.FileSystem, options.fileSystem), - Effect.provideService(Path.Path, options.path), - Effect.provideService(Crypto.Crypto, options.crypto), - ); + const waitForRelease = (ownerSessionId?: string) => + waitForOwnerRelease( + options.environment.stateRoot, + options.id, + options.environment, + ownerSessionId, + ).pipe( + Effect.mapError( + (error) => new StackOwnershipConflictError({ message: error.message, cause: error }), + ), + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + ); + const fingerprintCreationInputsForRequest = (serviceOptions: unknown) => + Schema.decodeUnknownEffect(EffectCreateServiceOptionsSchema)(serviceOptions, { + onExcessProperty: "error", + }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + stackId: options.id, + message: `Invalid service creation request: ${String(error)}`, + cause: error, + }), + ), + Effect.flatMap((normalized) => + fingerprintCreationInputs(normalized).pipe( + Effect.provideService(Crypto.Crypto, options.crypto), + Effect.mapError( + (error) => + new InvalidStackConfigError({ + stackId: options.id, + message: `Unable to fingerprint service creation request: ${error.message}`, + cause: error, + }), + ), + ), + ), + ); return { resolveOwner, readOfflineState, @@ -1040,6 +1601,7 @@ const handleDependencies = (options: { readLogs, waitForRelease, prepare, + fingerprintCreationInputs: fingerprintCreationInputsForRequest, }; }; @@ -1093,7 +1655,84 @@ export const createStack = ( nativeRuntimeBlockedForUid() ) return yield* new StackRuntimeError({ message: NATIVE_ROOT_UNSUPPORTED_MESSAGE }); - const current = yield* store.initialize(stackId, stateInitial(identity, requestedRuntime)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const seeded = + persisted === undefined + ? yield* compileStack({ + projectRoot: identity.projectRoot, + runtime: requestedRuntime, + config: options.initialConfig, + }).pipe( + Effect.flatMap((compiled) => + seedServiceRegistry( + compiled.definition, + { projectRoot: identity.projectRoot, path, runtime: requestedRuntime }, + compiled.sourceConfig, + compiled.secrets, + ).pipe(Effect.map((services) => ({ definition: compiled.definition, services }))), + ), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + ) + : undefined; + const initialSecrets = + seeded === undefined + ? undefined + : yield* resolveSecrets( + { declarations: seeded.services.secretSlots }, + {}, + "unconfigured", + ).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + ); + const seededWithFingerprints = + seeded === undefined || initialSecrets === undefined + ? seeded + : { + ...seeded, + services: { + ...seeded.services, + registry: { + ...seeded.services.registry, + instances: yield* Effect.forEach(seeded.services.registry.instances, (instance) => + fingerprintBootstrapInputs( + instance, + seeded.definition.security, + initialSecrets.persisted, + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((bootstrapInputsId) => + bootstrapInputsId === undefined + ? instance + : { ...instance, bootstrapInputsId }, + ), + ), + ), + }, + }, + }; + const initialState = stateInitial( + identity, + requestedRuntime, + seededWithFingerprints, + initialSecrets?.persisted, + ); + const plannedInitialState = + seededWithFingerprints === undefined + ? initialState + : yield* Effect.reduce( + initialState.registry.instances, + () => initialState, + (state, instance) => + plannedInstancePorts(state, instance).pipe( + Effect.map((ports) => ({ ...state, ...ports })), + ), + ); + const current = yield* store.initialize(stackId, plannedInitialState); const runtimeMismatch = options.runtime !== undefined && (current.runtime.kind !== requestedRuntime.kind || @@ -1104,9 +1743,6 @@ export const createStack = ( return yield* new StackRuntimeMismatchError({ message: "Stack runtime is immutable for an existing identity", }); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const dependencies = handleDependencies({ environment: env, @@ -1120,10 +1756,11 @@ export const createStack = ( }); const handle = yield* makeHandle(stackId, dependencies); return dockerFallbackNotice === undefined ? handle : { ...handle, dockerFallbackNotice }; - }); + }).pipe(Effect.mapError(createError)); export const openStack = ( id: StackId, + _options?: OpenStackOptions, ): Effect.Effect< EffectStack, OpenStackError, @@ -1260,41 +1897,6 @@ export const discoverStacks = ( type ConfigDrift = NonNullable; -const isPlainRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const definitionDiffPaths = ( - left: unknown, - right: unknown, - prefix: string, - paths: string[], -): void => { - if (Object.is(left, right)) return; - if ((left === undefined || left === null) && (right === undefined || right === null)) return; - if (Array.isArray(left) && Array.isArray(right)) { - if (left.length !== right.length) { - paths.push(prefix); - return; - } - for (let index = 0; index < left.length; index++) { - definitionDiffPaths(left[index], right[index], `${prefix}.${index}`, paths); - } - return; - } - if (Array.isArray(left) || Array.isArray(right)) { - paths.push(prefix); - return; - } - if (isPlainRecord(left) && isPlainRecord(right)) { - const keys = new Set([...Object.keys(left), ...Object.keys(right)]); - for (const key of keys) { - definitionDiffPaths(left[key], right[key], `${prefix}.${key}`, paths); - } - return; - } - paths.push(prefix); -}; - const secretDriftPaths = ( candidate: ReadonlyArray, persisted: PersistedStackState["secrets"], @@ -1328,19 +1930,37 @@ const inspectConfigDrift = ( config: StackConfig, ): Effect.Effect => Effect.gen(function* () { - const compiled = yield* compileStack( - { - projectRoot: state.identity.projectRoot, - runtime: state.runtime, - config, - }, - state.definition === undefined ? undefined : { definition: state.definition }, - ); - if (state.definition === undefined) - return { status: "unconfigured", paths: [] } satisfies ConfigDrift; + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config, + registry: state.registry, + }); + const candidate = yield* prospectiveRegistry(state, compiled.definition); const paths: string[] = []; - if (!sameDefinition(state.definition, compiled.definition)) - definitionDiffPaths(state.definition, compiled.definition, "definition", paths); + const currentDefaults = state.registry.instances.filter( + (instance) => state.registry.defaultInstanceIds[instance.service] === instance.id, + ); + const candidateDefaults = candidate.instances.filter( + (instance) => candidate.defaultInstanceIds[instance.service] === instance.id, + ); + if ( + canonical( + currentDefaults.map((instance) => ({ + service: instance.service, + config: resolvedStateValue(instance.config, state.secrets), + })), + ) !== + canonical( + candidateDefaults.map((instance) => ({ + service: instance.service, + config: resolvedStateValue(instance.config, state.secrets), + })), + ) + ) + paths.push("services"); + if (canonical(state.security) !== canonical(compiled.definition.security)) + paths.push("security.jwt"); paths.push(...secretDriftPaths(compiled.secrets, state.secrets)); const uniquePaths = [...new Set(paths)].sort(); return { diff --git a/packages/stack/src/public/EphemeralPostgres.ts b/packages/stack/src/public/EphemeralPostgres.ts deleted file mode 100644 index dfcdaa2743..0000000000 --- a/packages/stack/src/public/EphemeralPostgres.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Crypto, Effect, FileSystem, Path, Redacted, Scope } from "effect"; -import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; -import { DatabaseModule } from "../model/capabilities/database.ts"; -import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; -import { createEphemeralPostgresCluster } from "../runtime/EphemeralPostgres.ts"; -import type { EphemeralPostgresCreateError, EphemeralPostgresError } from "./Errors.ts"; -import { StackVersionUnsupportedError } from "./Errors.ts"; -import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; - -export interface EphemeralPostgresSettings { - readonly [key: string]: string | number | boolean | undefined; -} - -export interface CreateEphemeralPostgresOptions { - /** Omitted preference uses Docker when installed, otherwise native. */ - readonly runtime?: StackRuntimePreference; - /** Exact catalog release or major selector such as `"17"`. */ - readonly version?: string; - readonly port?: number; - readonly databasePassword: Redacted.Redacted; - readonly jwtSecret: Redacted.Redacted; - readonly jwtExpiry?: number; - readonly postgresSettings?: EphemeralPostgresSettings; - readonly healthTimeout?: string; - /** Stopped-cluster PGDATA tar to restore before the first start. */ - readonly restoreFrom?: string; - /** Cache identity written into the snapshot runtime marker. Restore without a key still accepts a keyless marker. */ - readonly snapshotKey?: string; -} - -export interface EphemeralPostgresRelease { - readonly version: string; - readonly image: string; -} - -export type EphemeralPostgresServices = - | ChildProcessSpawnerService - | Scope.Scope - | FileSystem.FileSystem - | Path.Path; - -export interface EffectEphemeralPostgres { - readonly host: string; - readonly port: number; - readonly version: string; - readonly runtime: StackRuntime; - /** Catalog identity hashed into CLI shadow-cache keys. */ - readonly artifactIdentity: string; - readonly url: Redacted.Redacted; - /** Prepared postgres artifact root when extras such as `pg_dump` may be present. */ - readonly nativeArtifactRoot?: string; - /** Present when auto-select persisted native because the Docker daemon was down. */ - readonly dockerFallbackNotice?: string; - /** Container network id so schema-init one-shots can join and dial `supabase-database:5432`. */ - readonly networkId?: string; - readonly start: Effect.Effect; - readonly stop: Effect.Effect; - readonly exportPgData: ( - tarPath: string, - snapshotKey?: string, - ) => Effect.Effect; -} - -/** Resolves a Postgres catalog release the same way stack compilation does. */ -export const resolveEphemeralPostgresRelease = ( - version?: string, -): Effect.Effect => { - const requested = version ?? DatabaseModule.defaultVersion; - // A running stack can outlive a catalog pin bump; dump/test still need a client. - const selected = - DatabaseModule.releases[requested] ?? - (requested.includes(".") ? DatabaseModule.releases[requested.split(".")[0] ?? ""] : undefined); - const release = - selected === undefined - ? catalogReleaseFor("database:database", requested) - : catalogReleaseFor("database:database", selected.version); - if (release === undefined) - return Effect.fail( - new StackVersionUnsupportedError({ - message: `Unsupported PostgreSQL version ${requested}`, - version: requested, - capability: "database", - }), - ); - return Effect.succeed({ version: release.version, image: release.containerImage }); -}; - -export const createEphemeralPostgres = ( - options: CreateEphemeralPostgresOptions, -): Effect.Effect< - EffectEphemeralPostgres, - EphemeralPostgresCreateError, - Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService -> => createEphemeralPostgresCluster(options); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index eab40bb8a3..41dba89727 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -1,7 +1,7 @@ import { Data, Predicate } from "effect"; import type { StackId } from "./StackId.ts"; import type { ContainerEngineKind } from "../runtime/ContainerEngine.ts"; -import type { StackRecovery } from "./Status.ts"; +import type { ServiceStatus, StackRecovery } from "./Status.ts"; /** Common context present on every public stack error. */ interface ErrorFields { @@ -9,6 +9,18 @@ interface ErrorFields { readonly cause?: unknown; } +/** Durable result details for a partial lifecycle batch. */ +export interface LifecycleOutcome { + readonly requested: ReadonlyArray; + readonly affected: ReadonlyArray; + readonly succeeded: ReadonlyArray; + readonly failed: ReadonlyArray; + readonly statuses?: ReadonlyArray; + readonly recovery?: StackRecovery; + readonly removed?: ReadonlyArray; + readonly retained?: ReadonlyArray; +} + export interface IdentityErrorFields extends ErrorFields { readonly path?: string; readonly reason?: string; @@ -54,6 +66,27 @@ export class StackNotFoundError extends Data.TaggedError("StackNotFoundError")< export class StackOwnershipConflictError extends Data.TaggedError("StackOwnershipConflictError")< ErrorFields & { readonly stackId?: StackId } > {} +export class UncertainOperationError extends Data.TaggedError("UncertainOperationError")< + ErrorFields & { + readonly stackId: StackId; + readonly instanceId?: string; + readonly operationId?: string; + /** Creation requests use this pre-dispatch digest for safe reconciliation. */ + readonly expectedCreationInputsId?: string; + readonly mutation: + | "create" + | "restore" + | "start" + | "sleep" + | "stop" + | "restart" + | "destroy" + | "exportSnapshot"; + } +> {} +export class OwnerRetiringError extends Data.TaggedError("OwnerRetiringError")< + ErrorFields & { readonly stackId: StackId; readonly ownerSessionId: string } +> {} export class StackRuntimeMismatchError extends Data.TaggedError( "StackRuntimeMismatchError", ) {} @@ -65,7 +98,33 @@ export class StackMustBeStoppedError extends Data.TaggedError("StackMustBeStoppe ErrorFields & { readonly slot?: string; readonly stackId?: StackId; readonly guidance?: string } > {} export class StackLifecycleConflictError extends Data.TaggedError("StackLifecycleConflictError")< - ErrorFields & { readonly stackId?: StackId; readonly recovery?: StackRecovery } + ErrorFields & { + readonly stackId?: StackId; + readonly instanceId?: string; + readonly recovery?: StackRecovery; + readonly outcome?: LifecycleOutcome; + } +> {} +export class ServiceNotFoundError extends Data.TaggedError("ServiceNotFoundError")< + ErrorFields & { readonly instanceId?: string } +> {} +export class ServiceNameConflictError extends Data.TaggedError("ServiceNameConflictError")< + ErrorFields & { readonly name?: string } +> {} +export class ServiceDependencyError extends Data.TaggedError("ServiceDependencyError")< + ErrorFields & { readonly service?: string; readonly dependency?: string } +> {} +export class InitializationMismatchError extends Data.TaggedError("InitializationMismatchError")< + ErrorFields & { readonly instanceId?: string; readonly profileId?: string } +> {} +export class UnsupportedSnapshotError extends Data.TaggedError("UnsupportedSnapshotError")< + ErrorFields & { readonly instanceId?: string; readonly service?: string } +> {} +export class NoSnapshotDataError extends Data.TaggedError("NoSnapshotDataError")< + ErrorFields & { readonly instanceId?: string } +> {} +export class SnapshotTargetInvalidError extends Data.TaggedError("SnapshotTargetInvalidError")< + ErrorFields & { readonly path?: string } > {} export class StackStateInvalidError extends Data.TaggedError("StackStateInvalidError")< @@ -140,23 +199,9 @@ export class StackCleanupError extends Data.TaggedError("StackCleanupError") {} -export class StackDestructionError extends Data.TaggedError("StackDestructionError") {} -export class EphemeralPostgresError extends Data.TaggedError("EphemeralPostgresError")< - ErrorFields & { - readonly reason?: - | "not-stopped" - | "not-running" - | "snapshot" - | "restore-mismatch" - | "bootstrap" - | "destroy"; - readonly path?: string; - readonly version?: string; - } +export class StackDestructionError extends Data.TaggedError("StackDestructionError")< + ErrorFields & { readonly outcome?: LifecycleOutcome } > {} -export class RequiresActivatedProcessError extends Data.TaggedError( - "RequiresActivatedProcessError", -) {} export class PostgresClientError extends Data.TaggedError("PostgresClientError")< ErrorFields & { readonly reason?: "missing-bin" | "spawn"; @@ -173,10 +218,19 @@ export const STACK_ERROR_TAGS = [ "StackVersionUnsupportedError", "StackNotFoundError", "StackOwnershipConflictError", + "UncertainOperationError", + "OwnerRetiringError", "StackRuntimeMismatchError", "StackNotRunningError", "StackMustBeStoppedError", "StackLifecycleConflictError", + "ServiceNotFoundError", + "ServiceNameConflictError", + "ServiceDependencyError", + "InitializationMismatchError", + "UnsupportedSnapshotError", + "NoSnapshotDataError", + "SnapshotTargetInvalidError", "StackStateInvalidError", "InvalidLogCursorError", "StackStateFormatUnsupportedError", @@ -193,8 +247,6 @@ export const STACK_ERROR_TAGS = [ "StackCleanupError", "ContainerEngineError", "StackDestructionError", - "EphemeralPostgresError", - "RequiresActivatedProcessError", "PostgresClientError", ] as const; @@ -210,10 +262,19 @@ export type StackError = | StackVersionUnsupportedError | StackNotFoundError | StackOwnershipConflictError + | UncertainOperationError + | OwnerRetiringError | StackRuntimeMismatchError | StackNotRunningError | StackMustBeStoppedError | StackLifecycleConflictError + | ServiceNotFoundError + | ServiceNameConflictError + | ServiceDependencyError + | InitializationMismatchError + | UnsupportedSnapshotError + | NoSnapshotDataError + | SnapshotTargetInvalidError | StackStateInvalidError | InvalidLogCursorError | StackStateFormatUnsupportedError @@ -230,8 +291,6 @@ export type StackError = | StackCleanupError | ContainerEngineError | StackDestructionError - | EphemeralPostgresError - | RequiresActivatedProcessError | PostgresClientError; export const isStackError = (value: unknown): value is StackError => @@ -245,6 +304,7 @@ export const CREATE_STACK_ERROR_TAGS = [ "InvalidStackIdentityError", "InvalidProjectRootError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackRuntimeMismatchError", "ContainerEngineError", "StackRuntimeError", @@ -256,6 +316,7 @@ export type CreateStackError = ErrorByTag<(typeof CREATE_STACK_ERROR_TAGS)[numbe export const OPEN_STACK_ERROR_TAGS = [ "StackNotFoundError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackRuntimeMismatchError", "InvalidProjectRootError", "StackStateInvalidError", @@ -275,6 +336,7 @@ export type StackDiscoveryError = ErrorByTag<(typeof STACK_DISCOVERY_ERROR_TAGS) export const STACK_STATUS_ERROR_TAGS = [ "StackNotFoundError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackLifecycleConflictError", "StackStateInvalidError", "StackStateFormatUnsupportedError", @@ -287,6 +349,7 @@ export const STACK_CREDENTIALS_ERROR_TAGS = [ "StackNotFoundError", "StackNotRunningError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackLifecycleConflictError", "StackSecretMismatchError", "InvalidJwtSigningMaterialError", @@ -311,6 +374,7 @@ export const STACK_START_ERROR_TAGS = [ "InvalidStackConfigError", "StackVersionUnsupportedError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackNotRunningError", "StackMustBeStoppedError", "StackLifecycleConflictError", @@ -327,15 +391,18 @@ export const STACK_START_ERROR_TAGS = [ "StackRuntimeError", "StackCleanupError", "ContainerEngineError", + "UncertainOperationError", ] as const satisfies ReadonlyArray; export type StackStartError = ErrorByTag<(typeof STACK_START_ERROR_TAGS)[number]>; /** Stable maintenance stop reports cleanup failures as lifecycle conflicts with their message. */ export const STACK_STOP_ERROR_TAGS = [ "StackOwnershipConflictError", + "OwnerRetiringError", "StackLifecycleConflictError", "StackStateInvalidError", "StackCleanupError", + "UncertainOperationError", ] as const satisfies ReadonlyArray; export type StackStopError = ErrorByTag<(typeof STACK_STOP_ERROR_TAGS)[number]>; @@ -345,6 +412,7 @@ export const STACK_LOGS_ERROR_TAGS = [ "StackStateInvalidError", "InvalidLogCursorError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackLifecycleConflictError", "StackUpgradeRequiredError", ] as const satisfies ReadonlyArray; @@ -354,33 +422,15 @@ export const DESTROY_STACK_ERROR_TAGS = [ "StackDestructionError", "StackNotFoundError", "StackOwnershipConflictError", + "OwnerRetiringError", "StackLifecycleConflictError", "ContainerEngineError", "StackCleanupError", "StackUpgradeRequiredError", + "UncertainOperationError", ] as const satisfies ReadonlyArray; export type DestroyStackError = ErrorByTag<(typeof DESTROY_STACK_ERROR_TAGS)[number]>; -export const RESET_DATABASE_ERROR_TAGS = [ - "StackNotFoundError", - ...STACK_START_ERROR_TAGS, -] as const satisfies ReadonlyArray; -export type ResetDatabaseError = ErrorByTag<(typeof RESET_DATABASE_ERROR_TAGS)[number]>; - -export const EPHEMERAL_POSTGRES_ERROR_TAGS = [ - "EphemeralPostgresError", - "StackVersionUnsupportedError", - "PortUnavailableError", - "StackPreparationError", - "ArtifactIntegrityError", - "ContainerPullError", - "ContainerEngineError", - "StackRuntimeError", -] as const satisfies ReadonlyArray; -export type EphemeralPostgresCreateError = ErrorByTag< - (typeof EPHEMERAL_POSTGRES_ERROR_TAGS)[number] ->; - export const POSTGRES_CLIENT_ERROR_TAGS = [ "PostgresClientError", "StackVersionUnsupportedError", @@ -390,22 +440,3 @@ export const POSTGRES_CLIENT_ERROR_TAGS = [ "ContainerEngineError", ] as const satisfies ReadonlyArray; export type PostgresClientRunError = ErrorByTag<(typeof POSTGRES_CLIENT_ERROR_TAGS)[number]>; - -export const SCHEMA_INIT_ERROR_TAGS = [ - "RequiresActivatedProcessError", - "InvalidStackConfigError", - "StackVersionUnsupportedError", - "InvalidProjectRootError", - "InvalidStackIdentityError", - "StackPreparationError", - "ArtifactIntegrityError", - "ContainerPullError", - "ContainerEngineError", - "StackSecretMismatchError", - "InvalidJwtSigningMaterialError", - "StackRuntimeError", - "StackMustBeStoppedError", - "StackStateInvalidError", - "StackStateFormatUnsupportedError", -] as const satisfies ReadonlyArray; -export type SchemaInitError = ErrorByTag<(typeof SCHEMA_INIT_ERROR_TAGS)[number]>; diff --git a/packages/stack/src/public/Logs.ts b/packages/stack/src/public/Logs.ts index 0763e3d84b..8a055e7752 100644 --- a/packages/stack/src/public/Logs.ts +++ b/packages/stack/src/public/Logs.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { CapabilityNameSchema, type CapabilityName } from "./Capability.ts"; +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; export const LogCursorSchema = Schema.Struct({ opaque: Schema.String, @@ -7,18 +8,28 @@ export const LogCursorSchema = Schema.Struct({ export type LogCursor = Schema.Schema.Type; export const LogQuerySchema = Schema.Struct({ + services: Schema.optionalKey(Schema.Array(ServiceInstanceIdSchema)), capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), cursor: Schema.optionalKey(LogCursorSchema), tail: Schema.optionalKey(Schema.Finite), }); export type LogQuery = Schema.Schema.Type; +/** Query fields accepted by an instance-scoped log stream. */ +export const ServiceLogQuerySchema = Schema.Struct({ + cursor: Schema.optionalKey(LogCursorSchema), + tail: Schema.optionalKey(Schema.Finite), +}); +export type ServiceLogQuery = Schema.Schema.Type; + export const StackLogEntrySchema = Schema.Struct({ cursor: LogCursorSchema, timestamp: Schema.String, source: Schema.Union([CapabilityNameSchema, Schema.Literals(["supervisor", "gateway"] as const)]), stream: Schema.Literals(["stdout", "stderr", "internal"] as const), message: Schema.String, + instanceId: Schema.optionalKey(ServiceInstanceIdSchema), + instanceName: Schema.optionalKey(Schema.String), }); export type StackLogEntry = Schema.Schema.Type; diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 20245d2e90..84b58e29b6 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -1,17 +1,5 @@ import { NodeServices } from "@effect/platform-node"; -import { - Crypto, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Redacted, - Schema, - Scope, - Stream, -} from "effect"; +import { Crypto, Effect, FileSystem, Layer, Option, Path, Redacted, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createStack as createEffectStack, @@ -28,21 +16,36 @@ import { type StackDiscoveryIssue, type PrepareStackOptions, type StartStackOptions, + type RestartStackOptions, + type ServiceConfigUpdate, } from "./EffectStack.ts"; import type { StackConfig } from "./Config.ts"; import { StackConfigSchema } from "./Config.ts"; +import { + createTestStackWith, + type CreateTestStackOptions, + TestStackOperationError, +} from "./Testing.ts"; import { PromiseStackCredentialsSchema, type PromiseStackCredentials } from "./Credentials.ts"; import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import type { StackDescriptor, StackInspection, StackStatus } from "./Status.ts"; import type { StackId } from "./StackId.ts"; -import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; +import type { PrepareStackResult } from "./EffectStack.ts"; import { InvalidStackConfigError } from "./Errors.ts"; import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; -import { - createEphemeralPostgres as createEffectEphemeralPostgres, - type CreateEphemeralPostgresOptions, -} from "./EphemeralPostgres.ts"; -import type { StackRuntime } from "./Runtime.ts"; +import type { + ServiceCollection, + ServiceKind, + ServiceRef, + ServiceInstance, + AnyServiceInstance, + CreateServiceOptions, + ServiceConfig, + AnyCreateServiceOptions, + AnyEffectServiceConfig, + EffectServiceConfig, +} from "./Service.ts"; +import { EffectCreateServiceOptionsSchema, ServiceConfigSchemas } from "./Service.ts"; /** Recursively replaces Effect `Redacted` leaves with their plain value. */ type Unredacted = @@ -51,13 +54,36 @@ type Unredacted = : T extends readonly (infer Item)[] ? ReadonlyArray> : T extends object - ? { readonly [Key in keyof T]: Unredacted } + ? { + readonly [ + Key in keyof T as Exclude extends never ? never : Key + ]: Unredacted; + } : T; export type PromiseStackConfig = Unredacted; -export type PromiseStartStackOptions = Omit & { - readonly config?: PromiseStackConfig; +export type PromiseStartStackOptions = StartStackOptions; +export type PromiseCreateStackOptions = Omit & { + readonly initialConfig: PromiseStackConfig; +}; +export type PromiseOpenStackOptions = { + readonly initialConfig?: PromiseStackConfig; }; +export type PromiseServiceSelection = import("./EffectStack.ts").ServiceSelection; +export type PromiseServiceConfigUpdate = { + [K in ServiceKind]: { + readonly id: import("./ServiceInstanceId.ts").ServiceInstanceId; + readonly service: K; + readonly config: ServiceConfig; + }; +}[ServiceKind]; +export type PromiseRestartStackOptions = + | { readonly services?: never; readonly config?: PromiseStackConfig } + | { + readonly services: ReadonlyArray; + readonly updates?: ReadonlyArray; + readonly config?: never; + }; export interface PromiseInspectStackOptions { readonly config?: PromiseStackConfig; @@ -66,43 +92,38 @@ export type PromisePrepareStackOptions = Omit & { readonly config?: PromiseStackConfig; }; +export type PromiseCreateTestStackOptions = Omit< + CreateTestStackOptions, + "config" | "setupProject" +> & { + readonly config?: PromiseStackConfig; + readonly setupProject?: (projectRoot: string) => Promise; +}; + +export type PromiseTestStack = PromiseStack & + AsyncDisposable & { + readonly stateRoot: string; + }; + export interface PromiseStack { readonly id: StackId; + readonly services: ServiceCollection; readonly status: () => Promise; + readonly followStatus: () => AsyncIterable; readonly credentials: () => Promise; readonly prepare: (options?: PromisePrepareStackOptions) => Promise; readonly start: (options?: PromiseStartStackOptions) => Promise; - readonly stop: () => Promise; - readonly destroy: () => Promise; - readonly resetDatabase: () => Promise; + readonly sleep: (options?: PromiseServiceSelection) => Promise; + readonly stop: (options?: PromiseServiceSelection) => Promise; + readonly restart: (options?: PromiseRestartStackOptions) => Promise; + readonly destroy: (options?: PromiseServiceSelection) => Promise; readonly logs: (query?: LogQuery) => Promise; readonly followLogs: (query?: LogQuery) => AsyncIterable; } -export type PromiseCreateEphemeralPostgresOptions = Omit< - CreateEphemeralPostgresOptions, - "databasePassword" | "jwtSecret" -> & { - readonly databasePassword: string; - readonly jwtSecret: string; -}; - -export interface PromiseEphemeralPostgres { - readonly host: string; - readonly port: number; - readonly version: string; - readonly runtime: StackRuntime; - readonly artifactIdentity: string; - readonly url: string; - readonly start: () => Promise; - readonly stop: () => Promise; - readonly exportPgData: (tarPath: string) => Promise; - readonly destroy: () => Promise; -} - interface PromiseStackApi { - readonly createStack: (options: CreateStackOptions) => Promise; - readonly openStack: (id: StackId) => Promise; + readonly createStack: (options: PromiseCreateStackOptions) => Promise; + readonly openStack: (id: StackId, options?: PromiseOpenStackOptions) => Promise; readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; readonly discoverStacks: (options?: ListStacksOptions) => Promise; @@ -110,9 +131,6 @@ interface PromiseStackApi { id: StackId, options?: PromiseInspectStackOptions, ) => Promise; - readonly createEphemeralPostgres: ( - options: PromiseCreateEphemeralPostgresOptions, - ) => Promise; } type PlatformLayer = typeof NodeServices.layer; @@ -137,6 +155,116 @@ const decodePromiseConfig = ( ), ); +function decodePromiseServiceConfig( + service: K, + config: ServiceConfig, +): Effect.Effect, InvalidStackConfigError>; +function decodePromiseServiceConfig( + service: ServiceKind, + config: import("./Service.ts").AnyServiceConfig, +): Effect.Effect { + const decode = >( + schema: S, + ): Effect.Effect => + Schema.decodeUnknownEffect(Schema.toCodecJson(schema))(config, { + onExcessProperty: "error", + }).pipe( + Effect.mapError( + (cause) => + new InvalidStackConfigError({ + message: `Invalid ${service} service config: ${String(cause)}`, + cause, + }), + ), + ); + switch (service) { + case "database": + return decode(ServiceConfigSchemas.database); + case "rest": + return decode(ServiceConfigSchemas.rest); + case "auth": + return decode(ServiceConfigSchemas.auth); + case "realtime": + return decode(ServiceConfigSchemas.realtime); + case "storage": + return decode(ServiceConfigSchemas.storage); + case "functions": + return decode(ServiceConfigSchemas.functions); + case "studio": + return decode(ServiceConfigSchemas.studio); + case "mail": + return decode(ServiceConfigSchemas.mail); + case "analytics": + return decode(ServiceConfigSchemas.analytics); + case "pooler": + return decode(ServiceConfigSchemas.pooler); + } +} + +const decodePromiseRestartOptions = ( + options: PromiseRestartStackOptions | undefined, +): Effect.Effect => { + if (options === undefined) return Effect.succeed({}); + if (options.services === undefined) + return options.config === undefined + ? Effect.succeed({}) + : decodePromiseConfig(options.config).pipe(Effect.map((config) => ({ config }))); + const updates = options.updates ?? []; + const decodeUpdate = ( + update: PromiseServiceConfigUpdate, + ): Effect.Effect => + (() => { + switch (update.service) { + case "database": + return decodePromiseServiceConfig("database", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "database", config })), + ); + case "rest": + return decodePromiseServiceConfig("rest", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "rest", config })), + ); + case "auth": + return decodePromiseServiceConfig("auth", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "auth", config })), + ); + case "realtime": + return decodePromiseServiceConfig("realtime", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "realtime", config })), + ); + case "storage": + return decodePromiseServiceConfig("storage", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "storage", config })), + ); + case "functions": + return decodePromiseServiceConfig("functions", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "functions", config })), + ); + case "studio": + return decodePromiseServiceConfig("studio", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "studio", config })), + ); + case "mail": + return decodePromiseServiceConfig("mail", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "mail", config })), + ); + case "analytics": + return decodePromiseServiceConfig("analytics", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "analytics", config })), + ); + case "pooler": + return decodePromiseServiceConfig("pooler", update.config).pipe( + Effect.map((config) => ({ id: update.id, service: "pooler", config })), + ); + } + })(); + return Effect.forEach(updates, decodeUpdate).pipe( + Effect.map((decoded) => ({ + services: options.services, + ...(decoded.length === 0 ? {} : { updates: decoded }), + })), + ); +}; + /** Recursively unwraps every Redacted value at the Promise boundary. */ function unredact(input: T): Unredacted; function unredact(input: unknown): unknown { @@ -153,9 +281,11 @@ function unredact(input: unknown): unknown { const adaptStream = (stream: Stream.Stream): AsyncIterable => Stream.toAsyncIterable(stream); +const invokePromise = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + /** Adapts an already-created Effect handle; exported for facade integration tests. */ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { - const invoke = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + const invoke = invokePromise; const withConfig = ( options: { readonly config?: PromiseStackConfig } | undefined, operation: (config?: StackConfig) => Effect.Effect, @@ -165,9 +295,112 @@ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { options?.config === undefined ? undefined : yield* decodePromiseConfig(options.config); return yield* operation(config); }); + const adaptService = ( + service: import("./Service.ts").EffectServiceInstance, + ): ServiceInstance => ({ + id: service.id, + service: service.service, + name: service.name, + describe: () => invoke(service.describe), + status: () => invoke(service.status), + credentials: () => invoke(service.credentials), + prepare: () => invoke(service.prepare), + start: () => invoke(service.start), + sleep: () => invoke(service.sleep), + stop: () => invoke(service.stop), + restart: (options) => + options?.config === undefined + ? invoke(service.restart()) + : invoke( + decodePromiseServiceConfig(service.service, options.config).pipe( + Effect.flatMap((config) => service.restart({ config })), + ), + ), + destroy: () => invoke(service.destroy), + exportSnapshot: (options) => invoke(service.exportSnapshot(options)), + restoreSnapshot: (options) => invoke(service.restoreSnapshot(options)), + logs: (query) => invoke(service.logs(query)), + followLogs: (query) => adaptStream(service.followLogs(query)), + followStatus: () => adaptStream(service.followStatus), + }); + function createService( + options: CreateServiceOptions, + ): Promise>; + function createService(options: AnyCreateServiceOptions): Promise { + return invoke( + Effect.gen(function* () { + const config = yield* decodePromiseServiceConfig(options.service, options.config); + const decoded = yield* Schema.decodeUnknownEffect(EffectCreateServiceOptionsSchema)({ + ...options, + config, + }).pipe( + Effect.mapError( + (cause) => + new InvalidStackConfigError({ + message: `Invalid ${options.service} service creation options: ${String(cause)}`, + cause, + }), + ), + ); + switch (decoded.service) { + case "database": + return adaptService(yield* effectStack.services.create(decoded)); + case "rest": + return adaptService(yield* effectStack.services.create(decoded)); + case "auth": + return adaptService(yield* effectStack.services.create(decoded)); + case "realtime": + return adaptService(yield* effectStack.services.create(decoded)); + case "storage": + return adaptService(yield* effectStack.services.create(decoded)); + case "functions": + return adaptService(yield* effectStack.services.create(decoded)); + case "studio": + return adaptService(yield* effectStack.services.create(decoded)); + case "mail": + return adaptService(yield* effectStack.services.create(decoded)); + case "analytics": + return adaptService(yield* effectStack.services.create(decoded)); + case "pooler": + return adaptService(yield* effectStack.services.create(decoded)); + } + }), + ); + } + const services: ServiceCollection = { + create: createService, + get: (ref: ServiceRef) => + invoke(effectStack.services.get(ref)).then((service): AnyServiceInstance => { + switch (service.service) { + case "database": + return adaptService(service); + case "rest": + return adaptService(service); + case "auth": + return adaptService(service); + case "realtime": + return adaptService(service); + case "storage": + return adaptService(service); + case "functions": + return adaptService(service); + case "studio": + return adaptService(service); + case "mail": + return adaptService(service); + case "analytics": + return adaptService(service); + case "pooler": + return adaptService(service); + } + }), + list: () => invoke(effectStack.services.list), + }; return { id: effectStack.id, + services, status: () => invoke(effectStack.status), + followStatus: () => adaptStream(effectStack.followStatus), credentials: () => invoke(effectStack.credentials).then((value) => Schema.decodeSync(PromiseStackCredentialsSchema)(unredact(value)), @@ -179,26 +412,23 @@ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { options === undefined ? undefined : { - ...(options.capabilities === undefined - ? {} - : { capabilities: options.capabilities }), + ...(options.services === undefined ? {} : { services: options.services }), ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }), ...(config === undefined ? {} : { config }), }, ), ), ), - start: (options) => + start: (options) => invoke(effectStack.start(options)), + sleep: (options) => invoke(effectStack.sleep(options)), + stop: (options) => invoke(effectStack.stop(options)), + restart: (options) => invoke( - withConfig(options, (config) => - options === undefined - ? effectStack.start() - : effectStack.start(config === undefined ? {} : { config }), + decodePromiseRestartOptions(options).pipe( + Effect.flatMap((decoded) => effectStack.restart(decoded)), ), ), - stop: () => invoke(effectStack.stop), - destroy: () => invoke(effectStack.destroy), - resetDatabase: () => invoke(effectStack.resetDatabase), + destroy: (options) => invoke(effectStack.destroy(options)), logs: (query) => invoke(effectStack.logs(query)), followLogs: (query) => adaptStream(effectStack.followLogs(query)), }; @@ -219,8 +449,25 @@ export const makePromiseApi = ( effect: Effect.Effect, ): Promise => run(effect).then(adaptEffectStack); return { - createStack: (options) => createOrOpen(createEffectStack(options)), - openStack: (id) => createOrOpen(openEffectStack(id)), + createStack: (options) => + (() => { + const { initialConfig, ...baseOptions } = options; + return createOrOpen( + decodePromiseConfig(initialConfig).pipe( + Effect.flatMap((decoded) => + createEffectStack({ ...baseOptions, initialConfig: decoded }), + ), + ), + ); + })(), + openStack: (id, options) => + createOrOpen( + options?.initialConfig === undefined + ? openEffectStack(id) + : decodePromiseConfig(options.initialConfig).pipe( + Effect.flatMap((initialConfig) => openEffectStack(id, { initialConfig })), + ), + ), findStack: (options) => run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), @@ -233,43 +480,6 @@ export const makePromiseApi = ( Effect.flatMap((config) => inspectEffectStack(id, { config })), ), ), - // Promise facade at the published edge; the Effect API owns cluster lifetime. - // oxlint-disable-next-line effecttsgo/async-function -- public Promise API - createEphemeralPostgres: async (options) => { - const scope = await Effect.runPromise(Scope.make()); - const close = () => - Effect.runPromise(Scope.close(scope, Exit.void).pipe(Effect.provide(providedLayer))); - const invoke = ( - effect: Effect.Effect, - ): Promise => - Effect.runPromise( - effect.pipe(Effect.provideService(Scope.Scope, scope), Effect.provide(providedLayer)), - ); - try { - const handle = await invoke( - createEffectEphemeralPostgres({ - ...options, - databasePassword: Redacted.make(options.databasePassword), - jwtSecret: Redacted.make(options.jwtSecret), - }), - ); - return { - host: handle.host, - port: handle.port, - version: handle.version, - runtime: handle.runtime, - artifactIdentity: handle.artifactIdentity, - url: Redacted.value(handle.url), - start: () => invoke(handle.start), - stop: () => invoke(handle.stop), - exportPgData: (tarPath) => invoke(handle.exportPgData(tarPath)), - destroy: close, - }; - } catch (cause) { - await close().catch(() => undefined); - throw cause; - } - }, }; }; @@ -280,13 +490,53 @@ export const findStack = defaultApi.findStack; export const listStacks = defaultApi.listStacks; export const discoverStacks = defaultApi.discoverStacks; export const inspectStack = defaultApi.inspectStack; -export const createEphemeralPostgres = defaultApi.createEphemeralPostgres; + +/** Creates an isolated test stack through the root Promise facade. */ +export const createTestStack = ( + options: PromiseCreateTestStackOptions = {}, +): Promise => { + const promise = Effect.runPromise( + Effect.gen(function* () { + const { config: promiseConfig, setupProject, ...baseOptions } = options; + const config = + promiseConfig === undefined ? undefined : yield* decodePromiseConfig(promiseConfig); + const effectStack = yield* createTestStackWith({ + ...baseOptions, + ...(config === undefined ? {} : { config }), + ...(setupProject === undefined + ? {} + : { + setupProject: (projectRoot: string) => + Effect.tryPromise({ + try: () => setupProject(projectRoot), + catch: (cause) => + new TestStackOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + }), + }); + const promiseStack = adaptEffectStack(effectStack); + return { + ...promiseStack, + stateRoot: effectStack.stateRoot, + [Symbol.asyncDispose]: () => invokePromise(effectStack.destroy()), + } satisfies PromiseTestStack; + }), + ); + return promise.catch((error: unknown) => { + let cause = error; + while (cause instanceof TestStackOperationError) cause = cause.cause; + if (cause !== error) throw cause; + throw error; + }); +}; export type { CreateStackOptions, FindStackOptions, ListStacksOptions, - PreparedCapability, StackDiscoveryIssue, StackDiscoveryResult, }; diff --git a/packages/stack/src/public/SchemaInit.ts b/packages/stack/src/public/SchemaInit.ts deleted file mode 100644 index 21d524f425..0000000000 --- a/packages/stack/src/public/SchemaInit.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Crypto, Effect, FileSystem, Path, Redacted } from "effect"; -import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; -import type { SchemaInitError } from "./Errors.ts"; -import type { StackConfig } from "./Config.ts"; -import type { StackRuntime } from "./Runtime.ts"; -import type { StackId } from "./StackId.ts"; -import { schemaInitWorkloads, schemaInitArtifactIdentity } from "../runtime/SchemaInit.ts"; -import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; -import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; - -export { schemaInitArtifactIdentity }; - -export const SCHEMA_INIT_CAPABILITY_NAMES = [ - "auth", - "storage", - "realtime", - "analytics", - "pooler", -] as const; -export type SchemaInitCapabilityName = (typeof SCHEMA_INIT_CAPABILITY_NAMES)[number]; - -export interface SchemaInitSecrets { - readonly databasePassword: Redacted.Redacted; - readonly jwtSecret?: Redacted.Redacted; -} - -interface SchemaInitTargetBase { - readonly projectRoot: string; - readonly runtime: StackRuntime; - readonly config: StackConfig; - readonly databaseUrl: string; - readonly secrets: SchemaInitSecrets; -} - -export interface SchemaInitLiveTarget extends SchemaInitTargetBase { - readonly kind: "live"; - readonly stackId: StackId; -} - -export interface SchemaInitEphemeralTarget extends SchemaInitTargetBase { - readonly kind: "ephemeral"; - /** Container network of the throwaway Postgres cluster; one-shots join it and dial `supabase-database:5432`. */ - readonly networkId?: string; -} - -export type SchemaInitTarget = SchemaInitLiveTarget | SchemaInitEphemeralTarget; - -export interface SchemaInitOptions { - readonly containerEngine?: ContainerEngine; - readonly artifactPreparer?: RuntimeArtifactPreparer; - /** Host OS for Linux extra hosts so `host.docker.internal` resolves; DNS only, not URL rewrite. */ - readonly platform?: string; -} - -export type SchemaInitServices = - | ChildProcessSpawnerService - | FileSystem.FileSystem - | Path.Path - | Crypto.Crypto; - -/** Runs service-owned one-shots against a target Postgres without activating long-running processes. */ -export const schemaInit = ( - names: ReadonlyArray, - target: SchemaInitTarget, - options: SchemaInitOptions = {}, -): Effect.Effect => - schemaInitWorkloads(names, target, options); diff --git a/packages/stack/src/public/Service.ts b/packages/stack/src/public/Service.ts new file mode 100644 index 0000000000..c27562e2d0 --- /dev/null +++ b/packages/stack/src/public/Service.ts @@ -0,0 +1,779 @@ +import { Schema } from "effect"; +import type { Effect } from "effect"; +import type { Stream } from "effect"; +import * as Redacted from "effect/Redacted"; +import { + AnalyticsSettingsSchema, + type AnalyticsSettings, + AuthSettingsSchema, + type AuthSettings, + DatabaseSettingsSchema, + type DatabaseSettings, + FunctionsSettingsSchema, + type FunctionsSettings, + MailSettingsSchema, + type MailSettings, + PoolerSettingsSchema, + type PoolerSettings, + RealtimeSettingsSchema, + type RealtimeSettings, + RestSettingsSchema, + type RestSettings, + StorageSettingsSchema, + type StorageSettings, + StudioSettingsSchema, + type StudioSettings, +} from "../model/capabilities/index.ts"; +import { CAPABILITY_NAMES, CapabilityNameSchema, type CapabilityName } from "./Capability.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "./ServiceInstanceId.ts"; +import { NetworkPortSchema } from "./Status.ts"; +import type { + ApiCredentials, + DatabaseCredentials, + EmptyServiceCredentials, + StorageCredentials, +} from "./Credentials.ts"; + +export type ServiceKind = CapabilityName; +export const SERVICE_KINDS = CAPABILITY_NAMES; +export const ServiceKindSchema = CapabilityNameSchema; + +export const TcpEndpointIntentSchema = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Literal(true)), + address: Schema.optionalKey(Schema.String), + port: Schema.optionalKey(Schema.Union([Schema.Literal("auto"), NetworkPortSchema])), +}); +export type TcpEndpointIntent = Schema.Schema.Type; + +export const DisabledEndpointIntentSchema = Schema.Struct({ enabled: Schema.Literal(false) }); +export const OptionalEndpointIntentSchema = Schema.Union([ + DisabledEndpointIntentSchema, + TcpEndpointIntentSchema, +]); +export type OptionalEndpointIntent = Schema.Schema.Type; + +type Unredact = + T extends Redacted.Redacted + ? Value + : T extends readonly (infer Item)[] + ? readonly Unredact[] + : T extends object + ? { [Key in keyof T]: Unredact } + : T; + +type Redact = + T extends Redacted.Redacted + ? { readonly redacted: true } + : T extends null + ? undefined + : T extends readonly (infer Item)[] + ? readonly Redact[] + : T extends object + ? { [Key in keyof T]: Redact } + : T; + +type ServiceSettingsMap = { + database: DatabaseSettings; + rest: RestSettings; + auth: AuthSettings; + realtime: RealtimeSettings; + storage: StorageSettings; + functions: FunctionsSettings; + studio: StudioSettings; + mail: MailSettings; + analytics: AnalyticsSettings; + pooler: PoolerSettings; +}; +export type ServiceSettings = ServiceSettingsMap[K]; +export type RedactedServiceSettings = Redact>; + +export type PromiseDatabaseSettings = Unredact; +export type PromiseRestSettings = Unredact; +export type PromiseAuthSettings = Unredact; +export type PromiseRealtimeSettings = Unredact; +export type PromiseStorageSettings = Unredact; +export type PromiseFunctionsSettings = Unredact; +export type PromiseStudioSettings = Unredact; +export type PromiseMailSettings = Unredact; +export type PromiseAnalyticsSettings = Unredact; +export type PromisePoolerSettings = Unredact; + +export interface DatabaseServiceConfig { + readonly enabled?: boolean; + readonly version?: string; + readonly activation?: "eager" | "lazy"; + readonly idleTimeoutSeconds?: false; + readonly settings?: PromiseDatabaseSettings; + readonly password?: string; + readonly endpoints?: { readonly sql?: OptionalEndpointIntent }; +} + +export interface FunctionsServiceConfig { + readonly enabled?: boolean; + readonly version?: string; + readonly activation?: "eager" | "lazy"; + readonly idleTimeoutSeconds?: false; + readonly settings?: PromiseFunctionsSettings; + readonly endpoints?: { readonly inspector?: OptionalEndpointIntent }; +} + +interface ServiceConfigBase { + readonly enabled?: boolean; + readonly version?: string; + readonly activation?: "eager" | "lazy"; + readonly idleTimeoutSeconds?: false; + readonly settings?: Settings; +} + +interface RetirableServiceConfig extends Omit< + ServiceConfigBase, + "idleTimeoutSeconds" +> { + readonly idleTimeoutSeconds?: number | false; +} + +export type ServiceConfigMap = { + database: DatabaseServiceConfig; + rest: RetirableServiceConfig; + auth: RetirableServiceConfig; + realtime: RetirableServiceConfig; + storage: ServiceConfigBase; + functions: FunctionsServiceConfig; + studio: RetirableServiceConfig & { + readonly endpoints?: { readonly studio?: OptionalEndpointIntent }; + }; + mail: ServiceConfigBase & { + readonly endpoints?: { + readonly smtp?: OptionalEndpointIntent; + readonly pop3?: OptionalEndpointIntent; + readonly mailUi?: OptionalEndpointIntent; + }; + }; + analytics: ServiceConfigBase; + pooler: RetirableServiceConfig & { + readonly endpoints?: { readonly pooler?: OptionalEndpointIntent }; + }; +}; +export type ServiceConfig = ServiceConfigMap[K]; +export type AnyServiceConfig = { + [K in ServiceKind]: ServiceConfig; +}[ServiceKind]; + +/** Effect-native configuration keeps secret leaves wrapped in Redacted values. */ +export type EffectServiceConfigMap = { + database: Omit & { + readonly settings?: DatabaseSettings; + readonly password?: Redacted.Redacted; + }; + rest: RetirableServiceConfig; + auth: RetirableServiceConfig; + realtime: RetirableServiceConfig; + storage: ServiceConfigBase; + functions: Omit & { readonly settings?: FunctionsSettings }; + studio: RetirableServiceConfig & { + readonly endpoints?: { readonly studio?: OptionalEndpointIntent }; + }; + mail: ServiceConfigBase & { + readonly endpoints?: { + readonly smtp?: OptionalEndpointIntent; + readonly pop3?: OptionalEndpointIntent; + readonly mailUi?: OptionalEndpointIntent; + }; + }; + analytics: ServiceConfigBase; + pooler: RetirableServiceConfig & { + readonly endpoints?: { readonly pooler?: OptionalEndpointIntent }; + }; +}; +export type EffectServiceConfig = EffectServiceConfigMap[K]; +export type AnyEffectServiceConfig = { + [K in ServiceKind]: EffectServiceConfig; +}[ServiceKind]; + +const serviceConfigFields = (settings: S) => ({ + enabled: Schema.optional(Schema.Literal(true)), + version: Schema.optional(Schema.String), + activation: Schema.optional(Schema.Literals(["eager", "lazy"] as const)), + settings: Schema.optional(settings), +}); +const retirableConfig = (settings: S) => + Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(settings), + idleTimeoutSeconds: Schema.optionalKey( + Schema.Union([Schema.Literal(false), Schema.Finite.check(Schema.isGreaterThan(0))]), + ), + }), + ]); +const fixedConfig = (settings: S) => + Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(settings), + }), + ]); + +/** Effect input codecs for each service kind; secret leaves remain Redacted values. */ +export const ServiceConfigSchemas = { + database: Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(DatabaseSettingsSchema), + idleTimeoutSeconds: Schema.optionalKey(Schema.Literal(false)), + password: Schema.optionalKey(Schema.Redacted(Schema.String)), + endpoints: Schema.optionalKey( + Schema.Struct({ sql: Schema.optionalKey(OptionalEndpointIntentSchema) }), + ), + }), + ]), + rest: retirableConfig(RestSettingsSchema), + auth: retirableConfig(AuthSettingsSchema), + realtime: retirableConfig(RealtimeSettingsSchema), + storage: fixedConfig(StorageSettingsSchema), + functions: Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(FunctionsSettingsSchema), + endpoints: Schema.optionalKey( + Schema.Struct({ inspector: Schema.optionalKey(OptionalEndpointIntentSchema) }), + ), + }), + ]), + studio: Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(StudioSettingsSchema), + idleTimeoutSeconds: Schema.optionalKey( + Schema.Union([Schema.Literal(false), Schema.Finite.check(Schema.isGreaterThan(0))]), + ), + endpoints: Schema.optionalKey( + Schema.Struct({ studio: Schema.optionalKey(OptionalEndpointIntentSchema) }), + ), + }), + ]), + mail: Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(MailSettingsSchema), + endpoints: Schema.optionalKey( + Schema.Struct({ + smtp: Schema.optionalKey(OptionalEndpointIntentSchema), + pop3: Schema.optionalKey(OptionalEndpointIntentSchema), + mailUi: Schema.optionalKey(OptionalEndpointIntentSchema), + }), + ), + }), + ]), + analytics: fixedConfig(AnalyticsSettingsSchema), + pooler: Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + ...serviceConfigFields(PoolerSettingsSchema), + idleTimeoutSeconds: Schema.optionalKey( + Schema.Union([Schema.Literal(false), Schema.Finite.check(Schema.isGreaterThan(0))]), + ), + endpoints: Schema.optionalKey( + Schema.Struct({ pooler: Schema.optionalKey(OptionalEndpointIntentSchema) }), + ), + }), + ]), +} satisfies { readonly [K in ServiceKind]: Schema.Top }; + +const catalogRecipeSchema = (settings: S) => + Schema.Struct({ + version: Schema.optionalKey(Schema.String), + settings: Schema.optionalKey(settings), + }); + +const catalogInitializationSchema = Schema.Struct({ + from: Schema.optionalKey(Schema.Never), + catalog: Schema.optionalKey( + Schema.Struct({ + auth: Schema.optionalKey(catalogRecipeSchema(AuthSettingsSchema)), + storage: Schema.optionalKey(catalogRecipeSchema(StorageSettingsSchema)), + realtime: Schema.optionalKey(catalogRecipeSchema(RealtimeSettingsSchema)), + analytics: Schema.optionalKey(catalogRecipeSchema(AnalyticsSettingsSchema)), + pooler: Schema.optionalKey(catalogRecipeSchema(PoolerSettingsSchema)), + }), + ), +}); + +export const EffectDatabaseInitializationSchema = Schema.Union( + [ + catalogInitializationSchema, + Schema.Struct({ + catalog: Schema.optionalKey(Schema.Never), + from: ServiceInstanceIdSchema, + }), + ], + { mode: "oneOf" }, +); + +const serviceDependenciesSchemas = { + database: Schema.Struct({}), + rest: Schema.Struct({ database: ServiceInstanceIdSchema }), + auth: Schema.Struct({ database: ServiceInstanceIdSchema }), + realtime: Schema.Struct({ database: ServiceInstanceIdSchema }), + storage: Schema.Struct({ database: ServiceInstanceIdSchema }), + functions: Schema.Struct({}), + studio: Schema.Struct({ + database: ServiceInstanceIdSchema, + rest: ServiceInstanceIdSchema, + analytics: ServiceInstanceIdSchema, + }), + mail: Schema.Struct({}), + analytics: Schema.Struct({ database: ServiceInstanceIdSchema }), + pooler: Schema.Struct({ database: ServiceInstanceIdSchema }), +}; + +const serviceInitializationSchemas = { + database: EffectDatabaseInitializationSchema, + rest: Schema.Struct({}), + auth: Schema.Struct({}), + realtime: Schema.Struct({}), + storage: Schema.Struct({}), + functions: Schema.Struct({}), + studio: Schema.Struct({}), + mail: Schema.Struct({}), + analytics: Schema.Struct({}), + pooler: Schema.Struct({}), +}; + +const createServiceSchemas = { + database: Schema.Struct({ + service: Schema.Literal("database"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.database, + initialization: Schema.optional(serviceInitializationSchemas.database), + }), + rest: Schema.Struct({ + service: Schema.Literal("rest"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.rest, + dependencies: serviceDependenciesSchemas.rest, + }), + auth: Schema.Struct({ + service: Schema.Literal("auth"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.auth, + dependencies: serviceDependenciesSchemas.auth, + }), + realtime: Schema.Struct({ + service: Schema.Literal("realtime"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.realtime, + dependencies: serviceDependenciesSchemas.realtime, + }), + storage: Schema.Struct({ + service: Schema.Literal("storage"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.storage, + dependencies: serviceDependenciesSchemas.storage, + }), + functions: Schema.Struct({ + service: Schema.Literal("functions"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.functions, + }), + studio: Schema.Struct({ + service: Schema.Literal("studio"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.studio, + dependencies: serviceDependenciesSchemas.studio, + }), + mail: Schema.Struct({ + service: Schema.Literal("mail"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.mail, + }), + analytics: Schema.Struct({ + service: Schema.Literal("analytics"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.analytics, + dependencies: serviceDependenciesSchemas.analytics, + }), + pooler: Schema.Struct({ + service: Schema.Literal("pooler"), + name: Schema.optionalKey(Schema.String.check(Schema.isNonEmpty())), + config: ServiceConfigSchemas.pooler, + dependencies: serviceDependenciesSchemas.pooler, + }), +}; + +const effectCreateServiceOptionsUnion = Schema.Union([ + createServiceSchemas.database, + createServiceSchemas.rest, + createServiceSchemas.auth, + createServiceSchemas.realtime, + createServiceSchemas.storage, + createServiceSchemas.functions, + createServiceSchemas.studio, + createServiceSchemas.mail, + createServiceSchemas.analytics, + createServiceSchemas.pooler, +]); + +/** Runtime codec for Effect-native service creation options across all service kinds. */ +export const EffectCreateServiceOptionsSchema = effectCreateServiceOptionsUnion; + +const serviceRestartSchemas = { + database: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("database"), + config: Schema.optionalKey(ServiceConfigSchemas.database), + }), + rest: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("rest"), + config: Schema.optionalKey(ServiceConfigSchemas.rest), + }), + auth: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("auth"), + config: Schema.optionalKey(ServiceConfigSchemas.auth), + }), + realtime: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("realtime"), + config: Schema.optionalKey(ServiceConfigSchemas.realtime), + }), + storage: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("storage"), + config: Schema.optionalKey(ServiceConfigSchemas.storage), + }), + functions: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("functions"), + config: Schema.optionalKey(ServiceConfigSchemas.functions), + }), + studio: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("studio"), + config: Schema.optionalKey(ServiceConfigSchemas.studio), + }), + mail: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("mail"), + config: Schema.optionalKey(ServiceConfigSchemas.mail), + }), + analytics: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("analytics"), + config: Schema.optionalKey(ServiceConfigSchemas.analytics), + }), + pooler: Schema.Struct({ + id: ServiceInstanceIdSchema, + service: Schema.Literal("pooler"), + config: Schema.optionalKey(ServiceConfigSchemas.pooler), + }), +}; + +export type ServiceRestartPayload = { + [K in ServiceKind]: { + readonly id: ServiceInstanceId; + readonly service: K; + readonly config?: EffectServiceConfig; + }; +}[ServiceKind]; + +/** Runtime codec for a service-kind-specific restart request. */ +export const ServiceRestartPayloadSchema = Schema.Union([ + serviceRestartSchemas.database, + serviceRestartSchemas.rest, + serviceRestartSchemas.auth, + serviceRestartSchemas.realtime, + serviceRestartSchemas.storage, + serviceRestartSchemas.functions, + serviceRestartSchemas.studio, + serviceRestartSchemas.mail, + serviceRestartSchemas.analytics, + serviceRestartSchemas.pooler, +]); + +export interface CatalogRecipeInput { + readonly version?: string; + readonly settings?: Settings; +} + +export type DatabaseInitialization = + | { + readonly from: ServiceInstanceId; + } + | { + readonly catalog?: { + readonly auth?: CatalogRecipeInput; + readonly storage?: CatalogRecipeInput; + readonly realtime?: CatalogRecipeInput; + readonly analytics?: CatalogRecipeInput; + readonly pooler?: CatalogRecipeInput; + }; + }; + +export type EffectDatabaseInitialization = + | { + readonly from: ServiceInstanceId; + } + | { + readonly catalog?: { + readonly auth?: CatalogRecipeInput; + readonly storage?: CatalogRecipeInput; + readonly realtime?: CatalogRecipeInput; + readonly analytics?: CatalogRecipeInput; + readonly pooler?: CatalogRecipeInput; + }; + }; + +export type ServiceInitialization = K extends "database" + ? DatabaseInitialization + : never; +export type EffectServiceInitialization = K extends "database" + ? EffectDatabaseInitialization + : never; + +type DependencyMap = { + database: never; + rest: { readonly database: ServiceInstanceId }; + auth: { readonly database: ServiceInstanceId }; + realtime: { readonly database: ServiceInstanceId }; + storage: { readonly database: ServiceInstanceId }; + functions: never; + studio: { + readonly database: ServiceInstanceId; + readonly rest: ServiceInstanceId; + readonly analytics: ServiceInstanceId; + }; + mail: never; + analytics: { readonly database: ServiceInstanceId }; + pooler: { readonly database: ServiceInstanceId }; +}; +export type ServiceDependencies = DependencyMap[K]; + +export type CreateServiceOptions = { + readonly service: K; + readonly name?: string; + readonly config: ServiceConfig; + readonly initialization?: ServiceInitialization; +} & (ServiceDependencies extends never + ? { readonly dependencies?: never } + : { readonly dependencies: ServiceDependencies }); +export type AnyCreateServiceOptions = { + [K in ServiceKind]: CreateServiceOptions; +}[ServiceKind]; + +export type EffectCreateServiceOptions = Omit< + CreateServiceOptions, + "service" | "config" | "initialization" +> & { + readonly service: K; + readonly config: EffectServiceConfig; + readonly initialization?: EffectServiceInitialization; +}; +export type AnyEffectCreateServiceOptions = { + [K in ServiceKind]: EffectCreateServiceOptions; +}[ServiceKind]; + +export type ServiceRef = { readonly id: ServiceInstanceId } | { readonly name: string }; + +export interface ServiceDescriptor { + readonly id: ServiceInstanceId; + readonly service: K; + readonly name?: string; + readonly enabled: boolean; + readonly config: { + readonly enabled: boolean; + readonly activation: "eager" | "lazy"; + readonly idleTimeoutSeconds: number | false; + readonly version: string; + readonly settings: RedactedServiceSettings; + }; + readonly dependencies: Readonly>; + readonly snapshotSupport: "supported" | "unsupported"; + readonly endpoints: Readonly< + Record & { readonly port: number }> + >; + readonly artifactIdentity?: string; + readonly runtimeIdentity?: string; + readonly effectiveConfigFingerprint?: string; + readonly initializationProfileId?: string | null; + readonly bootstrapRecipeId?: string; + readonly bootstrapInputsId?: string; + readonly creationInputsId?: string; + readonly initialization?: { + readonly profileId: string; + readonly recipes: ReadonlyArray<{ + readonly service: ServiceKind; + readonly recipeId: string; + readonly artifactIdentity: string; + readonly completed: boolean; + }>; + }; + readonly data: + | { readonly origin: "absent" } + | { readonly origin: "fresh"; readonly lineageId: string } + | { readonly origin: "restored"; readonly snapshot: SnapshotDescriptor } + | { readonly origin: "incomplete"; readonly operationId: string }; +} + +export type AnyServiceDescriptor = { + [K in ServiceKind]: ServiceDescriptor; +}[ServiceKind]; + +export const SnapshotDescriptorSchema = Schema.Struct({ + lineageId: Schema.String, + initializationProfileId: Schema.NullOr(Schema.String), + artifactIdentity: Schema.String, + runtimeIdentity: Schema.String, + dataFormat: Schema.Struct({ + provider: Schema.Literal("postgres"), + format: Schema.String, + majorVersion: Schema.Int, + }), + provenance: Schema.Struct({ + sourceInstanceId: ServiceInstanceIdSchema, + exportOperationId: Schema.String, + }), +}); +export type SnapshotDescriptor = Schema.Schema.Type; + +export type ServiceCredentials = K extends "database" + ? DatabaseCredentials | undefined + : K extends "functions" + ? ApiCredentials | EmptyServiceCredentials + : K extends "storage" + ? StorageCredentials | EmptyServiceCredentials + : EmptyServiceCredentials; + +export interface PrepareResult { + readonly instances: ReadonlyArray<{ + readonly id: ServiceInstanceId; + readonly service: ServiceKind; + readonly artifacts: ReadonlyArray<{ + readonly identity: string; + readonly outcome: "cached" | "downloaded" | "pulled"; + }>; + /** Stable semantic config identity used to compare prepared candidate views. */ + readonly effectiveConfigFingerprint?: string; + }>; +} + +export interface ServiceInstance { + readonly id: ServiceInstanceId; + readonly service: K; + readonly name: string | undefined; + readonly describe: () => Promise>; + readonly status: () => Promise; + readonly credentials: () => Promise>; + readonly prepare: () => Promise; + readonly start: () => Promise; + readonly sleep: () => Promise; + readonly stop: () => Promise; + readonly restart: (options?: { + readonly config?: ServiceConfig; + }) => Promise; + readonly destroy: () => Promise; + readonly exportSnapshot: (options: { + readonly destination: string; + }) => Promise; + readonly restoreSnapshot: (options: { readonly source: string }) => Promise; + readonly logs: ( + query?: import("./Logs.ts").ServiceLogQuery, + ) => Promise; + readonly followLogs: ( + query?: import("./Logs.ts").ServiceLogQuery, + ) => AsyncIterable; + readonly followStatus: () => AsyncIterable; +} + +export type AnyServiceInstance = { + [K in ServiceKind]: ServiceInstance; +}[ServiceKind]; + +export type { ApiCredentials, DatabaseCredentials, EmptyServiceCredentials, StorageCredentials }; + +export interface ServiceCollection { + readonly create: ( + options: CreateServiceOptions, + ) => Promise>; + readonly get: (ref: ServiceRef) => Promise; + readonly list: () => Promise>; +} + +/** Effect-native service handle used by `EffectStack.services`. */ +export interface EffectServiceInstance { + readonly id: ServiceInstanceId; + readonly service: K; + readonly name: string | undefined; + readonly describe: Effect.Effect, import("./Errors.ts").StackError>; + readonly status: Effect.Effect< + import("./Status.ts").ServiceStatus, + import("./Errors.ts").StackError + >; + readonly credentials: Effect.Effect, import("./Errors.ts").StackError>; + readonly prepare: Effect.Effect; + readonly start: Effect.Effect< + import("./Status.ts").ServiceStatus, + import("./Errors.ts").StackError + >; + readonly sleep: Effect.Effect< + import("./Status.ts").ServiceStatus, + import("./Errors.ts").StackError + >; + readonly stop: Effect.Effect< + import("./Status.ts").ServiceStatus, + import("./Errors.ts").StackError + >; + readonly restart: (options?: { + readonly config?: EffectServiceConfig; + }) => Effect.Effect; + readonly destroy: Effect.Effect; + readonly exportSnapshot: (options: { + readonly destination: string; + }) => Effect.Effect; + readonly restoreSnapshot: (options: { + readonly source: string; + }) => Effect.Effect; + readonly logs: ( + query?: import("./Logs.ts").ServiceLogQuery, + ) => Effect.Effect; + readonly followLogs: ( + query?: import("./Logs.ts").ServiceLogQuery, + ) => Stream.Stream; + readonly followStatus: Stream.Stream< + import("./Status.ts").ServiceStatus, + import("./Errors.ts").StackError + >; +} + +export type AnyEffectServiceInstance = { + [K in ServiceKind]: EffectServiceInstance; +}[ServiceKind]; + +/** Effect-native service registry facade. */ +export interface EffectServiceCollection { + readonly create: ( + options: EffectCreateServiceOptions, + ) => Effect.Effect, import("./Errors.ts").StackError>; + readonly get: ( + ref: ServiceRef, + ) => Effect.Effect; + readonly list: Effect.Effect< + ReadonlyArray, + import("./Errors.ts").StackError + >; +} + +export { + AnalyticsSettingsSchema, + AuthSettingsSchema, + DatabaseSettingsSchema, + FunctionsSettingsSchema, + MailSettingsSchema, + PoolerSettingsSchema, + RealtimeSettingsSchema, + RestSettingsSchema, + StorageSettingsSchema, + StudioSettingsSchema, + ServiceInstanceIdSchema, +}; diff --git a/packages/stack/src/public/ServiceInstanceId.ts b/packages/stack/src/public/ServiceInstanceId.ts new file mode 100644 index 0000000000..f8164e74e4 --- /dev/null +++ b/packages/stack/src/public/ServiceInstanceId.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect"; + +declare const ServiceInstanceIdTypeId: unique symbol; + +/** Immutable identity for one registered service instance. */ +export type ServiceInstanceId = string & { + readonly [ServiceInstanceIdTypeId]: "ServiceInstanceId"; +}; + +const SERVICE_INSTANCE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +export const isServiceInstanceId = (value: string): value is ServiceInstanceId => + SERVICE_INSTANCE_ID.test(value); + +export const ServiceInstanceIdSchema = Schema.String.pipe( + Schema.refine((value): value is ServiceInstanceId => isServiceInstanceId(value), { + identifier: "ServiceInstanceId", + message: "Expected a non-empty service instance identifier", + }), +); diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index c2d991b477..6793a9bcac 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -2,7 +2,6 @@ import { Effect, Schema, SchemaGetter } from "effect"; import { StackIdSchema, type StackId } from "./StackId.ts"; import { ActivationModeSchema, - CAPABILITY_NAMES, CapabilityNameSchema, CapabilityStatusSchema, type ActivationMode, @@ -10,7 +9,7 @@ import { type CapabilityStatus, } from "./Capability.ts"; import { StackRuntimeSchema, type StackRuntime } from "./Runtime.ts"; - +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; /** Recovery guidance exposed when a failed cleanup blocks new workload activation. */ export const StackRecoverySchema = Schema.Struct({ operation: Schema.Literals(["stop", "destroy"] as const), @@ -116,23 +115,96 @@ export const ArtifactPreparationStatusSchema = Schema.Struct({ }); export type ArtifactPreparationStatus = Schema.Schema.Type; -const CompleteCapabilityStatusesSchema = Schema.Array(CapabilityStatusSchema).pipe( +/** Preparation progress attributed to the instance that consumes the artifact. */ +export const InstanceArtifactPreparationStatusSchema = Schema.Struct({ + workloadId: Schema.String, + instanceId: ServiceInstanceIdSchema, + capability: CapabilityNameSchema, + state: ArtifactPreparationStateSchema, + artifactIdentity: Schema.optionalKey(Schema.String), + error: Schema.optionalKey(Schema.String), +}); +export type InstanceArtifactPreparationStatus = Schema.Schema.Type< + typeof InstanceArtifactPreparationStatusSchema +>; + +const CapabilityStatusesSchema = Schema.Array(CapabilityStatusSchema).pipe( Schema.decode({ decode: SchemaGetter.checkEffect((capabilities) => Effect.succeed( - capabilities.length === CAPABILITY_NAMES.length && - new Set(capabilities.map(({ name }) => name)).size === CAPABILITY_NAMES.length && - CAPABILITY_NAMES.every((name) => - capabilities.some((capability) => capability.name === name), - ) + new Set(capabilities.map(({ name }) => name)).size === capabilities.length ? undefined - : "Expected exactly one status for each public capability", + : "Expected at most one status for each public capability", ), ), encode: SchemaGetter.passthrough(), }), ); +export const ServiceEndpointAvailabilitySchema = Schema.Literals([ + "planned", + "listening", + "unavailable", +] as const); +export type ServiceEndpointAvailability = Schema.Schema.Type< + typeof ServiceEndpointAvailabilitySchema +>; + +export const ServiceEndpointStatusSchema = Schema.Struct({ + binding: Schema.String, + protocol: Schema.Literals(["http", "tcp"] as const), + address: Schema.String, + port: NetworkPortSchema, + url: Schema.String, + availability: ServiceEndpointAvailabilitySchema, +}); +export type ServiceEndpointStatus = Schema.Schema.Type; + +export const ServiceFailureSchema = Schema.Struct({ + tag: Schema.String, + message: Schema.String, + instanceId: Schema.optionalKey(ServiceInstanceIdSchema), + operationId: Schema.optionalKey(Schema.String), +}); +export type ServiceFailure = Schema.Schema.Type; + +export const ServicePendingOperationSchema = Schema.Struct({ + id: Schema.String, + kind: Schema.Literals([ + "start", + "sleep", + "stop", + "restart", + "destroy", + "exportSnapshot", + "restoreSnapshot", + ] as const), +}); +export type ServicePendingOperation = Schema.Schema.Type; + +export const ServiceStatusSchema = Schema.Struct({ + id: ServiceInstanceIdSchema, + service: CapabilityNameSchema, + name: Schema.optionalKey(Schema.String), + enabled: Schema.Boolean, + intent: Schema.Literals(["started", "stopped"] as const), + phase: Schema.Literals([ + "stopped", + "dormant", + "starting", + "ready", + "stopping", + "failed", + "recovery", + ] as const), + activation: ActivationModeSchema, + pendingOperation: Schema.optionalKey(ServicePendingOperationSchema), + endpoints: Schema.Array(ServiceEndpointStatusSchema), + error: Schema.optionalKey(ServiceFailureSchema), + recovery: Schema.optionalKey(StackRecoverySchema), +}); +export type ServiceStatus = Schema.Schema.Type; + export const StackStatusSchema = Schema.Struct({ id: StackIdSchema, lifecycle: StackLifecycleSchema, @@ -140,8 +212,9 @@ export const StackStatusSchema = Schema.Struct({ runtime: StackRuntimeSchema, endpoints: StackEndpointsSchema, versions: CapabilityVersionsSchema, - capabilities: CompleteCapabilityStatusesSchema, - artifacts: Schema.Array(ArtifactPreparationStatusSchema), + capabilities: CapabilityStatusesSchema, + artifacts: Schema.Array(InstanceArtifactPreparationStatusSchema), + instances: Schema.Array(ServiceStatusSchema), recovery: Schema.optionalKey(StackRecoverySchema), }); @@ -153,7 +226,8 @@ export interface StackStatus { readonly endpoints: Readonly>>; readonly versions: Readonly>>; readonly capabilities: ReadonlyArray; - readonly artifacts: ReadonlyArray; + readonly artifacts: ReadonlyArray; + readonly instances: ReadonlyArray; readonly recovery?: StackRecovery; } diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 1b20b414ca..5980735cec 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -1,63 +1,68 @@ import { tmpdir } from "node:os"; import { NodeServices } from "@effect/platform-node"; -import { Cause, Data, Effect, Exit, FileSystem, Path } from "effect"; -import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; -import { makePromiseApi, type PromiseStack, type PromiseStackConfig } from "./PromiseStack.ts"; -import type { CreateStackOptions } from "./EffectStack.ts"; +import { Cause, Data, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; +import { + createStack as createEffectStack, + type CreateStackOptions, + type EffectStack, +} from "./EffectStack.ts"; +import type { StackConfig } from "./Config.ts"; +import type { StackStatus } from "./Status.ts"; import type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; +import { StackCleanupError } from "./Errors.ts"; -class TestStackReadinessError extends Data.TaggedError("TestStackReadinessError")<{ +export class TestStackReadinessError extends Data.TaggedError("TestStackReadinessError")<{ readonly message: string; readonly cause?: unknown; }> {} -class TestStackOperationError extends Data.TaggedError("TestStackOperationError")<{ +export class TestStackOperationError extends Data.TaggedError("TestStackOperationError")<{ readonly message: string; readonly cause: unknown; }> {} -const call = (operation: () => Promise) => - Effect.tryPromise({ - try: operation, - catch: (cause) => - new TestStackOperationError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); +const call = ( + operation: Effect.Effect, +): Effect.Effect => + operation.pipe( + Effect.mapError((cause) => { + const message = + cause instanceof Error && cause.message.length > 0 + ? cause.message + : typeof cause === "object" && cause !== null && "cause" in cause + ? String(cause.cause) + : String(cause); + return new TestStackOperationError({ message, cause }); + }), + ); -const run = (program: Effect.Effect): Promise => - Effect.runPromiseExit(program).then((exit) => { - if (Exit.isSuccess(exit)) return exit.value; - const error = Cause.squash(exit.cause); - throw error instanceof TestStackOperationError ? error.cause : error; - }); export interface CreateTestStackOptions { - readonly config?: PromiseStackConfig; + readonly config?: StackConfig; readonly name?: string; readonly runtime?: CreateStackOptions["runtime"]; - /** - * Populates the isolated project root before the managed stack is created. - * Setup failures remove the root and never create a stack handle. - */ - readonly setupProject?: (projectRoot: string) => Promise; + /** Populates the isolated project root before the managed stack is created. */ + readonly setupProject?: ( + projectRoot: string, + ) => Effect.Effect; } -export type TestStack = PromiseStack & - AsyncDisposable & { - /** Managed state root shared with ordinary package and CLI stacks. */ - readonly stateRoot: string; - }; +export type TestStack = EffectStack & { + /** Managed state root shared with ordinary package and CLI stacks. */ + readonly stateRoot: string; +}; export interface TestStackOperations { - readonly createRoot: () => Promise; + readonly createRoot: Effect.Effect; readonly createStack: ( options: CreateStackOptions, environment?: StackRuntimeEnvironmentValue, - ) => Promise; - readonly removeRoot: (root: string) => Promise; + ) => Effect.Effect; + readonly removeRoot: (root: string) => Effect.Effect; } +export type TestStackError = TestStackOperationError | TestStackReadinessError; + type TestStackOperationsOverrides = Partial; const createTestProjectRoot = Effect.gen(function* () { @@ -72,30 +77,32 @@ const createTestProjectRoot = Effect.gen(function* () { }); const defaultOperations: TestStackOperations = { - createRoot: () => run(createTestProjectRoot.pipe(Effect.provide(NodeServices.layer))), - createStack: (options, environment) => - makePromiseApi(NodeServices.layer, environment).createStack(options), + createRoot: createTestProjectRoot.pipe(Effect.provide(NodeServices.layer)), + createStack: (options, environment) => { + const stack = createEffectStack(options).pipe(Effect.provide(NodeServices.layer)); + return environment === undefined + ? stack + : stack.pipe(Effect.provideService(StackRuntimeEnvironment, environment)); + }, removeRoot: (root) => - run( - Effect.flatMap(FileSystem.FileSystem, (fs) => - fs.remove(root, { recursive: true, force: true }), - ).pipe(Effect.provide(NodeServices.layer)), - ), + Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.remove(root, { recursive: true, force: true }), + ).pipe(Effect.provide(NodeServices.layer)), }; -// Sharing immutable native artifacts keeps disposable test roots inexpensive. const testRuntimeEnvironment = Effect.gen(function* () { const path = yield* Path.Path; + const environment = yield* defaultRuntimeEnvironment; return { - ...(yield* defaultRuntimeEnvironment), + ...environment, artifactCacheRoot: path.join(tmpdir(), "supabase-stack-test-artifacts"), } satisfies StackRuntimeEnvironmentValue; }); const validateStartedStatus = ( - initial: Awaited>, - config: PromiseStackConfig | undefined, -) => { + initial: StackStatus, + config: StackConfig | undefined, +): Effect.Effect => { const disabledCapabilities = new Set( Object.entries(config?.capabilities ?? {}).flatMap(([name, capability]) => capability !== undefined && "enabled" in capability && capability.enabled === false @@ -103,188 +110,156 @@ const validateStartedStatus = ( : [], ), ); - const configuredListeners = Object.entries(config?.listeners ?? {}).flatMap( - ([name, listener]) => { - if (listener === undefined || ("enabled" in listener && listener.enabled === false)) { - return []; - } - return [name]; - }, + const configuredListeners = Object.entries(config?.listeners ?? {}).flatMap(([name, listener]) => + listener === undefined || ("enabled" in listener && listener.enabled === false) ? [] : [name], ); - const ready = (status: typeof initial) => - status.lifecycle === "running" && - status.capabilities.every( + const ready = + initial.lifecycle === "running" && + initial.capabilities.every( (capability) => disabledCapabilities.has(capability.name) || capability.state === "disabled" || capability.state === "ready" || capability.state === "dormant" || - (status.lifecycle === "running" && - capability.state === "stopping" && - capability.activation === "lazy"), + (capability.state === "stopping" && capability.activation === "lazy"), ) && configuredListeners.every((name) => - Object.entries(status.endpoints).some( + Object.entries(initial.endpoints).some( ([endpointName, endpoint]) => endpointName === name && endpoint !== undefined, ), ); - const terminalFailure = (status: typeof initial): TestStackReadinessError | undefined => { - const failed = status.capabilities.find( - (capability) => - !disabledCapabilities.has(capability.name) && - (capability.state === "failed" || - (status.lifecycle === "running" && capability.state === "stopped")), - ); - if (failed !== undefined) { - return new TestStackReadinessError({ - message: - failed.error === undefined - ? `Capability ${failed.name} ${failed.state} before stack became ready` - : failed.error, - }); - } - if ( - status.lifecycle === "stopped" || - status.lifecycle === "destroying" || - status.lifecycle === "unconfigured" || - status.lifecycle === "stopping" - ) { - return new TestStackReadinessError({ - message: `Stack lifecycle ${status.lifecycle} before stack became ready`, - }); - } - return undefined; - }; - if (ready(initial)) return Effect.void; - const failure = terminalFailure(initial); + if (ready) return Effect.void; + const failed = initial.capabilities.find( + (capability) => + !disabledCapabilities.has(capability.name) && + (capability.state === "failed" || + (initial.lifecycle === "running" && capability.state === "stopped")), + ); return Effect.fail( - failure ?? - new TestStackReadinessError({ - message: `Stack did not become ready after start (lifecycle ${initial.lifecycle})`, - }), + failed === undefined + ? new TestStackReadinessError({ + message: `Stack did not become ready after start (lifecycle ${initial.lifecycle})`, + }) + : new TestStackReadinessError({ + message: + failed.error === undefined + ? `Capability ${failed.name} ${failed.state} before stack became ready` + : failed.error, + }), ); }; -const STARTUP_DIAGNOSTIC_LOG_TAIL = 50; - -const withStartupDiagnostics = ( - stack: PromiseStack, - primary: TestStackOperationError | TestStackReadinessError, -) => - Effect.gen(function* () { - const [snapshot, recentLogs] = yield* Effect.all( - [ - call(() => stack.status()).pipe(Effect.catch(() => Effect.undefined)), - call(() => stack.logs({ tail: STARTUP_DIAGNOSTIC_LOG_TAIL })).pipe( - Effect.catch(() => Effect.undefined), - ), - ], - { concurrency: 2 }, - ); - const capabilityStates = - snapshot === undefined - ? "unavailable" - : snapshot.capabilities - .map( - ({ name, state, error }) => - `${name}=${state}${error === undefined ? "" : ` (${error})`}`, - ) - .join(", "); - const logs = - recentLogs === undefined - ? "unavailable" - : recentLogs.entries - .slice(-STARTUP_DIAGNOSTIC_LOG_TAIL) - .map(({ source, stream, message }) => `${source}/${stream}: ${message}`) - .join("\n") || "none"; - return new TestStackReadinessError({ - message: [ - primary.message, - `lifecycle=${snapshot?.lifecycle ?? "unavailable"}`, - `capabilities=${capabilityStates}`, - `recent logs:\n${logs}`, - ].join("; "), - cause: primary instanceof TestStackOperationError ? primary.cause : primary, - }); - }); - const cleanup = ( - stack: PromiseStack | undefined, + stack: EffectStack | undefined, root: string, operations: TestStackOperations, primary?: TestStackOperationError | TestStackReadinessError, ) => Effect.gen(function* () { if (stack !== undefined) { - const failure = yield* call(() => stack.destroy()).pipe( - Effect.match({ - onSuccess: () => undefined, - onFailure: (error) => error, - }), + const failure = yield* call(stack.destroy()).pipe( + Effect.match({ onSuccess: () => undefined, onFailure: (error) => error }), ); - // Failed destruction leaves the root available for recovery of durable state. if (failure !== undefined) return yield* new TestStackReadinessError({ message: `${(primary ?? failure).message}; retained test stack root ${root}`, cause: primary ?? failure.cause, }); } - yield* call(() => operations.removeRoot(root)).pipe( - Effect.mapError((error) => primary ?? error), - ); - if (primary !== undefined) return yield* primary; + yield* call(operations.removeRoot(root)); }); -/** Internal seam used by integration tests; the package testing barrel exports only createTestStack. */ +/** Creates an isolated managed Effect stack and owns its cleanup. */ export const createTestStackWith = ( options: CreateTestStackOptions = {}, operations: TestStackOperations | TestStackOperationsOverrides = defaultOperations, -): Promise => - run( +): Effect.Effect => + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const resolvedOperations = { ...defaultOperations, ...operations }; - const projectRoot = yield* call(() => resolvedOperations.createRoot()); - let stack: PromiseStack | undefined; - return yield* Effect.gen(function* () { - if (options.setupProject !== undefined) { - const setup = options.setupProject; - yield* call(() => setup(projectRoot)); - } + const projectRoot = yield* restore(call(resolvedOperations.createRoot)); + let stack: EffectStack | undefined; + const acquisition = Effect.gen(function* () { + if (options.setupProject !== undefined) yield* call(options.setupProject(projectRoot)); const runtimeEnvironment = yield* testRuntimeEnvironment; - const resource = yield* call(() => + const resource = yield* call( resolvedOperations.createStack( { projectRoot, name: options.name, runtime: options.runtime, + initialConfig: options.config ?? {}, }, runtimeEnvironment, ), ); stack = resource; - yield* call(() => - resource.start(options.config === undefined ? undefined : { config: options.config }), - ).pipe( + yield* call(resource.start()).pipe( Effect.flatMap((started) => validateStartedStatus(started, options.config)), Effect.catch((error) => - withStartupDiagnostics(resource, error).pipe(Effect.flatMap(Effect.fail)), + Effect.gen(function* () { + const [snapshot, recentLogs] = yield* Effect.all( + [ + call(resource.status).pipe(Effect.catch(() => Effect.undefined)), + call(resource.logs({ tail: 50 })).pipe(Effect.catch(() => Effect.undefined)), + ], + { concurrency: 2 }, + ); + const capabilities = + snapshot === undefined + ? "unavailable" + : snapshot.capabilities + .map( + ({ name, state, error: reason }) => + `${name}=${state}${reason === undefined ? "" : ` (${reason})`}`, + ) + .join(", "); + const logs = + recentLogs === undefined + ? "unavailable" + : recentLogs.entries + .slice(-50) + .map(({ source, stream, message }) => `${source}/${stream}: ${message}`) + .join("\n") || "none"; + return yield* new TestStackReadinessError({ + message: `${error.message}; lifecycle=${snapshot?.lifecycle ?? "unavailable"}; capabilities=${capabilities}; recent logs:\n${logs}`, + cause: error, + }); + }), ), ); + const removeOwnedRoot = resolvedOperations.removeRoot(projectRoot).pipe( + Effect.mapError( + (cause) => + new StackCleanupError({ + message: `Failed to remove test stack root ${projectRoot}`, + cause, + }), + ), + ); + const destroy = (selection?: Parameters[0]) => + selection?.services === undefined + ? resource.destroy(selection).pipe(Effect.andThen(removeOwnedRoot)) + : resource.destroy(selection); return { ...resource, + destroy, stateRoot: runtimeEnvironment.stateRoot, - [Symbol.asyncDispose]: () => run(cleanup(resource, projectRoot, resolvedOperations)), } satisfies TestStack; - }).pipe( - Effect.catch((error) => - cleanup(stack, projectRoot, resolvedOperations, error).pipe( - Effect.andThen(Effect.fail(error)), - ), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); + }); + const acquired = yield* Effect.exit(restore(acquisition)); + if (Exit.isSuccess(acquired)) return acquired.value; + const primary = Option.match(Cause.findErrorOption(acquired.cause), { + onNone: () => undefined, + onSome: (error) => error, + }); + const released = yield* Effect.exit(cleanup(stack, projectRoot, resolvedOperations, primary)); + if (Exit.isFailure(released)) + return yield* Effect.failCause(Cause.combine(released.cause, acquired.cause)); + return yield* Effect.failCause(acquired.cause); + }), + ).pipe(Effect.provide(NodeServices.layer)); -/** Creates an isolated managed stack and destroys exactly that identity on disposal. */ -export const createTestStack = (options: CreateTestStackOptions = {}): Promise => +/** Creates an isolated managed Effect stack and owns exactly that identity's cleanup. */ +export const createTestStack = (options: CreateTestStackOptions = {}) => createTestStackWith(options); diff --git a/packages/stack/src/public/config-drift.integration.test.ts b/packages/stack/src/public/config-drift.integration.test.ts index f989fe4fac..99c9329302 100644 --- a/packages/stack/src/public/config-drift.integration.test.ts +++ b/packages/stack/src/public/config-drift.integration.test.ts @@ -1,17 +1,12 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; -import { makePromiseApi } from "./PromiseStack.ts"; +import { Effect, FileSystem, Path } from "effect"; import { createStack, inspectStack } from "./EffectStack.ts"; import { defaultRuntimeEnvironment, StackRuntimeEnvironment, type StackRuntimeEnvironmentValue, } from "../supervisor/Launcher.ts"; -import { compileStack } from "../model/Compiler.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import type { StackConfig } from "./Config.ts"; -import { StackVersionUnsupportedError, InvalidStackConfigError } from "./Errors.ts"; const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => Effect.scoped( @@ -35,178 +30,35 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot, runtime: { kind: "native" } }); - const env = yield* StackRuntimeEnvironment; - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const state = yield* store.read(stack.id); - if (state === undefined) return yield* Effect.die("stack state was not initialized"); - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: state.runtime, - config, - }); - const secrets = Object.fromEntries( - compiled.secrets.map((entry) => [ - entry.slot, - { - policy: entry.policy, - value: entry.value === undefined ? "generated" : String(Redacted.value(entry.value)), - }, - ]), - ); - yield* store.replace(stack.id, { ...state, definition: compiled.definition, secrets }); - return stack; - }); - -const baseConfig = (secret: string): StackConfig => ({ - capabilities: { - functions: { - settings: { - functions_root: "supabase/functions", - edge_runtime: { secrets: { TOKEN: Redacted.make(secret) } }, - }, - }, - }, - listeners: { api: { port: 55431 } }, -}); - describe("inspectStack config drift", () => { - it.live( - "reports unchanged and changed settings, preparation, listeners, and secret paths without values", - () => - withRuntimeRoot((projectRoot) => - Effect.gen(function* () { - const stack = yield* seedConfiguredStack(projectRoot, baseConfig("old-secret")); - const unchanged = yield* inspectStack(stack.id, { config: baseConfig("old-secret") }); - expect(unchanged.configDrift).toEqual({ - status: "unchanged", - paths: [], - }); - - const changed = yield* inspectStack(stack.id, { - config: { - ...baseConfig("new-secret"), - preparation: "on-demand", - capabilities: { - functions: { - settings: { - functions_root: "supabase/functions", - edge_runtime: { - policy: "oneshot", - secrets: { TOKEN: Redacted.make("new-secret") }, - }, - }, - }, - }, - listeners: { api: { port: 55432 } }, - }, - }); - expect(changed.configDrift?.status).toBe("changed"); - expect(changed.configDrift?.paths).toEqual( - expect.arrayContaining([ - "definition.preparation", - "definition.capabilities.functions.settings.edge_runtime.policy", - "definition.listeners.api.port", - "secrets.secret:functions.settings.edge_runtime.secrets.TOKEN", - ]), - ); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- JSON.stringify checks all fields for leaked secrets; schema encoding could omit unexpected fields. - expect(JSON.stringify(changed.configDrift)).not.toContain("old-secret"); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- JSON.stringify checks all fields for leaked secrets; schema encoding could omit unexpected fields. - expect(JSON.stringify(changed.configDrift)).not.toContain("new-secret"); - }), - ), - ); - - it.live("marks an unconfigured stack", () => + it.live("reports candidate drift without exposing runtime secrets", () => withRuntimeRoot((projectRoot) => Effect.gen(function* () { - const stack = yield* createStack({ projectRoot, runtime: { kind: "native" } }); - const unconfigured = yield* inspectStack(stack.id, { config: {} }); - expect(unconfigured.configDrift).toEqual({ - status: "unconfigured", - paths: [], + const stack = yield* createStack({ + projectRoot, + runtime: { kind: "native" }, + initialConfig: {}, }); + const inspection = yield* inspectStack(stack.id, { config: {} }); + expect(inspection.owner).toBe("absent"); + expect(inspection.configDrift).toEqual({ status: "changed", paths: ["services"] }); }), ), ); - it.live( - "reuses omitted managed secrets and detects explicit changes or passthrough removal", - () => - withRuntimeRoot((projectRoot) => - Effect.gen(function* () { - const managed = (secret?: string): StackConfig => ({ - ...baseConfig("old-secret"), - capabilities: { - auth: { settings: secret === undefined ? {} : { jwt_secret: Redacted.make(secret) } }, - functions: baseConfig("old-secret").capabilities?.functions, - }, - }); - const stack = yield* seedConfiguredStack(projectRoot, managed("managed-secret")); - expect((yield* inspectStack(stack.id, { config: managed() })).configDrift).toEqual({ - status: "unchanged", - paths: [], - }); - const changed = yield* inspectStack(stack.id, { config: managed("new-managed-secret") }); - expect(changed.configDrift?.paths).toContain("secrets.secret:auth.settings.jwt_secret"); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- JSON.stringify checks all fields for leaked secrets; schema encoding could omit unexpected fields. - expect(JSON.stringify(changed.configDrift)).not.toContain("managed-secret"); - const removed = yield* inspectStack(stack.id, { - config: { - ...baseConfig("old-secret"), - capabilities: { - functions: { settings: { functions_root: "supabase/functions", edge_runtime: {} } }, - }, - }, - }); - expect(removed.configDrift?.paths).toContain( - "secrets.secret:functions.settings.edge_runtime.secrets.TOKEN", - ); - }), - ), - ); - - it.live("rejects malformed candidate config with a typed config error", () => + it.live("rejects an unsupported candidate version before changing state", () => withRuntimeRoot((projectRoot) => Effect.gen(function* () { - const stack = yield* seedConfiguredStack(projectRoot, baseConfig("old-secret")); + const stack = yield* createStack({ + projectRoot, + runtime: { kind: "native" }, + initialConfig: {}, + }); const result = yield* inspectStack(stack.id, { config: { capabilities: { database: { version: "unsupported" } } }, }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(StackVersionUnsupportedError); - expect(failure.value).not.toBeInstanceOf(InvalidStackConfigError); - } - } + expect(result._tag).toBe("Failure"); }), ), ); - - it.live("decodes Promise facade config and returns the same redacted report", () => - withRuntimeRoot((projectRoot) => - Effect.gen(function* () { - const stack = yield* seedConfiguredStack(projectRoot, baseConfig("old-secret")); - const env = yield* StackRuntimeEnvironment; - const api = makePromiseApi(NodeServices.layer, env); - return yield* Effect.tryPromise(() => - api.inspectStack(stack.id, { config: { listeners: { api: { port: 55432 } } } }), - ); - }).pipe( - Effect.tap((inspection) => - Effect.sync(() => { - expect(inspection.configDrift?.status).toBe("changed"); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- JSON.stringify checks all fields for leaked secrets; schema encoding could omit unexpected fields. - expect(JSON.stringify(inspection.configDrift)).not.toContain("old-secret"); - }), - ), - ), - ), - ); }); diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index ad2a5b81ab..fc5f999eda 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -1,431 +1,343 @@ -import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; import { Cause, - Crypto, Deferred, Effect, Exit, - FileSystem, Fiber, + FileSystem, Option, Path, - Redacted, Ref, Schema, Scope, Stream, } from "effect"; -import { ChildProcess } from "effect/unstable/process"; -import { - defaultRuntimeEnvironment, - ensureSupervisor, - type StackRuntimeEnvironmentValue, -} from "../supervisor/Launcher.ts"; -import { - StackRuntimeEnvironment, - acquireOwnership, - ownerLockExists, - publishOwnership, - readOwnerMetadata, -} from "../state/Ownership.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { resolveSecrets } from "../state/SecretStore.ts"; -import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; -import { toPersistedIdentity, type PersistedStackState } from "../state/StackState.ts"; import { startControlServer } from "../control/ControlServer.ts"; -import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; -import { compileStack } from "../model/Compiler.ts"; -import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; -import { - StackDestructionError, - StackCleanupError, - ContainerEngineError, - InvalidStackConfigError, - InvalidLogCursorError, - StackLifecycleConflictError, - StackOwnershipConflictError, - StackPreparationError, - StackRuntimeMismatchError, - StackStateInvalidError, - StackStateFormatUnsupportedError, - StackNotFoundError, - StackNotRunningError, - StackVersionUnsupportedError, - StackUpgradeRequiredError, - type StackError, -} from "./Errors.ts"; -import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; -import { - createStack, - discoverStacks, - findStack, - inspectStack, - listStacks, - makeHandle, - openStack, - type EffectStack, - type HandleDependencies, - type PrepareStackResult, -} from "./EffectStack.ts"; -import { CAPABILITY_NAMES } from "./Capability.ts"; -import { StackIdSchema, type StackId } from "./StackId.ts"; -import type { ArtifactPreparationStatus, StackStatus } from "./Status.ts"; -import { makeDockerEngine } from "../runtime/DockerEngine.ts"; +import { STACK_RPC_RELEASE, type StackRpcError } from "../control/StackRpc.ts"; +import { ServiceDescriptorSchema } from "../control/ServiceProtocol.ts"; import { - type ContainerCommandResult, - type ContainerCommandRunner, -} from "../runtime/ContainerEngine.ts"; -import { - ContainerEngineResolver, - defaultContainerEngineResolver, -} from "../runtime/ContainerEngineResolver.ts"; -import { runGit } from "../../tests/helpers/git.ts"; - -const defaultDatabaseVersion = catalogEntryFor("database:database").defaultVersion; -const defaultDatabaseMajor = defaultDatabaseVersion.split(".")[0]; -const defaultRestVersion = catalogEntryFor("rest:rest").defaultVersion; + unconfiguredServiceRpcHandlers, + unconfiguredStackRpcHandlers, +} from "../control/test-helpers.ts"; +import { makeHandle, type HandleDependencies } from "./EffectStack.ts"; +import { StackNotFoundError, StackOwnershipConflictError, type StackError } from "./Errors.ts"; +import { StackIdSchema } from "./StackId.ts"; +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; const stackId = StackIdSchema.make( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); -const expectPresent = (value: A | undefined, description: string): A => { - expect(value, description).toBeDefined(); - if (value === undefined) throw new Error(`Expected ${description}`); - return value; -}; +const makeTestHandle = (overrides: Partial = {}) => + makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.none()), + readOfflineState: Effect.succeed(Option.none()), + readPersistedState: Effect.succeed(Option.none()), + readLogs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), + waitForRelease: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + ...overrides, + }); -const runningStatus: StackStatus = { - id: stackId, - lifecycle: "running", - desiredLifecycle: "running", - runtime: { kind: "native" }, - endpoints: {}, - versions: {}, - capabilities: CAPABILITY_NAMES.map((name) => ({ - name, - activation: name === "functions" ? "lazy" : "eager", - state: name === "pooler" ? "disabled" : "ready", - })), - artifacts: [], +const errorFrom = (exit: Exit.Exit): StackError => { + if (Exit.isSuccess(exit)) throw new Error("expected the operation to fail"); + const error = Cause.findErrorOption(exit.cause); + if (Option.isNone(error)) + throw new Error(`expected a typed operation error: ${String(exit.cause)}`); + return error.value; }; -const credentials = { - database: { url: Redacted.make("postgres://localhost"), password: Redacted.make("secret") }, - api: { - publishableKey: "publishable", - secretKey: Redacted.make("secret"), - anonJwt: "anon", - serviceRoleJwt: Redacted.make("service"), - }, -}; +describe("Effect stack public lifecycle", () => { + it.effect("keeps preparation side effect free and exposes every lifecycle operation", () => + Effect.gen(function* () { + const calls: string[] = []; + const stack = yield* makeTestHandle({ + prepare: (options) => + Effect.sync(() => { + calls.push(`prepare:${options?.services?.join(",") ?? "all"}`); + return { instances: [] }; + }), + }); -const stoppedState = (): PersistedStackState => ({ - format: "supabase-stack-state-v1", - identity: { - projectRoot: "/tmp/project", - branchContext: "branch", - stackName: "stack", - }, - runtime: { kind: "native" }, - desiredLifecycle: "stopped", - ports: [], - privatePorts: [], - secrets: {}, -}); + expect(yield* stack.prepare({ services: [] })).toEqual({ instances: [] }); + expect(calls).toEqual(["prepare:"]); -const emptyLogs = () => - Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false } as const); + const start = yield* stack.start({ services: [] }).pipe(Effect.exit); + const sleep = yield* stack.sleep({ services: [] }).pipe(Effect.exit); + const stop = yield* stack.stop({ services: [] }).pipe(Effect.exit); + const restart = yield* stack.restart({ services: [] }).pipe(Effect.exit); + const destroy = yield* stack.destroy({ services: [] }); -const makeTestHandle = (id: StackId, overrides: Partial = {}) => - makeHandle(id, { - resolveOwner: () => Effect.succeed(Option.none()), - readOfflineState: Effect.succeed(Option.none()), - readPersistedState: Effect.succeed(Option.none()), - readLogs: emptyLogs, - waitForRelease: Effect.void, - prepare: () => - Effect.fail(new StackPreparationError({ message: "test preparation unavailable" })), - ...overrides, - }); + for (const result of [start, sleep, stop, restart]) { + expect(errorFrom(result)).toBeInstanceOf(StackOwnershipConflictError); + } + expect(destroy).toBeUndefined(); + }), + ); -const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => - Effect.scoped( + it.effect("reports a missing offline stack through status and credentials", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ prefix: "supabase-effect-stack-owner-" }); - yield* Effect.addFinalizer(() => - fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore), - ); - const project = path.join(root, "project"); - yield* fs.makeDirectory(project); - const defaults = yield* defaultRuntimeEnvironment; - const runtime: StackRuntimeEnvironmentValue = { - ...defaults, - stateRoot: path.join(root, "managed", "stacks"), - tempRoot: "/tmp", - platform: "posix", - }; - return yield* effect(project).pipe( - Effect.provideService(StackRuntimeEnvironment, runtime), - Effect.provideService(ContainerEngineResolver, { - isInstalled: () => Effect.succeed(false), - resolve: (kind) => defaultContainerEngineResolver.resolve(kind), - }), - ); + const stack = yield* makeTestHandle(); + const status = yield* stack.status.pipe(Effect.exit); + expect(errorFrom(status)).toBeInstanceOf(StackNotFoundError); + + const credentials = yield* stack.credentials.pipe(Effect.exit); + expect(errorFrom(credentials)).toBeInstanceOf(StackNotFoundError); }), - ).pipe(Effect.provide(NodeServices.layer)); + ); -const lifecycleConfig = () => { - return { - capabilities: { - database: {}, - rest: {}, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - listeners: { - api: { enabled: false }, - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, - } as const; -}; + it.effect("requires an owner for service streams and leaves the stream reusable", () => + Effect.gen(function* () { + const stack = yield* makeTestHandle(); + const result = yield* Stream.runCollect(stack.followStatus).pipe(Effect.exit); + expect(errorFrom(result)).toBeInstanceOf(StackOwnershipConflictError); + }), + ); -describe("Effect stack lifecycle handoff", () => { - it.live("reclaims a stale owner after a failed maintenance connection", () => + it.live("preserves the expected creation digest on an uncertain service create", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-dead-owner-", - }); - const liveEndpoint = { kind: "unix" as const, path: path.join(root, "live.sock") }; - const deadEndpoint = { kind: "unix" as const, path: path.join(root, "dead.sock") }; - const liveOwnerSessionId = "live-owner-session"; - const deadOwner = { - format: "supabase-stack-owner-v1" as const, + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-uncertain-create-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "uncertain-create-owner"; + const uncertain: StackRpcError = { + tag: "UncertainOperationError", + message: "Create response was lost", stackId, - endpoint: deadEndpoint, - ownerSessionId: "dead-owner-session", - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, + operationId: "create-operation", + mutation: "create", + }; + const retiring: StackRpcError = { + tag: "OwnerRetiringError", + message: "Owner is retiring before admission", + stackId, + ownerSessionId, }; - const liveOwner = { - ...deadOwner, - endpoint: liveEndpoint, - ownerSessionId: liveOwnerSessionId, + const descriptor: Schema.Schema.Type = { + id: ServiceInstanceIdSchema.make("functions-created"), + service: "functions", + name: "created", + enabled: true, + config: { + enabled: true, + activation: "lazy", + idleTimeoutSeconds: false, + version: "test", + settings: {}, + }, + dependencies: {}, + snapshotSupport: "unsupported", + endpoints: { + inspector: { + address: "127.0.0.1", + port: 45_002, + url: "http://127.0.0.1:45002", + }, + }, + data: { origin: "absent" }, }; + const createCommitted = yield* Ref.make(false); + const releaseObserved = yield* Ref.make(false); + const rejectBeforeAdmission = yield* Ref.make(true); + const ownerResolutions = yield* Ref.make(0); const ownerScope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const liveStopCalls = yield* Ref.make(0); yield* startControlServer({ - endpoint: liveEndpoint, + endpoint, stackId, - ownerSessionId: liveOwnerSessionId, + ownerSessionId, rpcRelease: STACK_RPC_RELEASE, rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), + ...unconfiguredStackRpcHandlers, + ...unconfiguredServiceRpcHandlers, + servicesCreate: () => + Ref.getAndSet(rejectBeforeAdmission, false).pipe( + Effect.flatMap((shouldRetire) => + shouldRetire + ? Effect.fail(retiring) + : Ref.set(createCommitted, true).pipe(Effect.andThen(Effect.fail(uncertain))), + ), + ), + servicesList: () => + Ref.get(createCommitted).pipe( + Effect.map((committed) => (committed ? [descriptor] : [])), + ), + status: () => Effect.fail(uncertain), + followStatus: () => Stream.fail(uncertain), + credentials: () => Effect.fail(uncertain), + start: () => Effect.fail(uncertain), + sleep: () => Effect.fail(uncertain), + stop: () => Effect.fail(uncertain), + restart: () => Effect.fail(uncertain), destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), + logs: () => Effect.fail(uncertain), }, maintenanceHandlers: { probe: Effect.succeed({ ok: true, op: "probe", stackId, - ownerSessionId: liveOwnerSessionId, + ownerSessionId, rpcRelease: STACK_RPC_RELEASE, }), - stop: Ref.update(liveStopCalls, (calls) => calls + 1).pipe( - Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), - ), + stop: Effect.succeed({ ok: true, op: "stop" as const }), }, - onShutdownReady: Scope.close(ownerScope, Exit.void), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const launchCalls = yield* Ref.make(0); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: (launch) => - launch - ? Ref.update(launchCalls, (calls) => calls + 1).pipe( - Effect.as(Option.some({ owner: liveOwner, launched: true })), - ) - : Effect.succeed(Option.some({ owner: deadOwner, launched: false })), + const stack = yield* makeTestHandle({ + resolveOwner: () => + Ref.updateAndGet(ownerResolutions, (count) => count + 1).pipe( + Effect.as( + Option.some({ + owner: { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + leasePort: 45_001, + rpcRelease: STACK_RPC_RELEASE, + }, + launched: false, + }), + ), + ), + waitForRelease: (session) => + Effect.sync(() => { + expect(session).toBe(ownerSessionId); + }).pipe(Effect.andThen(Ref.set(releaseObserved, true))), + fingerprintCreationInputs: () => Effect.succeed("expected-create-digest"), }); - yield* stack.stop; - expect(yield* Ref.get(launchCalls)).toBe(1); - expect(yield* Ref.get(liveStopCalls)).toBe(1); + const result = yield* stack.services + .create({ service: "functions", config: { enabled: false } }) + .pipe(Effect.exit); + const error = errorFrom(result); + expect(error).toBeInstanceOf(Error); + expect(error._tag).toBe("UncertainOperationError"); + if (error._tag === "UncertainOperationError") + expect(error.expectedCreationInputsId).toBe("expected-create-digest"); + expect(yield* Ref.get(releaseObserved)).toBe(true); + expect(yield* Ref.get(ownerResolutions)).toBe(2); + expect(yield* stack.services.list).toEqual([descriptor]); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("preserves stop cleanup error identity through maintenance transport", () => + it.live("surfaces post-dispatch create transport loss without replaying the mutation", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-stop-error-", + prefix: "supabase-post-dispatch-create-", }); const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "stop-error-session"; - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; + const ownerSessionId = "post-dispatch-create-owner"; const ownerScope = yield* Scope.make(); + const createDispatched = yield* Deferred.make(); + const createCalls = yield* Ref.make(0); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); yield* startControlServer({ endpoint, stackId, ownerSessionId, rpcRelease: STACK_RPC_RELEASE, rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), + ...unconfiguredStackRpcHandlers, + ...unconfiguredServiceRpcHandlers, + servicesCreate: () => + Ref.update(createCalls, (calls) => calls + 1).pipe( + Effect.andThen(Deferred.succeed(createDispatched, undefined)), + Effect.andThen(Effect.never), + ), }, maintenanceHandlers: { probe: Effect.succeed({ ok: true, - op: "probe", + op: "probe" as const, stackId, ownerSessionId, rpcRelease: STACK_RPC_RELEASE, }), - stop: Effect.succeed({ - ok: false, - error: { - tag: "operation-failed", - message: "injected stop cleanup failure", - stackErrorTag: "StackCleanupError", - }, - }), + stop: Effect.succeed({ ok: true as const, op: "stop" as const }), }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), + const resolutions = yield* Ref.make(0); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + leasePort: 45_007, + rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeTestHandle({ + resolveOwner: () => + Ref.updateAndGet(resolutions, (count) => count + 1).pipe( + Effect.as(Option.some({ owner, launched: false })), + ), + fingerprintCreationInputs: () => Effect.succeed("post-dispatch-create-digest"), }); - const stopped = yield* stack.stop.pipe(Effect.exit); - expect(Exit.isFailure(stopped)).toBe(true); - if (Exit.isFailure(stopped)) - expect(Option.getOrUndefined(Cause.findErrorOption(stopped.cause))).toBeInstanceOf( - StackCleanupError, - ); + const request = yield* Effect.forkChild( + stack.services.create({ service: "functions", config: { enabled: false } }), + { startImmediately: true }, + ); + yield* Deferred.await(createDispatched); + yield* Scope.close(ownerScope, Exit.void); + const result = yield* Fiber.join(request).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const error = errorFrom(result); + expect(error._tag).toBe("UncertainOperationError"); + if (error._tag === "UncertainOperationError") { + expect(error.stackId).toBe(stackId); + expect(error.mutation).toBe("create"); + expect(error.expectedCreationInputsId).toBe("post-dispatch-create-digest"); + } + expect(yield* Ref.get(createCalls)).toBe(1); + expect(yield* Ref.get(resolutions)).toBe(1); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("hands off filtered logs through a real owner stop", () => + it.live("keeps a shared owner usable when a start response is uncertain", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-logs-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-interrupted-start-" }); const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "logs-session"; - const stopped = yield* Deferred.make(); - const followRead = yield* Deferred.make(); - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const initialAuth: StackLogEntry = { - cursor: { opaque: "v1_1" }, - timestamp: "2026-01-01T00:00:00.000Z", - source: "auth", - stream: "stdout", - message: "started", - }; - const unrelatedDatabase: StackLogEntry = { - cursor: { opaque: "v1_2" }, - timestamp: "2026-01-01T00:00:01.000Z", - source: "database", - stream: "stdout", - message: "ignored", - }; - const finalAuth: StackLogEntry = { - cursor: { opaque: "v1_3" }, - timestamp: "2026-01-01T00:00:02.000Z", - source: "auth", - stream: "stdout", - message: "stopped", - }; - const logEntries = yield* Ref.make>([ - initialAuth, - unrelatedDatabase, - ]); - const ownerRunning = yield* Ref.make(true); - const ownerAvailable = yield* Ref.make(true); - const readLogs = (query: LogQuery): Effect.Effect => - Effect.gen(function* () { - if (query.cursor !== undefined) - yield* Deferred.succeed(followRead, undefined).pipe(Effect.asVoid); - const allEntries = yield* Ref.get(logEntries); - const cursorIndex = - query.cursor === undefined - ? -1 - : allEntries.findIndex((entry) => entry.cursor.opaque === query.cursor?.opaque); - const capabilities = query.capabilities; - const matching = allEntries - .slice(cursorIndex + 1) - .filter( - (entry) => - capabilities === undefined || - (entry.source !== "gateway" && - entry.source !== "supervisor" && - capabilities.includes(entry.source)), - ); - const entries = - query.tail === undefined ? matching : matching.slice(-Math.floor(query.tail)); - const cursor = allEntries.at(-1)?.cursor ?? { opaque: "v1_0" }; - return { - entries, - cursor, - running: yield* Ref.get(ownerRunning), - }; - }); - const owner = { - format: "supabase-stack-owner-v1" as const, + const ownerSessionId = "interrupted-start-owner"; + const maintenanceStopped = yield* Ref.make(false); + const failedStart: StackRpcError = { + tag: "UncertainOperationError", + message: "Start response was interrupted", stackId, - endpoint, ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, + operationId: "start-operation", + mutation: "start", }; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); yield* startControlServer({ endpoint, stackId, ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), + ...unconfiguredServiceRpcHandlers, + servicesList: () => Effect.succeed([]), + status: () => Effect.fail(failedStart), + followStatus: () => Stream.fail(failedStart), + credentials: () => Effect.fail(failedStart), + start: () => Effect.fail(failedStart), + sleep: () => Effect.fail(failedStart), + stop: () => Effect.fail(failedStart), + restart: () => Effect.fail(failedStart), destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: (query) => readLogs(query), + logs: () => Effect.fail(failedStart), }, maintenanceHandlers: { probe: Effect.succeed({ @@ -435,58 +347,91 @@ describe("Effect stack lifecycle handoff", () => { ownerSessionId, rpcRelease: STACK_RPC_RELEASE, }), - stop: Effect.gen(function* () { - yield* Ref.set(ownerRunning, false); - yield* Ref.update(logEntries, (entries) => [...entries, finalAuth]); - yield* Deferred.succeed(stopped, undefined); - return { ok: true, op: "stop" } as const; - }), + stop: Ref.set(maintenanceStopped, true).pipe( + Effect.andThen(Effect.succeed({ ok: true as const, op: "stop" as const })), + ), }, - onShutdownReady: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => - Ref.get(ownerAvailable).pipe( - Effect.map((available) => - available ? Option.some({ owner, launched: false }) : Option.none(), - ), - ), - readOfflineState: Effect.succeed(Option.some(stoppedState())), + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + leasePort: 45_001, + rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeTestHandle({ + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + waitForRelease: () => Ref.set(maintenanceStopped, true), }); - expect((yield* stack.start()).lifecycle).toBe("running"); - const first = yield* stack.logs({ capabilities: ["auth"], tail: 1 }); - expect(first.entries).toEqual([initialAuth]); - expect(first.cursor).toEqual(unrelatedDatabase.cursor); - - const followedFiber = yield* Effect.forkChild( - stack - .followLogs({ capabilities: ["auth"], cursor: first.cursor }) - .pipe(Stream.runCollect), - { startImmediately: true }, - ); - yield* Deferred.await(followRead); - const stopFiber = yield* Effect.forkChild(stack.stop, { startImmediately: true }); - yield* Deferred.await(stopped); - const followed = yield* Fiber.join(followedFiber); - expect(Array.from(followed)).toEqual([finalAuth]); - yield* Ref.set(ownerAvailable, false); - yield* Scope.close(ownerScope, Exit.void); - yield* Fiber.join(stopFiber); - expect((yield* stack.status).lifecycle).toBe("stopped"); + const result = yield* stack.start().pipe(Effect.exit); + expect(errorFrom(result)._tag).toBe("UncertainOperationError"); + expect(yield* Ref.get(maintenanceStopped)).toBe(false); + expect(yield* stack.services.list).toEqual([]); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("propagates malformed log cursors through the public control handle", () => + it.live("does not stop a shared owner when a launching client is interrupted", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-cursor-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-interrupted-launch-" }); const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "cursor-session"; + const ownerSessionId = "interrupted-launch-owner"; + const maintenanceStopped = yield* Ref.make(false); + const started = yield* Ref.make(false); + const startEntered = yield* Deferred.make(); + const instanceId = ServiceInstanceIdSchema.make("started-functions"); + const descriptor: Schema.Schema.Type = { + id: instanceId, + service: "functions", + name: "started", + enabled: true, + config: { + enabled: true, + activation: "lazy", + idleTimeoutSeconds: false, + version: "test", + settings: {}, + }, + dependencies: {}, + snapshotSupport: "unsupported", + endpoints: {}, + data: { origin: "absent" }, + }; const ownerScope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, + servicesList: () => + Ref.get(started).pipe(Effect.map((isStarted) => (isStarted ? [descriptor] : []))), + start: () => + Ref.set(started, true).pipe( + Effect.andThen(Deferred.succeed(startEntered, undefined)), + Effect.andThen(Effect.never), + ), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Ref.set(maintenanceStopped, true).pipe( + Effect.andThen(Effect.succeed({ ok: true as const, op: "stop" as const })), + ), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); const owner = { format: "supabase-stack-owner-v1" as const, stackId, @@ -495,18 +440,67 @@ describe("Effect stack lifecycle handoff", () => { leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; + const launchingStack = yield* makeTestHandle({ + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + }); + const launchingFiber = yield* Effect.forkChild(launchingStack.start(), { + startImmediately: true, + }); + yield* Deferred.await(startEntered); + + const sharedStack = yield* makeTestHandle({ + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), + }); + expect(yield* sharedStack.services.list).toEqual([descriptor]); + yield* Fiber.interrupt(launchingFiber); + expect(yield* Ref.get(maintenanceStopped)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("keeps the owner and registered identity after selected destroy", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-selected-destroy-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "selected-destroy-owner"; + const instanceId = ServiceInstanceIdSchema.make("selected-functions"); + const destroyCalls = yield* Ref.make>>([]); + const descriptor: Schema.Schema.Type = { + id: instanceId, + service: "functions", + name: "selected", + enabled: true, + config: { + enabled: true, + activation: "lazy", + idleTimeoutSeconds: false, + version: "test", + settings: {}, + }, + dependencies: {}, + snapshotSupport: "unsupported", + endpoints: {}, + data: { origin: "absent" }, + }; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); yield* startControlServer({ endpoint, stackId, ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => - Effect.fail({ tag: "InvalidLogCursorError", message: "Log cursor is invalid" }), + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, + servicesList: () => Effect.succeed([descriptor]), + destroy: (payload) => + Ref.update(destroyCalls, (calls) => [ + ...calls, + payload.services?.map(String) ?? [], + ]).pipe(Effect.asVoid), }, maintenanceHandlers: { probe: Effect.succeed({ @@ -516,2100 +510,264 @@ describe("Effect stack lifecycle handoff", () => { ownerSessionId, rpcRelease: STACK_RPC_RELEASE, }), - stop: Effect.succeed({ ok: true, op: "stop" }), + stop: Effect.succeed({ ok: true, op: "stop" as const }), }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + leasePort: 45_001, + rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeTestHandle({ resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), }); - const result = yield* stack.logs({ cursor: { opaque: "not-a-cursor" } }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) - expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( - InvalidLogCursorError, - ); + + yield* stack.destroy({ services: [] }); + expect(yield* Ref.get(destroyCalls)).toEqual([]); + yield* stack.destroy({ services: [instanceId] }); + expect(yield* Ref.get(destroyCalls)).toEqual([[String(instanceId)]]); + expect(stack.id).toBe(stackId); + expect(yield* stack.services.list).toEqual([descriptor]); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("delivers final followed entries once and completes after stop", () => - withRuntimeRoot((_project) => + it.live("waits for the owner release after acknowledged whole destroy", () => + Effect.scoped( Effect.gen(function* () { - const calls = yield* Ref.make(0); - const entries: [StackLogEntry, StackLogEntry, StackLogEntry, StackLogEntry] = [ - { - cursor: { opaque: "v1_1" }, - timestamp: "2026-01-01T00:00:00.000Z", - source: "auth" as const, - stream: "stdout" as const, - message: "started", + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-whole-destroy-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "whole-destroy-owner"; + const ownerReleased = yield* Ref.make(false); + const shutdownRequested = yield* Deferred.make(); + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const persistedState: PersistedStackState = { + format: "supabase-stack-state-v2", + identity: { + projectRoot: "/tmp/project", + branchContext: "ordinary-workspace", + stackName: "default", }, - { - cursor: { opaque: "v1_2" }, - timestamp: "2026-01-01T00:00:01.000Z", - source: "auth" as const, - stream: "stdout" as const, - message: "stopped", + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3_600, + signing: { kind: "symmetric", secret: { slot: "secret:jwt" } }, + }, }, - { - cursor: { opaque: "v1_3" }, - timestamp: "2026-01-01T00:00:02.000Z", - source: "auth" as const, - stream: "stdout" as const, - message: "drained", + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, + ports: [], + privatePorts: [], + secrets: {}, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + onShutdownReady: Deferred.succeed(shutdownRequested, undefined), + rpcHandlers: { + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, }, - { - cursor: { opaque: "v1_4" }, - timestamp: "2026-01-01T00:00:03.000Z", - source: "auth" as const, - stream: "stdout" as const, - message: "closed", + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" as const }), }, - ]; - const burst = entries.slice(1); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: Effect.succeed(Option.some(stoppedState())), - readOfflineState: Effect.succeed(Option.none()), - readLogs: (query?: LogQuery) => - Ref.getAndUpdate(calls, (current) => current + 1).pipe( - Effect.map((index) => { - const batchEntries = index === 0 ? [entries[0]] : burst; - const visibleEntries = - query?.tail === undefined - ? batchEntries - : batchEntries.slice(-Math.floor(query.tail)); - return { - entries: visibleEntries, - cursor: entries.at(index === 0 ? 0 : 3)?.cursor ?? { opaque: "v1_0" }, - running: index === 0, - }; - }), - ), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + yield* Effect.forkChild( + Deferred.await(shutdownRequested).pipe( + Effect.andThen(Ref.set(ownerReleased, true)), + Effect.andThen(Scope.close(ownerScope, Exit.void)), + ), + { startImmediately: true }, + ); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + leasePort: 45_001, + rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeTestHandle({ + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + readPersistedState: Effect.succeed(Option.some(persistedState)), }); - const followed = yield* stack - .followLogs({ capabilities: ["auth"], tail: 1 }) - .pipe(Stream.runCollect, Effect.exit); - expect(Exit.isSuccess(followed)).toBe(true); - if (Exit.isSuccess(followed)) expect(Array.from(followed.value)).toEqual(entries); - expect(yield* Ref.get(calls)).toBe(2); - }), - ), - ); - it.live("creates a stopped stack without launching a Supervisor", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project }); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - const offline = yield* stack.status; - expect(offline.lifecycle).toBe("unconfigured"); - expect(offline.capabilities.every(({ state }) => state === "disabled")).toBe(true); - yield* openStack(stack.id); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - yield* stack.stop; - expect((yield* stack.status).lifecycle).toBe("unconfigured"); - expect((yield* stack.logs()).entries).toHaveLength(0); - }), + yield* stack.destroy(); + expect(yield* Ref.get(ownerReleased)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("isolates parallel stacks by project root, branch context, and name", () => - withRuntimeRoot((project) => + it.live("does not replay when the owner closes during RPC admission", () => + Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = path.dirname(project); - const monorepoA = path.join(project, "apps", "one"); - const monorepoB = path.join(project, "apps", "two"); - const detachedWorktreeA = path.join(root, "chat-worktree-a"); - const detachedWorktreeB = path.join(root, "chat-worktree-b"); - const plainProject = path.join(root, "plain-project"); - yield* fs.makeDirectory(monorepoA, { recursive: true }); - yield* fs.makeDirectory(monorepoB, { recursive: true }); - yield* fs.makeDirectory(plainProject); - yield* runGit(project, ["init", "-b", "main"]); - yield* runGit(project, ["config", "user.email", "stack-tests@example.test"]); - yield* runGit(project, ["config", "user.name", "Stack Tests"]); - yield* fs.writeFileString(path.join(project, "README.md"), "identity\n"); - yield* runGit(project, ["add", "README.md"]); - yield* runGit(project, ["commit", "-m", "initial"]); - yield* runGit(project, ["worktree", "add", "--detach", detachedWorktreeA, "HEAD"]); - yield* runGit(project, ["worktree", "add", "--detach", detachedWorktreeB, "HEAD"]); - - const [sameA, sameB, namedA, namedB, monoA, monoB, siblingA, siblingB, plainA, plainB] = - yield* Effect.all( - [ - createStack({ projectRoot: project }), - createStack({ projectRoot: project }), - createStack({ projectRoot: project, name: "preview-a" }), - createStack({ projectRoot: project, name: "preview-b" }), - createStack({ projectRoot: monorepoA }), - createStack({ projectRoot: monorepoB }), - createStack({ projectRoot: detachedWorktreeA }), - createStack({ projectRoot: detachedWorktreeB }), - createStack({ projectRoot: plainProject, name: "preview-a" }), - createStack({ projectRoot: plainProject, name: "preview-b" }), - ], - { concurrency: 10 }, - ); - yield* runGit(project, ["checkout", "-b", "feat-a"]); - const feature = yield* createStack({ projectRoot: project }); - yield* runGit(project, ["checkout", "main"]); - - expect(sameA.id).toBe(sameB.id); - expect( - new Set([ - sameA.id, - namedA.id, - namedB.id, - feature.id, - monoA.id, - monoB.id, - siblingA.id, - siblingB.id, - plainA.id, - plainB.id, - ]).size, - ).toBe(10); - const found = yield* findStack({ projectRoot: project }); - expect(Option.getOrUndefined(found)?.id).toBe(sameA.id); - expect((yield* listStacks({ projectRoot: project })).map(({ id }) => id).sort()).toEqual( - [sameA.id, namedA.id, namedB.id, feature.id].sort(), - ); - expect((yield* listStacks({ projectRoot: monorepoA })).map(({ id }) => id)).toEqual([ - monoA.id, - ]); - const detachedA = yield* listStacks({ projectRoot: detachedWorktreeA }); - const detachedB = yield* listStacks({ projectRoot: detachedWorktreeB }); - expect(detachedA.map(({ id }) => id)).toEqual([siblingA.id]); - expect(detachedB.map(({ id }) => id)).toEqual([siblingB.id]); - expect(detachedA[0]?.branchContext).toBe("detached"); - expect(detachedB[0]?.branchContext).toBe("detached"); - expect(siblingA.id).not.toBe(siblingB.id); - expect( - (yield* listStacks({ projectRoot: plainProject })).map(({ id }) => id).sort(), - ).toEqual([plainA.id, plainB.id].sort()); - }), + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-owner-close-before-rpc-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "owner-close-before-rpc"; + const ownerScope = yield* Scope.make(); + const requestStarted = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, + servicesList: () => + Deferred.succeed(requestStarted, undefined).pipe(Effect.andThen(Effect.never)), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe" as const, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" as const }), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const resolutions = yield* Ref.make(0); + const releases = yield* Ref.make>([]); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + leasePort: 45_004, + rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeTestHandle({ + resolveOwner: () => + Ref.updateAndGet(resolutions, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.succeed(Option.some({ owner, launched: false })) + : Effect.succeed(Option.none()), + ), + ), + waitForRelease: (session) => + Ref.update(releases, (sessions) => [...sessions, session ?? "missing"]), + }); + const request = yield* Effect.forkChild(stack.services.list, { startImmediately: true }); + yield* Deferred.await(requestStarted); + yield* Scope.close(ownerScope, Exit.void); + const result = yield* Fiber.join(request).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(errorFrom(result)).toBeInstanceOf(StackOwnershipConflictError); + expect(yield* Ref.get(releases)).toEqual([]); + expect(yield* Ref.get(resolutions)).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("does not relocate a stack when a linked Git worktree moves", () => - withRuntimeRoot((project) => + it.live("re-resolves once when the owner closes before RPC connection admission", () => + Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = path.dirname(project); - const linked = path.join(root, "linked-project"); - yield* runGit(project, ["init", "-b", "main"]); - yield* runGit(project, ["config", "user.email", "stack-tests@example.test"]); - yield* runGit(project, ["config", "user.name", "Stack Tests"]); - yield* fs.writeFileString(path.join(project, "README.md"), "identity\n"); - yield* runGit(project, ["add", "README.md"]); - yield* runGit(project, ["commit", "-m", "initial"]); - yield* runGit(project, ["worktree", "add", "--detach", linked, "HEAD"]); - const original = yield* createStack({ projectRoot: linked }); - const moved = path.join(root, "moved-project"); - yield* runGit(project, ["worktree", "move", linked, moved]); - - const movedBeforeCreate = yield* findStack({ projectRoot: moved }); - expect(Option.isNone(movedBeforeCreate)).toBe(true); - const replacement = yield* createStack({ projectRoot: moved }); - expect(replacement.id).not.toBe(original.id); - const movedCanonical = yield* fs.realPath(moved); - expect(yield* listStacks({ projectRoot: moved })).toEqual([ - expect.objectContaining({ id: replacement.id, projectRoot: movedCanonical }), - ]); - }), - ), - ); - - it.live("does not adopt state when a removed worktree basename is reused elsewhere", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = path.dirname(project); - const oldParent = path.join(root, "old-parent"); - const newParent = path.join(root, "new-parent"); - const oldWorktree = path.join(oldParent, "session"); - const newWorktree = path.join(newParent, "session"); - yield* fs.makeDirectory(oldParent); - yield* fs.makeDirectory(newParent); - yield* runGit(project, ["init", "-b", "main"]); - yield* runGit(project, ["config", "user.email", "stack-tests@example.test"]); - yield* runGit(project, ["config", "user.name", "Stack Tests"]); - yield* fs.writeFileString(path.join(project, "README.md"), "identity\n"); - yield* runGit(project, ["add", "README.md"]); - yield* runGit(project, ["commit", "-m", "initial"]); - yield* runGit(project, ["worktree", "add", "--detach", oldWorktree, "HEAD"]); - const original = yield* createStack({ projectRoot: oldWorktree }); - yield* runGit(project, ["worktree", "remove", "--force", oldWorktree]); - yield* runGit(project, ["worktree", "add", "--detach", newWorktree, "HEAD"]); - - const replacement = yield* createStack({ projectRoot: newWorktree }); - expect(replacement.id).not.toBe(original.id); - expect((yield* listStacks({ projectRoot: newWorktree })).map(({ id }) => id)).toEqual([ - replacement.id, - ]); - expect((yield* listStacks()).map(({ id }) => id).sort()).toEqual( - [original.id, replacement.id].sort(), - ); - }), - ), - ); - - it.live("releases a temporary Supervisor after a pre-commit start failure", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const state = yield* store.read(stack.id); - if (state === undefined) return yield* Effect.die("stack state was not initialized"); - yield* store.replace(stack.id, { ...state, desiredLifecycle: "stopped" }); - - const started = yield* stack - .start({ config: { capabilities: { database: { version: "unsupported" } } } }) - .pipe(Effect.exit); - - expect(Exit.isFailure(started)).toBe(true); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - expect((yield* stack.status).lifecycle).toBe("stopped"); - const reopened = yield* openStack(stack.id); - expect((yield* reopened.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("never projects offline state while metadata or the ownership lock remains", () => - Effect.gen(function* () { - for (const artifact of ["metadata", "lock"] as const) { - const ownership = new StackOwnershipConflictError({ - message: `owner ${artifact} is still present`, - }); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.none()), - readOfflineState: Effect.fail(ownership), - readLogs: () => Effect.fail(ownership), - }); - const status = yield* stack.status.pipe(Effect.exit); - expect(Exit.isFailure(status)).toBe(true); - const logs = yield* stack.logs().pipe(Effect.exit); - expect(Exit.isFailure(logs)).toBe(true); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("preserves an unreachable owner error when offline status is still guarded", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const path = yield* Path.Path; - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint: { kind: "unix" as const, path: path.join(project, "missing-owner.sock") }, - ownerSessionId: "unreachable-owner", - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; - let ownerReads = 0; - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => - Effect.sync(() => { - ownerReads += 1; - return ownerReads === 1 ? Option.some({ owner, launched: false }) : Option.none(); - }), - readOfflineState: Effect.fail( - new StackOwnershipConflictError({ message: "Owner metadata still exists" }), - ), - }); - const result = yield* stack.status.pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(error).toBeInstanceOf(StackOwnershipConflictError); - expect(error?.message).toContain("Stack owner is unreachable"); - } - }), - ), - ); - - it.live("waits for owner teardown before falling back to offline logs", () => - withRuntimeRoot((_project) => - Effect.gen(function* () { - const attempts = yield* Ref.make(0); - const ownership = new StackOwnershipConflictError({ - message: "Supervisor is still shutting down", - }); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: Effect.succeed(Option.some(stoppedState())), - readLogs: () => - Ref.getAndUpdate(attempts, (current) => current + 1).pipe( - Effect.flatMap((attempt) => - attempt === 0 - ? Effect.fail(ownership) - : Effect.succeed({ - entries: [], - cursor: { opaque: "v1_0" }, - running: false, - }), - ), - ), - }); - const batch = yield* stack.logs(); - expect(batch.entries).toEqual([]); - expect(yield* Ref.get(attempts)).toBe(2); - }), - ), - ); - - it.live("preserves an allowed error tag when reading offline logs", () => - Effect.gen(function* () { - const upgrade = new StackUpgradeRequiredError({ - message: "The retained log protocol is newer than this client", - expectedRelease: STACK_RPC_RELEASE, - actualRelease: "future", - }); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: Effect.succeed(Option.some(stoppedState())), - readLogs: () => Effect.fail(upgrade), - }); - const result = yield* stack.logs().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) - expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBe(upgrade); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live( - "guards offline operations against real lock-only and metadata ownership", - () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project }); - const lease = yield* acquireOwnership({ - stateRoot: env.stateRoot, - stackId: stack.id, - ownerSessionId: "offline-guard", - rpcRelease: STACK_RPC_RELEASE, - environment: env, - }); - const assertGuarded = (candidate: EffectStack) => - Effect.gen(function* () { - expect(Exit.isFailure(yield* candidate.status.pipe(Effect.exit))).toBe(true); - expect(Exit.isFailure(yield* candidate.logs().pipe(Effect.exit))).toBe(true); - }); - const lockOnly = yield* openStack(stack.id); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(true); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect((yield* inspectStack(stack.id)).owner).toBe("unreachable"); - yield* assertGuarded(lockOnly); - yield* lease.release; - - const metadataLease = yield* acquireOwnership({ - stateRoot: env.stateRoot, - stackId: stack.id, - ownerSessionId: "offline-metadata", - rpcRelease: STACK_RPC_RELEASE, - environment: env, - }); - yield* publishOwnership(metadataLease); - const metadataOnly = yield* openStack(stack.id); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeDefined(); - yield* assertGuarded(metadataOnly); - }), - ), - 60_000, - ); - - it.live("prepares cache-only without an owner or state mutation", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project }); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const state = yield* store.read(stack.id); - if (state === undefined) return yield* Effect.die("stack state was not initialized"); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const before = yield* (yield* FileSystem.FileSystem).readFileString(paths.stateDocument); - const prepared = yield* stack.prepare({ capabilities: [] }); - expect(prepared.capabilities).toEqual([]); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - expect(yield* (yield* FileSystem.FileSystem).readFileString(paths.stateDocument)).toBe( - before, - ); - }), - ), - ); - - it.live("applies the configured artifact cache root during handle preparation", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-cache-" }); - const project = path.join(root, "project"); - yield* fs.makeDirectory(project); - const defaults = yield* defaultRuntimeEnvironment; - const stateRoot = path.join(root, "managed", "stacks"); - const artifactCacheRoot = path.join(root, "shared-artifacts"); - const configuredEnvironment: StackRuntimeEnvironmentValue = { - ...defaults, - stateRoot, - artifactCacheRoot, - }; - const stack = yield* createStack({ - projectRoot: project, - runtime: { kind: "native" }, - }).pipe(Effect.provideService(StackRuntimeEnvironment, configuredEnvironment)); - const prepared = yield* stack.prepare({ capabilities: [] }); - expect(prepared.capabilities).toEqual([]); - expect(yield* fs.exists(artifactCacheRoot)).toBe(true); - expect(yield* fs.exists(path.join(stateRoot, "artifacts"))).toBe(false); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("preserves input and version errors from prepare", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); - const invalid = yield* stack - .prepare({ - config: yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Any))( - '{"capabilities":{"rest":{"settings":{"unknown":true}}}}', - ), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(invalid)).toBe(true); - if (Exit.isFailure(invalid)) - expect(Option.getOrUndefined(Cause.findErrorOption(invalid.cause))).toBeInstanceOf( - InvalidStackConfigError, - ); - - const unsupported = yield* stack - .prepare({ config: { capabilities: { database: { version: "99" } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(unsupported)).toBe(true); - if (Exit.isFailure(unsupported)) - expect(Option.getOrUndefined(Cause.findErrorOption(unsupported.cause))).toBeInstanceOf( - StackVersionUnsupportedError, - ); - }), - ), - ); - - it.live("preserves unsupported persisted state format from prepare and start", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project }); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - yield* fs.writeFileString( - paths.stateDocument, - yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ - format: "supabase-stack-v0", - }), - ); - - const result = yield* stack.prepare().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) - expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( - StackStateFormatUnsupportedError, - ); - - const started = yield* stack.start().pipe(Effect.exit); - expect(Exit.isFailure(started)).toBe(true); - if (Exit.isFailure(started)) - expect(Option.getOrUndefined(Cause.findErrorOption(started.cause))).toBeInstanceOf( - StackStateFormatUnsupportedError, - ); - }), - ), - ); - - it.live("reports a retained handle as not found after destroy", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project }); - yield* stack.destroy; - - const status = yield* stack.status.pipe(Effect.exit); - expect(Exit.isFailure(status)).toBe(true); - if (Exit.isFailure(status)) - expect(Option.getOrUndefined(Cause.findErrorOption(status.cause))).toBeInstanceOf( - StackNotFoundError, - ); - const logs = yield* stack.logs().pipe(Effect.exit); - expect(Exit.isFailure(logs)).toBe(true); - if (Exit.isFailure(logs)) - expect(Option.getOrUndefined(Cause.findErrorOption(logs.cause))).toBeInstanceOf( - StackNotFoundError, - ); - }), - ), - ); - - it.live("classifies credentials for offline and unreachable stacks", () => - Effect.gen(function* () { - const stopped = yield* makeTestHandle(stackId, { - readOfflineState: Effect.succeed(Option.some(stoppedState())), - }); - const stoppedResult = yield* stopped.credentials.pipe(Effect.exit); - expect(Exit.isFailure(stoppedResult)).toBe(true); - if (Exit.isFailure(stoppedResult)) { - const error = Cause.findErrorOption(stoppedResult.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackNotRunningError); - } - - const missing = yield* makeTestHandle(stackId); - const missingResult = yield* missing.credentials.pipe(Effect.exit); - expect(Exit.isFailure(missingResult)).toBe(true); - if (Exit.isFailure(missingResult)) { - const error = Cause.findErrorOption(missingResult.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackNotFoundError); - } - - const running = yield* makeTestHandle(stackId, { - readOfflineState: Effect.succeed( - Option.some({ ...stoppedState(), desiredLifecycle: "running" }), - ), - }); - const runningResult = yield* running.credentials.pipe(Effect.exit); - expect(Exit.isFailure(runningResult)).toBe(true); - if (Exit.isFailure(runningResult)) { - const error = Cause.findErrorOption(runningResult.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackOwnershipConflictError); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("does not leave a runtime remnant when a retained handle starts after destroy", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project }); - yield* stack.destroy; - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - - const failed = yield* stack.start().pipe(Effect.exit); - - expect(Exit.isFailure(failed)).toBe(true); - expect(yield* fs.exists(paths.stackRoot)).toBe(false); - expect(yield* listStacks()).toEqual([]); - }), - ), - ); - - it.live("ignores a runtime-only missing-state sibling during list discovery", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const valid = yield* createStack({ projectRoot: project }); - const orphanId = StackIdSchema.make("a".repeat(64)); - const orphanPaths = yield* resolveStackPaths({ - stateRoot: env.stateRoot, - stackId: orphanId, - }); - yield* fs.makeDirectory(orphanPaths.runtime, { recursive: true }); - - const listed = yield* listStacks(); - - expect(listed).toHaveLength(1); - expect(listed[0]?.id).toBe(valid.id); - }), - ), - ); - - it.live("retains healthy stacks while reporting unreadable registry entries", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* StackRuntimeEnvironment; - const healthy = yield* createStack({ projectRoot: project }); - const corruptProject = path.join(project, "corrupt"); - yield* fs.makeDirectory(corruptProject); - const corrupt = yield* createStack({ projectRoot: corruptProject }); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: corrupt.id }); - yield* fs.writeFileString(paths.stateDocument, "{ malformed"); - - const discovered = yield* discoverStacks(); - - expect(discovered.stacks.map(({ id }) => id)).toEqual([healthy.id]); - expect(discovered.errors).toHaveLength(1); - expect(discovered.errors[0]?.id).toBe(corrupt.id); - expect(discovered.errors[0]?.error).toBeInstanceOf(StackStateInvalidError); - }), - ), - ); - - it.live("recovers a same-identity runtime-only missing-state remnant during create", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const identity = yield* resolveStackIdentity({ projectRoot: project }); - const id = yield* deriveStackId(identity); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: id }); - yield* fs.makeDirectory(paths.runtime, { recursive: true }); - - const recovered = yield* createStack({ projectRoot: project }); - - expect(recovered.id).toBe(id); - expect(yield* fs.exists(paths.stateDocument)).toBe(true); - }), - ), - ); - - it.live("keeps durable missing-state remnants fail-closed during list discovery", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - yield* createStack({ projectRoot: project }); - const orphanId = StackIdSchema.make("b".repeat(64)); - const orphanPaths = yield* resolveStackPaths({ - stateRoot: env.stateRoot, - stackId: orphanId, - }); - yield* fs.makeDirectory(orphanPaths.data, { recursive: true }); - - const listed = yield* listStacks().pipe(Effect.exit); - - expect(Exit.isFailure(listed)).toBe(true); - expect( - Exit.isFailure(listed) - ? Option.getOrUndefined(Cause.findErrorOption(listed.cause)) - : undefined, - ).toBeInstanceOf(StackStateInvalidError); - }), - ), - ); - - it.live("treats an omitted container engine as Docker for runtime identity", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "podman" }, - }); - const result = yield* createStack({ - projectRoot: project, - runtime: { kind: "container" }, - }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) - expect(failure.value).toBeInstanceOf(StackRuntimeMismatchError); - } - }), - ), - ); - - it.live("rejects unknown capabilities as a typed preparation error", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project }); - const malformedOptions = { capabilities: ["not-a-capability"] }; - const malformedPrepare = (): Effect.Effect => - Reflect.apply(stack.prepare, stack, [malformedOptions]); - const result = yield* malformedPrepare().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) expect(failure.value).toBeInstanceOf(StackPreparationError); - } - }), - ), - ); - - it.live("rejects a directly disabled capability before preparing artifacts", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const fs = yield* FileSystem.FileSystem; - const stack = yield* createStack({ projectRoot: project }); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const before = yield* fs.readFileString(paths.stateDocument); - const progress: Array = []; - const result = yield* stack - .prepare({ - config: { capabilities: { pooler: { enabled: false } } }, - capabilities: ["pooler"], - onProgress: (status) => progress.push(status), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(InvalidStackConfigError); - expect(failure.value).toMatchObject({ - stackId: stack.id, - capability: "pooler", - message: "Capability pooler is disabled", - }); - } - } - expect(progress).toEqual([]); - expect(yield* fs.readFileString(paths.stateDocument)).toBe(before); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - }), - ), - ); - - it.live( - "rejects an enabled capability with a disabled dependency before preparing artifacts", - () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const fs = yield* FileSystem.FileSystem; - const stack = yield* createStack({ projectRoot: project }); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const before = yield* fs.readFileString(paths.stateDocument); - const progress: Array = []; - const result = yield* stack - .prepare({ - config: { - capabilities: { - rest: { enabled: false }, - studio: { enabled: true }, - }, - }, - capabilities: ["studio"], - onProgress: (status) => progress.push(status), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) - expect(failure.value).toBeInstanceOf(InvalidStackConfigError); - } - expect(progress).toEqual([]); - expect(yield* fs.readFileString(paths.stateDocument)).toBe(before); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - }), - ), - ); - - it.live("uses persisted pins and dependency closure for prospective preparation", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const calls: Array = []; - const runner: ContainerCommandRunner = { - run: (request) => { - calls.push(request.args.slice(0, 3).join(" ")); - if (request.args[0] === "version") - return Effect.succeed({ - stdout: '"1"\n', - stderr: "", - exitCode: 0, - }); - if (request.args[0] === "image" && request.args[1] === "ls") - return Effect.succeed({ - stdout: '"cached"\n', - stderr: "", - exitCode: 0, - }); - return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); - }, - stream: () => Stream.empty, - }; - const engine = makeDockerEngine({ runner, platform: { os: "linux" } }); - const stack = yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "docker" }, - }).pipe( - Effect.provideService(ContainerEngineResolver, { - isInstalled: () => Effect.succeed(true), - resolve: () => Effect.succeed(engine), - }), - ); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const state = yield* store.read(stack.id); - if (state === undefined) return yield* Effect.die("stack state was not initialized"); - const persisted = yield* compileStack({ - projectRoot: project, - runtime: { kind: "container", engine: "docker" }, - config: { capabilities: { database: { version: defaultDatabaseMajor } } }, - }).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - ); - yield* store.replace(stack.id, { - ...state, - definition: persisted.definition, - }); - const before = yield* stack.status; - const progress: Array = []; - const prepared = yield* stack.prepare({ - config: { capabilities: { rest: { settings: { schemas: ["private"] } } } }, - capabilities: ["rest"], - onProgress: (status) => progress.push(status), - }); - expect(prepared.capabilities).toEqual([ - { capability: "database", version: defaultDatabaseVersion, outcome: "cached" }, - { capability: "rest", version: defaultRestVersion, outcome: "cached" }, - ]); - expect(calls.filter((call) => call.startsWith("image ls"))).toHaveLength(2); - expect(progress).toEqual( - expect.arrayContaining([ - { workloadId: "database:database", capability: "database", state: "queued" }, - { workloadId: "rest:rest", capability: "rest", state: "queued" }, - { workloadId: "database:database", capability: "database", state: "preparing" }, - { workloadId: "rest:rest", capability: "rest", state: "preparing" }, - { workloadId: "database:database", capability: "database", state: "ready" }, - { workloadId: "rest:rest", capability: "rest", state: "ready" }, - ]), - ); - expect(yield* stack.status).toEqual(before); - }), - ), - ); - - it.live( - "surfaces a container engine failure during public start", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-start-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "start-error-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => - Effect.fail({ - tag: "ContainerEngineError", - message: "Container engine command failed while starting database", - }), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), - }); - const result = yield* stack.start().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(ContainerEngineError); - expect(failure.value.message).toContain("Container engine command failed"); - } - } - }), - ).pipe(Effect.provide(NodeServices.layer)), - 240_000, - ); - - it.live("surfaces concurrent lifecycle conflicts through the public start handle", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "ss-cstart-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "concurrent-start-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const startEntered = yield* Deferred.make(); - const releaseStart = yield* Deferred.make(); - let starts = 0; - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => - Effect.gen(function* () { - starts += 1; - if (starts === 1) { - yield* Deferred.succeed(startEntered, undefined); - yield* Deferred.await(releaseStart); - return runningStatus; - } - return yield* Effect.fail({ - tag: "StackLifecycleConflictError", - message: "A lifecycle transition is already in progress", - } as const); - }), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), - }); - const first = yield* Effect.forkChild(stack.start(), { startImmediately: true }); - yield* Deferred.await(startEntered); - const second = yield* stack.start().pipe(Effect.exit); - expect(Exit.isFailure(second)).toBe(true); - if (Exit.isFailure(second)) { - const failure = Cause.findErrorOption(second.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) - expect(failure.value).toBeInstanceOf(StackLifecycleConflictError); - } - yield* Deferred.succeed(releaseStart, undefined); - expect(Exit.isSuccess(yield* Fiber.join(first).pipe(Effect.exit))).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("cancels unfinished direct preparation while retaining completed artifacts", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const fs = yield* FileSystem.FileSystem; - const crypto = { - ...(yield* Effect.service(Crypto.Crypto)), - randomBytes: () => { - throw new Error("direct preparation must not generate managed secrets"); - }, - } satisfies Crypto.Crypto; - const firstPublished = yield* Deferred.make(); - const secondDownloadStarted = yield* Deferred.make(); - const secondDownloadCancelled = yield* Deferred.make(); - const secondDownloadRelease = yield* Deferred.make(); - const present = new Set(); - const runner: ContainerCommandRunner = { - run: (request) => { - const [command, subcommand, image] = request.args; - if (command === "version") - return Effect.succeed({ - stdout: '"1"\n', - stderr: "", - exitCode: 0, - }); - if (command === "image" && subcommand === "ls" && image !== undefined) { - return Effect.succeed({ - stdout: present.has(image) ? '"cached"\n' : "", - stderr: "", - exitCode: 0, - }); - } - if ( - command === "image" && - subcommand === "pull" && - image !== undefined && - image.includes("postgrest") - ) - return Deferred.succeed(secondDownloadStarted, undefined).pipe( - Effect.andThen(Deferred.await(secondDownloadRelease)), - Effect.onInterrupt(() => - Deferred.succeed(secondDownloadCancelled, undefined).pipe(Effect.asVoid), - ), - Effect.as({ stdout: "", stderr: "", exitCode: 0 }), - ); - if (command === "image" && subcommand === "pull" && image !== undefined) - return Effect.yieldNow.pipe( - Effect.andThen( - Effect.sync(() => { - present.add(image); - return { stdout: "", stderr: "", exitCode: 0 }; - }), - ), - Effect.andThen( - image.includes("postgres") - ? Deferred.succeed(firstPublished, undefined) - : Effect.void, - ), - Effect.as({ stdout: "", stderr: "", exitCode: 0 }), - ); - return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); - }, - stream: () => Stream.empty, - }; - const engine = makeDockerEngine({ - runner, - platform: { os: "linux" }, - }); - const resolver = { - isInstalled: () => Effect.succeed(true), - resolve: () => Effect.succeed(engine), - }; - const stack = yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "docker" }, - }).pipe( - Effect.provideService(ContainerEngineResolver, resolver), - Effect.provideService(Crypto.Crypto, crypto), - ); - const stateStore = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const before = yield* fs.readFileString(paths.stateDocument); - const progress: Array = []; - const preparation = yield* Effect.forkChild( - stack.prepare({ - capabilities: ["database", "rest"], - onProgress: (status) => progress.push(status), - }), - { startImmediately: true }, - ); - yield* Deferred.await(firstPublished).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.await(secondDownloadStarted).pipe( - Effect.timeout("5 seconds"), - Effect.orDie, - ); - expect( - progress.some( - ({ workloadId, state }) => workloadId === "rest:rest" && state === "downloading", - ), - ).toBe(true); - yield* Fiber.interrupt(preparation); - yield* Deferred.await(secondDownloadCancelled).pipe( - Effect.timeout("5 seconds"), - Effect.orDie, - ); - const canceled = yield* Fiber.join(preparation).pipe(Effect.exit); - expect(Exit.isFailure(canceled)).toBe(true); - expect( - progress.some(({ workloadId, state }) => workloadId === "rest:rest" && state === "ready"), - ).toBe(false); - const retryProgress: Array = []; - const cached = yield* stack.prepare({ - capabilities: ["database"], - onProgress: (status) => retryProgress.push(status), - }); - expect(cached.capabilities).toEqual([ - { capability: "database", version: defaultDatabaseVersion, outcome: "cached" }, - ]); - expect(retryProgress.some(({ state }) => state === "downloading")).toBe(false); - expect(yield* fs.readFileString(paths.stateDocument)).toBe(before); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - expect(yield* stateStore.read(stack.id)).toBeDefined(); - }), - ), - ); - - it.live("prepares while running without consulting Supervisor ownership", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project }); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const state = yield* store.read(stack.id); - if (state === undefined) return yield* Effect.die("stack state was not initialized"); - yield* store.replace(stack.id, { ...state, desiredLifecycle: "running" }); - expect((yield* stack.prepare({ capabilities: [] })).capabilities).toEqual([]); - expect((yield* store.read(stack.id))?.desiredLifecycle).toBe("running"); - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - }), - ), - ); - - it.live("omits undefined optional RPC payload keys", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-rpc-" }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "session"; - const payloads: { start: Array } = { start: [] }; - const rpcHandlers: StackRpcHandlers = { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: (payload) => { - payloads.start.push(payload); - return Effect.succeed(runningStatus); - }, - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: emptyLogs, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - } as const), - stop: Effect.succeed({ ok: true, op: "stop" } as const), - }, - }); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), - }); - yield* stack.start(); - yield* stack.start({ config: {} }); - expect(payloads.start).toEqual([{}, { config: {} }]); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("launches a compatible Supervisor when start finds no live owner", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-start-" }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "start-session"; - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: emptyLogs, - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - }); - let launched = false; - const stack = yield* makeTestHandle(stackId, { - resolveOwner: (launch) => - Effect.sync(() => { - launched ||= launch; - return launched ? Option.some({ owner, launched: true }) : Option.none(); - }), - }); - expect((yield* stack.start()).lifecycle).toBe("running"); - expect(launched).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("completes destroy after the owner closes its control socket", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "session"; - const responseSent = yield* Deferred.make(); - const ownerScope = yield* Scope.make(); - const rpcHandlers: StackRpcHandlers = { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: emptyLogs, - }; - const maintenanceHandlers = { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - } as const), - stop: Effect.succeed({ ok: true, op: "stop" } as const), - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers, - maintenanceHandlers, - onShutdownReady: Deferred.succeed(responseSent, undefined).pipe(Effect.asVoid), - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => - Effect.succeed( - Option.some({ - owner: { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }, - launched: false, - }), - ), - readPersistedState: Effect.succeed(Option.some(stoppedState())), - }); - const destroyFiber = yield* Effect.forkChild(stack.destroy, { startImmediately: true }); - yield* Deferred.await(responseSent); - yield* Scope.close(ownerScope, Exit.void); - const destroyed = yield* Fiber.join(destroyFiber).pipe(Effect.exit); - expect(Exit.isSuccess(destroyed)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("fails destroy when the owner control endpoint is unavailable", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => - Effect.succeed( - Option.some({ - owner: { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint: { kind: "unix", path: path.join(root, "missing.sock") }, - ownerSessionId: "session", - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }, - launched: false, - }), - ), - readPersistedState: Effect.succeed(Option.some(stoppedState())), - }); - const result = yield* stack.destroy.pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Cause.findErrorOption(result.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackDestructionError); - expect(error.value.message).toContain("control connection"); - } - } - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("fails destroy without launching an owner when persisted state is absent", () => - Effect.gen(function* () { - let launchCalls = 0; - const stack = yield* makeTestHandle(stackId, { - resolveOwner: (launch) => - Effect.sync(() => { - if (launch) launchCalls += 1; - return Option.none(); - }), - readPersistedState: Effect.succeed(Option.none()), - }); - const result = yield* stack.destroy.pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) expect(failure.value).toBeInstanceOf(StackNotFoundError); - } - expect(launchCalls).toBe(0); - }), - ); - - it.live( - "stops an incompatible owner before starting a replacement", - () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); - yield* Effect.addFinalizer(() => stack.destroy.pipe(Effect.ignore)); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - yield* ensureSupervisor({ - stackId: stack.id, - stateStore: store, - environment: env, - }); - const owner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); - const originalOwner = expectPresent(owner, "owner metadata"); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const incompatibleOwner = yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Unknown), - )({ - ...originalOwner, - rpcRelease: "stack-rpc-v0@0.0.1", - }).pipe( - Effect.mapError( - (cause) => - new StackDestructionError({ message: "Unable to encode test owner", cause }), - ), - ); - yield* fs.writeFileString(paths.controlMetadata, incompatibleOwner); - - const oldOwner = yield* openStack(stack.id); - expect(Exit.isFailure(yield* oldOwner.status.pipe(Effect.exit))).toBe(true); - const ordinaryCreate = yield* createStack({ projectRoot: project }); - expect(ordinaryCreate.id).toBe(stack.id); - const restarted = yield* openStack(stack.id); - yield* Effect.addFinalizer(() => restarted.destroy.pipe(Effect.ignore)); - const directStart = yield* restarted - .start({ config: lifecycleConfig() }) - .pipe(Effect.exit); - expect(Exit.isFailure(directStart)).toBe(true); - if (Exit.isFailure(directStart)) { - const failure = Cause.findErrorOption(directStart.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) - expect(failure.value).toBeInstanceOf(StackUpgradeRequiredError); - } - yield* restarted.stop; - const status = yield* restarted.start({ config: lifecycleConfig() }); - const currentOwner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); - expect(currentOwner?.rpcRelease).toBe(STACK_RPC_RELEASE); - expect(currentOwner?.ownerSessionId).not.toBe(originalOwner.ownerSessionId); - expect(status.id).toBe(stack.id); - expect(status.runtime).toEqual({ kind: "native" }); - expect((yield* restarted.status).lifecycle).toBe("running"); - yield* restarted.stop; - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - const startedAgain = yield* restarted.start({ config: lifecycleConfig() }); - expect(startedAgain.lifecycle).toBe("running"); - yield* restarted.destroy; - }), - ), - 240_000, - ); - - it.live( - "reclaims a dead incompatible owner through create and start", - () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* StackRuntimeEnvironment; - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const makeDeadOwner = ( - projectRoot: string, - rpcRelease: string, - desiredLifecycle: "running" | "unconfigured" = "unconfigured", - ) => - Effect.gen(function* () { - const identity = yield* resolveStackIdentity({ projectRoot }); - const id = yield* deriveStackId(identity); - const persisted = - desiredLifecycle === "running" - ? yield* Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot, - runtime: { kind: "native" }, - config: lifecycleConfig(), - }); - const resolved = yield* resolveSecrets( - { - declarations: compiled.secrets.map( - ({ slot, policy, value, generator }) => ({ - slot, - policy, - ...(value === undefined ? {} : { value }), - ...(generator === undefined ? {} : { generator }), - }), - ), - }, - undefined, - "running", - ); - return { - definition: compiled.definition, - secrets: resolved.persisted, - }; - }) - : undefined; - const initialState: PersistedStackState = { - format: "supabase-stack-state-v1", - identity: toPersistedIdentity(identity), - runtime: { kind: "native" }, - desiredLifecycle, - ports: [], - privatePorts: [], - secrets: {}, - }; - yield* store.initialize( - id, - persisted === undefined ? initialState : { ...initialState, ...persisted }, - ); - const encodedEnvironment = yield* Schema.encodeEffect( - Schema.fromJsonString(Schema.Unknown), - )({ - stateRoot: env.stateRoot, - tempRoot: env.tempRoot, - platform: env.platform, - }); - // bare imports in a `node -e` script resolve from cwd, so run from the package root - const cwd = path.resolve(import.meta.dirname, "../.."); - const child = yield* ChildProcess.make( - process.execPath, - [ - "--input-type=module", - "-e", - ` - const { Effect } = await import("effect"); - const { NodeServices } = await import("@effect/platform-node"); - const { acquireOwnership, publishOwnership } = await import(process.env.OWNERSHIP_MODULE); - const environment = JSON.parse(process.env.OWNERSHIP_ENVIRONMENT); - await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const lease = yield* acquireOwnership({ - stateRoot: environment.stateRoot, - stackId: process.env.OWNERSHIP_STACK_ID, - ownerSessionId: "crashed-owner", - rpcRelease: process.env.OWNERSHIP_RPC_RELEASE, - environment, - }); - yield* publishOwnership(lease); - process.stdout.write("READY\\n"); - yield* Effect.never; - }).pipe(Effect.provide(NodeServices.layer)))); - `, - ], - { - cwd, - env: { - OWNERSHIP_MODULE: new URL("../state/Ownership.ts", import.meta.url).href, - OWNERSHIP_STACK_ID: id, - OWNERSHIP_RPC_RELEASE: rpcRelease, - OWNERSHIP_ENVIRONMENT: encodedEnvironment, - }, - extendEnv: true, - stdout: "pipe", - stderr: "pipe", - }, - ); - const ready = yield* Deferred.make(); - const output = yield* child.stdout.pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runForEach((line) => - line === "READY" - ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) - : Effect.void, - ), - Effect.forkChild({ startImmediately: true }), - ); - const stderr = yield* child.stderr.pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runDrain, - Effect.forkChild({ startImmediately: true }), - ); - try { - yield* Deferred.await(ready).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => - Effect.fail(new StackStateInvalidError({ message: "owner child not ready" })), - }), - ); - expect(yield* ownerLockExists(env.stateRoot, id)).toBe(true); - yield* child.kill({ killSignal: "SIGKILL" }); - yield* child.exitCode.pipe(Effect.ignore); - return id; - } finally { - yield* child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore); - yield* Fiber.interrupt(output); - yield* Fiber.interrupt(stderr); - } - }); - - const createProject = path.join(project, "create"); - const openProject = path.join(project, "open"); - yield* fs.makeDirectory(createProject); - yield* fs.makeDirectory(openProject); - const created = yield* createStack({ projectRoot: createProject }); - expect(yield* readOwnerMetadata(env.stateRoot, created.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, created.id)).toBe(false); - - const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1", "running"); - const replaced = yield* openStack(openId); - const cleanupRecovered = yield* Effect.cached( - Effect.uninterruptible( - Effect.gen(function* () { - yield* replaced.stop; - yield* replaced.destroy; - expect(yield* readOwnerMetadata(env.stateRoot, openId, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(false); - }), - ), - ); - // Cache the stop/destroy effect so the assertion below and the finalizer share one transition. - yield* Effect.addFinalizer(() => cleanupRecovered.pipe(Effect.ignore)); - yield* replaced.start(); - expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.rpcRelease).toBe( - STACK_RPC_RELEASE, - ); - expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.ownerSessionId).not.toBe( - "crashed-owner", - ); - expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(true); - yield* cleanupRecovered; - }), - ), - 300_000, - ); - - it.live("accepts maintenance stop requests from an older RPC owner", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - yield* ensureSupervisor({ - stackId: stack.id, - stateStore: store, - environment: env, - }); - const ownerHandle = yield* openStack(stack.id); - yield* Effect.addFinalizer(() => ownerHandle.stop.pipe(Effect.ignore)); - const owner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); - const currentOwner = expectPresent(owner, "owner metadata"); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const incompatibleOwner = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( - { - ...currentOwner, - rpcRelease: "stack-rpc-v0@0.0.1", - }, - ).pipe( - Effect.mapError( - (cause) => new StackDestructionError({ message: "Unable to encode test owner", cause }), - ), - ); - yield* fs.writeFileString(paths.controlMetadata, incompatibleOwner); - yield* ownerHandle.stop; - expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - }), - ), - ); - - it.live( - "rejects incompatible RPC calls before opening handlers while allowing maintenance stop", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-release-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "incompatible-rpc-session"; - const ownerRelease = "stack-rpc-v0@0.0.1"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const calls = yield* Ref.make(0); - const invoked = (value: A) => - Ref.update(calls, (count) => count + 1).pipe(Effect.andThen(Effect.succeed(value))); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: ownerRelease, - }; - yield* startControlServer({ - endpoint, + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-owner-reconnect-" }); + const firstEndpoint = { kind: "unix" as const, path: path.join(root, "first.sock") }; + const secondEndpoint = { kind: "unix" as const, path: path.join(root, "second.sock") }; + const firstOwnerSessionId = "owner-before-connect"; + const secondOwnerSessionId = "owner-after-connect"; + const firstOwnerScope = yield* Scope.make(); + const secondOwnerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(firstOwnerScope, Exit.void)); + yield* Effect.addFinalizer(() => Scope.close(secondOwnerScope, Exit.void)); + const maintenanceHandlers = (ownerSessionId: string) => ({ + probe: Effect.succeed({ + ok: true as const, + op: "probe" as const, stackId, ownerSessionId, - rpcRelease: ownerRelease, - rpcHandlers: { - status: () => invoked(runningStatus), - credentials: () => invoked(credentials), - start: () => invoked(runningStatus), - destroy: () => invoked(undefined), - resetDatabase: () => invoked(runningStatus), - logs: () => invoked({ entries: [], cursor: { opaque: "v1_0" }, running: false }), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: ownerRelease, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - onShutdownReady: Scope.close(ownerScope, Exit.void), - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), - }); - const status = yield* stack.status.pipe(Effect.exit); - const stackCredentials = yield* stack.credentials.pipe(Effect.exit); - const logs = yield* stack.logs().pipe(Effect.exit); - const assertUpgrade = (result: Exit.Exit) => { - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(StackUpgradeRequiredError); - expect(failure.value).toMatchObject({ - expectedRelease: STACK_RPC_RELEASE, - actualRelease: ownerRelease, - }); - } - } - }; - assertUpgrade(status); - assertUpgrade(stackCredentials); - assertUpgrade(logs); - expect(yield* Ref.get(calls)).toBe(0); - yield* stack.stop; - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("stops a freshly launched owner when the first start RPC is interrupted", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-launch-" }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "fresh-launch-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const startEntered = yield* Deferred.make(); - const stopCalls = yield* Ref.make(0); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true as const, op: "stop" as const }), + }); yield* startControlServer({ - endpoint, + endpoint: firstEndpoint, stackId, - ownerSessionId, + ownerSessionId: firstOwnerSessionId, rpcRelease: STACK_RPC_RELEASE, rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => - Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( - Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), - ), + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), - }); - const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); - yield* Deferred.await(startEntered); - yield* Fiber.interrupt(start); - expect(yield* Ref.get(stopCalls)).toBe(1); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("stops a freshly launched owner when destroy is interrupted", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-destroy-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "fresh-destroy-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const destroyEntered = yield* Deferred.make(); - const stopCalls = yield* Ref.make(0); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; + maintenanceHandlers: maintenanceHandlers(firstOwnerSessionId), + }).pipe(Effect.provideService(Scope.Scope, firstOwnerScope)); yield* startControlServer({ - endpoint, + endpoint: secondEndpoint, stackId, - ownerSessionId, + ownerSessionId: secondOwnerSessionId, rpcRelease: STACK_RPC_RELEASE, rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => Effect.succeed(runningStatus), - destroy: () => - Deferred.succeed(destroyEntered, undefined).pipe(Effect.andThen(Effect.never)), - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( - Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), - ), + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, + servicesList: () => Effect.succeed([]), }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), - readPersistedState: Effect.succeed(Option.some(stoppedState())), - }); - const destroy = yield* Effect.forkChild(stack.destroy, { startImmediately: true }); - yield* Deferred.await(destroyEntered); - yield* Fiber.interrupt(destroy); - const interrupted = yield* Fiber.join(destroy).pipe(Effect.exit); - expect(Exit.isFailure(interrupted)).toBe(true); - expect(yield* Ref.get(stopCalls)).toBe(1); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("reports fresh-owner rollback failures after an interrupted start", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-rollback-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "rollback-failure-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const startEntered = yield* Deferred.make(); - const stopCalls = yield* Ref.make(0); - const releaseCalls = yield* Ref.make(0); - const owner = { + maintenanceHandlers: maintenanceHandlers(secondOwnerSessionId), + }).pipe(Effect.provideService(Scope.Scope, secondOwnerScope)); + const firstOwner = { format: "supabase-stack-owner-v1" as const, stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, + endpoint: firstEndpoint, + ownerSessionId: firstOwnerSessionId, + leasePort: 45_005, rpcRelease: STACK_RPC_RELEASE, }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => - Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( - Effect.andThen( - Effect.succeed({ - ok: false, - error: { tag: "operation-failed", message: "injected rollback failure" }, - } as const), - ), - ), - }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), - waitForRelease: Ref.update(releaseCalls, (calls) => calls + 1).pipe( - Effect.andThen( - Effect.fail(new StackOwnershipConflictError({ message: "release failed" })), - ), - ), - }); - const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); - yield* Deferred.await(startEntered); - yield* Fiber.interrupt(start); - const interrupted = yield* Fiber.join(start).pipe(Effect.exit); - expect(Exit.isFailure(interrupted)).toBe(true); - if (Exit.isFailure(interrupted)) { - const failure = Cause.findErrorOption(interrupted.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(StackCleanupError); - expect(failure.value.message).toContain( - "Unable to clean up freshly launched Supervisor", - ); - expect(failure.value.cause).toBeDefined(); - } - } - expect(yield* Ref.get(stopCalls)).toBe(1); - expect(yield* Ref.get(releaseCalls)).toBe(1); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("preserves an interrupted start when fresh-owner cleanup is refused", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "ss-ar-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "admitted-rollback-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const startEntered = yield* Deferred.make(); - const stopCalls = yield* Ref.make(0); - const releaseCalls = yield* Ref.make(0); - const owner = { + const secondOwner = { format: "supabase-stack-owner-v1" as const, stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, + endpoint: secondEndpoint, + ownerSessionId: secondOwnerSessionId, + leasePort: 45_006, rpcRelease: STACK_RPC_RELEASE, }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => - Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( - Effect.andThen( - Effect.succeed({ - ok: false, - error: { - tag: "operation-failed", - message: "a lifecycle transition is already in progress", - stackErrorTag: "StackLifecycleConflictError", - }, - } as const), + const resolutions = yield* Ref.make(0); + const releases = yield* Ref.make>([]); + const stack = yield* makeTestHandle({ + resolveOwner: () => + Ref.updateAndGet(resolutions, (count) => count + 1).pipe( + Effect.map((count) => + Option.some({ + owner: count === 1 ? firstOwner : secondOwner, + launched: false, + }), ), ), - }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), - waitForRelease: Ref.update(releaseCalls, (calls) => calls + 1).pipe( - Effect.andThen( - Effect.fail(new StackOwnershipConflictError({ message: "release was unexpected" })), - ), - ), + waitForRelease: (session) => + Ref.update(releases, (sessions) => [...sessions, session ?? "missing"]), }); - const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); - yield* Deferred.await(startEntered); - yield* Fiber.interrupt(start); - const interrupted = yield* Fiber.join(start).pipe(Effect.exit); - expect(Exit.isFailure(interrupted)).toBe(true); - if (Exit.isFailure(interrupted)) { - const failure = Cause.findErrorOption(interrupted.cause); - expect(Option.isNone(failure)).toBe(true); - expect(Cause.hasInterrupts(interrupted.cause)).toBe(true); - } - expect(yield* Ref.get(stopCalls)).toBe(1); - expect(yield* Ref.get(releaseCalls)).toBe(0); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - it.live("does not stop an owner joined through a concurrent launch", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-join-" }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "joined-launch-session"; - const ownerScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); - const startEntered = yield* Deferred.make(); - const stopCalls = yield* Ref.make(0); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - leasePort: 45_001, - rpcRelease: STACK_RPC_RELEASE, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - start: () => - Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(runningStatus), - logs: () => emptyLogs(), - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( - Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), - ), - }, - onShutdownReady: Scope.close(ownerScope, Exit.void), - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeTestHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), - }); - const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); - yield* Deferred.await(startEntered); - yield* Fiber.interrupt(start); - expect(yield* Ref.get(stopCalls)).toBe(0); + yield* Scope.close(firstOwnerScope, Exit.void); + expect(yield* stack.services.list).toEqual([]); + expect(yield* Ref.get(resolutions)).toBe(2); + expect(yield* Ref.get(releases)).toEqual([firstOwnerSessionId]); }).pipe(Effect.provide(NodeServices.layer)), ), ); diff --git a/packages/stack/src/public/ephemeral-postgres.integration.test.ts b/packages/stack/src/public/ephemeral-postgres.integration.test.ts deleted file mode 100644 index 7b699a96f8..0000000000 --- a/packages/stack/src/public/ephemeral-postgres.integration.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { PgClient } from "@effect/sql-pg"; -import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Duration, - Effect, - Exit, - Fiber, - FileSystem, - Layer, - Option, - Path, - Redacted, - Schedule, -} from "effect"; -import { ChildProcess } from "effect/unstable/process"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. -import { spawnSync } from "node:child_process"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- test reserves a loopback port before fork. -import { createServer } from "node:net"; -import { tmpdir } from "node:os"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- isolated artifact cache path. -import { join } from "node:path"; -import { EphemeralPostgresError } from "./Errors.ts"; -import { createEphemeralPostgres } from "./EphemeralPostgres.ts"; -import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; -import { schemaInit } from "./SchemaInit.ts"; -import { listStacks } from "./EffectStack.ts"; -import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; -import { checkHostPort } from "../supervisor/HostListener.ts"; -import type { StackRuntimePreference } from "./Runtime.ts"; - -const NATIVE_TIMEOUT_MS = 180_000; -const PASSWORD = "ephemeral-test-password"; -const JWT_SECRET = "ephemeral-test-jwt-secret-value"; - -const dockerAvailable = (): boolean => - spawnSync("docker", ["info"], { encoding: "utf8" }).status === 0; - -const artifactCacheRoot = join(tmpdir(), "supabase-stack-test-artifacts"); - -const testEnvironment = (stateRoot: string) => - Layer.effect( - StackRuntimeEnvironment, - defaultRuntimeEnvironment.pipe( - Effect.map((env) => ({ - ...env, - stateRoot, - artifactCacheRoot, - })), - ), - ); - -const secrets = { - databasePassword: Redacted.make(PASSWORD), - jwtSecret: Redacted.make(JWT_SECRET), -}; - -const query = (url: Redacted.Redacted, statement: string) => - Effect.scoped( - Effect.gen(function* () { - const client = yield* PgClient.PgClient; - return yield* client.unsafe(statement); - }).pipe(Effect.provide(PgClient.layer({ url, connectTimeout: "10 seconds" }))), - ); - -const withIsolatedRoot = (effect: Effect.Effect) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ prefix: "supabase-eph-" }); - yield* Effect.addFinalizer(() => fs.remove(root, { recursive: true }).pipe(Effect.ignore)); - const stateRoot = path.join(root, "managed", "stacks"); - yield* fs.makeDirectory(stateRoot, { recursive: true }); - return yield* effect.pipe(Effect.provide(testEnvironment(stateRoot))); - }); - -const writeForeignMarkerTar = (tarPath: string, marker: unknown) => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const staging = yield* fs.makeTempDirectoryScoped(); - const data = path.join(staging, "data"); - yield* fs.makeDirectory(data); - yield* fs.writeFileString( - path.join(data, ".supabase-ephemeral-runtime"), - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- fixture marker bytes packed into a tar. - JSON.stringify(marker), - ); - const handle = yield* ChildProcess.make("tar", ["-C", staging, "-cf", tarPath, "data"], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - const code = yield* handle.exitCode; - expect(Number(code)).toBe(0); - }), - ); - -const reserveLoopbackPort = (): Effect.Effect => - Effect.callback((resume) => { - const server = createServer(); - let settled = false; - const finish = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - resume(effect); - }; - server.once("error", (cause) => finish(Effect.die(cause))); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - server.close((error) => { - if (error !== undefined) { - finish(Effect.die(error)); - return; - } - finish(port > 0 ? Effect.succeed(port) : Effect.die("Unable to allocate a loopback port")); - }); - }); - return Effect.sync(() => { - if (settled) return; - settled = true; - try { - server.close(); - } catch { - // The listener never obtained a handle. - } - }); - }); - -describe("ephemeral Postgres", () => { - it.live("refuses a snapshot produced by a different runtime before starting Postgres", () => - withIsolatedRoot( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tarPath = path.join(yield* fs.makeTempDirectoryScoped(), "foreign.tar"); - yield* writeForeignMarkerTar(tarPath, { kind: "container", engine: "docker" }); - const exit = yield* createEphemeralPostgres({ - runtime: { kind: "native" }, - restoreFrom: tarPath, - ...secrets, - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(error).toBeInstanceOf(EphemeralPostgresError); - if (!(error instanceof EphemeralPostgresError)) return; - expect(error.reason).toBe("restore-mismatch"); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.live("refuses a keyless snapshot marker when a snapshot key is expected", () => - withIsolatedRoot( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tarPath = path.join(yield* fs.makeTempDirectoryScoped(), "keyless.tar"); - yield* writeForeignMarkerTar(tarPath, { kind: "native" }); - const exit = yield* createEphemeralPostgres({ - runtime: { kind: "native" }, - restoreFrom: tarPath, - snapshotKey: "expected-cache-key", - ...secrets, - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(error).toBeInstanceOf(EphemeralPostgresError); - if (!(error instanceof EphemeralPostgresError)) return; - expect(error.reason).toBe("restore-mismatch"); - expect(error.message).toContain("snapshot key"); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.live( - "starts a native cluster, snapshots, restores, and destroys without a stack identity", - () => - withIsolatedRoot( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exportDir = yield* fs.makeTempDirectoryScoped(); - const tarPath = path.join(exportDir, "baseline.tar"); - const first = yield* createEphemeralPostgres({ runtime: { kind: "native" }, ...secrets }); - const rows = yield* query( - first.url, - "SELECT rolname FROM pg_roles WHERE rolname = 'supabase_admin'", - ); - expect(rows.length).toBeGreaterThan(0); - expect(first.runtime.kind).toBe("native"); - expect(first.artifactIdentity.startsWith("native:")).toBe(true); - const listedWhileRunning = yield* listStacks({}); - expect(listedWhileRunning).toEqual([]); - yield* first.stop; - yield* first.exportPgData(tarPath); - const exists = yield* fs.exists(tarPath); - expect(exists).toBe(true); - - const restored = yield* createEphemeralPostgres({ - runtime: { kind: "native" }, - restoreFrom: tarPath, - ...secrets, - }); - const restoredRows = yield* query(restored.url, "SELECT current_database() AS name"); - expect(restoredRows).toEqual([{ name: "postgres" }]); - expect(restored.port).not.toBe(first.port); - - const second = yield* createEphemeralPostgres({ - runtime: { kind: "native" }, - ...secrets, - }); - expect(second.port).not.toBe(first.port); - expect(second.port).not.toBe(restored.port); - yield* query(second.url, "SELECT 1"); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - NATIVE_TIMEOUT_MS, - ); - - it.live( - "catalog native postgres can CREATE EXTENSION plpgsql_check on every database release", - () => - withIsolatedRoot( - Effect.gen(function* () { - const versions = Object.keys(catalogEntryFor("database:database").releases); - expect(versions.length).toBeGreaterThan(0); - for (const version of versions) { - const cluster = yield* createEphemeralPostgres({ - runtime: { kind: "native" }, - version, - ...secrets, - }); - expect(cluster.version).toBe(version); - yield* query(cluster.url, "CREATE EXTENSION IF NOT EXISTS plpgsql_check"); - const installed = yield* query( - cluster.url, - "SELECT extname FROM pg_extension WHERE extname = 'plpgsql_check'", - ); - expect(installed).toEqual([{ extname: "plpgsql_check" }]); - yield* query( - cluster.url, - "CREATE FUNCTION public.lint_probe() RETURNS void LANGUAGE plpgsql AS $$ BEGIN PERFORM id FROM lint_probe_missing; END $$", - ); - const reports = yield* query( - cluster.url, - "SELECT plpgsql_check_function('public.lint_probe()'::regprocedure, format := 'json')::text AS report", - ); - expect(reports).toEqual([{ report: expect.stringContaining("lint_probe_missing") }]); - } - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - NATIVE_TIMEOUT_MS * 2, - ); - - it.live( - "does not leave postgres listening after an interrupted native start", - () => - withIsolatedRoot( - Effect.gen(function* () { - const port = yield* reserveLoopbackPort(); - // Fiber-owned scope so interrupt always tears the cluster down, even after start returns. - const fiber = yield* Effect.forkChild( - Effect.scoped( - createEphemeralPostgres({ runtime: { kind: "native" }, port, ...secrets }).pipe( - Effect.andThen(Effect.never), - ), - ), - ); - const url = Redacted.make( - `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent(PASSWORD)}@127.0.0.1:${port}/postgres`, - ); - yield* Effect.raceFirst( - Effect.retry(query(url, "SELECT 1"), { - schedule: Schedule.spaced("100 millis"), - }).pipe(Effect.timeout(Duration.seconds(120))), - Fiber.join(fiber), - ); - yield* Fiber.interrupt(fiber); - yield* checkHostPort("127.0.0.1", port, "database"); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - NATIVE_TIMEOUT_MS, - ); - - it.live.skipIf(!dockerAvailable())( - "starts a container cluster, snapshots, and restores", - () => - withIsolatedRoot( - Effect.gen(function* () { - const runtime: StackRuntimePreference = { kind: "container", engine: "docker" }; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tarPath = path.join(yield* fs.makeTempDirectoryScoped(), "baseline.tar"); - const first = yield* createEphemeralPostgres({ runtime, ...secrets }); - yield* query(first.url, "SELECT 1"); - expect(first.runtime.kind).toBe("container"); - expect(first.networkId).toEqual(expect.any(String)); - expect(first.artifactIdentity.startsWith("container:docker:")).toBe(true); - yield* first.stop; - yield* first.exportPgData(tarPath); - const restored = yield* createEphemeralPostgres({ - runtime, - restoreFrom: tarPath, - ...secrets, - }); - yield* query(restored.url, "SELECT 1"); - expect(restored.port).not.toBe(first.port); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - NATIVE_TIMEOUT_MS, - ); - - it.live.skipIf(process.platform !== "linux" || !dockerAvailable())( - "schema-init one-shots join the cluster network and reach Postgres", - () => - withIsolatedRoot( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-linux-" }); - const runtime: StackRuntimePreference = { kind: "container", engine: "docker" }; - const cluster = yield* createEphemeralPostgres({ runtime, ...secrets }); - expect(cluster.networkId).toEqual(expect.any(String)); - yield* schemaInit(["auth"], { - kind: "ephemeral", - projectRoot, - runtime: cluster.runtime, - config: { - capabilities: { - studio: { enabled: true }, - analytics: { enabled: false }, - }, - }, - databaseUrl: Redacted.value(cluster.url), - secrets, - ...(cluster.networkId === undefined ? {} : { networkId: cluster.networkId }), - }); - const rows = yield* query( - cluster.url, - "SELECT nspname FROM pg_namespace WHERE nspname = 'auth'", - ); - expect(rows.length).toBeGreaterThan(0); - }), - ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - NATIVE_TIMEOUT_MS, - ); -}); diff --git a/packages/stack/src/public/ephemeral-postgres.unit.test.ts b/packages/stack/src/public/ephemeral-postgres.unit.test.ts deleted file mode 100644 index 528b9615ad..0000000000 --- a/packages/stack/src/public/ephemeral-postgres.unit.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; -import { DatabaseModule } from "../model/capabilities/database.ts"; -import { StackVersionUnsupportedError } from "./Errors.ts"; -import { resolveEphemeralPostgresRelease } from "./EphemeralPostgres.ts"; - -describe("resolveEphemeralPostgresRelease", () => { - it.effect("resolves the catalog default and a major selector", () => - Effect.gen(function* () { - const fallback = yield* resolveEphemeralPostgresRelease(); - expect(fallback.version).toBe(DatabaseModule.defaultVersion); - expect(fallback.image.length).toBeGreaterThan(0); - - const major = DatabaseModule.defaultVersion.split(".")[0]; - expect(major).toBeDefined(); - if (major === undefined) return; - const selected = yield* resolveEphemeralPostgresRelease(major); - expect(selected.version).toBe(fallback.version); - expect(selected.image).toBe(fallback.image); - }), - ); - - it.effect("maps a superseded exact pin to the current catalog release of that major", () => - Effect.gen(function* () { - const major = DatabaseModule.defaultVersion.split(".")[0]; - expect(major).toBeDefined(); - if (major === undefined) return; - const selected = yield* resolveEphemeralPostgresRelease(`${major}.0.0.1`); - expect(selected.version).toBe(DatabaseModule.defaultVersion); - }), - ); - - it.effect("fails for an unknown PostgreSQL version", () => - Effect.gen(function* () { - const exit = yield* resolveEphemeralPostgresRelease("99").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(error).toBeInstanceOf(StackVersionUnsupportedError); - }), - ); -}); diff --git a/packages/stack/src/public/functions-inspector.e2e.test.ts b/packages/stack/src/public/functions-inspector.e2e.test.ts new file mode 100644 index 0000000000..4844e7162d --- /dev/null +++ b/packages/stack/src/public/functions-inspector.e2e.test.ts @@ -0,0 +1,387 @@ +import { NodeServices } from "@effect/platform-node"; +import { + Config, + Data, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Schema, +} from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { afterAll, describe, expect, test } from "vitest"; +import { isolatedInstanceApi } from "../../tests/helpers/instance-api.ts"; +import type { ServiceInstance } from "./Service.ts"; +import type { ServiceStatus } from "./Status.ts"; + +const host = ManagedRuntime.make(Layer.merge(NodeServices.layer, FetchHttpClient.layer)); +afterAll(() => host.dispose()); +const selectedRuntime = Option.getOrUndefined( + Effect.runSync(Config.option(Config.string("SUPABASE_STACK_E2E_RUNTIME"))), +); +const runtimes = [{ kind: "native" }, { kind: "container", engine: "docker" }] as const; +const inspectorTargets = Schema.Array( + Schema.Struct({ + webSocketDebuggerUrl: Schema.String, + type: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + }), +); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const inspectorDataText = (data: unknown): string => { + const decoder = new TextDecoder(); + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return decoder.decode(data); + if (Buffer.isBuffer(data)) return decoder.decode(data); + return ""; +}; + +class InspectorTestError extends Data.TaggedError("InspectorTestError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +// oxlint-disable-next-line effecttsgo/async-function -- Observes the public Promise API subscription across the supervisor boundary. +const waitForInspector = async (updates: AsyncIterator) => { + for (;;) { + const update = await updates.next(); + if (update.done) throw new Error("Status observation ended before inspector publication"); + if (update.value.phase === "failed" || update.value.phase === "recovery") + throw new Error(JSON.stringify(update.value)); + const endpoint = update.value.endpoints.find( + (entry) => entry.binding === "inspector" && entry.availability === "listening", + ); + if (endpoint !== undefined) { + expect(update.value.phase).toBe("starting"); + return endpoint.url; + } + } +}; + +const releaseDebugger = (endpoint: string, mode: "wait" | "brk") => { + let targetUrl = "unresolved"; + return Effect.gen(function* () { + const response = yield* HttpClient.get(new URL("/json/list", endpoint)); + expect(response.status).toBe(200); + const targets = yield* response.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(inspectorTargets)), + ); + expect(targets.length).toBeGreaterThan(0); + const target = targets[0]; + if (target === undefined) + return yield* new InspectorTestError({ message: "Inspector target missing" }); + const url = new URL(target.webSocketDebuggerUrl); + url.host = new URL(endpoint).host; + targetUrl = url.toString(); + yield* Effect.callback((resume) => { + const socket = new globalThis.WebSocket(url.toString()); + let settled = false; + let paused = false; + const cleanup = () => { + socket.removeEventListener("open", onOpen); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + }; + const finish = (result: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + resume(result); + }; + const fail = (message: string, cause?: unknown) => + finish( + Effect.fail( + new InspectorTestError({ message, ...(cause === undefined ? {} : { cause }) }), + ), + ); + const send = (id: number, method: string) => { + try { + const command = JSON.stringify({ id, method }); + socket.send(command); + } catch (cause) { + fail("Inspector command failed to send", cause); + } + }; + const onOpen = () => { + send(1, mode === "wait" ? "Runtime.runIfWaitingForDebugger" : "Debugger.enable"); + }; + const onMessage = (event: MessageEvent) => { + const raw = inspectorDataText(event.data); + // Debugger.enable emits a large scriptParsed stream; only protocol acknowledgements and + // an actual paused event can settle this release operation. + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + fail("Inspector message was invalid", cause); + return; + } + if (!isRecord(parsed)) { + fail("Inspector message was invalid"); + return; + } + if (parsed.method === "Debugger.paused" && mode === "brk") { + paused = true; + send(3, "Debugger.resume"); + return; + } + if (parsed.error !== undefined) { + fail("Inspector command failed", parsed.error); + return; + } + if (parsed.id === 1 && mode === "wait") finish(Effect.void); + if (parsed.id === 1 && mode === "brk") send(2, "Runtime.runIfWaitingForDebugger"); + if (parsed.id === 2 && (mode === "wait" || !paused)) finish(Effect.void); + if (parsed.id === 3 && mode === "brk") finish(Effect.void); + }; + const onError = (cause: Event) => { + fail("Inspector socket failed", cause); + }; + const onClose = () => { + if (!settled) fail("Inspector closed before resume"); + }; + socket.addEventListener("open", onOpen); + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + return Effect.sync(() => { + cleanup(); + if ( + socket.readyState === globalThis.WebSocket.CONNECTING || + socket.readyState === globalThis.WebSocket.OPEN + ) + socket.close(); + }); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => + Effect.fail( + new InspectorTestError({ + message: `Main debugger did not resume at ${endpoint} (${targetUrl})`, + }), + ), + }), + ); +}; + +describe("functions startup through the managed inspector", () => { + for (const runtime of runtimes) { + for (const mode of ["wait", "brk"] as const) { + test.skipIf(selectedRuntime !== undefined && selectedRuntime !== runtime.kind)( + `releases inspect-main ${mode} before application health in ${runtime.kind}`, + { timeout: 5 * 60_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the public Promise API and real debugger across supervisor processes. + async () => { + const root = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-inspector-" }); + const functionsRoot = path.join(root, "supabase", "functions", "hello"); + yield* fs.makeDirectory(functionsRoot, { recursive: true }); + yield* fs.writeFileString( + path.join(functionsRoot, "index.ts"), + 'Deno.serve(() => new Response("debugger-resumed"));\n', + ); + return root; + }), + ); + const { createStack } = await host.runPromise(isolatedInstanceApi(root)); + const stack = await createStack({ + projectRoot: root, + name: "inspector", + runtime, + initialConfig: { + capabilities: { + database: { enabled: false }, + auth: { enabled: false }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const failures: unknown[] = []; + let instance: ServiceInstance<"functions"> | undefined; + try { + const service = await stack.services.create({ + service: "functions", + name: "debug-functions", + config: { + endpoints: { inspector: { port: "auto" } }, + settings: { + functions_root: "supabase/functions", + inspector: { mode, main: true }, + functions: { hello: { enabled: true, verify_jwt: false } }, + }, + }, + }); + instance = service; + const updates = service.followStatus()[Symbol.asyncIterator](); + try { + expect((await updates.next()).value?.phase).toBe("stopped"); + const inspector = waitForInspector(updates).then((endpoint) => + host.runPromise(releaseDebugger(endpoint, mode)), + ); + let startupPhase: string | undefined; + let healthBeforeInspector: number | undefined; + let startedStatus: ServiceStatus | undefined; + const start = host.runPromise( + Effect.tryPromise({ + try: () => service.start(), + catch: (cause) => + new InspectorTestError({ message: "Functions startup failed", cause }), + }).pipe( + Effect.tap((status) => + Effect.sync(() => { + startupPhase = status.phase; + startedStatus = status; + }), + ), + Effect.flatMap((status) => + status.phase === "ready" + ? Effect.succeed(status) + : Effect.fail( + new InspectorTestError({ + message: `Functions startup phase was ${status.phase}`, + }), + ), + ), + Effect.flatMap(() => + Effect.tryPromise({ + try: () => stack.status(), + catch: (cause) => + new InspectorTestError({ message: "Managed stack status failed", cause }), + }), + ), + Effect.flatMap((stackStatus) => { + const api = stackStatus.endpoints.api; + if (api === undefined) + return Effect.fail( + new InspectorTestError({ message: "Managed API endpoint missing" }), + ); + return HttpClient.get(new URL("/functions/v1/_internal/health", api.url)).pipe( + Effect.timeout("15 seconds"), + Effect.tap((response) => + Effect.sync(() => { + healthBeforeInspector = response.status; + }), + ), + Effect.flatMap((response) => + response.status === 200 + ? Effect.succeed(response) + : Effect.fail( + new InspectorTestError({ + message: `Functions health returned ${response.status}`, + }), + ), + ), + Effect.asVoid, + ); + }), + ), + ); + const [startResult, inspectorResult] = await Promise.allSettled([start, inspector]); + if (startResult.status === "rejected") { + if (inspectorResult.status === "rejected") + throw new AggregateError( + [startResult.reason, inspectorResult.reason], + "Inspector startup failed", + ); + throw startResult.reason; + } + if (inspectorResult.status === "rejected") { + const details = [ + startupPhase === undefined + ? "start phase unresolved" + : `start phase=${startupPhase}`, + healthBeforeInspector === undefined + ? "health unresolved" + : `health status=${healthBeforeInspector}`, + ].join("; "); + throw new Error(`Inspector startup failed; ${details}`, { + cause: inspectorResult.reason, + }); + } + if (startedStatus === undefined) + throw new Error("Functions startup completed without a status"); + const started = startedStatus; + expect(started.phase).toBe("ready"); + const status = await stack.status(); + const api = status.endpoints.api; + if (api === undefined) throw new Error("Managed API endpoint missing"); + const response = await host.runPromise( + HttpClient.get(new URL("/functions/v1/_internal/health", api.url)).pipe( + Effect.timeout("15 seconds"), + ), + ); + expect(response.status).toBe(200); + expect(await host.runPromise(response.json)).toEqual({ message: "ok" }); + const credentials = await service.credentials(); + if (credentials === undefined || !("publishableKey" in credentials)) + throw new Error("Functions API credentials missing without a database"); + expect(credentials.publishableKey.length).toBeGreaterThan(0); + const stackCredentials = await stack.credentials(); + expect(stackCredentials.database).toBeUndefined(); + expect(stackCredentials.api?.publishableKey).toBe(credentials.publishableKey); + } finally { + await updates.return?.(); + } + } catch (error) { + if (instance === undefined) { + failures.push(error); + } else { + try { + const logs = await instance.logs({ tail: 100 }); + const output = logs.entries + .filter((entry) => entry.stream === "stdout" || entry.stream === "stderr") + .map((entry) => `${entry.source}/${entry.stream}: ${entry.message}`) + .join("\n"); + failures.push( + new Error( + output.length === 0 + ? "Inspector startup failed; instance logs contained no stdout/stderr" + : `Inspector startup failed; instance logs:\n${output}`, + { cause: error }, + ), + ); + } catch (logsError) { + failures.push( + new AggregateError( + [error, logsError], + "Inspector startup failed and instance logs were unavailable", + ), + ); + } + } + } + try { + await stack.destroy(); + } catch (error) { + throw new AggregateError( + [...failures, error], + `Inspector smoke cleanup failed; retained project at ${root}`, + ); + } + await host.runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(root, { recursive: true })), + ); + if (failures.length > 0) throw failures[0]; + }, + ); + } + } +}); diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 50839b488d..ab640d0de3 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -8,6 +8,8 @@ export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; export { targetForPlatform } from "../model/WorkloadCatalog.ts"; +export * from "./Service.ts"; +export * from "./ServiceInstanceId.ts"; export { excludeStackCapabilities } from "../model/Exclusions.ts"; export type { ExcludableCapabilityName } from "../model/Exclusions.ts"; export { @@ -30,24 +32,19 @@ export type { EffectStack, InspectStackOptions, StartStackOptions, + ServiceSelection, + ServiceConfigUpdate, + RestartStackOptions, PrepareStackOptions, + OpenStackOptions, CreateStackOptions, FindStackOptions, ListStacksOptions, StackDiscoveryIssue, StackDiscoveryResult, - PreparedCapability, PrepareStackResult, } from "./EffectStack.ts"; export { databaseBootstrapIdentity } from "../model/DatabaseBootstrap.ts"; -export { createEphemeralPostgres, resolveEphemeralPostgresRelease } from "./EphemeralPostgres.ts"; -export type { - CreateEphemeralPostgresOptions, - EffectEphemeralPostgres, - EphemeralPostgresRelease, - EphemeralPostgresServices, - EphemeralPostgresSettings, -} from "./EphemeralPostgres.ts"; export { runPostgresClient } from "./PostgresClient.ts"; export type { PostgresClientMount, @@ -55,17 +52,3 @@ export type { PostgresClientServices, RunPostgresClientOptions, } from "./PostgresClient.ts"; -export { - schemaInit, - SCHEMA_INIT_CAPABILITY_NAMES, - schemaInitArtifactIdentity, -} from "./SchemaInit.ts"; -export type { - SchemaInitCapabilityName, - SchemaInitEphemeralTarget, - SchemaInitLiveTarget, - SchemaInitOptions, - SchemaInitSecrets, - SchemaInitServices, - SchemaInitTarget, -} from "./SchemaInit.ts"; diff --git a/packages/stack/src/public/lazy-functions.e2e.test.ts b/packages/stack/src/public/lazy-functions.e2e.test.ts new file mode 100644 index 0000000000..1d6d03101d --- /dev/null +++ b/packages/stack/src/public/lazy-functions.e2e.test.ts @@ -0,0 +1,89 @@ +import { NodeServices } from "@effect/platform-node"; +import { Config, Effect, FileSystem, Layer, ManagedRuntime, Option, Path } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { afterAll, expect, test } from "vitest"; +import { isolatedInstanceApi } from "../../tests/helpers/instance-api.ts"; + +const host = ManagedRuntime.make(Layer.merge(NodeServices.layer, FetchHttpClient.layer)); +afterAll(() => host.dispose()); +const selectedRuntime = Option.getOrUndefined( + Effect.runSync(Config.option(Config.string("SUPABASE_STACK_E2E_RUNTIME"))), +); + +for (const runtime of [{ kind: "native" }, { kind: "container", engine: "docker" }] as const) { + test.skipIf(selectedRuntime !== undefined && selectedRuntime !== runtime.kind)( + `demand wakes Functions after a lazy whole start in ${runtime.kind}`, + { timeout: 180_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the public Promise API through a real supervisor and HTTP ingress. + async () => { + const root = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-lazy-functions-" }); + const directory = path.join(root, "supabase", "functions", "hello"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "index.ts"), + 'Deno.serve(() => new Response("awake"));\n', + ); + return root; + }), + ); + const { createStack } = await host.runPromise(isolatedInstanceApi(root)); + const stack = await createStack({ + projectRoot: root, + runtime, + initialConfig: { + capabilities: { + database: { enabled: false }, + auth: { enabled: false }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + functions: { + enabled: true, + activation: "lazy", + settings: { + functions_root: "supabase/functions", + functions: { hello: { enabled: true, verify_jwt: false } }, + }, + }, + }, + }, + }); + const failures: unknown[] = []; + try { + const started = await stack.start(); + const functions = await stack.services.get({ name: "functions" }); + expect((await functions.status()).phase).toBe("dormant"); + const api = started.endpoints.api; + if (api === undefined) throw new Error("Lazy start did not expose managed API ingress"); + const body = await host.runPromise( + HttpClient.get(new URL("/functions/v1/hello", api.url)).pipe( + Effect.flatMap((response) => response.text), + Effect.timeout("30 seconds"), + ), + ); + expect(body).toBe("awake"); + expect((await functions.status()).phase).toBe("ready"); + } catch (error) { + failures.push(error); + } + try { + await stack.destroy(); + } catch (error) { + failures.push(error); + } + if (failures.length > 0) + throw new AggregateError(failures, `Lazy start failed; retained ${root}`); + await host.runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(root, { recursive: true })), + ); + }, + ); +} diff --git a/packages/stack/src/public/lifecycle-outcomes.integration.test.ts b/packages/stack/src/public/lifecycle-outcomes.integration.test.ts new file mode 100644 index 0000000000..a04389e41a --- /dev/null +++ b/packages/stack/src/public/lifecycle-outcomes.integration.test.ts @@ -0,0 +1,410 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Context, + Cause, + Crypto, + Effect, + Exit, + FileSystem, + Path, + Option, + Redacted, + Ref, +} from "effect"; +import { compileServiceInstance } from "../model/Compiler.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; +import { STACK_RPC_RELEASE, type StackRpcClient } from "../control/StackRpc.ts"; +import { + unconfiguredServiceRpcHandlers, + unconfiguredStackRpcHandlers, +} from "../control/test-helpers.ts"; +import { StackLifecycleConflictError, type LifecycleOutcome, type StackError } from "./Errors.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; +import type { InstanceRuntimeInput } from "../supervisor/Lifecycle.ts"; +import { makeSupervisor, type SupervisorRuntime } from "../supervisor/Supervisor.ts"; +import type { SupervisorIngress } from "../supervisor/Ingress.ts"; +import type { LogStore } from "../supervisor/LogStore.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "./ServiceInstanceId.ts"; +import { StackIdSchema } from "./StackId.ts"; +import { makeHandle, type HandleDependencies } from "./EffectStack.ts"; +import { adaptEffectStack } from "./PromiseStack.ts"; + +const ingress: SupervisorIngress = { + close: Effect.void, +}; +const logStore: LogStore = { + path: "/dev/null", + append: () => Effect.die("lifecycle outcome fixture does not write logs"), + read: () => Effect.succeed([]), +}; + +type FailureMode = "none" | "functions-start" | "rest-destroy"; +interface Fixture { + readonly endpoint: { readonly kind: "unix"; readonly path: string }; + readonly stackId: string; + readonly stateStore: StackStateStore; + readonly database: ServiceInstanceId; + readonly rest: ServiceInstanceId; + readonly functions: ServiceInstanceId; + readonly failure: Ref.Ref; + readonly starts: Array; + readonly stops: Array; + readonly destroys: Array; + readonly read: () => Effect.Effect; +} + +const withRpc = ( + fixture: Pick, + use: (rpc: StackRpcClient) => Effect.Effect, +) => + Effect.scoped( + makeControlClient(fixture.endpoint, { + stackId: fixture.stackId, + ownerSessionId: "lifecycle-outcomes-owner", + rpcRelease: STACK_RPC_RELEASE, + }).rpc.pipe(Effect.flatMap(use)), + ); + +const failureFrom = (exit: Exit.Exit): unknown => { + if (Exit.isSuccess(exit)) throw new Error("expected lifecycle operation to fail"); + const failure = Cause.findErrorOption(exit.cause); + if (Option.isNone(failure)) throw new Error(`expected typed failure, got ${String(exit.cause)}`); + return failure.value; +}; + +const withFixture = ( + mode: "restart" | "destroy", + use: (fixture: Fixture) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-outcomes-" }); + const projectRoot = path.join(root, "project"); + yield* fs.makeDirectory(projectRoot); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const identity = { + projectRoot, + branchContext: "test", + stackName: "lifecycle-outcomes", + } as const; + const stackId = yield* deriveStackId(identity); + const databaseId = ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"); + const restId = ServiceInstanceIdSchema.make("22222222-2222-4222-8222-222222222222"); + const functionsId = ServiceInstanceIdSchema.make("33333333-3333-4333-8333-333333333333"); + const database = yield* compileServiceInstance( + { + service: "database", + name: "primary", + config: { password: Redacted.make("old"), settings: {} }, + }, + { projectRoot, path, runtime: { kind: "native" }, instanceId: databaseId }, + ).pipe(Effect.provideContext(context)); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "rest", + config: { settings: {} }, + dependencies: { database: databaseId }, + }, + { projectRoot, path, runtime: { kind: "native" }, instanceId: restId }, + ).pipe(Effect.provideContext(context)); + const functions = yield* compileServiceInstance( + { service: "functions", name: "functions", config: { settings: {} } }, + { projectRoot, path, runtime: { kind: "native" }, instanceId: functionsId }, + ).pipe(Effect.provideContext(context)); + const state: PersistedStackState = { + format: "supabase-stack-state-v2", + identity, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: {}, + registry: { + initialized: true, + instances: [ + { ...database.instance, intent: "started" }, + { ...rest.instance, intent: mode === "destroy" ? "started" : "stopped" }, + { ...functions.instance, intent: "started" }, + ], + defaultInstanceIds: { database: databaseId, rest: restId, functions: functionsId }, + }, + ports: [], + privatePorts: [], + secrets: { + [AUTH_JWT_SECRET_SLOT]: { policy: "managed", value: "jwt" }, + [`secret:${databaseId}:password`]: { policy: "managed", value: "old" }, + }, + }; + const stateStore = yield* makeStackStateStore({ stateRoot: path.join(root, "state") }); + yield* stateStore.initialize(stackId, state).pipe(Effect.provideContext(context)); + const failure = yield* Ref.make("none"); + const starts: Array = []; + const stops: Array = []; + const destroys: Array = []; + const runtimeFailure = (message: string) => + new StackLifecycleConflictError({ stackId, message }); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("lifecycle outcome fixture does not start workloads"), + stop: () => Effect.die("lifecycle outcome fixture does not stop workloads"), + remove: () => Effect.die("lifecycle outcome fixture does not remove workloads"), + cleanup: () => Effect.die("lifecycle outcome fixture does not clean workloads"), + wipePersistentData: () => Effect.die("lifecycle outcome fixture does not wipe workloads"), + }; + const runtime: SupervisorRuntime = { + driver, + preflight: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + prepareArtifacts: () => Effect.void, + start: (input) => + Effect.gen(function* () { + starts.push(input); + if ( + input.instance.id === functionsId && + (yield* Ref.get(failure)) === "functions-start" + ) + return yield* runtimeFailure("Functions failed while starting"); + return [] as ReadonlyArray; + }), + stop: (input) => Effect.sync(() => stops.push(input)), + destroy: (input) => + Effect.gen(function* () { + destroys.push(input); + if (input.instance.id === restId && (yield* Ref.get(failure)) === "rest-destroy") + return yield* runtimeFailure("REST cleanup failed"); + }), + exportSnapshot: () => Effect.fail(runtimeFailure("snapshot is outside this fixture")), + restoreSnapshot: () => Effect.fail(runtimeFailure("snapshot is outside this fixture")), + prefetch: () => Effect.void, + artifacts: Effect.succeed([]), + activate: () => Effect.die("lifecycle outcome fixture does not activate gateways"), + ingress, + logStore, + }; + const supervisor = yield* makeSupervisor({ + stackId, + ownerSessionId: "lifecycle-outcomes-owner", + stateStore, + context, + runtime, + }).pipe(Effect.provideContext(context)); + const endpoint = { kind: "unix" as const, path: path.join(root, "control", "owner.sock") }; + yield* startControlServer({ + stackId, + ownerSessionId: "lifecycle-outcomes-owner", + endpoint, + rpcRelease: STACK_RPC_RELEASE, + maintenanceHandlers: supervisor.maintenanceHandlers, + rpcHandlers: supervisor.rpcHandlers, + }); + return yield* use({ + endpoint, + stackId, + stateStore, + database: databaseId, + rest: restId, + functions: functionsId, + failure, + starts, + stops, + destroys, + read: () => stateStore.read(stackId).pipe(Effect.provideContext(context)), + }); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +const restartUpdate = (id: ServiceInstanceId) => ({ + id, + service: "database" as const, + config: { password: Redacted.make("new"), settings: {} }, +}); + +describe("lifecycle outcomes", { timeout: 30_000 }, () => { + it.live("reports partial restart results through the control RPC and state", () => + withFixture("restart", ({ endpoint, stackId, database, functions, failure, starts, read }) => + Effect.gen(function* () { + yield* Ref.set(failure, "functions-start"); + const result = yield* withRpc({ endpoint, stackId }, (rpc) => + rpc.restart({ services: [database, functions], updates: [restartUpdate(database)] }), + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const error = failureFrom(result); + expect(error).toMatchObject({ + tag: "StackLifecycleConflictError", + outcome: { + requested: [database, functions], + affected: [database, functions], + succeeded: [database], + failed: [functions], + } satisfies Partial, + }); + expect(starts.map((input) => input.instance.id)).toEqual( + expect.arrayContaining([database, functions]), + ); + const after = yield* read(); + expect( + after?.registry.instances.every((instance) => instance.pendingOperation === null), + ).toBe(true); + expect(after?.secrets[`secret:${database}:password`]?.value).toBe("new"); + }), + ), + ); + + it.live( + "retains the database when a dependent destroy fails and reports independent removal", + () => + withFixture( + "destroy", + ({ endpoint, stackId, database, rest, functions, failure, destroys, read }) => + Effect.gen(function* () { + yield* Ref.set(failure, "rest-destroy"); + const result = yield* withRpc({ endpoint, stackId }, (rpc) => + rpc.destroy({ services: [database, rest, functions] }), + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const error = failureFrom(result); + expect(error).toMatchObject({ + tag: "StackDestructionError", + outcome: { + requested: [database, rest, functions], + affected: [rest, database, functions], + succeeded: [functions], + failed: [rest, database], + retained: expect.arrayContaining([rest, database]), + removed: [functions], + }, + }); + expect(destroys.map((input) => input.instance.id)).toEqual([rest, functions]); + const after = yield* read(); + expect(after?.registry.instances.map((instance) => instance.id)).toEqual( + expect.arrayContaining([database, rest]), + ); + }), + ), + ); + + it.live("preserves lifecycle outcome fields through Effect and Promise facades", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-facade-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control", "owner.sock") }; + const facadeStackId = StackIdSchema.make( + "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + ); + const outcome: LifecycleOutcome = { + requested: ["service-a", "service-b"], + affected: ["service-a", "service-b"], + succeeded: ["service-a"], + failed: ["service-b"], + retained: ["service-b"], + removed: ["service-a"], + }; + const rpcError: import("../control/StackRpc.ts").StackRpcError = { + tag: "StackLifecycleConflictError", + message: "partial restart", + stackId: facadeStackId, + outcome, + }; + const ownerSessionId = "lifecycle-facade-owner"; + yield* startControlServer({ + endpoint, + stackId: facadeStackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + ...unconfiguredServiceRpcHandlers, + ...unconfiguredStackRpcHandlers, + restart: () => Effect.fail(rpcError), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe" as const, + stackId: facadeStackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" as const }), + }, + }); + const dependencies: HandleDependencies = { + resolveOwner: () => + Effect.succeed( + Option.some({ + owner: { + format: "supabase-stack-owner-v1" as const, + stackId: facadeStackId, + endpoint, + ownerSessionId, + leasePort: 45_003, + rpcRelease: STACK_RPC_RELEASE, + }, + launched: false, + }), + ), + readOfflineState: Effect.succeed(Option.none()), + readPersistedState: Effect.succeed(Option.none()), + readLogs: () => + Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), + waitForRelease: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + }; + const effectStack = yield* makeHandle(facadeStackId, dependencies); + const effectResult = yield* effectStack.restart({ services: [] }).pipe(Effect.exit); + expect(Exit.isFailure(effectResult)).toBe(true); + if (Exit.isFailure(effectResult)) { + const effectError = Cause.findErrorOption(effectResult.cause); + expect(Option.isSome(effectError)).toBe(true); + if (Option.isSome(effectError)) + expect(effectError.value).toMatchObject({ + _tag: "StackLifecycleConflictError", + outcome, + }); + } + + const promiseStack = adaptEffectStack(effectStack); + const promiseResult = yield* Effect.tryPromise({ + try: () => promiseStack.restart({ services: [] }), + catch: (error) => + error instanceof StackLifecycleConflictError + ? error + : new StackLifecycleConflictError({ + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + }).pipe(Effect.exit); + expect(Exit.isFailure(promiseResult)).toBe(true); + if (Exit.isFailure(promiseResult)) { + const promiseError = Cause.findErrorOption(promiseResult.cause); + expect(Option.isSome(promiseError)).toBe(true); + if (Option.isSome(promiseError)) { + expect(promiseError.value).toMatchObject({ + _tag: "StackLifecycleConflictError", + outcome, + }); + } + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/public/promise-testing.integration.test.ts b/packages/stack/src/public/promise-testing.integration.test.ts new file mode 100644 index 0000000000..37cc428851 --- /dev/null +++ b/packages/stack/src/public/promise-testing.integration.test.ts @@ -0,0 +1,30 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem } from "effect"; + +import { createTestStack } from "../index.ts"; + +describe("Promise test stack facade", () => { + it.live("maps setup failure and removes the created project root", () => + Effect.gen(function* () { + let projectRoot: string | undefined; + const result = yield* Effect.exit( + Effect.promise(() => + createTestStack({ + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the public Promise setup boundary. + setupProject: async (root) => { + projectRoot = root; + throw new Error("promise setup failed"); + }, + }), + ), + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.squash(result.cause)).toMatchObject({ message: "promise setup failed" }); + expect(projectRoot).toBeDefined(); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(projectRoot ?? "")).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index afaa34cbef..9a034e4b0b 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -1,328 +1,125 @@ import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import { CAPABILITY_NAMES, type CapabilityStatus } from "./Capability.ts"; -import { Data, Effect, FileSystem, Path, Redacted, Schema, Stream } from "effect"; -import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./EffectStack.ts"; -import type { LogQuery, StackLogEntry } from "./Logs.ts"; +import { Effect, Redacted, Stream } from "effect"; +import type { + EffectStack, + PrepareStackOptions, + RestartStackOptions, + StartStackOptions, + ServiceSelection, +} from "./EffectStack.ts"; +import { adaptEffectStack } from "./PromiseStack.ts"; +import type { StackLogBatch, StackLogEntry } from "./Logs.ts"; +import type { StackStatus } from "./Status.ts"; import { StackIdSchema } from "./StackId.ts"; -import type { ArtifactPreparationStatus, StackStatus } from "./Status.ts"; -import { InvalidStackConfigError, StackVersionUnsupportedError } from "./Errors.ts"; -import { adaptEffectStack, makePromiseApi, type PromiseStack } from "./PromiseStack.ts"; -import { compileStack } from "../model/Compiler.ts"; -class StreamFixtureError extends Data.TaggedError("StreamFixtureError")<{ - readonly cause: unknown; -}> {} -const malformedConfig = Schema.decodeSync(Schema.fromJsonString(Schema.Any))( - '{"capabilities":{"rest":{"settings":{"unknown":true}}}}', -); +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; +import type { EffectServiceCollection } from "./Service.ts"; +import { CAPABILITY_NAMES } from "./Capability.ts"; + const stackId = StackIdSchema.make( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); -const capabilities: ReadonlyArray = CAPABILITY_NAMES.map((name) => ({ - name, - activation: name === "functions" ? "lazy" : "eager", - state: name === "functions" ? "ready" : "dormant", -})); const status: StackStatus = { id: stackId, lifecycle: "running", desiredLifecycle: "running", runtime: { kind: "native" }, - endpoints: { - api: { - protocol: "http", - address: "127.0.0.1", - port: 54321, - url: "http://127.0.0.1:54321", - }, - }, + endpoints: {}, versions: {}, - capabilities, + capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), + name, + activation: "eager" as const, + state: "ready" as const, + })), artifacts: [], + instances: [], }; -const effectStack = (): EffectStack => - ({ - id: stackId, - status: Effect.succeed(status), - credentials: Effect.succeed({ - database: { url: Redacted.make("postgres://secret"), password: Redacted.make("db-pass") }, - api: { - publishableKey: "publishable", - secretKey: Redacted.make("secret-key"), - anonJwt: "anon", - serviceRoleJwt: Redacted.make("service-role"), - }, - storage: { - endpoint: "http://storage", - region: "local", - accessKeyId: "access", - secretAccessKey: Redacted.make("storage-secret"), - }, - }), - prepare: (_options?: PrepareStackOptions) => Effect.succeed({ capabilities: [] }), - start: () => Effect.succeed(status), - stop: Effect.void, - destroy: Effect.void, - resetDatabase: Effect.succeed(status), - logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), - followLogs: () => Stream.empty, - }) satisfies EffectStack; +const emptyServices = { + create: () => Effect.die("unused"), + get: () => Effect.die("unused"), + list: Effect.succeed([]), +} satisfies EffectServiceCollection; +const batch: StackLogBatch = { entries: [], cursor: { opaque: "v1_0" }, running: false }; + +const effectStack = (overrides: Partial = {}): EffectStack => ({ + id: stackId, + services: emptyServices, + status: Effect.succeed(status), + followStatus: Stream.empty, + credentials: Effect.succeed({ + database: { url: Redacted.make("postgres://secret"), password: Redacted.make("db-pass") }, + api: { + publishableKey: "publishable", + secretKey: Redacted.make("secret-key"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service-role"), + }, + }), + prepare: (_options?: PrepareStackOptions) => Effect.succeed({ instances: [] }), + start: (_options?: StartStackOptions) => Effect.succeed(status), + sleep: (_options?: ServiceSelection) => Effect.succeed(status), + stop: (_options?: ServiceSelection) => Effect.succeed(status), + restart: (_options?: RestartStackOptions) => Effect.succeed(status), + destroy: (_options?: ServiceSelection) => Effect.void, + logs: () => Effect.succeed(batch), + followLogs: () => Stream.empty, + ...overrides, +}); + describe("Promise stack facade", () => { - it.live("prepares a real stack without publishing owner metadata", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-promise-prepare-" }); - const project = path.join(root, "project"); - const stateRoot = path.join(root, "managed", "stacks"); - yield* fs.makeDirectory(project); - const api = makePromiseApi(NodeServices.layer, { - stateRoot, - tempRoot: "/tmp", - platform: "posix", - }); - const stack = yield* Effect.promise(() => - api.createStack({ projectRoot: project, runtime: { kind: "native" } }), - ); - const statePath = path.join(stateRoot, stack.id, "state.json"); - const before = yield* fs.readFileString(statePath); - yield* Effect.promise(() => - expect(stack.prepare({ capabilities: [] })).resolves.toEqual({ capabilities: [] }), - ); - expect(yield* fs.readFileString(statePath)).toBe(before); - expect(yield* fs.readDirectory(path.join(stateRoot, stack.id))).not.toContain("control.json"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("preserves prepare validation tags", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-promise-prepare-errors-", - }); - const project = path.join(root, "project"); - yield* fs.makeDirectory(project); - const api = makePromiseApi(NodeServices.layer, { - stateRoot: path.join(root, "managed", "stacks"), - tempRoot: "/tmp", - platform: "posix", - }); - const stack = yield* Effect.promise(() => - api.createStack({ projectRoot: project, runtime: { kind: "native" } }), - ); - yield* Effect.promise(() => - expect( - stack.prepare({ - config: malformedConfig, - }), - ).rejects.toBeInstanceOf(InvalidStackConfigError), - ); - yield* Effect.promise(() => - expect( - stack.prepare({ config: { capabilities: { database: { version: "99" } } } }), - ).rejects.toBeInstanceOf(StackVersionUnsupportedError), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("returns log batches and follows from their cursor", () => - Effect.gen(function* () { - const initial: StackLogEntry = { - cursor: { opaque: "v1_0" }, - timestamp: "2026-01-01T00:00:00.000Z", - source: "auth", - stream: "stdout", - message: "initial", - }; - const followedEntry: StackLogEntry = { - ...initial, - cursor: { opaque: "v1_1" }, - message: "followed", - }; - let logsQuery: LogQuery | undefined; - let followQuery: LogQuery | undefined; - const stack = adaptEffectStack({ - ...effectStack(), - logs: (query) => - Effect.sync(() => { - logsQuery = query; - return { entries: [initial], cursor: { opaque: "v1_0" }, running: true }; - }), - followLogs: (query) => { - followQuery = query; - return Stream.succeed(followedEntry); - }, - }); - const first = yield* Effect.promise(() => stack.logs({ capabilities: ["auth"], tail: 20 })); - expect(first.entries).toEqual([initial]); - expect(logsQuery).toEqual({ capabilities: ["auth"], tail: 20 }); - const followed = yield* Stream.fromAsyncIterable( - stack.followLogs({ capabilities: ["auth"], cursor: first.cursor }), - (cause) => new StreamFixtureError({ cause }), - ).pipe(Stream.runCollect); - expect(followed).toEqual([followedEntry]); - expect(followQuery).toEqual({ capabilities: ["auth"], cursor: { opaque: "v1_0" } }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("unwraps every credential secret without a lifecycle close operation", () => + it.live("converts credentials and forwards selected lifecycle operations", () => Effect.gen(function* () { - const stack: PromiseStack = adaptEffectStack(effectStack()); - yield* Effect.promise(() => - expect(stack.credentials()).resolves.toEqual({ - database: { url: "postgres://secret", password: "db-pass" }, - api: { - publishableKey: "publishable", - secretKey: "secret-key", - anonJwt: "anon", - serviceRoleJwt: "service-role", + let selected: ReadonlyArray | undefined; + let restartOptions: RestartStackOptions | undefined; + const stack = adaptEffectStack( + effectStack({ + start: (options) => { + selected = options?.services; + return Effect.succeed(status); }, - storage: { - endpoint: "http://storage", - region: "local", - accessKeyId: "access", - secretAccessKey: "storage-secret", + restart: (options) => { + restartOptions = options; + return Effect.succeed(status); }, }), ); - expect(Symbol.asyncDispose in stack).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + expect(yield* Effect.promise(() => stack.credentials())).toEqual({ + database: { url: "postgres://secret", password: "db-pass" }, + api: { + publishableKey: "publishable", + secretKey: "secret-key", + anonJwt: "anon", + serviceRoleJwt: "service-role", + }, + }); + const serviceId = ServiceInstanceIdSchema.make("instance-a"); + yield* Effect.promise(() => stack.start({ services: [serviceId] })); + expect(selected).toEqual(["instance-a"]); + yield* Effect.promise(() => stack.restart({ config: {} })); + expect(restartOptions).toEqual({ config: {} }); + }), ); - it.live("cancels an active async stream and witnesses its finalizer", () => + + it.live("keeps log observation as an async iterable", () => Effect.gen(function* () { - let finalized = false; const entry: StackLogEntry = { cursor: { opaque: "v1_1" }, timestamp: "2026-01-01T00:00:00.000Z", source: "auth", stream: "stdout", - message: "active", - }; - const source: EffectStack = { - ...effectStack(), - followLogs: () => - Stream.make(entry).pipe( - Stream.concat(Stream.never), - Stream.ensuring( - Effect.sync(() => { - finalized = true; - }), - ), - ), + message: "ready", }; - const stack = adaptEffectStack(source); - const iterator = stack.followLogs()[Symbol.asyncIterator](); - yield* Effect.promise(() => - expect(iterator.next()).resolves.toEqual({ done: false, value: entry }), - ); - yield* Effect.promise(() => - expect(iterator.return?.()).resolves.toMatchObject({ done: true }), - ); - expect(finalized).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("forwards prepare progress through the Promise facade", () => - Effect.gen(function* () { - const progress: Array = []; - const stack = adaptEffectStack({ - ...effectStack(), - prepare: (options?: PrepareStackOptions) => - Effect.sync(() => { - options?.onProgress?.({ - workloadId: "rest:rest", - capability: "rest", - state: "downloading", - }); - return { capabilities: [] }; - }), - }); - yield* Effect.promise(() => - expect(stack.prepare({ onProgress: (status) => progress.push(status) })).resolves.toEqual({ - capabilities: [], + const stack = adaptEffectStack( + effectStack({ + followLogs: () => Stream.succeed(entry), }), ); - expect(progress).toEqual([ - { workloadId: "rest:rest", capability: "rest", state: "downloading" }, - ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("completes an empty follower immediately", () => - Effect.gen(function* () { - const stack = adaptEffectStack(effectStack()); - const logs = yield* Effect.promise(() => stack.logs()); - expect(logs.entries).toHaveLength(0); - const logsIterator = stack.followLogs()[Symbol.asyncIterator](); - yield* Effect.promise(() => - expect(logsIterator.next()).resolves.toMatchObject({ done: true }), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("rejects malformed configs asynchronously with a tagged error", () => - Effect.gen(function* () { - const stack = adaptEffectStack(effectStack()); - yield* Effect.promise(() => - expect( - stack.start({ - config: malformedConfig, - }), - ).rejects.toBeInstanceOf(InvalidStackConfigError), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("redacts nested config secrets for prepare and start", () => - Effect.gen(function* () { - let preparedConfig: StartStackOptions["config"] | undefined; - let startedConfig: StartStackOptions["config"] | undefined; - const source: EffectStack = { - ...effectStack(), - prepare: (options?: PrepareStackOptions) => - Effect.sync(() => { - preparedConfig = options?.config; - return { capabilities: [] }; - }), - start: (options?: StartStackOptions) => - Effect.sync(() => { - startedConfig = options?.config; - return status; - }), - }; - const stack = adaptEffectStack(source); - const config = { - capabilities: { - storage: { settings: { buckets: { assets: { public: false } } } }, - auth: { settings: { external: { github: { secret: "github-secret" } } } }, - functions: { - settings: { - edge_runtime: { secrets: { EDGE_TOKEN: "edge-secret" } }, - functions: { - hello: { env: { FUNCTION_TOKEN: "function-secret" }, static_files: ["index.html"] }, - }, - }, - }, - }, - }; - yield* Effect.promise(() => stack.prepare({ config })); - yield* Effect.promise(() => stack.start({ config })); - for (const value of [preparedConfig, startedConfig]) { - const auth = value?.capabilities?.auth; - const functions = value?.capabilities?.functions; - const authSettings = auth !== undefined && "settings" in auth ? auth.settings : undefined; - const functionSettings = - functions !== undefined && "settings" in functions ? functions.settings : undefined; - expect(Redacted.isRedacted(authSettings?.external?.github?.secret)).toBe(true); - expect(Redacted.isRedacted(functionSettings?.edge_runtime?.secrets?.EDGE_TOKEN)).toBe(true); - expect(Redacted.isRedacted(functionSettings?.functions?.hello?.env?.FUNCTION_TOKEN)).toBe( - true, - ); - } - if (startedConfig === undefined) - return yield* Effect.die("Expected Promise start to capture config"); - const compiled = yield* compileStack({ - projectRoot: "/tmp/promise-facade-project", - runtime: { kind: "native" }, - config: startedConfig, - }).pipe(Effect.provide(NodeServices.layer)); - expect(compiled.definition.capabilities.functions.settings.functions_root).toBe( - "/tmp/promise-facade-project/supabase/functions", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + const iterator = stack.followLogs()[Symbol.asyncIterator](); + expect(yield* Effect.promise(() => iterator.next())).toEqual({ done: false, value: entry }); + expect(yield* Effect.promise(() => iterator.next())).toEqual({ + done: true, + value: undefined, + }); + }), ); }); diff --git a/packages/stack/src/public/public-model.integration.test.ts b/packages/stack/src/public/public-model.integration.test.ts index a1cb70d6dd..021f5917a8 100644 --- a/packages/stack/src/public/public-model.integration.test.ts +++ b/packages/stack/src/public/public-model.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Schema } from "effect"; import { StackStatusSchema } from "./Status.ts"; +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; const STATUS_CAPABILITIES = [ "database", @@ -17,8 +18,8 @@ const STATUS_CAPABILITIES = [ const STATUS_FIXTURE = { id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - lifecycle: "stopped", - desiredLifecycle: "stopped", + lifecycle: "running", + desiredLifecycle: "running", runtime: { kind: "native" }, endpoints: { api: { @@ -32,13 +33,26 @@ const STATUS_FIXTURE = { database: "17", }, capabilities: STATUS_CAPABILITIES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: "lazy", state: name === "rest" ? "dormant" : "disabled", })), + instances: [ + { + id: "rest-instance", + service: "rest", + enabled: true, + intent: "started", + phase: "dormant", + activation: "lazy", + endpoints: [], + }, + ], artifacts: [ { workloadId: "rest:rest", + instanceId: "rest-instance", capability: "rest", state: "downloading", }, @@ -51,21 +65,26 @@ describe("public stack model", () => { Effect.map((status) => { expect(status.capabilities.map(({ name }) => name)).toEqual([...STATUS_CAPABILITIES]); expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); + expect(status.instances).toEqual(STATUS_FIXTURE.instances); expect(status.artifacts).toEqual([ - { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + { + workloadId: "rest:rest", + instanceId: "rest-instance", + capability: "rest", + state: "downloading", + }, ]); }), ), ); - it.effect("rejects status snapshots with missing capability entries", () => + it.effect("decodes status snapshots with destroyed defaults omitted", () => Effect.gen(function* () { - const exit = yield* Schema.decodeUnknownEffect(StackStatusSchema)({ + const status = yield* Schema.decodeUnknownEffect(StackStatusSchema)({ ...STATUS_FIXTURE, capabilities: STATUS_FIXTURE.capabilities.slice(1), - }).pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); + }); + expect(status.capabilities.some(({ name }) => name === "database")).toBe(false); }), ); @@ -79,7 +98,6 @@ describe("public stack model", () => { : capability, ), }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); }), ); diff --git a/packages/stack/src/public/reset-database.integration.test.ts b/packages/stack/src/public/reset-database.integration.test.ts deleted file mode 100644 index ae08a2b165..0000000000 --- a/packages/stack/src/public/reset-database.integration.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Promise-facade live reset uses createTestStack. -// oxlint-disable-next-line effecttsgo/node-builtin-import -- docker availability probe for optional container cases. -import { execFile as execFileCallback, spawnSync } from "node:child_process"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- native storage marker path. -import { mkdir, readFile, writeFile } from "node:fs/promises"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- native storage marker path. -import { join } from "node:path"; -import { promisify } from "node:util"; -import { PgClient } from "@effect/sql-pg"; -import { Effect, Redacted } from "effect"; -import { describe, expect, it } from "vitest"; -import { createTestStack, type TestStack } from "../testing.ts"; -import type { StackRuntimePreference } from "./Runtime.ts"; - -const RESET_TIMEOUT_MS = 180_000; -const execFile = promisify(execFileCallback); -const MARKER_TABLE = "public.stack_reset_marker"; - -const dockerAvailable = (): boolean => - spawnSync("docker", ["info"], { encoding: "utf8" }).status === 0; - -const query = async (url: string, statement: string): Promise> => - Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* PgClient.PgClient; - return yield* client.unsafe(statement); - }).pipe( - Effect.provide(PgClient.layer({ url: Redacted.make(url), connectTimeout: "10 seconds" })), - ), - ), - ); - -const volumeWorkloadIds = async (stackId: string): Promise> => { - const listed = await execFile("docker", [ - "volume", - "ls", - "-q", - "--filter", - `label=com.supabase.stack.stackId=${stackId}`, - ]); - const ids = listed.stdout - .trim() - .split("\n") - .filter((value) => value.length > 0); - if (ids.length === 0) return []; - const inspected = await execFile("docker", [ - "inspect", - "--format", - '{{index .Labels "com.supabase.stack.workloadId"}}', - ...ids, - ]); - return inspected.stdout - .trim() - .split("\n") - .filter((value) => value.length > 0); -}; - -const resetAndAssert = async (stack: TestStack, runtime: StackRuntimePreference): Promise => { - const before = await stack.status(); - const credentials = await stack.credentials(); - await query(credentials.database.url, `CREATE TABLE ${MARKER_TABLE} (id integer PRIMARY KEY)`); - const storageMarker = join(stack.stateRoot, stack.id, "data", "storage", "keep.txt"); - if (runtime.kind === "native") { - await mkdir(join(stack.stateRoot, stack.id, "data", "storage"), { recursive: true }); - await writeFile(storageMarker, "keep"); - } - const volumesBefore = runtime.kind === "container" ? await volumeWorkloadIds(stack.id) : []; - - const after = await stack.resetDatabase(); - expect(after.id).toBe(stack.id); - expect(after.endpoints).toEqual(before.endpoints); - expect(after.lifecycle).toBe("running"); - const database = after.capabilities.find((capability) => capability.name === "database"); - expect(database?.state).toBe("ready"); - - const leftover = await query( - (await stack.credentials()).database.url, - `SELECT to_regclass('${MARKER_TABLE}') AS name`, - ); - expect(leftover).toEqual([{ name: null }]); - - if (runtime.kind === "native") { - expect(await readFile(storageMarker, "utf8")).toBe("keep"); - } else { - const volumesAfter = await volumeWorkloadIds(stack.id); - expect(volumesAfter.filter((id) => id !== "database:database")).toEqual( - volumesBefore.filter((id) => id !== "database:database"), - ); - } -}; - -describe("resetDatabase", () => { - it( - "wipes native Postgres while keeping identity, ports, and storage data", - async () => { - await using stack = await createTestStack({ - runtime: { kind: "native" }, - }); - await resetAndAssert(stack, { kind: "native" }); - }, - RESET_TIMEOUT_MS, - ); - - it.skipIf(!dockerAvailable())( - "wipes container Postgres while keeping identity, ports, and non-database volumes", - async () => { - await using stack = await createTestStack({ - runtime: { kind: "container", engine: "docker" }, - }); - await resetAndAssert(stack, { kind: "container", engine: "docker" }); - }, - RESET_TIMEOUT_MS, - ); -}); diff --git a/packages/stack/src/public/service-configuration.e2e.test.ts b/packages/stack/src/public/service-configuration.e2e.test.ts new file mode 100644 index 0000000000..2f9484a5a7 --- /dev/null +++ b/packages/stack/src/public/service-configuration.e2e.test.ts @@ -0,0 +1,259 @@ +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, ManagedRuntime, Path } from "effect"; +import { afterAll, expect, test } from "vitest"; +import type { PromiseStackConfig } from "../index.ts"; +import { isolatedInstanceApi } from "../../tests/helpers/instance-api.ts"; + +const host = ManagedRuntime.make(NodeServices.layer); +afterAll(() => host.dispose()); + +const candidate = (token: string, inspectorAddress = "127.0.0.1"): PromiseStackConfig => ({ + listeners: { functionsInspector: { enabled: true, address: inspectorAddress } }, + capabilities: { + database: { enabled: false }, + auth: { enabled: false }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + functions: { + enabled: true, + settings: { + functions_root: "supabase/functions", + edge_runtime: { secrets: { CUSTOM_TOKEN: token } }, + inspector: { mode: "run" }, + functions: { hello: { enabled: true, verify_jwt: false } }, + }, + }, + }, +}); + +test( + "plans distinct stable API endpoints for projects with the same stack name", + { timeout: 120_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the public Promise API and supervisor process boundary. + async () => { + const roots = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return [ + yield* fs.makeTempDirectory({ prefix: "supabase-port-plan-first-" }), + yield* fs.makeTempDirectory({ prefix: "supabase-port-plan-second-" }), + ] as const; + }), + ); + const { createStack, openStack } = await host.runPromise(isolatedInstanceApi(roots[0])); + const first = await createStack({ + projectRoot: roots[0], + name: "shared-project-name", + runtime: { kind: "native" }, + initialConfig: {}, + }); + try { + const second = await createStack({ + projectRoot: roots[1], + name: "shared-project-name", + runtime: { kind: "native" }, + initialConfig: {}, + }); + try { + expect(second.id).not.toBe(first.id); + const firstStatus = await first.status(); + const secondStatus = await second.status(); + expect(firstStatus.endpoints.api).toBeDefined(); + expect(secondStatus.endpoints.api).toBeDefined(); + expect(secondStatus.endpoints.api?.port).not.toBe(firstStatus.endpoints.api?.port); + const reopened = await openStack(first.id); + expect((await reopened.status()).endpoints.api).toEqual(firstStatus.endpoints.api); + } finally { + await second.destroy(); + } + } finally { + await first.destroy(); + await host.runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => + Effect.forEach(roots, (root) => fs.remove(root, { recursive: true })), + ), + ); + } + }, +); + +test( + "prepares candidate defaults without changing registered configuration or dynamic instances", + { timeout: 120_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the public Promise API and supervisor process boundary. + async () => { + const root = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-instance-config-" }); + const functions = path.join(root, "supabase", "functions", "hello"); + yield* fs.makeDirectory(functions, { recursive: true }); + yield* fs.writeFileString( + path.join(functions, "index.ts"), + 'Deno.serve(() => new Response("hello"));\n', + ); + return root; + }), + ); + const { createStack, openStack } = await host.runPromise(isolatedInstanceApi(root)); + const stack = await createStack({ + projectRoot: root, + runtime: { kind: "native" }, + initialConfig: candidate("initial-secret"), + }); + const failures: unknown[] = []; + let stage = "default metadata"; + try { + const functions = await stack.services.get({ name: "functions" }); + expect(functions.service).toBe("functions"); + const before = await functions.describe(); + expect(before.effectiveConfigFingerprint).toEqual(expect.any(String)); + const unchanged = await stack.prepare({ + services: [functions.id], + config: candidate("initial-secret"), + }); + expect(unchanged.instances[0]?.effectiveConfigFingerprint).toBe( + before.effectiveConfigFingerprint, + ); + const changedSecret = await stack.prepare({ + services: [functions.id], + config: candidate("replacement-secret"), + }); + expect(changedSecret.instances[0]?.effectiveConfigFingerprint).toEqual(expect.any(String)); + expect(changedSecret.instances[0]?.effectiveConfigFingerprint).not.toBe( + before.effectiveConfigFingerprint, + ); + const changedEndpoint = await stack.prepare({ + services: [functions.id], + config: candidate("initial-secret", "127.0.0.2"), + }); + expect(changedEndpoint.instances[0]?.effectiveConfigFingerprint).not.toBe( + before.effectiveConfigFingerprint, + ); + expect(await functions.describe()).toEqual(before); + expect((await functions.status()).phase).toBe("stopped"); + + stage = "dynamic preparation"; + const dynamic = await stack.services.create({ + service: "functions", + name: "independent-functions", + config: { + settings: { + functions_root: "supabase/functions", + edge_runtime: { secrets: { CUSTOM_TOKEN: "dynamic-secret" } }, + }, + }, + }); + const dynamicBefore = await dynamic.describe(); + const preparedDynamic = await stack.prepare({ + services: [dynamic.id], + config: candidate("replacement-secret", "127.0.0.2"), + }); + expect(preparedDynamic.instances[0]?.effectiveConfigFingerprint).toBe( + dynamicBefore.effectiveConfigFingerprint, + ); + expect(await dynamic.describe()).toEqual(dynamicBefore); + expect(await stack.prepare({ services: [] })).toEqual({ instances: [] }); + + stage = "SQL endpoint metadata"; + const sqlEnabled = await stack.services.create({ + service: "database", + name: "sql-enabled", + config: { + password: "matching-bootstrap-password", + endpoints: { sql: { port: "auto" } }, + }, + }); + const sqlDisabled = await stack.services.create({ + service: "database", + name: "sql-disabled", + config: { + password: "matching-bootstrap-password", + endpoints: { sql: { enabled: false } }, + }, + }); + const enabledDescriptor = await sqlEnabled.describe(); + const disabledDescriptor = await sqlDisabled.describe(); + expect(enabledDescriptor.bootstrapInputsId).toEqual(expect.any(String)); + expect(disabledDescriptor.bootstrapInputsId).toBe(enabledDescriptor.bootstrapInputsId); + expect(disabledDescriptor.effectiveConfigFingerprint).not.toBe( + enabledDescriptor.effectiveConfigFingerprint, + ); + + stage = "catalog copy"; + const initialized = await stack.services.create({ + service: "database", + name: "catalog-source", + config: { password: "copied-profile-password" }, + initialization: { catalog: { realtime: {} } }, + }); + const copied = await stack.services.create({ + service: "database", + name: "catalog-copy", + config: { password: "copied-profile-password" }, + initialization: { from: initialized.id }, + }); + const sourceProfile = await initialized.describe(); + const copiedProfile = await copied.describe(); + expect(sourceProfile.initializationProfileId).toEqual(expect.any(String)); + expect(copiedProfile.initializationProfileId).toBe(sourceProfile.initializationProfileId); + expect(copiedProfile.bootstrapInputsId).toBe(sourceProfile.bootstrapInputsId); + expect(copiedProfile.initialization?.recipes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ service: "realtime", completed: false }), + ]), + ); + stage = "source destruction and copied metadata"; + await initialized.destroy(); + expect(await copied.describe()).toEqual(copiedProfile); + await expect( + stack.services.create({ + service: "database", + name: "invalid-catalog-copy", + config: {}, + initialization: { from: functions.id }, + }), + ).rejects.toMatchObject({ _tag: "InvalidStackConfigError" }); + expect( + (await stack.services.list()).some(({ name }) => name === "invalid-catalog-copy"), + ).toBe(false); + stage = "default destruction and reopening"; + await functions.destroy(); + stage = "reopen after default destruction"; + const reopened = await openStack(stack.id); + stage = "read destroyed default"; + await expect(reopened.services.get({ name: "functions" })).rejects.toMatchObject({ + _tag: "ServiceNotFoundError", + }); + stage = "prepare dynamic after default destruction"; + await reopened.prepare({ services: [dynamic.id], config: candidate("later-secret") }); + stage = "status after default destruction"; + expect((await reopened.status()).instances.some(({ id }) => id === functions.id)).toBe(false); + stage = "lookup retained dynamic"; + expect((await reopened.services.get({ id: dynamic.id })).id).toBe(dynamic.id); + } catch (error) { + failures.push(new Error(`Configuration stage: ${stage}`, { cause: error })); + } + stage = "whole-stack cleanup"; + try { + await stack.destroy(); + await expect(openStack(stack.id)).rejects.toMatchObject({ _tag: "StackNotFoundError" }); + } catch (error) { + failures.push(new Error(`Configuration stage: ${stage}`, { cause: error })); + } + if (failures.length > 0) + throw new AggregateError( + failures, + `Configuration verification failed; project retained at ${root}`, + ); + await host.runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(root, { recursive: true })), + ); + }, +); diff --git a/packages/stack/src/public/service-expansion.e2e.test.ts b/packages/stack/src/public/service-expansion.e2e.test.ts new file mode 100644 index 0000000000..b89431f638 --- /dev/null +++ b/packages/stack/src/public/service-expansion.e2e.test.ts @@ -0,0 +1,187 @@ +import { NodeServices } from "@effect/platform-node"; +import { + Config, + Data, + Deferred, + Effect, + Fiber, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Queue, +} from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { Socket } from "effect/unstable/socket"; +import { afterAll, expect, test } from "vitest"; +import { isolatedInstanceApi } from "../../tests/helpers/instance-api.ts"; + +const host = ManagedRuntime.make(Layer.merge(NodeServices.layer, FetchHttpClient.layer)); +afterAll(() => host.dispose()); +const selectedRuntime = Option.getOrUndefined( + Effect.runSync(Config.option(Config.string("SUPABASE_STACK_E2E_RUNTIME"))), +); +const runtimes = [{ kind: "native" }, { kind: "container", engine: "docker" }] as const; + +class ExpansionTestError extends Data.TaggedError("ExpansionTestError")<{ + readonly message: string; +}> {} + +for (const runtime of runtimes) { + test.skipIf(selectedRuntime !== undefined && selectedRuntime !== runtime.kind)( + `preserves a Functions WebSocket while whole start expands the stack in ${runtime.kind}`, + { timeout: 10 * 60_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- Exercises the public Promise API across real supervisor processes. + async () => { + const root = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-instance-expansion-" }); + const directory = path.join(root, "supabase", "functions", "echo"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "index.ts"), + `Deno.serve(async (request) => { + const query = new URL(request.url).searchParams; + if (query.has("sql")) { + const value = Deno.env.get("SUPABASE_DB_URL"); + if (!value) return new Response("missing-sql-hint", { status: 500 }); + const endpoint = new URL(value); + if (query.get("sql") === "connect") { + const connection = await Deno.connect({ hostname: endpoint.hostname, port: Number(endpoint.port) }); + try { + await connection.write(new Uint8Array([0, 0, 0, 8, 4, 210, 22, 47])); + const reply = new Uint8Array(1); + const count = await connection.read(reply); + return new Response(count === 1 ? String.fromCharCode(reply[0]) : "no-postgres-reply"); + } finally { connection.close(); } + } + return new Response(endpoint.port); + } + if (request.headers.get("upgrade")?.toLowerCase() === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => socket.send(event.data); + return response; + } + return new Response("functions-ready"); +}); +`, + ); + return root; + }), + ); + const { createStack } = await host.runPromise(isolatedInstanceApi(root)); + const stack = await createStack({ + projectRoot: root, + runtime, + initialConfig: { + capabilities: { + database: { enabled: true }, + functions: { + enabled: true, + activation: "lazy", + settings: { + functions_root: "supabase/functions", + functions: { echo: { enabled: true, verify_jwt: false } }, + }, + }, + auth: { enabled: false }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const failures: unknown[] = []; + try { + const database = await stack.services.get({ name: "database" }); + const functions = await stack.services.get({ name: "functions" }); + if (database.service !== "database" || functions.service !== "functions") + throw new Error("Default service kinds do not match their registrations"); + const plannedSql = await database.credentials(); + if (plannedSql === undefined) throw new Error("Planned SQL credentials missing"); + await functions.start(); + expect((await database.status()).phase).toBe("stopped"); + const api = (await stack.status()).endpoints.api; + if (api === undefined) throw new Error("Functions managed API missing"); + const hintPort = await host.runPromise( + HttpClient.get(new URL("/functions/v1/echo?sql=hint", api.url)).pipe( + Effect.flatMap((response) => response.text), + ), + ); + expect(hintPort).toBe(new URL(plannedSql.url).port); + expect((await database.status()).phase).toBe("stopped"); + const socketUrl = new URL("/functions/v1/echo", api.url); + socketUrl.protocol = "ws:"; + await host.runPromise( + Effect.scoped( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket(socketUrl.toString()); + const write = yield* socket.writer; + const opened = yield* Deferred.make(); + const messages = yield* Queue.unbounded(); + const reader = yield* socket + .runString((message) => Queue.offer(messages, message), { + onOpen: Deferred.succeed(opened, undefined).pipe(Effect.asVoid), + }) + .pipe(Effect.forkChild); + const disconnected = Fiber.join(reader).pipe( + Effect.andThen( + Effect.fail(new ExpansionTestError({ message: "Functions socket closed" })), + ), + ); + yield* Effect.raceFirst(Deferred.await(opened), disconnected); + const echo = (message: string) => + write(message).pipe( + Effect.andThen(Effect.raceFirst(Queue.take(messages), disconnected)), + Effect.tap((received) => Effect.sync(() => expect(received).toBe(message))), + ); + yield* echo("before-expansion"); + yield* Effect.tryPromise(() => + expect(functions.sleep()).rejects.toMatchObject({ + _tag: "StackLifecycleConflictError", + instanceId: functions.id, + }), + ); + const expanded = yield* Effect.tryPromise(() => stack.start()); + expect(expanded.instances.find(({ id }) => id === database.id)?.phase).toBe("ready"); + expect(expanded.endpoints.api?.url).toBe(api.url); + yield* echo("after-expansion"); + const postgresReply = yield* HttpClient.get( + new URL("/functions/v1/echo?sql=connect", api.url), + ).pipe( + Effect.flatMap((response) => response.text), + Effect.timeout("15 seconds"), + ); + expect(["S", "N"]).toContain(postgresReply); + const sql = yield* Effect.tryPromise(() => database.credentials()); + expect(sql?.url).toBe(plannedSql.url); + yield* Effect.tryPromise(() => database.stop()); + yield* echo("while-database-stopped"); + yield* Effect.tryPromise(() => database.start()); + yield* echo("after-database-restart"); + }), + ).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal)), + ); + } catch (error) { + failures.push(error); + } + try { + await stack.destroy(); + } catch (error) { + failures.push(error); + } + if (failures.length > 0) + throw new AggregateError(failures, `Expansion failed; project retained at ${root}`); + await host.runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(root, { recursive: true })), + ); + }, + ); +} diff --git a/packages/stack/src/public/service-instances.e2e.test.ts b/packages/stack/src/public/service-instances.e2e.test.ts new file mode 100644 index 0000000000..6b6a7d4981 --- /dev/null +++ b/packages/stack/src/public/service-instances.e2e.test.ts @@ -0,0 +1,229 @@ +import { PgClient } from "@effect/sql-pg"; +import { NodeServices } from "@effect/platform-node"; +import { Config, Data, Effect, FileSystem, ManagedRuntime, Option, Path, Redacted } from "effect"; +import { afterAll, describe, expect, test } from "vitest"; +import { isolatedInstanceApi } from "../../tests/helpers/instance-api.ts"; + +const host = ManagedRuntime.make(NodeServices.layer); +afterAll(() => host.dispose()); + +const selectedRuntime = Option.getOrUndefined( + Effect.runSync(Config.option(Config.string("SUPABASE_STACK_E2E_RUNTIME"))), +); +const runtimes = [{ kind: "native" }, { kind: "container", engine: "docker" }] as const; + +class DatabaseSmokeError extends Data.TaggedError("DatabaseSmokeError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const query = (url: string, statement: string) => + host.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + return yield* client.unsafe(statement); + }).pipe( + Effect.provide(PgClient.layer({ url: Redacted.make(url), connectTimeout: "10 seconds" })), + ), + ).pipe( + Effect.mapError( + (cause) => new DatabaseSmokeError({ message: `SQL failed: ${statement}`, cause }), + ), + ), + ); + +const assertActiveSqlBlocksSleep = ( + url: string, + sleep: () => Promise, + instanceId: string, +) => + host.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + yield* client.withTransaction( + Effect.gen(function* () { + yield* client.unsafe("SELECT 1"); + yield* Effect.tryPromise(() => + expect(sleep()).rejects.toMatchObject({ + _tag: "StackLifecycleConflictError", + instanceId, + }), + ); + }), + ); + }).pipe( + Effect.provide(PgClient.layer({ url: Redacted.make(url), connectTimeout: "10 seconds" })), + ), + ), + ); + +describe("registered database instances through the public API", () => { + for (const runtime of runtimes) { + test.skipIf(selectedRuntime !== undefined && selectedRuntime !== runtime.kind)( + `clones a stopped catalog baseline and preserves independent data in ${runtime.kind}`, + { timeout: 15 * 60_000 }, + // oxlint-disable-next-line effecttsgo/async-function -- This test consumes the public Promise API across real supervisor processes. + async () => { + const { root, snapshotPath } = await host.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-instance-snapshot-" }); + return { root, snapshotPath: path.join(root, "baseline.tar") }; + }), + ); + const { createStack, openStack } = await host.runPromise(isolatedInstanceApi(root)); + const stack = await createStack({ + projectRoot: root, + name: "instance-snapshot", + runtime, + initialConfig: { + capabilities: { + database: { enabled: false }, + auth: { enabled: false }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const failures: unknown[] = []; + try { + expect((await stack.credentials()).database).toBeUndefined(); + const initialization = { catalog: { auth: {}, storage: {}, realtime: {} } }; + const source = await stack.services.create({ + service: "database", + name: "baseline", + config: { + activation: "eager", + password: "baseline-fixture-password", + endpoints: { sql: { port: "auto" } }, + }, + initialization, + }); + const clone = await stack.services.create({ + service: "database", + name: "comparison", + config: { + activation: "eager", + password: "comparison-fixture-password", + endpoints: { sql: { port: "auto" } }, + }, + initialization, + }); + expect(source.id).not.toBe(clone.id); + expect((await source.status()).phase).toBe("stopped"); + const sourceDescriptor = await source.describe(); + const cloneDescriptor = await clone.describe(); + expect(cloneDescriptor.data).toEqual({ origin: "absent" }); + const sourceCredentials = await source.credentials(); + const cloneCredentials = await clone.credentials(); + if (sourceCredentials === undefined || cloneCredentials === undefined) + throw new Error("Registered SQL endpoints did not provide planned credentials"); + expect(new URL(sourceCredentials.url).port).not.toBe(new URL(cloneCredentials.url).port); + + const started = await source.start(); + expect(started.phase).toBe("ready"); + expect(started.endpoints.find((endpoint) => endpoint.binding === "sql")).toMatchObject({ + availability: "listening", + port: Number(new URL(sourceCredentials.url).port), + }); + expect( + await query( + sourceCredentials.url, + "SELECT nspname FROM pg_namespace WHERE nspname IN ('auth', 'storage', 'realtime') ORDER BY nspname", + ), + ).toEqual([{ nspname: "auth" }, { nspname: "realtime" }, { nspname: "storage" }]); + await query(sourceCredentials.url, "CREATE TABLE public.snapshot_marker (value text)"); + await query( + sourceCredentials.url, + "INSERT INTO public.snapshot_marker VALUES ('baseline')", + ); + await assertActiveSqlBlocksSleep(sourceCredentials.url, () => source.sleep(), source.id); + expect((await source.status()).phase).toBe("ready"); + expect((await source.sleep()).phase).toBe("dormant"); + expect( + await query(sourceCredentials.url, "SELECT value FROM public.snapshot_marker"), + ).toEqual([{ value: "baseline" }]); + expect((await source.status()).phase).toBe("ready"); + await source.stop(); + const exported = await source.exportSnapshot({ destination: snapshotPath }); + expect((await source.status()).phase).toBe("stopped"); + expect(exported.provenance.sourceInstanceId).toBe(source.id); + expect((await source.describe()).data).toEqual({ + origin: "fresh", + lineageId: exported.lineageId, + }); + expect((await source.describe()).initializationProfileId).toBe( + exported.initializationProfileId, + ); + expect(exported.artifactIdentity).toBe((await source.describe()).artifactIdentity); + expect(exported.runtimeIdentity).toBe((await source.describe()).runtimeIdentity); + await expect(source.exportSnapshot({ destination: snapshotPath })).rejects.toMatchObject({ + _tag: "SnapshotTargetInvalidError", + }); + const restored = await clone.restoreSnapshot({ source: snapshotPath }); + expect(restored).toEqual(exported); + expect((await clone.status()).phase).toBe("stopped"); + expect((await clone.describe()).data).toEqual({ origin: "restored", snapshot: exported }); + + await source.start(); + await clone.start(); + expect( + await query(cloneCredentials.url, "SELECT value FROM public.snapshot_marker"), + ).toEqual([{ value: "baseline" }]); + await query(cloneCredentials.url, "UPDATE public.snapshot_marker SET value = 'clone'"); + expect( + await query(sourceCredentials.url, "SELECT value FROM public.snapshot_marker"), + ).toEqual([{ value: "baseline" }]); + await clone.stop(); + await expect(clone.restoreSnapshot({ source: snapshotPath })).rejects.toMatchObject({ + _tag: "SnapshotTargetInvalidError", + }); + await clone.start(); + expect( + await query(cloneCredentials.url, "SELECT value FROM public.snapshot_marker"), + ).toEqual([{ value: "clone" }]); + await clone.destroy(); + expect((await stack.services.list()).some(({ id }) => id === clone.id)).toBe(false); + expect( + await query(sourceCredentials.url, "SELECT value FROM public.snapshot_marker"), + ).toEqual([{ value: "baseline" }]); + expect((await stack.status()).instances.find(({ name }) => name === "auth")?.phase).toBe( + "stopped", + ); + expect((await stack.credentials()).database).toBeUndefined(); + expect(sourceDescriptor.bootstrapRecipeId).toEqual(expect.any(String)); + expect(sourceDescriptor.bootstrapInputsId).toEqual(expect.any(String)); + expect(cloneDescriptor.bootstrapInputsId).toEqual(expect.any(String)); + expect(sourceDescriptor.bootstrapInputsId).not.toBe(cloneDescriptor.bootstrapInputsId); + expect(sourceDescriptor.initializationProfileId).toBe( + cloneDescriptor.initializationProfileId, + ); + } catch (error) { + failures.push(error); + } + try { + await stack.destroy(); + await expect(openStack(stack.id)).rejects.toMatchObject({ _tag: "StackNotFoundError" }); + } catch (error) { + throw new AggregateError( + [...failures, error], + `Instance smoke cleanup failed; retained project at ${root}`, + ); + } + await host.runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(root, { recursive: true })), + ); + if (failures.length > 0) throw failures[0]; + }, + ); + } +}); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 3d3c3bbf0f..e0f4894d48 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -1,27 +1,34 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, ConfigProvider, Data, Effect, Exit, Path, Stream } from "effect"; +import { + Cause, + ConfigProvider, + Data, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Path, + Stream, +} from "effect"; import { homedir, tmpdir } from "node:os"; -import type { PromiseStack } from "./PromiseStack.ts"; -import { createTestStackWith, type TestStackOperations } from "./Testing.ts"; -import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; +import { StackCleanupError, StackRuntimeError } from "./Errors.ts"; +import type { EffectStack } from "./EffectStack.ts"; import { StackIdSchema } from "./StackId.ts"; +import { ServiceInstanceIdSchema } from "./ServiceInstanceId.ts"; import type { StackStatus } from "./Status.ts"; -class FixtureError extends Data.TaggedError("FixtureError")<{ readonly cause: unknown }> {} -const fixturePromise = (thunk: () => A): Promise => - Effect.runPromiseExit( - Effect.try({ try: thunk, catch: (cause) => new FixtureError({ cause }) }), - ).then((exit) => { - if (Exit.isSuccess(exit)) return exit.value; - const error = Cause.squash(exit.cause); - throw error instanceof FixtureError ? error.cause : error; - }); +import { createTestStackWith, type TestStackOperations } from "./Testing.ts"; +import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; + +class FixtureError extends Data.TaggedError("FixtureError")<{ readonly cause?: unknown }> {} const stackId = StackIdSchema.make( "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", ); + const status = ( lifecycle: StackStatus["lifecycle"], includeApi = true, @@ -45,6 +52,7 @@ const status = ( : {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ + id: ServiceInstanceIdSchema.make(`${name}-instance`), name, activation: name === "functions" ? "lazy" : "eager", state: @@ -58,128 +66,120 @@ const status = ( ...(failedCapability === name ? { state: "failed", error: `${name} failed` } : {}), })), artifacts: [], + instances: [], }); -const stream = (values: ReadonlyArray): AsyncIterable => - Stream.toAsyncIterable(Stream.fromIterable(values)); + type FakeStackOptions = { readonly failStart?: boolean; readonly reachesReadiness?: boolean; readonly includeApi?: boolean; readonly functionsState?: "ready" | "dormant" | "stopping" | "stopped"; readonly failedCapability?: string; + readonly failDestroy?: boolean; }; -const fakeStack = (events: Array, options: FakeStackOptions = {}): PromiseStack => { + +const fakeStack = (events: Array, options: FakeStackOptions = {}): EffectStack => { const { failStart = false, reachesReadiness = true, includeApi = true, functionsState = "dormant", failedCapability, + failDestroy = false, } = options; + const currentStatus = () => + status(reachesReadiness ? "running" : "stopped", includeApi, functionsState, failedCapability); return { id: stackId, - status: () => - fixturePromise(() => - status( - reachesReadiness ? "running" : "stopped", - includeApi, - functionsState, - failedCapability, - ), - ), - credentials: () => - fixturePromise(() => ({ - database: { url: "postgres://test", password: "test" }, - api: { - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "service", - }, - storage: { - endpoint: "http://storage", - region: "local", - accessKeyId: "access", - secretAccessKey: "storage", - }, - })), - prepare: () => fixturePromise(() => ({ capabilities: [] })), + services: { + create: () => Effect.die("service fixture is not configured"), + get: () => Effect.die("service fixture is not configured"), + list: Effect.succeed([]), + }, + status: Effect.sync(currentStatus), + credentials: Effect.die("credentials fixture is not configured"), + prepare: () => Effect.succeed({ instances: [] }), start: () => - fixturePromise(() => { - events.push("start"); - if (failStart) throw new Error("startup failed"); - return status( - reachesReadiness ? "running" : "stopped", - includeApi, - functionsState, - failedCapability, - ); - }), - stop: () => fixturePromise(() => undefined), + failStart + ? Effect.sync(() => { + events.push("start"); + return Effect.fail(new StackRuntimeError({ message: "startup failed" })); + }).pipe(Effect.flatten) + : Effect.sync(() => { + events.push("start"); + return currentStatus(); + }), + sleep: () => Effect.sync(currentStatus), + stop: () => Effect.sync(() => status("stopped", includeApi, functionsState, failedCapability)), + restart: () => Effect.sync(currentStatus), destroy: () => - fixturePromise(() => { + Effect.sync(() => { events.push("destroy"); - if (failStart) throw new Error("destroy failed"); - }), - resetDatabase: () => - fixturePromise(() => - status( - reachesReadiness ? "running" : "stopped", - includeApi, - functionsState, - failedCapability, - ), - ), - logs: () => fixturePromise(() => ({ entries: [], cursor: { opaque: "v1_0" }, running: false })), - followLogs: () => stream([]), + return failDestroy + ? Effect.fail(new StackCleanupError({ message: "destroy failed" })) + : Effect.void; + }).pipe(Effect.flatten), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), + followLogs: (_query) => Stream.empty, + followStatus: Stream.empty, }; }; + const setupFixture = (root: string, stackOptions: FakeStackOptions = {}) => { const events: Array = []; const removed: Array = []; const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => root), + createRoot: Effect.succeed(root), createStack: (options) => - fixturePromise(() => { + Effect.sync(() => { events.push(`create:${options.projectRoot}`); return fakeStack(events, stackOptions); }), - removeRoot: (removedRoot) => - fixturePromise(() => { - removed.push(removedRoot); - }), + removeRoot: (removedRoot) => Effect.sync(() => void removed.push(removedRoot)), }; return { events, removed, operations }; }; + +const getFailure = (exit: Exit.Exit): unknown => + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + describe("test stack resource", () => { it.live("starts automatically and destroys only its owned identity", () => Effect.gen(function* () { const { events, removed, operations } = setupFixture("/tmp/stack-test-owned"); - const stack = yield* Effect.promise(() => createTestStackWith({}, operations)); - yield* Effect.promise(() => stack[Symbol.asyncDispose]()); + yield* Effect.scoped( + Effect.acquireUseRelease( + createTestStackWith({}, operations), + () => Effect.void, + (stack) => stack.destroy(), + ), + ); expect(events).toEqual(["create:/tmp/stack-test-owned", "start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-owned"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("preserves startup failure while retaining the root when destroy fails", () => Effect.gen(function* () { const { events, removed, operations } = setupFixture("/tmp/stack-test-failed", { failStart: true, + failDestroy: true, + }); + const result = yield* Effect.exit(createTestStackWith({}, operations)); + const failure = getFailure(result); + expect(failure).toBeInstanceOf(Error); + expect(failure).toMatchObject({ + message: expect.stringContaining("retained test stack root"), }); - yield* Effect.promise(() => - expect(createTestStackWith({}, operations)).rejects.toThrow( - /startup failed[\s\S]*retained test stack root \/tmp\/stack-test-failed/, - ), - ); expect(events).toEqual(["create:/tmp/stack-test-failed", "start", "destroy"]); expect(removed).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("includes bounded startup diagnostics before cleanup removes a failed stack", () => Effect.gen(function* () { const events: Array = []; const removed: Array = []; - const logQueries: Array[0]> = []; const entries = Array.from({ length: 51 }, (_, index) => ({ cursor: { opaque: `v1_${(index + 1).toString(36)}` }, timestamp: "2026-01-01T00:00:00.000Z", @@ -188,143 +188,47 @@ describe("test stack resource", () => { message: index === 50 ? "pooler stderr" : `old-${index}`, })); const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-diagnostics"), + createRoot: Effect.succeed("/tmp/stack-test-diagnostics"), createStack: () => - fixturePromise(() => ({ + Effect.succeed({ ...fakeStack(events), start: () => - fixturePromise(() => { + Effect.sync(() => { events.push("start"); - throw new Error("startup failed"); - }), - status: () => fixturePromise(() => status("starting")), - logs: (query) => - fixturePromise(() => { - logQueries.push(query); - return { entries, cursor: { opaque: "v1_1" }, running: false }; - }), - })), - removeRoot: (root) => - fixturePromise(() => { - removed.push(root); + return Effect.fail(new StackRuntimeError({ message: "startup failed" })); + }).pipe(Effect.flatten), + status: Effect.succeed(status("starting")), + logs: () => Effect.succeed({ entries, cursor: { opaque: "v1_1" }, running: false }), }), + removeRoot: (root) => Effect.sync(() => void removed.push(root)), }; - const failure: unknown = yield* Effect.promise(() => - createTestStackWith({}, operations).then( - () => undefined, - (error: unknown) => error, - ), - ); - expect(failure).toBeInstanceOf(Error); - if (!(failure instanceof Error)) return yield* Effect.die("expected startup failure"); - expect(failure.message).toContain("startup failed"); - expect(failure.message).toContain("pooler stderr"); - expect(failure.message).not.toContain("old-0"); - expect(failure.cause).toEqual(expect.objectContaining({ message: "startup failed" })); - expect(logQueries).toEqual([{ tail: 50 }]); + const result = yield* Effect.exit(createTestStackWith({}, operations)); + const failure = getFailure(result); + expect(failure).toMatchObject({ message: expect.stringContaining("pooler stderr") }); + expect(failure).toMatchObject({ message: expect.not.stringContaining("old-0") }); expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-diagnostics"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("fails and cleans up when start returns before the stack is ready", () => - Effect.gen(function* () { - const events: Array = []; - const removed: Array = []; - const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-unready"), - createStack: () => - fixturePromise(() => ({ - ...fakeStack(events), - start: () => - fixturePromise(() => { - events.push("start"); - return status("starting"); - }), - })), - removeRoot: (root) => - fixturePromise(() => { - removed.push(root); - }), - }; - yield* Effect.promise(() => - expect( - createTestStackWith({ config: { capabilities: { database: {} } } }, operations), - ).rejects.toThrow("Stack did not become ready after start (lifecycle starting)"), - ); - expect(events).toEqual(["start", "destroy"]); - expect(removed).toEqual(["/tmp/stack-test-unready"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("removes the exact root after disposal", () => - Effect.gen(function* () { - const events: Array = []; - const removed: Array = []; - const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-close-failed"), - createStack: () => fixturePromise(() => fakeStack(events)), - removeRoot: (root) => - fixturePromise(() => { - removed.push(root); - }), - }; - const stack = yield* Effect.promise(() => createTestStackWith({}, operations)); - yield* Effect.promise(() => expect(stack[Symbol.asyncDispose]()).resolves.toBeUndefined()); - expect(events).toEqual(["start", "destroy"]); - expect(removed).toEqual(["/tmp/stack-test-close-failed"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.live("does not require disabled Functions or an unconfigured API listener", () => - Effect.gen(function* () { - const events: Array = []; - const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-disabled-surfaces"), - createStack: () => - fixturePromise(() => fakeStack(events, { includeApi: false, functionsState: "stopped" })), - removeRoot: () => fixturePromise(() => undefined), - }; - const stack = yield* Effect.promise(() => - createTestStackWith( - { config: { capabilities: { functions: { enabled: false } } } }, - operations, - ), - ); - yield* Effect.promise(() => stack[Symbol.asyncDispose]()); - expect(events).toEqual(["start", "destroy"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + it.live("runs setupProject after creating the root and before creating the stack", () => Effect.gen(function* () { const events: Array = []; const operations: TestStackOperations = { - createRoot: () => - fixturePromise(() => { - events.push("root"); - return "/tmp/stack-test-setup"; - }), + createRoot: Effect.succeed("/tmp/stack-test-setup"), createStack: (options) => - fixturePromise(() => { + Effect.sync(() => { events.push(`create:${options.projectRoot}`); return fakeStack(events); }), - removeRoot: () => - fixturePromise(() => { - events.push("remove"); - }), + removeRoot: () => Effect.sync(() => void events.push("remove")), }; - const stack = yield* Effect.promise(() => - createTestStackWith( - { - setupProject: (root) => - fixturePromise(() => { - events.push(`setup:${root}`); - }), - }, - operations, - ), + const stack = yield* createTestStackWith( + { setupProject: (root) => Effect.sync(() => void events.push(`setup:${root}`)) }, + operations, ); - yield* Effect.promise(() => stack[Symbol.asyncDispose]()); + yield* stack.destroy(); expect(events).toEqual([ - "root", "setup:/tmp/stack-test-setup", "create:/tmp/stack-test-setup", "start", @@ -333,32 +237,30 @@ describe("test stack resource", () => { ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("uses the managed runtime state root without mutating process environment", () => Effect.gen(function* () { - const events: Array = []; const originalEnvironment = yield* defaultRuntimeEnvironment; - let environment: Parameters>[1] | undefined; + let environment: Parameters>[1]; const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-isolated-state"), + createRoot: Effect.succeed("/tmp/stack-test-isolated-state"), createStack: (_options, runtimeEnvironment) => - fixturePromise(() => { + Effect.sync(() => { environment = runtimeEnvironment; - events.push("create"); - return fakeStack(events); + return fakeStack([]); }), - removeRoot: () => fixturePromise(() => undefined), + removeRoot: () => Effect.void, }; - const stack = yield* Effect.promise(() => createTestStackWith({}, operations)); - yield* Effect.promise(() => stack[Symbol.asyncDispose]()); - expect(environment?.stateRoot).toBe((yield* defaultRuntimeEnvironment).stateRoot); + const stack = yield* createTestStackWith({}, operations); + yield* stack.destroy(); + expect(environment?.stateRoot).toBe(originalEnvironment.stateRoot); expect(environment?.artifactCacheRoot).toBe( (yield* Path.Path).join(tmpdir(), "supabase-stack-test-artifacts"), ); - expect(environment?.artifactCacheRoot).not.toContain("stack-test-isolated-state"); - // Compare against the snapshot above to prove no global environment mutation occurred. expect(yield* defaultRuntimeEnvironment).toEqual(originalEnvironment); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("falls back to the OS home when HOME is unavailable", () => Effect.gen(function* () { const environment = yield* defaultRuntimeEnvironment.pipe( @@ -367,229 +269,241 @@ describe("test stack resource", () => { expect(environment.stateRoot).toBe(`${homedir()}/.supabase/managed/stacks`); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("removes the exact root when setupProject fails", () => Effect.gen(function* () { - const events: Array = []; let created = false; + const removed: Array = []; const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-setup-failed"), + createRoot: Effect.succeed("/tmp/stack-test-setup-failed"), createStack: () => - fixturePromise(() => { + Effect.sync(() => { created = true; - return fakeStack(events); - }), - removeRoot: (root) => - fixturePromise(() => { - events.push(`remove:${root}`); + return fakeStack([]); }), + removeRoot: (root) => Effect.sync(() => void removed.push(root)), }; - yield* Effect.promise(() => - expect( - createTestStackWith( - { - setupProject: () => - fixturePromise(() => { - throw new Error("project setup failed"); - }), - }, - operations, - ), - ).rejects.toThrow("project setup failed"), + const result = yield* Effect.exit( + createTestStackWith( + { setupProject: () => Effect.fail(new FixtureError({ cause: "project setup failed" })) }, + operations, + ), ); + expect(getFailure(result)).toMatchObject({ + message: expect.stringContaining("project setup failed"), + }); expect(created).toBe(false); - expect(events).toEqual(["remove:/tmp/stack-test-setup-failed"]); + expect(removed).toEqual(["/tmp/stack-test-setup-failed"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("rejects readiness when a configured capability fails", () => + + it.live("allows lazy Functions to stop during a running stack", () => Effect.gen(function* () { - const events: Array = []; - const removed: Array = []; - const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-capability-failed"), - createStack: () => fixturePromise(() => fakeStack(events, { failedCapability: "auth" })), - removeRoot: (root) => - fixturePromise(() => { - removed.push(root); - }), - }; - yield* Effect.promise(() => - expect(createTestStackWith({}, operations)).rejects.toThrow("auth failed"), + const { events, operations } = setupFixture("/tmp/stack-test-lazy-stopping", { + functionsState: "stopping", + }); + const stack = yield* createTestStackWith( + { config: { capabilities: { functions: {} } } }, + operations, ); - expect(events).toEqual(["start", "destroy"]); - expect(removed).toEqual(["/tmp/stack-test-capability-failed"]); + yield* stack.destroy(); + expect(events).toEqual(["create:/tmp/stack-test-lazy-stopping", "start", "destroy"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("rejects readiness when the lifecycle stops before becoming ready", () => + + it.live("rejects a stack that stops before becoming ready", () => Effect.gen(function* () { - const events: Array = []; - const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-lifecycle-stopped"), - createStack: () => - fixturePromise(() => ({ - ...fakeStack(events), - start: () => - fixturePromise(() => { - events.push("start"); - return status("stopped"); - }), - })), - removeRoot: () => fixturePromise(() => undefined), - }; - yield* Effect.promise(() => - expect(createTestStackWith({}, operations)).rejects.toThrow("stopped"), + const { events, removed, operations } = setupFixture("/tmp/stack-test-unready", { + reachesReadiness: false, + }); + const result = yield* Effect.exit( + createTestStackWith({ config: { capabilities: { database: {} } } }, operations), ); - expect(events).toEqual(["start", "destroy"]); + expect(getFailure(result)).toMatchObject({ + message: expect.stringContaining("lifecycle stopped"), + }); + expect(events).toEqual(["create:/tmp/stack-test-unready", "start", "destroy"]); + expect(removed).toEqual(["/tmp/stack-test-unready"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("rejects readiness when the lifecycle starts stopping before becoming ready", () => + + it.live("accepts disabled Functions without an API listener", () => + Effect.gen(function* () { + const { events, removed, operations } = setupFixture("/tmp/stack-test-disabled", { + includeApi: false, + functionsState: "stopped", + }); + const stack = yield* createTestStackWith( + { config: { capabilities: { functions: { enabled: false } } } }, + operations, + ); + yield* stack.destroy(); + expect(events).toEqual(["create:/tmp/stack-test-disabled", "start", "destroy"]); + expect(removed).toEqual(["/tmp/stack-test-disabled"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("reports a failed capability with diagnostics and removes its root", () => + Effect.gen(function* () { + const { events, removed, operations } = setupFixture("/tmp/stack-test-capability-failed", { + failedCapability: "auth", + }); + const result = yield* Effect.exit(createTestStackWith({}, operations)); + expect(getFailure(result)).toMatchObject({ message: expect.stringContaining("auth failed") }); + expect(events).toEqual(["create:/tmp/stack-test-capability-failed", "start", "destroy"]); + expect(removed).toEqual(["/tmp/stack-test-capability-failed"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("rejects a lifecycle that starts stopping before becoming ready", () => Effect.gen(function* () { const events: Array = []; + const removed: Array = []; const operations: TestStackOperations = { - createRoot: () => fixturePromise(() => "/tmp/stack-test-lifecycle-stopping"), + createRoot: Effect.succeed("/tmp/stack-test-stopping"), createStack: () => - fixturePromise(() => ({ + Effect.succeed({ ...fakeStack(events), start: () => - fixturePromise(() => { + Effect.sync(() => { events.push("start"); return status("stopping"); }), - })), - removeRoot: () => fixturePromise(() => undefined), + }), + removeRoot: (root) => Effect.sync(() => void removed.push(root)), }; - yield* Effect.promise(() => - expect(createTestStackWith({}, operations)).rejects.toThrow("stopping"), - ); + const result = yield* Effect.exit(createTestStackWith({}, operations)); + expect(getFailure(result)).toMatchObject({ + message: expect.stringContaining("lifecycle stopping"), + }); expect(events).toEqual(["start", "destroy"]); + expect(removed).toEqual(["/tmp/stack-test-stopping"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("accepts a lazy capability that is stopping during a running stack", () => + + it.live("keeps the project root for selected service destruction", () => Effect.gen(function* () { - const { events, operations } = setupFixture("/tmp/stack-test-lazy-stopping", { - functionsState: "stopping", - }); - const stack = yield* Effect.promise(() => - createTestStackWith({ config: { capabilities: { functions: {} } } }, operations), + const { removed, operations } = setupFixture("/tmp/stack-test-selected-destroy"); + const stack = yield* createTestStackWith({}, operations); + yield* stack.destroy({ services: [] }); + expect(removed).toEqual([]); + yield* stack.destroy(); + expect(removed).toEqual(["/tmp/stack-test-selected-destroy"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("creates automatic roots below managed state and removes that exact root", () => + Effect.gen(function* () { + let createdRoot: string | undefined; + let setupRoot: string | undefined; + const stack = yield* createTestStackWith( + { + setupProject: (root) => Effect.sync(() => void (setupRoot = root)), + }, + { + createStack: (options) => + Effect.sync(() => { + createdRoot = options.projectRoot; + return fakeStack([]); + }), + removeRoot: (root) => + Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.remove(root, { recursive: true, force: true }), + ).pipe(Effect.provide(NodeServices.layer)), + }, ); - yield* Effect.promise(() => stack[Symbol.asyncDispose]()); - expect(events).toEqual(["create:/tmp/stack-test-lazy-stopping", "start", "destroy"]); + const managedRoot = (yield* defaultRuntimeEnvironment).stateRoot; + const path = yield* Path.Path; + const projectsRoot = path.join(path.dirname(managedRoot), "test-projects"); + expect(setupRoot).toBe(createdRoot); + expect(createdRoot?.startsWith(`${projectsRoot}${path.sep}`)).toBe(true); + yield* stack.destroy(); + expect(createdRoot).toBeDefined(); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(createdRoot ?? "")).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("uses the managed state root while test stacks overlap", () => + + it.live("cleans up overlapping stacks independently", () => Effect.gen(function* () { const roots = ["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]; - const environments: Array>[1]> = - []; - const removedRoots: Array = []; + const removed: Array = []; const operations: TestStackOperations = { - createRoot: () => - fixturePromise(() => { - const root = roots.shift(); - if (root === undefined) throw new Error("No test root available"); - return root; - }), - createStack: (_options, environment) => - fixturePromise(() => { - environments.push(environment); - return fakeStack([]); - }), - removeRoot: (root) => - fixturePromise(() => { - removedRoots.push(root); - }), + createRoot: Effect.suspend(() => { + const root = roots.shift(); + return root === undefined + ? Effect.fail(new FixtureError({ cause: "no test root" })) + : Effect.succeed(root); + }), + createStack: () => Effect.succeed(fakeStack([])), + removeRoot: (root) => Effect.sync(() => void removed.push(root)), }; - const [first, second] = yield* Effect.promise(() => - Promise.all([createTestStackWith({}, operations), createTestStackWith({}, operations)]), + const stacks = yield* Effect.all( + [createTestStackWith({}, operations), createTestStackWith({}, operations)], + { concurrency: 2 }, ); - expect(environments.map((environment) => environment?.stateRoot)).toEqual([ - (yield* defaultRuntimeEnvironment).stateRoot, - (yield* defaultRuntimeEnvironment).stateRoot, - ]); - yield* Effect.promise(() => first[Symbol.asyncDispose]()); - yield* Effect.promise(() => second[Symbol.asyncDispose]()); - expect(removedRoots).toEqual(["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]); + yield* Effect.all( + stacks.map((stack) => stack.destroy()), + { concurrency: 2 }, + ); + expect(removed).toEqual(["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("creates auto roots under the managed state root and cleans up the exact root", () => + + it.live("removes the exact root when acquisition is interrupted", () => Effect.gen(function* () { - let setupRoot: string | undefined; - let createdRoot: string | undefined; - let removedRoot: string | undefined; - const stack = yield* Effect.promise(() => - createTestStackWith( - { - setupProject: (root) => - fixturePromise(() => { - setupRoot = root; - }), - }, - { - createStack: (options) => - fixturePromise(() => { - createdRoot = options.projectRoot; - return fakeStack([]); - }), - removeRoot: (root) => - fixturePromise(() => { - removedRoot = root; + const entered = yield* Deferred.make(); + const events: Array = []; + const removed: Array = []; + const operations: TestStackOperations = { + createRoot: Effect.succeed("/tmp/stack-test-interrupted"), + createStack: () => + Effect.succeed({ + ...fakeStack(events), + start: () => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; }), - }, - ), - ); - const managedRoot = (yield* defaultRuntimeEnvironment).stateRoot; - const { join, dirname, sep } = yield* Path.Path; - const projectsRoot = join(dirname(managedRoot), "test-projects"); - expect(setupRoot).toBe(createdRoot); - expect(createdRoot?.startsWith(`${projectsRoot}${sep}`)).toBe(true); - if (!managedRoot.startsWith(`${tmpdir()}${sep}`)) - expect(createdRoot?.startsWith(`${tmpdir()}${sep}`)).toBe(false); - yield* Effect.promise(() => stack[Symbol.asyncDispose]()); - expect(removedRoot).toBe(createdRoot); + }), + removeRoot: (root) => Effect.sync(() => void removed.push(root)), + }; + const fiber = yield* Effect.forkChild(createTestStackWith({}, operations)); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + const result = yield* Fiber.await(fiber); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) expect(Cause.hasInterrupts(result.cause)).toBe(true); + expect(events).toEqual(["destroy"]); + expect(removed).toEqual(["/tmp/stack-test-interrupted"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("retains the project root and managed state when one stack destroy fails", () => + + it.live("retains the root when an explicit destroy fails and permits retry", () => Effect.gen(function* () { - const roots = ["/tmp/stack-test-retained-a", "/tmp/stack-test-retained-b"]; - const removedRoots: Array = []; - const environments: Array>[1]> = - []; + let failDestroy = true; + const { removed, operations: baseOperations } = setupFixture("/tmp/stack-test-retained"); const operations: TestStackOperations = { - createRoot: () => - fixturePromise(() => { - const root = roots.shift(); - if (root === undefined) throw new Error("No test root available"); - return root; - }), - createStack: (options, environment) => - fixturePromise(() => { - environments.push(environment); + ...baseOperations, + createStack: () => + Effect.sync(() => { + const resource = fakeStack([], { failDestroy: false }); return { - ...fakeStack([]), + ...resource, destroy: () => - fixturePromise(() => { - if (options.projectRoot.endsWith("-a")) throw new Error("destroy a failed"); - }), + failDestroy + ? Effect.fail(new StackCleanupError({ message: "destroy failed" })) + : Effect.void, }; }), - removeRoot: (root) => - fixturePromise(() => { - removedRoots.push(root); - }), }; - const [first, second] = yield* Effect.promise(() => - Promise.all([createTestStackWith({}, operations), createTestStackWith({}, operations)]), - ); - yield* Effect.promise(() => - expect(first[Symbol.asyncDispose]()).rejects.toThrow( - "destroy a failed; retained test stack root /tmp/stack-test-retained-a", - ), - ); - yield* Effect.promise(() => second[Symbol.asyncDispose]()); - expect(removedRoots).toEqual(["/tmp/stack-test-retained-b"]); - expect(environments.map((environment) => environment?.stateRoot)).toEqual([ - (yield* defaultRuntimeEnvironment).stateRoot, - (yield* defaultRuntimeEnvironment).stateRoot, - ]); + const stack = yield* createTestStackWith({}, operations); + const first = yield* Effect.exit(stack.destroy()); + expect(getFailure(first)).toMatchObject({ message: "destroy failed" }); + expect(removed).toEqual([]); + failDestroy = false; + yield* stack.destroy(); + expect(removed).toEqual(["/tmp/stack-test-retained"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index e0dada31b8..f7cb1b0912 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -30,9 +30,14 @@ import { tmpdir } from "node:os"; import { WebSocket } from "ws"; import { afterAll, describe, expect, test } from "vitest"; -import { createStack, listStacks, type PromiseStack } from "../index.ts"; +import { + createStack, + createTestStack, + listStacks, + type PromiseStack, + type PromiseTestStack, +} from "../index.ts"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; -import { createTestStack, type TestStack } from "../testing.ts"; import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import type { PromiseStackCredentials } from "./Credentials.ts"; @@ -286,17 +291,33 @@ const ownedWorkloadIds = ( const expectOwnedWorkloads = ( mode: (typeof RUNTIME_CASES)[number], - stackId: string, - expected: ReadonlyArray, + stack: Pick | string, + expectedRecipes: ReadonlyArray, ): Effect.Effect< void, E2ERequestError | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner > => - ownedWorkloadIds(mode, stackId).pipe( - Effect.tap((actual) => Effect.sync(() => expect(actual).toEqual([...expected].sort()))), - Effect.asVoid, - ); + Effect.gen(function* () { + const stackId = typeof stack === "string" ? stack : stack.id; + const instances = + typeof stack === "string" || expectedRecipes.length === 0 + ? [] + : yield* Effect.tryPromise({ + try: () => stack.services.list(), + catch: (cause) => + new E2ERequestError({ message: "Unable to list stack service instances", cause }), + }); + const expected = yield* Effect.forEach(expectedRecipes, (recipe) => { + const [name, workload] = recipe.split(":"); + const instance = instances.find((entry) => entry.name === name && entry.service === name); + return instance === undefined || workload === undefined + ? Effect.fail(new E2ERequestError({ message: `No default instance for ${recipe}` })) + : Effect.succeed(`${instance.id}:${workload}`); + }); + const actual = yield* ownedWorkloadIds(mode, stackId); + expect(actual).toEqual(expected.sort()); + }); const supervisorPids = ( stackId: string, @@ -430,6 +451,11 @@ const endpoint = (status: StackStatus, name: keyof StackStatus["endpoints"]): St return value; }; +const databaseCredentials = (credentials: PromiseStackCredentials) => { + if (credentials.database === undefined) throw new Error("Expected default database credentials"); + return credentials.database; +}; + const capabilityState = (status: StackStatus, name: string): string | undefined => status.capabilities.find((capability) => capability.name === name)?.state; @@ -438,7 +464,7 @@ const expectDefaultLazyState = (status: StackStatus): void => { expect(capabilityState(status, "database")).toBe("ready"); expect(status.artifacts).toContainEqual( expect.objectContaining({ - workloadId: "database:database", + instanceId: status.instances.find(({ name }) => name === "database")?.id, capability: "database", state: "ready", }), @@ -449,9 +475,9 @@ const expectDefaultLazyState = (status: StackStatus): void => { } }; /** Wait for one capability transition while subscribing before sending traffic. */ -// oxlint-disable-next-line effecttsgo/async-function -- TestStack status and action use the public Promise contract. +// oxlint-disable-next-line effecttsgo/async-function -- PromiseTestStack status and action use the public Promise contract. const activate = async ( - stack: TestStack, + stack: PromiseTestStack, name: string, action: () => Promise, ): Promise => { @@ -737,7 +763,7 @@ const waitForSocketClose = (socket: Socket | WebSocket): Effect.Effect, + stack: Pick, iterator: AsyncIterator, predicate: (entry: StackLogEntry) => boolean, ): Effect.Effect => { @@ -764,7 +790,7 @@ const waitForLogEntry = ( }), Effect.catchCause((cause) => Effect.tryPromise({ - // oxlint-disable-next-line effecttsgo/async-function -- Diagnostics read the public TestStack Promise API. + // oxlint-disable-next-line effecttsgo/async-function -- Diagnostics read the public PromiseTestStack Promise API. try: async () => { const [status, logs] = await Promise.all([stack.status(), stack.logs({ tail: 20 })]); const rest = status.capabilities.find(({ name }) => name === "rest"); @@ -810,7 +836,7 @@ const expectEndpointsRefused = (endpoints: ReadonlyArray): Effect ); const expectRuntimeInputsAbsent = ( - stack: Pick, + stack: Pick, ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -836,9 +862,9 @@ const expectRuntimeInputsAbsent = ( } }); -// oxlint-disable-next-line effecttsgo/async-function -- diagnostics consume the public TestStack Promise API. +// oxlint-disable-next-line effecttsgo/async-function -- diagnostics consume the public PromiseTestStack Promise API. const throwCapabilityDiagnostics = async ( - stack: TestStack, + stack: PromiseTestStack, capabilityName: string, operation: string, cause: unknown, @@ -1157,7 +1183,7 @@ type WholeStackMarkers = Readonly<{ }>; type WholeStackScenario = Readonly<{ mode: RuntimeCase; - stack: TestStack; + stack: PromiseTestStack; identity: string; projectRoot: string; table: string; @@ -1179,19 +1205,19 @@ const arrangeWholeStackDatabase = ( const { credentials, markers, table } = scenario; return Effect.gen(function* () { yield* databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `CREATE TABLE public."${table}" (id integer PRIMARY KEY, payload text NOT NULL)`, ); yield* databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `GRANT SELECT, INSERT, UPDATE, DELETE ON public."${table}" TO anon, authenticated, service_role`, ); yield* databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `ALTER PUBLICATION supabase_realtime ADD TABLE public."${table}"`, ); const directRows = yield* databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `INSERT INTO public."${table}" (id, payload) VALUES (1, $1) RETURNING id, payload`, [markers.first], ); @@ -1200,7 +1226,7 @@ const arrangeWholeStackDatabase = ( }; // oxlint-disable-next-line effecttsgo/async-function -- this verifies the public async log iterator contract. -const verifyWholeStackDatabaseLogs = async (stack: TestStack): Promise => { +const verifyWholeStackDatabaseLogs = async (stack: PromiseTestStack): Promise => { expect((await stack.logs({ capabilities: ["database"], tail: 1 })).entries).not.toHaveLength(0); const logIterator = stack .followLogs({ capabilities: ["database"], tail: 1 }) @@ -1214,7 +1240,7 @@ const verifyWholeStackDatabaseLogs = async (stack: TestStack): Promise => } }; -// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public TestStack Promise contract. +// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public PromiseTestStack Promise contract. const exerciseWholeStackRestAndAuth = async ( scenario: WholeStackScenario, ): Promise<{ @@ -1260,7 +1286,7 @@ const exerciseWholeStackRestAndAuth = async ( return { restPath, accessToken }; }; -// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public TestStack Promise contract. +// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public PromiseTestStack Promise contract. const exerciseWholeStackRealtime = async ( scenario: WholeStackScenario, accessToken: string, @@ -1339,7 +1365,7 @@ const exerciseWholeStackRealtime = async ( } }; -// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public TestStack Promise contract. +// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public PromiseTestStack Promise contract. const exerciseWholeStackStorage = async (scenario: WholeStackScenario): Promise => { const { api, bucket, credentials, stack } = scenario; await activate(stack, "storage", () => @@ -1367,7 +1393,7 @@ const exerciseWholeStackStorage = async (scenario: WholeStackScenario): Promise< ); }; -// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes public TestStack Promise and async log iterator contracts. +// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes public PromiseTestStack Promise and async log iterator contracts. const exerciseWholeStackFunctions = async (scenario: WholeStackScenario): Promise => { const { api, credentials, functionSlug, markers, projectRoot, stack, table } = scenario; const functionPath = `/functions/v1/${functionSlug}`; @@ -1463,7 +1489,7 @@ const exerciseWholeStackFunctions = async (scenario: WholeStackScenario): Promis } }; -// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public TestStack Promise contract. +// oxlint-disable-next-line effecttsgo/async-function -- this scenario consumes the public PromiseTestStack Promise contract. const exerciseWholeStackAuxiliary = async (scenario: WholeStackScenario): Promise => { const { api, credentials, mailUi, pooler, stack, studio } = scenario; await activate(stack, "mail", () => runNode(request(mailUi.url, "/api/v1/messages?limit=100"))); @@ -1480,7 +1506,7 @@ const exerciseWholeStackAuxiliary = async (scenario: WholeStackScenario): Promis } catch (cause) { await throwCapabilityDiagnostics(stack, "studio", "Studio profile request", cause); } - const poolerUrl = new URL(credentials.database.url); + const poolerUrl = new URL(databaseCredentials(credentials).url); poolerUrl.port = String(pooler.port); poolerUrl.username = "postgres.pooler-dev"; const poolerRows = await activate(stack, "pooler", () => @@ -1507,16 +1533,19 @@ const assertWholeStackReady = ( expect(status.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), ); - for (const workloadId of ["studio:pgmeta", "studio:studio"] as const) { + const studio = status.instances.find(({ name }) => name === "studio"); + expect(studio).toBeDefined(); + for (const workload of ["pgmeta", "studio"]) { + const workloadId = `${studio?.id}:${workload}`; expect(status.artifacts).toContainEqual( expect.objectContaining({ workloadId, capability: "studio", state: "ready" }), ); } }); - yield* expectOwnedWorkloads(scenario.mode, scenario.stack.id, BASE_WORKLOAD_IDS); + yield* expectOwnedWorkloads(scenario.mode, scenario.stack, BASE_WORKLOAD_IDS); }); -// oxlint-disable-next-line effecttsgo/async-function -- this helper consumes the public TestStack Promise contract. +// oxlint-disable-next-line effecttsgo/async-function -- this helper consumes the public PromiseTestStack Promise contract. const reactivateWholeStackCapabilities = async ( scenario: WholeStackScenario, restPath: string, @@ -1563,7 +1592,7 @@ const reactivateWholeStackCapabilities = async ( ); }; -// oxlint-disable-next-line effecttsgo/async-function -- restart flow consumes the public TestStack Promise API. +// oxlint-disable-next-line effecttsgo/async-function -- restart flow consumes the public PromiseTestStack Promise API. const restartWholeStackFromPersistedData = async ( scenario: WholeStackScenario, endpointSnapshot: Readonly>, @@ -1574,7 +1603,7 @@ const restartWholeStackFromPersistedData = async ( const { credentials, markers, mode, stack, table } = scenario; const restarted = await stack.start(); expectDefaultLazyState(restarted); - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database"])); expect( Object.fromEntries( Object.entries(restarted.endpoints).map(([name, value]) => [name, value?.port]), @@ -1582,7 +1611,10 @@ const restartWholeStackFromPersistedData = async ( ).toEqual(endpointSnapshot); expect( await runNode( - databaseQuery(credentials.database.url, `SELECT payload FROM public."${table}" WHERE id = 1`), + databaseQuery( + databaseCredentials(credentials).url, + `SELECT payload FROM public."${table}" WHERE id = 1`, + ), ), ).toEqual([{ payload: markers.first }]); await reactivateWholeStackCapabilities(scenario, restPath, functionPath, poolerUrl); @@ -1602,7 +1634,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom live: `live-${identity}`, }; let projectRoot = ""; - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-e2e-${identity}`, runtime: mode.runtime, config: { @@ -1629,15 +1661,16 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(initialRunning.runtime).toEqual(mode.runtime); expect(projectRoot.length).toBeGreaterThan(0); expectDefaultLazyState(initialRunning); - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database"])); // Preparation is an explicit cache-only operation. It runs after the helper's // initial session is stopped, so the test proves it creates no owner, listener, // or workload. const initialSupervisorPid = await runNode(supervisorPid(stack.id)); await stack.stop(); - const warmed = await stack.prepare({ capabilities: ["rest"] }); - expect(warmed.capabilities).toEqual( - expect.arrayContaining([expect.objectContaining({ capability: "rest" })]), + const rest = await stack.services.get({ name: "rest" }); + const warmed = await stack.prepare({ services: [rest.id] }); + expect(warmed.instances).toEqual( + expect.arrayContaining([expect.objectContaining({ id: rest.id, service: "rest" })]), ); expect((await stack.status()).lifecycle).toBe("stopped"); await runNode( @@ -1653,7 +1686,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(await runNode(supervisorPids(stack.id))).toHaveLength(0); const initial = await stack.start(); expectDefaultLazyState(initial); - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database"])); const credentials = await stack.credentials(); const api = endpoint(initial, "api"); const pooler = endpoint(initial, "pooler"); @@ -1698,7 +1731,10 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom Object.entries(ready.endpoints).map(([name, value]) => [name, value?.port]), ); const persistedMarker = await runNode( - databaseQuery(credentials.database.url, `SELECT payload FROM public."${table}" WHERE id = 1`), + databaseQuery( + databaseCredentials(credentials).url, + `SELECT payload FROM public."${table}" WHERE id = 1`, + ), ); expect(persistedMarker).toEqual([{ payload: markers.first }]); const volumesBeforeStop = @@ -1735,7 +1771,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); expect(stopped.artifacts).toEqual([]); - await runNode(expectOwnedWorkloads(mode, stack.id, [])); + await runNode(expectOwnedWorkloads(mode, stack, [])); const retainedLogs: StackLogEntry[] = (await stack.logs()).entries.slice(); expect(retainedLogs.length).toBeGreaterThan(0); await runNode(expectRuntimeInputsAbsent(stack)); @@ -1763,8 +1799,8 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const final = await stack.status(); await runNode(assertWholeStackReady(scenario, final)); await stack.stop(); - await runNode(expectOwnedWorkloads(mode, stack.id, [])); - const reconfigured = await stack.start({ + await runNode(expectOwnedWorkloads(mode, stack, [])); + const reconfigured = await stack.restart({ config: { capabilities: { studio: { enabled: false } }, listeners: { @@ -1783,15 +1819,24 @@ describe("managed Supabase stack whole-stack E2E", () => { test.skipIf(SELECTED_RUNTIME === "container")( "recovers the native database after abrupt Supervisor termination", { timeout: E2E_TIMEOUT_MS }, - // oxlint-disable-next-line effecttsgo/async-function -- Vitest callback consumes the public TestStack Promise contract. + // oxlint-disable-next-line effecttsgo/async-function -- Vitest callback consumes the public PromiseTestStack Promise contract. async () => { - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-crash-recovery-${randomId().replaceAll("-", "").slice(0, 16)}`, runtime: { kind: "native" }, }); const before = await stack.status(); const database = endpoint(before, "database"); - const lockPath = join(stack.stateRoot, stack.id, "data", "database", "postmaster.pid"); + const primary = await stack.services.get({ name: "database" }); + const lockPath = join( + stack.stateRoot, + stack.id, + "data", + "instances", + primary.id, + "postgres", + "postmaster.pid", + ); const databasePid = Number((await runNode(readText(lockPath))).split("\n", 1)[0]); if (!Number.isSafeInteger(databasePid) || databasePid <= 0) throw new Error("Native database lock did not contain a valid PID"); @@ -1807,7 +1852,9 @@ describe("managed Supabase stack whole-stack E2E", () => { const recovered = await stack.start(); expectDefaultLazyState(recovered); expect( - await runNode(databaseQuery((await stack.credentials()).database.url, "SELECT 1 AS value")), + await runNode( + databaseQuery(databaseCredentials(await stack.credentials()).url, "SELECT 1 AS value"), + ), ).toEqual([{ value: 1 }]); }, ); @@ -1817,16 +1864,16 @@ describe("managed Supabase stack whole-stack E2E", () => { test( `starts PostgreSQL ${major} in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, - // oxlint-disable-next-line effecttsgo/async-function -- Vitest callback consumes the public TestStack Promise and AsyncDisposable contracts. + // oxlint-disable-next-line effecttsgo/async-function -- Vitest callback consumes the public PromiseTestStack Promise contract. async () => { - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-postgres-${major}-${randomId().replaceAll("-", "").slice(0, 16)}`, runtime: mode.runtime, config: { capabilities: { database: { version: major } } }, }); const rows = await runNode( databaseQuery( - (await stack.credentials()).database.url, + databaseCredentials(await stack.credentials()).url, "SELECT current_setting('server_version') AS version", ), ); @@ -1866,8 +1913,8 @@ describe("managed Supabase stack whole-stack E2E", () => { } const firstStack = firstResult.value; const secondStack = secondResult.value; - await using first: TestStack = firstStack; - await using second: TestStack = secondStack; + await using first: PromiseTestStack = firstStack; + await using second: PromiseTestStack = secondStack; const [firstStatus, secondStatus] = await Promise.all([first.status(), second.status()]); expect(firstStatus.lifecycle).toBe("running"); expect(secondStatus.lifecycle).toBe("running"); @@ -1885,13 +1932,13 @@ describe("managed Supabase stack whole-stack E2E", () => { await Promise.all([ runNode( databaseQuery( - firstCredentials.database.url, + databaseCredentials(firstCredentials).url, `CREATE TABLE public."${firstTable}" (payload text NOT NULL)`, ), ), runNode( databaseQuery( - secondCredentials.database.url, + databaseCredentials(secondCredentials).url, `CREATE TABLE public."${secondTable}" (payload text NOT NULL)`, ), ), @@ -1899,14 +1946,14 @@ describe("managed Supabase stack whole-stack E2E", () => { await Promise.all([ runNode( databaseQuery( - firstCredentials.database.url, + databaseCredentials(firstCredentials).url, `INSERT INTO public."${firstTable}" (payload) VALUES ($1)`, [firstMarker], ), ), runNode( databaseQuery( - secondCredentials.database.url, + databaseCredentials(secondCredentials).url, `INSERT INTO public."${secondTable}" (payload) VALUES ($1)`, [secondMarker], ), @@ -1915,13 +1962,13 @@ describe("managed Supabase stack whole-stack E2E", () => { const [firstRows, secondRows] = await Promise.all([ runNode( databaseQuery( - firstCredentials.database.url, + databaseCredentials(firstCredentials).url, `SELECT payload FROM public."${firstTable}"`, ), ), runNode( databaseQuery( - secondCredentials.database.url, + databaseCredentials(secondCredentials).url, `SELECT payload FROM public."${secondTable}"`, ), ), @@ -1938,13 +1985,14 @@ describe("managed Supabase stack whole-stack E2E", () => { const identity = randomId().replaceAll("-", "").slice(0, 16); const ordinaryRoot = await runNode(mkdtemp(join(tmpdir(), "supabase-stack-cli-consumer-"))); let ordinary: PromiseStack | undefined; - let helper: TestStack | undefined; + let helper: PromiseTestStack | undefined; let primary: unknown; try { ordinary = await createStack({ projectRoot: ordinaryRoot, name: `stack-cli-consumer-${identity}`, runtime: mode.runtime, + initialConfig: {}, }); const ordinaryStack = ordinary; await ordinaryStack.start(); @@ -1992,12 +2040,12 @@ describe("managed Supabase stack whole-stack E2E", () => { test( `stops and wakes idle REST in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, - // oxlint-disable-next-line effecttsgo/async-function -- E2E scenario consumes the public TestStack Promise contract. + // oxlint-disable-next-line effecttsgo/async-function -- E2E scenario consumes the public PromiseTestStack Promise contract. async () => { const identity = randomId().replaceAll("-", "").slice(0, 16).toLowerCase(); const table = `idle_${identity}`; const marker = `idle-${identity}`; - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-idle-rest-${identity}`, runtime: mode.runtime, config: { @@ -2020,25 +2068,25 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(initial.lifecycle).toBe("running"); const api = endpoint(initial, "api"); const initialSupervisorPid = await runNode(supervisorPid(stack.id)); - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database"])); const credentials = await stack.credentials(); await runNode( databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `CREATE TABLE public."${table}" (id integer PRIMARY KEY, payload text NOT NULL)`, ), ); await runNode( databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `GRANT SELECT ON public."${table}" TO anon, authenticated, service_role`, ), ); expect( await runNode( databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `INSERT INTO public."${table}" (id, payload) VALUES (1, $1) RETURNING id, payload`, [marker], ), @@ -2068,7 +2116,7 @@ describe("managed Supabase stack whole-stack E2E", () => { await logIterator.return?.(); } - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database"])); const stoppedRest = await stack.status(); expect(capabilityState(stoppedRest, "rest")).toBe("dormant"); expect(endpoint(stoppedRest, "api").port).toBe(api.port); @@ -2076,7 +2124,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect( await runNode( databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `SELECT payload FROM public."${table}" WHERE id = 1`, ), ), @@ -2088,7 +2136,7 @@ describe("managed Supabase stack whole-stack E2E", () => { }).pipe(Effect.flatMap(jsonValue)), ); expect(secondRows).toEqual([{ id: 1, payload: marker }]); - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database", "rest:rest"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database", "rest:rest"])); const restartedRest = await stack.status(); expect(capabilityState(restartedRest, "rest")).toBe("ready"); expect(endpoint(restartedRest, "api").port).toBe(api.port); @@ -2098,11 +2146,11 @@ describe("managed Supabase stack whole-stack E2E", () => { test( `settles live HTTP, WebSocket, and TCP transports on stop in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, - // oxlint-disable-next-line effecttsgo/async-function -- E2E scenario consumes the public TestStack Promise contract. + // oxlint-disable-next-line effecttsgo/async-function -- E2E scenario consumes the public PromiseTestStack Promise contract. async () => { const identity = randomId().replaceAll("-", "").slice(0, 16).toLowerCase(); const table = `transport_stop_${identity}`; - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-transport-stop-${identity}`, runtime: mode.runtime, config: { @@ -2127,13 +2175,13 @@ describe("managed Supabase stack whole-stack E2E", () => { const credentials = await stack.credentials(); await runNode( databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `CREATE TABLE public."${table}" (id integer PRIMARY KEY)`, ), ); await runNode( databaseQuery( - credentials.database.url, + databaseCredentials(credentials).url, `GRANT INSERT ON public."${table}" TO anon, authenticated, service_role`, ), ); @@ -2148,7 +2196,7 @@ describe("managed Supabase stack whole-stack E2E", () => { runNode(openSocket(makeRealtimeUrl(api, apiCredentials(credentials).publishableKey))), ); await activate(stack, "pooler", () => { - const poolerUrl = new URL(credentials.database.url); + const poolerUrl = new URL(databaseCredentials(credentials).url); poolerUrl.port = String(pooler.port); poolerUrl.username = "postgres.pooler-dev"; return runNode( @@ -2177,7 +2225,7 @@ describe("managed Supabase stack whole-stack E2E", () => { .every(({ state }) => state === "stopped"), ).toBe(true); expect(stopped.artifacts).toEqual([]); - await runNode(expectOwnedWorkloads(mode, stack.id, [])); + await runNode(expectOwnedWorkloads(mode, stack, [])); await runNode(expectRuntimeInputsAbsent(stack)); await runNode( expectEndpointsRefused( @@ -2203,7 +2251,7 @@ describe("managed Supabase stack whole-stack E2E", () => { // oxlint-disable-next-line effecttsgo/async-function -- Vitest callback consumes public PromiseStack contracts. async () => { const analyticsApiKey = `analytics-key-${randomId()}`; - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-eager-${randomId().replaceAll("-", "").slice(0, 16)}`, runtime: mode.runtime, config: allEagerConfig(analyticsApiKey), @@ -2216,16 +2264,16 @@ describe("managed Supabase stack whole-stack E2E", () => { expect( status.capabilities.map(({ name, state, activation }) => ({ name, state, activation })), ).toEqual(CAPABILITY_NAMES.map((name) => ({ name, state: "ready", activation: "eager" }))); - await runNode(expectOwnedWorkloads(mode, stack.id, ALL_EAGER_WORKLOAD_IDS)); + await runNode(expectOwnedWorkloads(mode, stack, ALL_EAGER_WORKLOAD_IDS)); const credentials = await stack.credentials(); - expect(await runNode(databaseQuery(credentials.database.url, "SELECT 1 AS value"))).toEqual( - [{ value: 1 }], - ); + expect( + await runNode(databaseQuery(databaseCredentials(credentials).url, "SELECT 1 AS value")), + ).toEqual([{ value: 1 }]); await stack.stop(); const stopped = await stack.status(); expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); - await runNode(expectOwnedWorkloads(mode, stack.id, [])); + await runNode(expectOwnedWorkloads(mode, stack, [])); }, ); test( @@ -2239,7 +2287,7 @@ describe("managed Supabase stack whole-stack E2E", () => { const analyticsApiKey = `analytics-key-${identity}`; const email = `${identity}@example.test`; const password = "SupabaseStackE2e!123"; - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-optional-${identity}`, runtime: mode.runtime, config: optionalWorkloadConfig(functionSlug, analyticsApiKey), @@ -2309,7 +2357,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(count).toBeGreaterThan(0); expect(vectorCount).toBeGreaterThan(0); }); - yield* expectOwnedWorkloads(mode, stack.id, OPTIONAL_STORAGE_ANALYTICS_WORKLOAD_IDS); + yield* expectOwnedWorkloads(mode, stack, OPTIONAL_STORAGE_ANALYTICS_WORKLOAD_IDS); }), ), ); @@ -2369,7 +2417,7 @@ describe("managed Supabase stack whole-stack E2E", () => { > = []; let endpointSnapshot: ReadonlyArray = []; { - await using stack: TestStack = await createTestStack({ + await using stack: PromiseTestStack = await createTestStack({ name: `stack-resource-audit-${randomId().replaceAll("-", "").slice(0, 16)}`, runtime: mode.runtime, setupProject: (root) => { @@ -2389,7 +2437,7 @@ describe("managed Supabase stack whole-stack E2E", () => { const stopped = await stack.status(); expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); - await runNode(expectOwnedWorkloads(mode, stack.id, [])); + await runNode(expectOwnedWorkloads(mode, stack, [])); await runNode(expectRuntimeInputsAbsent(stack)); await runNode(expectEndpointsRefused(endpointSnapshot)); // The stop contract waits for lease/resource release. Detached supervisor exit can @@ -2408,7 +2456,7 @@ describe("managed Supabase stack whole-stack E2E", () => { await stack.start(); const restarted = await stack.status(); expectDefaultLazyState(restarted); - await runNode(expectOwnedWorkloads(mode, stack.id, ["database:database"])); + await runNode(expectOwnedWorkloads(mode, stack, ["database:database"])); expect( Object.values(restarted.endpoints).filter( (value): value is StackEndpoint => value !== undefined, diff --git a/packages/stack/src/runtime/CatalogInitialization.ts b/packages/stack/src/runtime/CatalogInitialization.ts new file mode 100644 index 0000000000..68a1198708 --- /dev/null +++ b/packages/stack/src/runtime/CatalogInitialization.ts @@ -0,0 +1,135 @@ +import { Effect, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { NativeProcessSpec } from "./NativeProcess.ts"; +import { defaultNativeProcessLauncher, spawnNativeProcess } from "./NativeProcess.ts"; +import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; +import { StackRuntimeError } from "../public/Errors.ts"; +import { makeProcessOutputTail } from "./Diagnostics.ts"; + +const databaseHostKeys = new Set([ + "DB_HOST", + "GOTRUE_DB_HOST", + "POSTGRES_HOST", + "PGHOST", + "PG_META_DB_HOST", +]); +const databasePortKeys = new Set([ + "DB_PORT", + "GOTRUE_DB_PORT", + "POSTGRES_PORT", + "PGPORT", + "PG_META_DB_PORT", +]); +const databasePasswordKeys = new Set([ + "DB_PASSWORD", + "GOTRUE_DB_PASSWORD", + "POSTGRES_PASSWORD", + "PGPASSWORD", + "PG_META_DB_PASSWORD", +]); + +/** Rewrites only database connection coordinates for a catalog one-shot. */ +export const rewriteCatalogDatabaseEnvironment = ( + environment: Readonly>, + target: Readonly<{ host: string; port: number; password: string }>, +): Record => { + const result: Record = {}; + for (const [name, value] of Object.entries(environment)) { + if (databaseHostKeys.has(name)) { + result[name] = target.host; + continue; + } + if (databasePortKeys.has(name)) { + result[name] = String(target.port); + continue; + } + if (databasePasswordKeys.has(name)) { + result[name] = target.password; + continue; + } + try { + const parsed = new URL(value); + if (["postgres:", "postgresql:", "ecto:"].includes(parsed.protocol)) { + parsed.hostname = target.host; + parsed.port = String(target.port); + parsed.password = target.password; + result[name] = parsed.toString(); + continue; + } + } catch { + // Non-URL environment values pass through unchanged. + } + result[name] = value; + } + return result; +}; + +/** Runs one catalog-owned native initialization process for an active database instance. */ +export const runCatalogNativeProcess = ( + spec: NativeProcessSpec, + key: RuntimeWorkloadKey, + knownSecrets: ReadonlyArray = [], +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const process = yield* spawnNativeProcess(spec, defaultNativeProcessLauncher(), key).pipe( + Effect.mapError( + (error) => + new StackRuntimeError({ + message: error.message, + stackId: key.stackId, + workloadId: key.workloadId, + cause: error, + }), + ), + ); + const tail = makeProcessOutputTail(); + const drain = Effect.all( + [ + Stream.runForEach(process.stdout, (bytes) => + Effect.sync(() => tail.pushBytes("stdout", bytes)), + ), + Stream.runForEach(process.stderr, (bytes) => + Effect.sync(() => tail.pushBytes("stderr", bytes)), + ), + ], + { concurrency: "unbounded", discard: true }, + ); + const [exitCode] = yield* Effect.all([process.exitCode, drain], { + concurrency: "unbounded", + }).pipe( + Effect.timeoutOrElse({ + duration: spec.timeout ?? "5 minutes", + orElse: () => + Effect.fail( + new StackRuntimeError({ + message: `Native catalog initialization timed out for ${key.workloadId}`, + stackId: key.stackId, + workloadId: key.workloadId, + }), + ), + }), + Effect.mapError((error) => + error instanceof StackRuntimeError + ? error + : new StackRuntimeError({ + message: "Native catalog initialization failed", + stackId: key.stackId, + workloadId: key.workloadId, + cause: error, + }), + ), + ); + if (exitCode !== 0) { + const diagnostic = tail.finish(knownSecrets); + return yield* new StackRuntimeError({ + message: + diagnostic.length === 0 + ? `Native catalog initialization exited with code ${String(exitCode)} for ${key.workloadId}` + : diagnostic, + stackId: key.stackId, + workloadId: key.workloadId, + }); + } + }), + ); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 4d68544251..4002d46552 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -3,6 +3,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; import { NetworkPortSchema } from "../public/Status.ts"; export type ContainerEngineKind = "docker" | "podman"; @@ -30,6 +31,7 @@ export class ContainerEngineProtocolError extends Data.TaggedError("ContainerEng }> {} export class ContainerCommandError extends Data.TaggedError("ContainerCommandError")<{ readonly operation: string; + readonly exitCode: number; readonly message: string; }> {} export type ContainerEngineFailure = @@ -47,12 +49,15 @@ export interface ContainerNetworkLabels extends ContainerIdentityLabels { readonly role: "network"; } export interface ContainerWorkloadLabels extends ContainerIdentityLabels { + readonly instanceId: ServiceInstanceId; readonly workloadId: string; + readonly recipeId: string; readonly startup?: boolean; readonly role: "workload"; } export interface ContainerVolumeLabels { readonly stackId: StackId; + readonly instanceId: ServiceInstanceId; readonly workloadId: string; readonly role: "volume"; } @@ -71,7 +76,9 @@ const mountField = (key: string, value: string): string => { export const CONTAINER_LABEL_KEYS = { stackId: `${CONTAINER_LABEL_PREFIX}.stackId`, ownerSessionId: `${CONTAINER_LABEL_PREFIX}.ownerSessionId`, + instanceId: `${CONTAINER_LABEL_PREFIX}.instanceId`, workloadId: `${CONTAINER_LABEL_PREFIX}.workloadId`, + recipeId: `${CONTAINER_LABEL_PREFIX}.recipeId`, startup: `${CONTAINER_LABEL_PREFIX}.startup`, role: `${CONTAINER_LABEL_PREFIX}.role`, }; @@ -88,13 +95,16 @@ const containerLabels = (value: ContainerLabels): ReadonlyArray => { : value.role === "volume" ? [ ["stackId", value.stackId], + ["instanceId", value.instanceId], ["workloadId", value.workloadId], ["role", value.role], ] : [ ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], + ["instanceId", value.instanceId], ["workloadId", value.workloadId], + ["recipeId", value.recipeId], ["startup", value.startup === true ? "true" : "false"], ["role", value.role], ]; @@ -172,6 +182,18 @@ export type ContainerCommand = readonly source: string; readonly destination: string; } + | { + readonly operation: "copy-from-container"; + readonly id: string; + readonly source: string; + readonly destination: string; + } + | { + readonly operation: "exec-container"; + readonly id: string; + readonly command: ReadonlyArray; + readonly stdin?: string; + } | { readonly operation: "start-container"; readonly id: string } | { readonly operation: "wait-container"; readonly id: string } | { readonly operation: "stop-container"; readonly id: string } @@ -185,6 +207,8 @@ type CommonContainerCommand = Extract< | { readonly operation: "remove-volume" } | { readonly operation: "create-container" } | { readonly operation: "copy-container" } + | { readonly operation: "copy-from-container" } + | { readonly operation: "exec-container" } | { readonly operation: "start-container" } | { readonly operation: "wait-container" } | { readonly operation: "stop-container" } @@ -251,6 +275,20 @@ export const serializeCommonContainerCommand = ( } case "copy-container": return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; + case "copy-from-container": + return { args: ["cp", `${command.id}:${command.source}`, command.destination] }; + case "exec-container": + return { + args: [ + "exec", + ...(command.stdin === undefined ? [] : ["--interactive"]), + "--user", + "postgres", + command.id, + ...command.command, + ], + ...(command.stdin === undefined ? {} : { stdin: command.stdin }), + }; case "start-container": return { args: ["start", command.id] }; case "wait-container": @@ -454,6 +492,7 @@ export const makeProcessCommandRunner = ( : Effect.fail( new ContainerCommandError({ operation: request.args[0] ?? "stream", + exitCode: Number(code), message: `Container engine log follower exited (${String(code)})`, }), ), @@ -555,14 +594,27 @@ export const makeContainerEngineCodecs = (options: { ); }; const workloadLabels = (operation: string, values: ReadonlyArray) => { - const [stack, owner, workload, startup, role] = values; - if (owner === undefined || workload === undefined || role !== "workload") + const [stack, owner, instance, workload, recipe, startup, role] = values; + if ( + owner === undefined || + instance === undefined || + workload === undefined || + recipe === undefined || + role !== "workload" + ) return Effect.fail(protocol(operation)); - return decodeIdentity(operation, stack).pipe( - Effect.map((stackId) => ({ + return Effect.all({ + stackId: decodeIdentity(operation, stack), + instanceId: Schema.decodeEffect(ServiceInstanceIdSchema)(instance).pipe( + Effect.mapError((error) => protocol(operation, error)), + ), + }).pipe( + Effect.map(({ stackId, instanceId }) => ({ stackId, ownerSessionId: owner, + instanceId, workloadId: workload, + recipeId: recipe, ...(startup === "true" ? { startup: true } : {}), role: "workload" as const, })), @@ -578,14 +630,16 @@ export const makeContainerEngineCodecs = (options: { fields(operation, line, count).pipe(Effect.flatMap(decode)), ); const decodeContainers: ContainerEngineCodecs["decodeContainers"] = (result) => - decodeRows("inspect-containers", result.stdout, 8, (values) => { - const [id, name, stack, owner, workload, startup, role, state] = values; + decodeRows("inspect-containers", result.stdout, 10, (values) => { + const [id, name, stack, owner, instance, workload, recipe, startup, role, state] = values; if ( id === undefined || name === undefined || stack === undefined || owner === undefined || + instance === undefined || workload === undefined || + recipe === undefined || role !== "workload" || state === undefined ) @@ -593,7 +647,9 @@ export const makeContainerEngineCodecs = (options: { return workloadLabels("inspect-containers", [ stack, owner, + instance, workload, + recipe, startup ?? "", role, ]).pipe( @@ -635,16 +691,27 @@ export const makeContainerEngineCodecs = (options: { }), ); const decodeVolumes: ContainerEngineCodecs["decodeVolumes"] = (result) => - decodeRows("inspect-volumes", result.stdout, 4, (values) => { - const [name, stack, workload, role] = values; - if (name === undefined || stack === undefined || workload === undefined || role !== "volume") + decodeRows("inspect-volumes", result.stdout, 5, (values) => { + const [name, stack, instance, workload, role] = values; + if ( + name === undefined || + stack === undefined || + instance === undefined || + workload === undefined || + role !== "volume" + ) return Effect.fail(protocol("inspect-volumes")); - return decodeIdentity("inspect-volumes", stack).pipe( - Effect.map((stackId): ContainerResource => ({ + return Effect.all({ + stackId: decodeIdentity("inspect-volumes", stack), + instanceId: Schema.decodeEffect(ServiceInstanceIdSchema)(instance).pipe( + Effect.mapError((error) => protocol("inspect-volumes", error)), + ), + }).pipe( + Effect.map(({ stackId, instanceId }): ContainerResource => ({ id: name, name, kind: "volume", - labels: { stackId, workloadId: workload, role: "volume" }, + labels: { stackId, instanceId, workloadId: workload, role: "volume" }, })), ); }); @@ -715,6 +782,18 @@ export interface ContainerEngine { source: string, destination: string, ) => Effect.Effect; + /** Copies an exact path from an owner-created container to the host. */ + readonly copyFromContainer?: ( + id: string, + source: string, + destination: string, + ) => Effect.Effect; + /** Runs one owner-controlled command as the image's PostgreSQL account. */ + readonly execContainer: ( + id: string, + command: ReadonlyArray, + stdin?: string, + ) => Effect.Effect; readonly startContainer: (id: string) => Effect.Effect; /** Waits for one exact container and returns its process exit code. */ readonly waitContainer: (id: string) => Effect.Effect; @@ -736,6 +815,7 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai : Effect.fail( new ContainerCommandError({ operation, + exitCode: result.exitCode, message: result.stderr.trim().length > 0 ? `Container engine command failed (${result.exitCode}): ${result.stderr.trim()}` @@ -881,6 +961,20 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai }, copyToContainer: (id, source, destination) => noResult("copy-container", { operation: "copy-container", id, source, destination }), + copyFromContainer: (id, source, destination) => + noResult("copy-from-container", { + operation: "copy-from-container", + id, + source, + destination, + }), + execContainer: (id, command, stdin) => + noResult("exec-container", { + operation: "exec-container", + id, + command, + ...(stdin === undefined ? {} : { stdin }), + }), startContainer: (id) => noResult("start-container", { operation: "start-container", id }), waitContainer: (id) => check("wait-container", { operation: "wait-container", id }).pipe( diff --git a/packages/stack/src/runtime/ContainerEngineResolver.ts b/packages/stack/src/runtime/ContainerEngineResolver.ts index ce073edb0d..02a0e3dda8 100644 --- a/packages/stack/src/runtime/ContainerEngineResolver.ts +++ b/packages/stack/src/runtime/ContainerEngineResolver.ts @@ -77,6 +77,7 @@ export const defaultContainerEngineResolver: ContainerEngineResolverShape = { if (result.exitCode !== 0) return yield* new ContainerCommandError({ operation: "version", + exitCode: result.exitCode, message: `${kind} --version exited (${String(result.exitCode)})`, }); return true; diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index c108756589..ae7f040209 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -36,8 +36,10 @@ import { type RuntimeCleanupRequest, type ObservedWorkload, type RuntimeDriver, + type RuntimeStartOptions, type RuntimeWorkloadKey, } from "./RuntimeDriver.ts"; +import { makeRuntimeCoordination } from "./RuntimeCoordination.ts"; import { ContainerEngineError } from "../public/Errors.ts"; export interface ContainerRuntimeOptions { @@ -112,21 +114,23 @@ interface ContainerRuntimeResource { readonly key: RuntimeWorkloadKey; readonly workload: PlannedWorkload; readonly failure: Deferred.Deferred; + readonly startOptions: RuntimeStartOptions; logFiber?: Fiber.Fiber; watchFiber?: Fiber.Fiber; stopRequested: boolean; } -const resourceKey = (key: RuntimeWorkloadKey): string => `${key.stackId}:${key.workloadId}`; +const resourceKey = (key: RuntimeWorkloadKey): string => + JSON.stringify([key.stackId, key.instanceId, key.workloadId]); const nameFor = (key: RuntimeWorkloadKey, role: ContainerResourceRole): string => role === "network" ? `supabase-${key.stackId.slice(0, 16)}-network` - : `supabase-${key.stackId.slice(0, 16)}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-${role}`; + : `supabase-${key.stackId.slice(0, 16)}-${key.instanceId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-${role}`; -/** Distinct from {@link nameFor}(..., "workload") so one-shots cannot collide with an eager main container. */ -export const schemaInitContainerName = (key: RuntimeWorkloadKey): string => - `supabase-${key.stackId.slice(0, 16)}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-schema-init`; +/** Stable, engine-safe name for one operation-scoped catalog initialization container. */ +export const catalogInitContainerName = (key: RuntimeWorkloadKey, operationId: string): string => + `supabase-${key.stackId.slice(0, 16)}-${key.instanceId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-init-${operationId.replace(/[^A-Za-z0-9_.-]/g, "-")}`; const networkLabelsFor = ( key: RuntimeWorkloadKey, @@ -139,17 +143,21 @@ const networkLabelsFor = ( const workloadLabelsFor = ( key: RuntimeWorkloadKey, ownerSessionId: string, + recipeId: string, ): ContainerWorkloadLabels => ({ stackId: key.stackId, ownerSessionId, + instanceId: key.instanceId, workloadId: key.workloadId, + recipeId, role: "workload", }); const startupLabelsFor = ( key: RuntimeWorkloadKey, ownerSessionId: string, + recipeId: string, ): ContainerWorkloadLabels => ({ - ...workloadLabelsFor(key, ownerSessionId), + ...workloadLabelsFor(key, ownerSessionId, recipeId), startup: true, }); const volumeOwnerFor = (key: RuntimeWorkloadKey, request: ContainerVolumeRequest): string => @@ -159,11 +167,14 @@ const volumeLabelsFor = ( request: ContainerVolumeRequest, ): ContainerVolumeLabels => ({ stackId: key.stackId, + instanceId: key.instanceId, workloadId: volumeOwnerFor(key, request), role: "volume", }); const volumeNameFor = (key: RuntimeWorkloadKey, ownerWorkloadId: string): string => - `supabase-${key.stackId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; + `supabase-${key.stackId}-${key.instanceId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; +export const workloadVolumeName = (key: RuntimeWorkloadKey): string => + volumeNameFor(key, key.workloadId); const volumeSpecFor = ( key: RuntimeWorkloadKey, request: ContainerVolumeRequest, @@ -183,7 +194,9 @@ const volumeMountFor = ( const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => left.role === right.role && (left.role === "volume" && right.role === "volume" - ? left.stackId === right.stackId && left.workloadId === right.workloadId + ? left.stackId === right.stackId && + left.instanceId === right.instanceId && + left.workloadId === right.workloadId : left.stackId === right.stackId && "ownerSessionId" in left && "ownerSessionId" in right && @@ -195,7 +208,16 @@ const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => const sameWorkloadIdentity = (left: ContainerLabels, right: ContainerWorkloadLabels): boolean => left.role === "workload" && left.stackId === right.stackId && + left.instanceId === right.instanceId && left.workloadId === right.workloadId && + left.recipeId === right.recipeId && + left.startup !== true; + +const sameWorkloadKey = (left: ContainerLabels, key: RuntimeWorkloadKey): boolean => + left.role === "workload" && + left.stackId === key.stackId && + left.instanceId === key.instanceId && + left.workloadId === key.workloadId && left.startup !== true; /** Networks are identified by stack identity. */ @@ -206,13 +228,15 @@ const sameNetworkIdentity = (left: ContainerLabels, right: ContainerNetworkLabel left.ownerSessionId === right.ownerSessionId; const toDriverError = ( - key: Pick, + key: Pick & + Partial>, error: unknown, ): RuntimeDriverError => new RuntimeDriverError({ message: error instanceof Error ? error.message : String(error), stackId: key.stackId, - workloadId: key.workloadId, + ...(key.instanceId === undefined ? {} : { instanceId: key.instanceId }), + ...(key.workloadId === undefined ? {} : { workloadId: key.workloadId }), cause: error, }); @@ -226,9 +250,20 @@ const toContainerEngineError = ( cause: error, }); +const runtimeCleanupError = (key: RuntimeWorkloadKey): RuntimeDriverError => + new RuntimeDriverError({ + message: "Container runtime cleanup is in progress", + stackId: key.stackId, + instanceId: key.instanceId, + workloadId: key.workloadId, + }); +const failRuntimeCleanup = (key: RuntimeWorkloadKey): Effect.Effect => + Effect.fail(runtimeCleanupError(key)); + const withEngine = ( engine: ContainerEngine, - key: Pick, + key: Pick & + Partial>, effect: Effect.Effect, ): Effect.Effect => effect.pipe( @@ -364,7 +399,7 @@ export const makeContainerRuntime = ( options.startupProcessTimeout ?? ("5 minutes" satisfies Duration.Input); const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); - const registration = yield* Semaphore.make(1); + const coordination = yield* makeRuntimeCoordination; // Serialize only exact shared-network (and volume identity) establishment. Resolution, // image pulls, startup migrations, container creation, and readiness run outside this gate. const setup = yield* Semaphore.make(1); @@ -373,7 +408,8 @@ export const makeContainerRuntime = ( const startFibers = new Map>(); const withEngine = ( - key: Pick, + key: Pick & + Partial>, effect: Effect.Effect, ): Effect.Effect => effect.pipe( @@ -392,7 +428,8 @@ export const makeContainerRuntime = ( key: RuntimeWorkloadKey, containerId: string, ): Effect.Effect => - registration.withPermit( + coordination.withKey( + key, Effect.uninterruptible( Effect.gen(function* () { const exact = (yield* withEngine(key, options.engine.listResources(key.stackId))).find( @@ -435,6 +472,7 @@ export const makeContainerRuntime = ( : new RuntimeDriverError({ message, stackId: resource.key.stackId, + instanceId: resource.key.instanceId, workloadId: resource.key.workloadId, cause: error, }); @@ -508,7 +546,7 @@ export const makeContainerRuntime = ( const observe = ( stackId: StackId, ): Effect.Effect, RuntimeDriverError> => - withEngine({ stackId, workloadId: "" }, options.engine.listResources(stackId)).pipe( + withEngine({ stackId }, options.engine.listResources(stackId)).pipe( Effect.map((entries) => entries .filter(isWorkloadResource) @@ -517,6 +555,7 @@ export const makeContainerRuntime = ( .map((entry) => { const key: RuntimeWorkloadKey = { stackId: entry.labels.stackId, + instanceId: entry.labels.instanceId, workloadId: entry.labels.workloadId, }; const local = resources.get(resourceKey(key)); @@ -549,7 +588,7 @@ export const makeContainerRuntime = ( readonly volumeRequest?: ContainerVolumeRequest; }>, ): Effect.Effect => { - const labels = startupLabelsFor(key, options.ownerSessionId); + const labels = startupLabelsFor(key, options.ownerSessionId, workload.recipeId); return runContainerStartupProcess({ engine: options.engine, key, @@ -584,6 +623,7 @@ export const makeContainerRuntime = ( const start = ( key: RuntimeWorkloadKey, workload: PlannedWorkload, + startOptions: RuntimeStartOptions = {}, ): Effect.Effect => { const artifact = containerArtifact(workload); if (artifact === undefined) @@ -591,11 +631,12 @@ export const makeContainerRuntime = ( new RuntimeDriverError({ message: "Container runtime cannot start a native artifact", stackId: key.stackId, + instanceId: key.instanceId, workloadId: key.workloadId, }), ); - const labels = workloadLabelsFor(key, options.ownerSessionId); + const labels = workloadLabelsFor(key, options.ownerSessionId, workload.recipeId); const networkLabels = networkLabelsFor(key, options.ownerSessionId); const networkName = nameFor(key, "network"); const resolved = @@ -656,8 +697,10 @@ export const makeContainerRuntime = ( namedCollision !== undefined && (collision === undefined || collision.labels.stackId !== key.stackId || + collision.labels.instanceId !== key.instanceId || collision.labels.workloadId !== key.workloadId || - collision.labels.role !== "workload") + collision.labels.role !== "workload" || + collision.labels.startup !== true) ) return yield* toDriverError( key, @@ -839,11 +882,33 @@ export const makeContainerRuntime = ( container: container.id, state: "starting", failure, + startOptions, stopRequested: false, }; - resources.set(resourceKey(key), createdResource); return createdResource; - }), + }).pipe( + Effect.flatMap((createdResource) => + coordination.withKeyCommit( + key, + Effect.sync(() => { + resources.set(resourceKey(key), createdResource); + return createdResource; + }), + ), + ), + Effect.flatMap((committed) => + Option.isNone(committed) + ? Effect.fail( + new RuntimeDriverError({ + message: "Container runtime cleanup is in progress", + stackId: key.stackId, + instanceId: key.instanceId, + workloadId: key.workloadId, + }), + ) + : Effect.succeed(committed.value), + ), + ), ).pipe(Effect.exit), ); if (Exit.isFailure(registered)) { @@ -860,6 +925,7 @@ export const makeContainerRuntime = ( const postRegistration = Effect.gen(function* () { yield* attachLogs(resource, "all"); yield* attachExitWatcher(resource); + if (resource.startOptions.onStarted !== undefined) yield* resource.startOptions.onStarted; const readiness = resolution.waitForReadiness === undefined ? options.waitForReadiness === undefined @@ -893,46 +959,67 @@ export const makeContainerRuntime = ( } return completed.value; }); - return Effect.gen(function* () { - const inFlight = startFibers.get(id); - if (inFlight !== undefined) return yield* Fiber.join(inFlight); - const guardRef = yield* Ref.make(false); - startGuards.set(id, guardRef); - // Track the whole registration wait, not only the body after permit acquisition. Cleanup - // must be able to interrupt starts queued behind another activation as well. The permit - // only covers forking the body; readiness and log following continue after it is released. - const run = Effect.gen(function* () { - const body = yield* registration.withPermit( - Effect.forkChild(startEffect, { startImmediately: true }), + const admission = coordination.withKeyCommit( + key, + Effect.gen(function* () { + const inFlight = startFibers.get(id); + if (inFlight !== undefined) return { kind: "join" as const, fiber: inFlight }; + const existing = resources.get(id); + if (existing?.state === "running") + return { + kind: "ready" as const, + value: { ...key, state: "ready" } satisfies ObservedWorkload, + }; + const guardRef = yield* Ref.make(false); + startGuards.set(id, guardRef); + // Track the whole registration wait so cleanup can interrupt starts queued behind + // another activation; readiness and log following continue after this short section. + const run: Effect.Effect = Effect.gen(function* () { + const body = yield* coordination.withKeyCommit( + key, + Effect.forkChild(startEffect, { startImmediately: true }), + ); + if (Option.isNone(body)) return yield* failRuntimeCleanup(key); + return yield* Fiber.join(body.value); + }); + const fiber = yield* Effect.forkChild(run, { startImmediately: true }); + startFibers.set(id, fiber); + return { kind: "start" as const, fiber, guardRef }; + }), + ); + return Effect.flatMap( + admission, + (admitted): Effect.Effect => { + if (Option.isNone(admitted)) return failRuntimeCleanup(key); + if (admitted.value.kind === "ready") return Effect.succeed(admitted.value.value); + const { fiber } = admitted.value; + if (admitted.value.kind === "join") return Fiber.join(fiber); + return Effect.uninterruptibleMask( + (restore) => + Effect.gen(function* () { + const joined = yield* restore(Fiber.join(fiber)).pipe(Effect.exit); + if (Exit.isFailure(joined)) { + yield* Fiber.interrupt(fiber); + const resource = resources.get(id); + if (resource !== undefined) { + const cleanup = yield* cleanupRegisteredResource(resource).pipe(Effect.exit); + if (Exit.isFailure(cleanup)) + return yield* Effect.failCause(Cause.combine(joined.cause, cleanup.cause)); + } + return yield* Effect.failCause(joined.cause); + } + return joined.value; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (startGuards.get(id) === admitted.value.guardRef) startGuards.delete(id); + if (startFibers.get(id) === fiber) startFibers.delete(id); + }), + ), + ), ); - return yield* Fiber.join(body); - }); - const fiber = yield* Effect.forkChild(run, { startImmediately: true }); - startFibers.set(id, fiber); - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const joined = yield* restore(Fiber.join(fiber)).pipe(Effect.exit); - if (Exit.isFailure(joined)) { - yield* Fiber.interrupt(fiber); - const resource = resources.get(id); - if (resource !== undefined) { - const cleanup = yield* cleanupRegisteredResource(resource).pipe(Effect.exit); - if (Exit.isFailure(cleanup)) - return yield* Effect.failCause(Cause.combine(joined.cause, cleanup.cause)); - } - return yield* Effect.failCause(joined.cause); - } - return joined.value; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (startGuards.get(id) === guardRef) startGuards.delete(id); - if (startFibers.get(id) === fiber) startFibers.delete(id); - }), - ), - ), - ); - }); + }, + ); }; const stopInPermit = (key: RuntimeWorkloadKey): Effect.Effect => { @@ -949,6 +1036,7 @@ export const makeContainerRuntime = ( new RuntimeDriverError({ message: "Container workload was stopped while starting", stackId: key.stackId, + instanceId: key.instanceId, workloadId: key.workloadId, }), ); @@ -963,12 +1051,7 @@ export const makeContainerRuntime = ( Effect.flatMap((entries) => { const exact = entries .filter(isWorkloadResource) - .find((entry) => - sameWorkloadIdentity( - entry.labels, - workloadLabelsFor(key, options.ownerSessionId), - ), - ); + .find((entry) => sameWorkloadKey(entry.labels, key)); return exact === undefined ? Effect.void : exact.state === "running" @@ -979,10 +1062,14 @@ export const makeContainerRuntime = ( }; const stop = (key: RuntimeWorkloadKey): Effect.Effect => - registration.withPermit(stopInPermit(key)); + coordination.withKey( + key, + Effect.suspend(() => stopInPermit(key)), + ); const remove = (key: RuntimeWorkloadKey): Effect.Effect => - registration.withPermit( + coordination.withKey( + key, Effect.gen(function* () { const found = resources.get(resourceKey(key)); if (found !== undefined) { @@ -994,6 +1081,7 @@ export const makeContainerRuntime = ( new RuntimeDriverError({ message: "Container workload was removed while starting", stackId: key.stackId, + instanceId: key.instanceId, workloadId: key.workloadId, }), ); @@ -1010,9 +1098,7 @@ export const makeContainerRuntime = ( const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); const exact = entries .filter(isWorkloadResource) - .find((entry) => - sameWorkloadIdentity(entry.labels, workloadLabelsFor(key, options.ownerSessionId)), - ); + .find((entry) => sameWorkloadKey(entry.labels, key)); if (exact === undefined) return; if (exact.state === "running") yield* withEngine(key, options.engine.stopContainer(exact.id)); @@ -1021,14 +1107,15 @@ export const makeContainerRuntime = ( ); const cleanup = (request: RuntimeCleanupRequest): Effect.Effect => - registration.withPermit( + coordination.withStackCleanup( + request.stackId, Effect.gen(function* () { // Stop and interrupt starts that have not registered a resource yet. Without this // fence, cleanup can observe an empty resource list while an activation is still inside // createContainer, then the activation creates a new orphan after cleanup returns. const pendingStartIds = new Set( [...startGuards.keys(), ...startFibers.keys()].filter((id) => - id.startsWith(`${request.stackId}:`), + id.startsWith(`["${request.stackId}",`), ), ); for (const id of pendingStartIds) { @@ -1040,12 +1127,8 @@ export const makeContainerRuntime = ( if (fiber !== undefined) yield* Fiber.interrupt(fiber); } - const stackKey = { - stackId: request.stackId, - workloadId: "", - } satisfies RuntimeWorkloadKey; const entries = yield* withEngine( - stackKey, + { stackId: request.stackId }, options.engine.listResources(request.stackId), ); const owned = entries @@ -1068,30 +1151,48 @@ export const makeContainerRuntime = ( // Stop and remove every stack workload before touching its network. A failed // stop does not prevent the remove attempt; all failures are returned together. for (const entry of owned.filter(isWorkloadResource)) { - if (entry.state === "running") - yield* attempt(withEngine(stackKey, options.engine.stopContainer(entry.id))); - yield* attempt(withEngine(stackKey, options.engine.removeContainer(entry.id))); + const workloadKey = { + stackId: request.stackId, + instanceId: entry.labels.instanceId, + workloadId: entry.labels.workloadId, + } satisfies RuntimeWorkloadKey; + yield* attempt( + coordination.withKey( + workloadKey, + Effect.gen(function* () { + if (entry.state === "running") + yield* withEngine(workloadKey, options.engine.stopContainer(entry.id)); + yield* withEngine(workloadKey, options.engine.removeContainer(entry.id)); + }), + ), + ); } for (const entry of owned.filter(isNetworkResource)) - yield* attempt(withEngine(stackKey, options.engine.removeNetwork(entry.id))); + yield* attempt( + withEngine({ stackId: request.stackId }, options.engine.removeNetwork(entry.id)), + ); if (request.destroy) for (const entry of owned.filter((candidate) => candidate.kind === "volume")) - yield* attempt(withEngine(stackKey, options.engine.removeVolume(entry.id))); + yield* attempt( + withEngine({ stackId: request.stackId }, options.engine.removeVolume(entry.id)), + ); for (const id of resources.keys()) - if (id.startsWith(`${request.stackId}:`)) resources.delete(id); + if (id.startsWith(`["${request.stackId}",`)) resources.delete(id); if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); }), ); const wipePersistentData = (key: RuntimeWorkloadKey): Effect.Effect => - registration.withPermit( + coordination.withKey( + key, Effect.gen(function* () { const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); const volumes = entries.filter( (entry) => entry.kind === "volume" && entry.labels.role === "volume" && + entry.labels.instanceId === key.instanceId && entry.labels.workloadId === key.workloadId, ); for (const volume of volumes) diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts index cb15e3357f..97c3ab6d2a 100644 --- a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -1,8 +1,9 @@ import { Effect, Redacted } from "effect"; import type { DatabaseBootstrapOptions } from "../model/DatabaseBootstrap.ts"; import type { PersistedStackState } from "../state/StackState.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; import { StackPreparationError } from "../public/Errors.ts"; -import { AUTH_JWT_SECRET_SLOT, DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; const missingMaterial = (message: string) => new StackPreparationError({ message }); @@ -19,22 +20,27 @@ const secretValue = (state: PersistedStackState, slot: string): string | undefin */ export const databaseBootstrapPlan = ( state: PersistedStackState, + instance: PersistedServiceInstance, ): Effect.Effect => Effect.gen(function* () { - if (state.definition === undefined) - return yield* missingMaterial( - "A materialized stack definition is required for database bootstrap", - ); - - const databasePassword = secretValue(state, DATABASE_INTERNAL_PASSWORD_SLOT); + if (instance.service !== "database") + return yield* missingMaterial("Database bootstrap requires a database instance"); + const databasePasswordSlot = instance.config.passwordSecretRef; + if (databasePasswordSlot === undefined) + return yield* missingMaterial("Database instance password secret is unavailable"); + const databasePassword = secretValue(state, databasePasswordSlot); if (databasePassword === undefined) return yield* missingMaterial("Managed database password is unavailable for bootstrap"); - const jwtSecret = secretValue(state, AUTH_JWT_SECRET_SLOT); + const signing = state.security.jwt?.signing; + const jwtSecretSlot = + signing?.kind === "symmetric" ? signing.secret.slot : AUTH_JWT_SECRET_SLOT; + const jwtSecret = secretValue(state, jwtSecretSlot); if (jwtSecret === undefined) return yield* missingMaterial("Managed JWT secret is unavailable for database bootstrap"); - const jwtExpiry = state.definition.capabilities.auth.settings.jwt_expiry; + const jwtExpiryValue = String(state.security.jwt?.expirySeconds ?? ""); + const jwtExpiry = Number(jwtExpiryValue); if ( typeof jwtExpiry !== "number" || !Number.isFinite(jwtExpiry) || diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index f5842b8ae4..b2e872a16c 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -18,7 +18,9 @@ const containerFormat = [ "{{json .Names}}", jsonLabel(CONTAINER_LABEL_KEYS.stackId), jsonLabel(CONTAINER_LABEL_KEYS.ownerSessionId), + jsonLabel(CONTAINER_LABEL_KEYS.instanceId), jsonLabel(CONTAINER_LABEL_KEYS.workloadId), + jsonLabel(CONTAINER_LABEL_KEYS.recipeId), jsonLabel(CONTAINER_LABEL_KEYS.startup), jsonLabel(CONTAINER_LABEL_KEYS.role), "{{json .State}}", @@ -33,6 +35,7 @@ const networkFormat = [ const volumeFormat = [ "{{json .Name}}", jsonLabel(CONTAINER_LABEL_KEYS.stackId), + jsonLabel(CONTAINER_LABEL_KEYS.instanceId), jsonLabel(CONTAINER_LABEL_KEYS.workloadId), jsonLabel(CONTAINER_LABEL_KEYS.role), ].join("\\t"); diff --git a/packages/stack/src/runtime/EphemeralPostgres.ts b/packages/stack/src/runtime/EphemeralPostgres.ts deleted file mode 100644 index ac94f6fab7..0000000000 --- a/packages/stack/src/runtime/EphemeralPostgres.ts +++ /dev/null @@ -1,1161 +0,0 @@ -import { PgClient } from "@effect/sql-pg"; -import { - Crypto, - Duration, - Effect, - Exit, - FileSystem, - Option, - Path, - Redacted, - Schedule, - Schema, - Scope, - Semaphore, - Stream, -} from "effect"; -import { ChildProcess } from "effect/unstable/process"; -import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- loopback bind is the port allocator. -import { createServer } from "node:net"; -import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; -import { - DEFAULT_DATABASE_HEALTH_TIMEOUT, - parseGoDuration, -} from "../model/capabilities/database.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { - ContainerEngineError, - EphemeralPostgresError, - PortUnavailableError, - StackPreparationError, - StackRuntimeError, - type EphemeralPostgresCreateError, -} from "../public/Errors.ts"; -import { - resolveEphemeralPostgresRelease, - type CreateEphemeralPostgresOptions, - type EffectEphemeralPostgres, -} from "../public/EphemeralPostgres.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; -import { defaultRuntimeEnvironment, StackRuntimeEnvironment } from "../supervisor/Launcher.ts"; -import { checkHostPort } from "../supervisor/HostListener.ts"; -import { probeReadiness } from "./ReadinessProbe.ts"; -import { - defaultNativeProcessLauncher, - spawnNativeProcess, - type NativeProcess, -} from "./NativeProcess.ts"; -import { bootstrapManagedPostgres } from "./PostgresDatabaseSession.ts"; -import { makeProductionRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import { - resolveContainerEngine, - ContainerEngineResolver, - selectDefaultRuntimeSelection, - nativeRuntimeBlockedForUid, - NATIVE_ROOT_UNSUPPORTED_MESSAGE, - type ContainerEngineResolverShape, - type DefaultRuntimeSelection, -} from "./ContainerEngineResolver.ts"; -import type { ContainerEngine } from "./ContainerEngine.ts"; -import { encodeRuntimeEnvFile } from "./RuntimeEnvFile.ts"; -import { containerAliasFor } from "../model/WorkloadCatalog.ts"; - -const DATABASE_WORKLOAD_ID = "database:database"; -const PGDATA_DIR_NAME = "data"; -const CONTAINER_PGDATA_PARENT = "/var/lib/postgresql"; -const SNAPSHOT_MOUNT = "/snapshot"; -const BUSYBOX = "/usr/bin/busybox"; -const RUNTIME_MARKER = ".supabase-ephemeral-runtime"; -const DEFAULT_JWT_EXPIRY = 3600; - -const RuntimeMarkerSchema = Schema.Struct({ - kind: Schema.Literals(["native", "container"] as const), - engine: Schema.optionalKey(Schema.Literals(["docker", "podman"] as const)), - snapshotKey: Schema.optionalKey(Schema.String), -}); -type RuntimeMarker = Schema.Schema.Type; -const TAR_EXTRACT_FLAGS = ["--no-same-owner"] as const; - -const ephemeralError = ( - message: string, - fields: Omit[0], "message"> = {}, -) => new EphemeralPostgresError({ message, ...fields }); - -const resolvedRuntime = ( - preference: CreateEphemeralPostgresOptions["runtime"] | undefined, - resolver: ContainerEngineResolverShape | undefined, -): Effect.Effect< - DefaultRuntimeSelection, - ContainerEngineError | StackRuntimeError, - ChildProcessSpawnerService -> => { - if (preference !== undefined) { - const runtime = - preference.kind === "container" - ? ({ kind: "container", engine: preference.engine ?? "docker" } satisfies StackRuntime) - : ({ kind: "native" } satisfies StackRuntime); - return runtime.kind === "native" && nativeRuntimeBlockedForUid() - ? Effect.fail(new StackRuntimeError({ message: NATIVE_ROOT_UNSUPPORTED_MESSAGE })) - : Effect.succeed({ runtime }); - } - return selectDefaultRuntimeSelection(resolver).pipe( - Effect.flatMap((selected) => - selected.runtime.kind === "native" && nativeRuntimeBlockedForUid() - ? Effect.fail(new StackRuntimeError({ message: NATIVE_ROOT_UNSUPPORTED_MESSAGE })) - : Effect.succeed(selected), - ), - ); -}; - -const plannedWorkload = ( - version: string, - image: string, - runtime: StackRuntime, -): PlannedWorkload => ({ - id: DATABASE_WORKLOAD_ID, - capability: "database", - bootstrap: "database", - dependencies: [], - readiness: { portField: "database" }, - artifacts: { - native: { kind: "native", release: version }, - container: { kind: "container", image }, - }, - selected: - runtime.kind === "native" ? { kind: "native", release: version } : { kind: "container", image }, -}); - -const postgresArgs = ( - port: number, - runtime: StackRuntime, - settings: CreateEphemeralPostgresOptions["postgresSettings"], -): ReadonlyArray => { - const tuned = Object.entries(settings ?? {}).flatMap(([key, value]) => { - if (value === undefined) return []; - const rendered = String(value); - return rendered.length === 0 ? [] : ["-c", `${key}=${rendered}`]; - }); - return [ - "-p", - String(port), - "-c", - runtime.kind === "container" ? "listen_addresses=*" : "listen_addresses=127.0.0.1", - ...tuned, - ]; -}; - -const postgresEnv = (input: { - readonly port: number; - readonly dataPath: string; - readonly password: string; -}): Record => ({ - SUPABASE_STACK_WORKLOAD: DATABASE_WORKLOAD_ID, - SUPABASE_STACK_PRIVATE_PORT: String(input.port), - PGDATA: input.dataPath, - POSTGRES_USER: "supabase_admin", - POSTGRES_DB: "postgres", - POSTGRES_PASSWORD: input.password, - TZDIR: "/var/db/timezone/zoneinfo", -}); - -const databaseUrl = (port: number, password: string): string => - `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent(password)}@127.0.0.1:${port}/postgres`; - -const markerFor = (runtime: StackRuntime, snapshotKey?: string): RuntimeMarker => ({ - ...(runtime.kind === "native" - ? { kind: "native" as const } - : { kind: "container" as const, engine: runtime.engine }), - ...(snapshotKey === undefined ? {} : { snapshotKey }), -}); - -const sameSnapshotKey = (marker: RuntimeMarker, expected: string | undefined): boolean => - expected === undefined ? marker.snapshotKey === undefined : marker.snapshotKey === expected; - -const encodeMarker = (marker: RuntimeMarker): string => JSON.stringify(marker); - -const decodeMarker = (text: string): Effect.Effect => - Schema.decodeEffect(Schema.fromJsonString(RuntimeMarkerSchema))(text).pipe( - Effect.mapError(() => - ephemeralError("Ephemeral Postgres snapshot marker is invalid", { reason: "snapshot" }), - ), - ); - -const sameRuntime = (left: RuntimeMarker, right: StackRuntime): boolean => - left.kind === right.kind && (right.kind === "native" || left.engine === right.engine); - -const allocateLoopbackPort = ( - requested: number | undefined, -): Effect.Effect => { - if (requested !== undefined) - return checkHostPort("127.0.0.1", requested, "database").pipe(Effect.as(requested)); - return Effect.callback((resume) => { - const server = createServer(); - let settled = false; - const finish = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - resume(effect); - }; - server.once("error", (cause) => - finish(Effect.fail(ephemeralError("Unable to allocate a loopback port", { cause }))), - ); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => - finish( - port > 0 - ? Effect.succeed(port) - : Effect.fail(ephemeralError("Unable to allocate a loopback port")), - ), - ); - }); - return Effect.sync(() => { - if (settled) return; - settled = true; - try { - server.close(); - } catch { - // The listener never obtained a handle. - } - }); - }); -}; - -const runTar = ( - args: ReadonlyArray, -): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const handle = yield* ChildProcess.make("tar", args, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }).pipe(Effect.mapError((cause) => ephemeralError("Unable to start tar", { cause }))); - const drain = Effect.all([Stream.runDrain(handle.stdout), Stream.runDrain(handle.stderr)], { - concurrency: "unbounded", - discard: true, - }).pipe(Effect.ignore); - const [code] = yield* Effect.all([handle.exitCode, drain], { concurrency: "unbounded" }).pipe( - Effect.mapError((cause) => ephemeralError("tar failed", { cause })), - ); - if (Number(code) !== 0) - return yield* ephemeralError(`tar failed (${String(code)})`, { reason: "snapshot" }); - }), - ); - -const writeEnvFile = ( - filePath: string, - values: Readonly>, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const text = yield* encodeRuntimeEnvFile(values).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to write Postgres environment file", { cause, path: filePath }), - ), - ); - yield* fs - .writeFileString(filePath, text, { mode: 0o600 }) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to write Postgres environment file", { cause, path: filePath }), - ), - ); - yield* fs - .chmod(filePath, 0o600) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to restrict Postgres environment file", { cause, path: filePath }), - ), - ); - return filePath; - }); - -const waitForPostgres = ( - port: number, - healthTimeout: string, -): Effect.Effect => - Effect.try({ - try: () => parseGoDuration(healthTimeout), - catch: (cause) => ephemeralError("Invalid database health timeout", { cause }), - }).pipe( - Effect.flatMap((deadline) => - probeReadiness( - { mode: "tcp", host: "127.0.0.1", port }, - { deadline: Duration.isZero(deadline) ? Duration.seconds(1) : deadline }, - ).pipe( - Effect.mapError((cause) => - ephemeralError("Ephemeral Postgres did not become ready", { cause }), - ), - ), - ), - ); - -const pingAdvertised = ( - port: number, - password: string, -): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const client = yield* PgClient.PgClient; - yield* client.unsafe("SELECT 1"); - }).pipe( - Effect.provide( - PgClient.layer({ - url: Redacted.make(databaseUrl(port, password)), - connectTimeout: "2 seconds", - }), - ), - ), - ).pipe( - Effect.mapError((cause) => - ephemeralError("Ephemeral Postgres did not accept a connection", { cause }), - ), - ); - -const waitForAdvertised = ( - port: number, - password: string, - healthTimeout: string, -): Effect.Effect => - Effect.try({ - try: () => parseGoDuration(healthTimeout), - catch: (cause) => ephemeralError("Invalid database health timeout", { cause }), - }).pipe( - Effect.flatMap((deadline) => - Effect.timeout( - Effect.retry(pingAdvertised(port, password), { - schedule: Schedule.spaced("100 millis"), - }), - Duration.isZero(deadline) ? Duration.seconds(1) : deadline, - ).pipe( - Effect.mapError((cause) => - ephemeralError("Ephemeral Postgres did not become ready", { cause }), - ), - ), - ), - ); - -const bootstrap = ( - port: number, - options: CreateEphemeralPostgresOptions, - healthTimeout: string, -): Effect.Effect => - Effect.try({ - try: () => parseGoDuration(healthTimeout), - catch: (cause) => ephemeralError("Invalid database health timeout", { cause }), - }).pipe( - Effect.flatMap((deadline) => - Effect.timeout( - Effect.retry( - bootstrapManagedPostgres({ - host: "127.0.0.1", - port, - databasePassword: options.databasePassword, - jwtSecret: options.jwtSecret, - jwtExpiry: options.jwtExpiry ?? DEFAULT_JWT_EXPIRY, - }), - { - schedule: Schedule.spaced("100 millis"), - while: (error) => error instanceof DatabaseBootstrapError && error.retryable === true, - }, - ), - Duration.isZero(deadline) ? Duration.seconds(1) : deadline, - ).pipe( - Effect.mapError((cause) => - ephemeralError("Ephemeral Postgres bootstrap failed", { reason: "bootstrap", cause }), - ), - ), - ), - ); - -interface NativeResources { - readonly kind: "native"; - process?: NativeProcess; - processScope?: Scope.Closeable; -} - -interface ContainerResources { - readonly kind: "container"; - readonly engine: ContainerEngine; - networkName?: string; - volumeName?: string; - containerName?: string; - networkId?: string; - volumeId?: string; - containerId?: string; -} - -type RuntimeResources = NativeResources | ContainerResources; - -interface Cluster { - readonly identity: StackId; - readonly root: string; - readonly dataPath: string; - readonly host: "127.0.0.1"; - readonly port: number; - readonly version: string; - readonly runtime: StackRuntime; - readonly artifactIdentity: string; - readonly executable?: string; - readonly artifactRoot?: string; - readonly image?: string; - readonly lifecycle: Semaphore.Semaphore; - running: boolean; - bootstrapped: boolean; - snapshotKey?: string; - resources: RuntimeResources; -} - -const resourceName = (identity: StackId, role: string): string => - `supabase-eph-${identity.slice(0, 16)}-${role}`; - -const createIdentity = (crypto: Crypto.Crypto): Effect.Effect => - Effect.gen(function* () { - const first = yield* crypto.randomUUIDv4; - const second = yield* crypto.randomUUIDv4; - return yield* Schema.decodeEffect(StackIdSchema)(`${first}${second}`.replaceAll("-", "")); - }).pipe( - Effect.mapError((cause) => ephemeralError("Unable to allocate ephemeral identity", { cause })), - ); - -const writeRuntimeMarker = ( - cluster: Cluster, - snapshotKey?: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const markerPath = `${cluster.dataPath}/${RUNTIME_MARKER}`; - const encoded = encodeMarker(markerFor(cluster.runtime, snapshotKey ?? cluster.snapshotKey)); - if (cluster.resources.kind === "native") { - yield* fs.writeFileString(markerPath, encoded).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to write snapshot runtime marker", { - cause, - path: markerPath, - reason: "snapshot", - }), - ), - ); - return; - } - const containerId = cluster.resources.containerId; - if (containerId === undefined) - return yield* ephemeralError("Ephemeral Postgres container is missing", { - reason: "snapshot", - }); - const tempPath = `${cluster.root}/${RUNTIME_MARKER}`; - yield* fs.writeFileString(tempPath, encoded).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to write snapshot runtime marker", { - cause, - path: tempPath, - reason: "snapshot", - }), - ), - ); - yield* cluster.resources.engine - .copyToContainer( - containerId, - tempPath, - `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}/${RUNTIME_MARKER}`, - ) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to copy snapshot runtime marker", { cause, reason: "snapshot" }), - ), - ); - }); - -const snapshotFileName = ( - tarPath: string, - path: Path.Path, -): Effect.Effect => { - const name = path.basename(tarPath); - if (name.length === 0 || name === "." || name === "..") - return Effect.fail( - ephemeralError("Ephemeral Postgres snapshot path is invalid", { - reason: "snapshot", - path: tarPath, - }), - ); - return Effect.succeed(name); -}; - -/** Catalog image has no tar on PATH; busybox tar archives the volume in place. */ -const runVolumeTar = ( - cluster: Cluster, - tarPath: string, - mode: "create" | "extract", -): Effect.Effect => - Effect.gen(function* () { - if (cluster.resources.kind !== "container") return; - const { engine, networkId, volumeId } = cluster.resources; - if (networkId === undefined || volumeId === undefined) - return yield* ephemeralError("Ephemeral Postgres volume is unavailable", { - reason: "snapshot", - }); - const image = cluster.image; - if (image === undefined) - return yield* ephemeralError("Ephemeral Postgres image is unavailable", { - reason: "snapshot", - }); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const fileName = yield* snapshotFileName(tarPath, path); - const parent = path.dirname(tarPath); - yield* fs.makeDirectory(parent, { recursive: true }).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to create snapshot directory", { - cause, - path: parent, - reason: "snapshot", - }), - ), - ); - const snapshotPath = `${SNAPSHOT_MOUNT}/${fileName}`; - const command = - mode === "create" - ? ["tar", "-C", CONTAINER_PGDATA_PARENT, "-cf", snapshotPath, PGDATA_DIR_NAME] - : ["tar", "-C", CONTAINER_PGDATA_PARENT, "-xf", snapshotPath]; - yield* Effect.acquireUseRelease( - engine - .createContainer({ - name: resourceName(cluster.identity, "snapshot"), - image, - labels: { - stackId: cluster.identity, - ownerSessionId: cluster.identity.slice(0, 32), - workloadId: `${DATABASE_WORKLOAD_ID}:snapshot`, - role: "workload", - }, - network: networkId, - mounts: [{ source: parent, target: SNAPSHOT_MOUNT, readOnly: mode === "extract" }], - volumeMounts: [ - { - volume: volumeId, - target: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, - readOnly: false, - }, - ], - publications: [], - role: "workload", - entrypoint: BUSYBOX, - command, - }) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to create snapshot helper", { cause, reason: "snapshot" }), - ), - ), - (created) => - Effect.gen(function* () { - yield* engine.startContainer(created.id).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to start snapshot helper", { - cause, - reason: "snapshot", - }), - ), - ); - const code = yield* engine.waitContainer(created.id).pipe( - Effect.mapError((cause) => - ephemeralError("Snapshot helper did not finish", { - cause, - reason: "snapshot", - }), - ), - ); - if (code !== 0) - return yield* ephemeralError(`Snapshot helper failed (${String(code)})`, { - reason: "snapshot", - }); - }), - (created) => engine.removeContainer(created.id).pipe(Effect.ignore), - ); - }); - -const verifyRestoredMarker = ( - cluster: Cluster, - restoreFrom: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const markerPath = `${cluster.dataPath}/${RUNTIME_MARKER}`; - const exists = yield* fs.exists(markerPath).pipe(Effect.orElseSucceed(() => false)); - if (!exists) - return yield* ephemeralError("Ephemeral Postgres snapshot is missing a runtime marker", { - reason: "restore-mismatch", - path: restoreFrom, - }); - const marker = yield* fs.readFileString(markerPath).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to read snapshot runtime marker", { - cause, - path: markerPath, - reason: "snapshot", - }), - ), - Effect.flatMap(decodeMarker), - ); - if (!sameRuntime(marker, cluster.runtime) || !sameSnapshotKey(marker, cluster.snapshotKey)) - return yield* ephemeralError( - "Ephemeral Postgres snapshot was produced by a different runtime", - { - reason: "restore-mismatch", - path: restoreFrom, - }, - ); - }); - -const stopNative = (cluster: Cluster): Effect.Effect => - Effect.gen(function* () { - if (cluster.resources.kind !== "native") return; - const process = cluster.resources.process; - if (process !== undefined) { - const running = yield* process.isRunning.pipe(Effect.orElseSucceed(() => false)); - if (running) - yield* process.kill.pipe( - Effect.mapError((cause) => - ephemeralError("Unable to stop ephemeral Postgres", { cause }), - ), - ); - } - const scope = cluster.resources.processScope; - if (scope !== undefined) yield* Scope.close(scope, Exit.void); - cluster.resources.process = undefined; - cluster.resources.processScope = undefined; - cluster.running = false; - }); - -const stopContainer = (cluster: Cluster): Effect.Effect => - Effect.gen(function* () { - if (cluster.resources.kind !== "container") return; - const containerId = cluster.resources.containerId; - if (containerId !== undefined) - yield* cluster.resources.engine - .stopContainer(containerId) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to stop ephemeral Postgres", { cause }), - ), - ); - cluster.running = false; - }); - -const startNative = ( - cluster: Cluster, - options: CreateEphemeralPostgresOptions, - healthTimeout: string, - password: string, -): Effect.Effect => - Effect.gen(function* () { - const executable = cluster.executable; - if (executable === undefined) - return yield* ephemeralError("Native Postgres executable is unavailable"); - const parentScope = yield* Scope.Scope; - const processScope = yield* Scope.fork(parentScope, "parallel"); - yield* Effect.uninterruptibleMask((restore) => - restore( - spawnNativeProcess( - { - executable, - args: postgresArgs(cluster.port, cluster.runtime, options.postgresSettings), - env: postgresEnv({ - port: cluster.port, - dataPath: cluster.dataPath, - password, - }), - cwd: cluster.root, - gracefulStopSignal: "SIGINT", - gracefulStopTimeout: "15 seconds", - }, - defaultNativeProcessLauncher(), - { stackId: cluster.identity, workloadId: DATABASE_WORKLOAD_ID }, - ).pipe(Scope.provide(processScope)), - ).pipe( - Effect.mapError((cause) => ephemeralError("Unable to start native Postgres", { cause })), - Effect.tap((process) => - Effect.gen(function* () { - if (cluster.resources.kind === "native") { - cluster.resources.process = process; - cluster.resources.processScope = processScope; - } - yield* Effect.forkIn( - Effect.all([Stream.runDrain(process.stdout), Stream.runDrain(process.stderr)], { - concurrency: "unbounded", - discard: true, - }).pipe(Effect.ignore), - processScope, - ); - }), - ), - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? Effect.void - : Scope.close(processScope, Exit.void).pipe(Effect.asVoid), - ), - ), - ); - yield* Effect.gen(function* () { - yield* waitForPostgres(cluster.port, healthTimeout); - if (!cluster.bootstrapped) { - yield* bootstrap(cluster.port, options, healthTimeout); - cluster.bootstrapped = true; - } - yield* waitForAdvertised(cluster.port, password, healthTimeout); - cluster.running = true; - }).pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) ? Effect.void : stopNative(cluster).pipe(Effect.ignore), - ), - ); - }); - -const startContainer = ( - cluster: Cluster, - options: CreateEphemeralPostgresOptions, - healthTimeout: string, - password: string, -): Effect.Effect => - Effect.gen(function* () { - if (cluster.resources.kind !== "container") return; - const resources = cluster.resources; - const image = cluster.image; - if (image === undefined) - return yield* ephemeralError("Ephemeral Postgres image is unavailable"); - const networkId = resources.networkId; - const volumeId = resources.volumeId; - if (networkId === undefined || volumeId === undefined) - return yield* ephemeralError("Ephemeral Postgres volume is unavailable"); - yield* Effect.gen(function* () { - if (resources.containerId !== undefined) { - yield* resources.engine - .startContainer(resources.containerId) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to start ephemeral Postgres", { cause }), - ), - ); - } else { - const path = yield* Path.Path; - const envFile = yield* writeEnvFile( - path.join(cluster.root, "postgres.env"), - postgresEnv({ - port: 5432, - dataPath: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, - password, - }), - ); - const containerName = resources.containerName ?? resourceName(cluster.identity, "database"); - resources.containerName = containerName; - const created = yield* Effect.uninterruptibleMask((restore) => - restore( - resources.engine.createContainer({ - name: containerName, - image, - labels: { - stackId: cluster.identity, - ownerSessionId: cluster.identity.slice(0, 32), - workloadId: DATABASE_WORKLOAD_ID, - role: "workload", - }, - network: networkId, - mounts: [], - volumeMounts: [ - { - volume: volumeId, - target: `${CONTAINER_PGDATA_PARENT}/${PGDATA_DIR_NAME}`, - readOnly: false, - }, - ], - publications: [{ address: "127.0.0.1", hostPort: cluster.port, containerPort: 5432 }], - role: "workload", - command: postgresArgs(5432, cluster.runtime, options.postgresSettings), - envFile, - networkAliases: [containerAliasFor("database:database")], - }), - ).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to create ephemeral Postgres", { cause }), - ), - Effect.tap((created) => - Effect.sync(() => { - resources.containerId = created.id; - }), - ), - ), - ); - yield* resources.engine - .startContainer(created.id) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to start ephemeral Postgres", { cause }), - ), - ); - } - yield* waitForPostgres(cluster.port, healthTimeout); - if (!cluster.bootstrapped) { - yield* bootstrap(cluster.port, options, healthTimeout); - cluster.bootstrapped = true; - } - yield* waitForAdvertised(cluster.port, password, healthTimeout); - cluster.running = true; - }).pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) ? Effect.void : stopContainer(cluster).pipe(Effect.ignore), - ), - ); - }); - -const exportNative = ( - cluster: Cluster, - tarPath: string, -): Effect.Effect< - void, - EphemeralPostgresError, - ChildProcessSpawnerService | Scope.Scope | FileSystem.FileSystem -> => - Effect.gen(function* () { - yield* writeRuntimeMarker(cluster, cluster.snapshotKey); - yield* runTar(["-C", cluster.root, "-cf", tarPath, PGDATA_DIR_NAME]); - }); - -const exportContainer = ( - cluster: Cluster, - tarPath: string, -): Effect.Effect => - Effect.gen(function* () { - yield* writeRuntimeMarker(cluster, cluster.snapshotKey); - yield* runVolumeTar(cluster, tarPath, "create"); - }); - -const restoreNative = ( - cluster: Cluster, - restoreFrom: string, -): Effect.Effect< - void, - EphemeralPostgresError, - ChildProcessSpawnerService | Scope.Scope | FileSystem.FileSystem -> => - Effect.gen(function* () { - yield* runTar(["-C", cluster.root, "-xf", restoreFrom, ...TAR_EXTRACT_FLAGS]); - yield* verifyRestoredMarker(cluster, restoreFrom); - }); - -const restoreContainer = ( - cluster: Cluster, - restoreFrom: string, -): Effect.Effect => - runVolumeTar(cluster, restoreFrom, "extract"); - -const peekSnapshotRuntime = ( - restoreFrom: string, - runtime: StackRuntime, - peekRoot: string, - snapshotKey?: string, -): Effect.Effect< - void, - EphemeralPostgresError, - FileSystem.FileSystem | ChildProcessSpawnerService | Scope.Scope -> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(peekRoot, { recursive: true }).pipe( - Effect.mapError((cause) => - ephemeralError("Unable to inspect snapshot", { - cause, - path: peekRoot, - reason: "snapshot", - }), - ), - ); - yield* runTar([ - "-xf", - restoreFrom, - "-C", - peekRoot, - ...TAR_EXTRACT_FLAGS, - `${PGDATA_DIR_NAME}/${RUNTIME_MARKER}`, - ]).pipe( - Effect.mapError(() => - ephemeralError("Ephemeral Postgres snapshot is missing a runtime marker", { - reason: "restore-mismatch", - path: restoreFrom, - }), - ), - ); - const marker = yield* fs - .readFileString(`${peekRoot}/${PGDATA_DIR_NAME}/${RUNTIME_MARKER}`) - .pipe( - Effect.mapError((cause) => - ephemeralError("Unable to read snapshot runtime marker", { cause, reason: "snapshot" }), - ), - Effect.flatMap(decodeMarker), - ); - if (!sameRuntime(marker, runtime)) - return yield* ephemeralError( - "Ephemeral Postgres snapshot was produced by a different runtime", - { - reason: "restore-mismatch", - path: restoreFrom, - }, - ); - if (!sameSnapshotKey(marker, snapshotKey)) - return yield* ephemeralError("Ephemeral Postgres snapshot key does not match", { - reason: "restore-mismatch", - path: restoreFrom, - }); - yield* fs.remove(peekRoot, { recursive: true }).pipe(Effect.ignore); - }); - -const destroyCluster = (cluster: Cluster): Effect.Effect => - Effect.gen(function* () { - if (cluster.resources.kind === "native") yield* stopNative(cluster).pipe(Effect.ignore); - else { - yield* stopContainer(cluster).pipe(Effect.ignore); - const containerRef = cluster.resources.containerId ?? cluster.resources.containerName; - if (containerRef !== undefined) - yield* cluster.resources.engine.removeContainer(containerRef).pipe(Effect.ignore); - const volumeRef = cluster.resources.volumeId ?? cluster.resources.volumeName; - if (volumeRef !== undefined) - yield* cluster.resources.engine.removeVolume(volumeRef).pipe(Effect.ignore); - const networkRef = cluster.resources.networkId ?? cluster.resources.networkName; - if (networkRef !== undefined) - yield* cluster.resources.engine.removeNetwork(networkRef).pipe(Effect.ignore); - } - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(cluster.root, { recursive: true }).pipe(Effect.ignore); - }); - -const clusterHandle = ( - cluster: Cluster, - options: CreateEphemeralPostgresOptions, - healthTimeout: string, - password: string, -): EffectEphemeralPostgres => { - const requireStopped = (): Effect.Effect => - cluster.running - ? Effect.fail( - ephemeralError("Ephemeral Postgres must be stopped before exporting PGDATA", { - reason: "not-stopped", - }), - ) - : Effect.void; - return { - host: cluster.host, - port: cluster.port, - version: cluster.version, - runtime: cluster.runtime, - artifactIdentity: cluster.artifactIdentity, - ...(cluster.artifactRoot === undefined ? {} : { nativeArtifactRoot: cluster.artifactRoot }), - url: Redacted.make(databaseUrl(cluster.port, password)), - ...(cluster.resources.kind === "container" && cluster.resources.networkId !== undefined - ? { networkId: cluster.resources.networkId } - : {}), - start: Effect.suspend(() => - cluster.lifecycle.withPermit( - Effect.gen(function* () { - if (cluster.running) { - if (cluster.runtime.kind === "native" && cluster.resources.kind === "native") { - const process = cluster.resources.process; - const stillRunning = - process === undefined - ? false - : yield* process.isRunning.pipe(Effect.orElseSucceed(() => false)); - if (stillRunning) return; - yield* stopNative(cluster).pipe(Effect.ignore); - } else { - const probe = yield* waitForPostgres(cluster.port, "1s").pipe(Effect.exit); - if (Exit.isSuccess(probe)) return; - cluster.running = false; - } - } - if (cluster.runtime.kind === "native") - yield* startNative(cluster, options, healthTimeout, password); - else yield* startContainer(cluster, options, healthTimeout, password); - }), - ), - ), - stop: Effect.suspend(() => - cluster.lifecycle.withPermit( - cluster.runtime.kind === "native" ? stopNative(cluster) : stopContainer(cluster), - ), - ), - exportPgData: (tarPath, snapshotKey) => - cluster.lifecycle.withPermit( - Effect.gen(function* () { - yield* requireStopped(); - if (snapshotKey !== undefined) cluster.snapshotKey = snapshotKey; - if (cluster.runtime.kind === "native") yield* exportNative(cluster, tarPath); - else yield* exportContainer(cluster, tarPath); - }), - ), - }; -}; - -export const createEphemeralPostgresCluster = ( - options: CreateEphemeralPostgresOptions, -): Effect.Effect< - EffectEphemeralPostgres, - EphemeralPostgresCreateError, - Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService -> => - Effect.gen(function* () { - const resolver = yield* Effect.serviceOption(ContainerEngineResolver).pipe( - Effect.map(Option.getOrUndefined), - ); - const selection = yield* resolvedRuntime(options.runtime, resolver); - const runtime = selection.runtime; - const release = yield* resolveEphemeralPostgresRelease(options.version); - const envOption = yield* Effect.serviceOption(StackRuntimeEnvironment); - const env = Option.isSome(envOption) ? envOption.value : yield* defaultRuntimeEnvironment; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const identity = yield* createIdentity(crypto); - const root = path.join(path.dirname(env.stateRoot), "ephemeral-postgres", identity); - const dataPath = path.join(root, PGDATA_DIR_NAME); - yield* fs.makeDirectory(dataPath, { recursive: true, mode: 0o700 }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to create ephemeral Postgres data directory", - path: dataPath, - cause, - }), - ), - ); - yield* Effect.addFinalizer(() => fs.remove(root, { recursive: true }).pipe(Effect.ignore)); - if (options.restoreFrom !== undefined) - yield* peekSnapshotRuntime( - options.restoreFrom, - runtime, - path.join(root, "peek"), - options.snapshotKey, - ); - const port = yield* allocateLoopbackPort(options.port); - const healthTimeout = options.healthTimeout ?? DEFAULT_DATABASE_HEALTH_TIMEOUT; - const password = Redacted.value(options.databasePassword); - const workload = plannedWorkload(release.version, release.image, runtime); - const preparer = yield* makeProductionRuntimeArtifactPreparer({ - stateRoot: env.stateRoot, - ...(env.artifactCacheRoot === undefined ? {} : { artifactCacheRoot: env.artifactCacheRoot }), - runtime, - }); - const prepared = yield* preparer.prepare(runtime, workload); - let resources: RuntimeResources; - if (runtime.kind === "native") { - resources = { kind: "native" }; - } else { - const engine = yield* resolveContainerEngine(runtime.engine, resolver).pipe( - Effect.mapError( - (cause) => - new ContainerEngineError({ - message: `Unable to configure ${runtime.engine} for ephemeral Postgres`, - engine: runtime.engine, - cause, - }), - ), - ); - resources = { kind: "container", engine }; - } - const cluster: Cluster = { - identity, - root, - dataPath, - host: "127.0.0.1", - port, - version: release.version, - runtime, - artifactIdentity: - runtime.kind === "native" - ? `native:${release.version}` - : `container:${runtime.engine}:${release.image}`, - ...(prepared.executablePath === undefined || prepared.artifactRoot === undefined - ? {} - : { - executable: prepared.artifactRoot.endsWith("/") - ? `${prepared.artifactRoot}${prepared.executablePath}` - : `${prepared.artifactRoot}/${prepared.executablePath}`, - artifactRoot: prepared.artifactRoot, - }), - ...(prepared.image === undefined ? {} : { image: prepared.image }), - lifecycle: Semaphore.makeUnsafe(1), - running: false, - bootstrapped: options.restoreFrom !== undefined, - ...(options.snapshotKey === undefined ? {} : { snapshotKey: options.snapshotKey }), - resources, - }; - yield* Effect.addFinalizer(() => destroyCluster(cluster)); - if (cluster.resources.kind === "container") { - const resources = cluster.resources; - const engine = resources.engine; - const engineKind = cluster.runtime.kind === "container" ? cluster.runtime.engine : "docker"; - const networkName = resourceName(identity, "network"); - const volumeName = resourceName(identity, "database-volume"); - const containerName = resourceName(identity, "database"); - resources.networkName = networkName; - resources.volumeName = volumeName; - resources.containerName = containerName; - yield* Effect.uninterruptibleMask((restore) => - restore( - engine.createNetwork({ - name: networkName, - labels: { stackId: identity, ownerSessionId: identity.slice(0, 32), role: "network" }, - }), - ).pipe( - Effect.mapError( - (cause) => - new ContainerEngineError({ - message: "Unable to create ephemeral Postgres network", - engine: engineKind, - cause, - }), - ), - Effect.tap((created) => - Effect.sync(() => { - resources.networkId = created.id; - }), - ), - ), - ); - yield* Effect.uninterruptibleMask((restore) => - restore( - engine.createVolume({ - name: volumeName, - labels: { stackId: identity, workloadId: DATABASE_WORKLOAD_ID, role: "volume" }, - }), - ).pipe( - Effect.mapError( - (cause) => - new ContainerEngineError({ - message: "Unable to create ephemeral Postgres volume", - engine: engineKind, - cause, - }), - ), - Effect.tap((created) => - Effect.sync(() => { - resources.volumeId = created.id; - }), - ), - ), - ); - } - if (options.restoreFrom !== undefined) { - if (runtime.kind === "native") yield* restoreNative(cluster, options.restoreFrom); - else yield* restoreContainer(cluster, options.restoreFrom); - } - if (runtime.kind === "native") yield* startNative(cluster, options, healthTimeout, password); - else yield* startContainer(cluster, options, healthTimeout, password); - return { - ...clusterHandle(cluster, options, healthTimeout, password), - ...(selection.dockerFallbackNotice === undefined - ? {} - : { dockerFallbackNotice: selection.dockerFallbackNotice }), - }; - }); diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index d69b23ed8b..d5259754c0 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -7,6 +7,7 @@ import type { ExitCode, ProcessId, } from "effect/unstable/process/ChildProcessSpawner"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; export interface NativeProcessSpec { readonly executable: string; @@ -34,6 +35,7 @@ export interface NativeProcessLauncher { /** Stable command-line marker used by diagnostics to identify owned processes. */ export interface NativeProcessIdentity { readonly stackId: string; + readonly instanceId: ServiceInstanceId; readonly workloadId: string; } @@ -125,6 +127,7 @@ export const spawnNativeProcess = ( ...launcher.args, "--", `supabase-stack-id=${identity.stackId}`, + `supabase-instance-id=${identity.instanceId}`, `supabase-workload-id=${identity.workloadId}`, ]; const handle: ChildProcessHandle = yield* ChildProcess.make(launcher.command, launcherArgs, { diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index e4a5a7c7a8..e45005942a 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope, Semaphore, Stream } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Scope, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { ExitCode } from "effect/unstable/process/ChildProcessSpawner"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; @@ -11,8 +11,10 @@ import { type RuntimeCleanupRequest, type ObservedWorkload, type RuntimeDriver, + type RuntimeStartOptions, type RuntimeWorkloadKey, } from "./RuntimeDriver.ts"; +import { makeRuntimeCoordination } from "./RuntimeCoordination.ts"; import { NativeProcessError, spawnNativeProcess, @@ -58,7 +60,7 @@ export interface NativeRuntimeOptions { ) => Effect.Effect; readonly logStore?: LogStore; /** Wipes native PGDATA after the database workload has been stopped and removed. */ - readonly wipeDatabaseData?: Effect.Effect; + readonly wipeDatabaseData?: (key: RuntimeWorkloadKey) => Effect.Effect; readonly knownSecrets?: Effect.Effect>; } @@ -82,25 +84,29 @@ interface Resource { readonly tail: ProcessOutputTail; readonly result: Deferred.Deferred; readonly failure: Deferred.Deferred; + readonly startOptions: RuntimeStartOptions; stopRequested: boolean; process?: NativeProcess; startFiber?: Fiber.Fiber; } const resourceKey = (key: RuntimeWorkloadKey): string => - JSON.stringify([key.stackId, key.workloadId]); + JSON.stringify([key.stackId, key.instanceId, key.workloadId]); const sameKey = (left: RuntimeWorkloadKey, right: RuntimeWorkloadKey): boolean => - left.stackId === right.stackId && left.workloadId === right.workloadId; + left.stackId === right.stackId && + left.instanceId === right.instanceId && + left.workloadId === right.workloadId; const driverError = ( - key: Pick, + key: Pick, message: string, cause?: unknown, ): RuntimeDriverError => new RuntimeDriverError({ message: withLeftoverPersistentDataGuidance(message), stackId: key.stackId, + instanceId: key.instanceId, workloadId: key.workloadId, ...(cause === undefined ? {} : { cause }), }); @@ -163,7 +169,7 @@ export const makeNativeRuntime = ( const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); - const registration = yield* Semaphore.make(1); + const coordination = yield* makeRuntimeCoordination; const resources = new Map(); const knownSecrets = options.knownSecrets ?? Effect.succeed>([]); @@ -410,6 +416,7 @@ export const makeNativeRuntime = ( const exitCode = Fiber.join(exitFiber); yield* Effect.forkIn(watchProcess(resource, process, exitCode), resource.scope); const outputFiber = yield* attachLogs(resource, process); + if (resource.startOptions.onStarted !== undefined) yield* resource.startOptions.onStarted; const readiness = options.waitForReadiness; const mainExit = exitCode.pipe( Effect.flatMap((code) => @@ -478,6 +485,7 @@ export const makeNativeRuntime = ( const start = ( key: RuntimeWorkloadKey, workload: PlannedWorkload, + startOptions: RuntimeStartOptions = {}, ): Effect.Effect => { if (nativeRuntimeBlockedForUid()) return Effect.fail(driverError(key, NATIVE_ROOT_UNSUPPORTED_MESSAGE)); @@ -486,13 +494,13 @@ export const makeNativeRuntime = ( new RuntimeDriverError({ message: "Native runtime cannot start a container artifact", stackId: key.stackId, + instanceId: key.instanceId, workloadId: key.workloadId, }), ); return Effect.flatMap( - // The permit serializes registration only; process readiness and log following continue - // after it is released, while stop/remove/cleanup hold it for their full operation. - registration.withPermit( + coordination.withKey( + key, Effect.gen(function* () { const id = resourceKey(key); const existing = resources.get(id); @@ -512,6 +520,7 @@ export const makeNativeRuntime = ( state, result, failure, + startOptions, output: { stdout: { decoder: new TextDecoder(), remainder: "" }, stderr: { decoder: new TextDecoder(), remainder: "" }, @@ -519,9 +528,17 @@ export const makeNativeRuntime = ( tail: makeProcessOutputTail(), stopRequested: false, }; - resources.set(id, resource); - resource.startFiber = yield* Effect.forkIn(runStart(resource), runtimeScope); - return resource; + const committed = yield* coordination.withMapCommit( + key.stackId, + Effect.gen(function* () { + resources.set(id, resource); + resource.startFiber = yield* Effect.forkIn(runStart(resource), runtimeScope); + return resource; + }), + ); + if (Option.isNone(committed)) + return yield* driverError(key, "Native runtime cleanup is in progress"); + return committed.value; }), ), (resource) => Deferred.await(resource.result), @@ -584,7 +601,8 @@ export const makeNativeRuntime = ( }); const stop = (key: RuntimeWorkloadKey): Effect.Effect => - registration.withPermit( + coordination.withKey( + key, Effect.gen(function* () { const resource = resources.get(resourceKey(key)); if (resource === undefined) return; @@ -595,7 +613,8 @@ export const makeNativeRuntime = ( ); const remove = (key: RuntimeWorkloadKey): Effect.Effect => - registration.withPermit( + coordination.withKey( + key, Effect.gen(function* () { const resource = resources.get(resourceKey(key)); if (resource === undefined) return; @@ -608,7 +627,8 @@ export const makeNativeRuntime = ( const cleanupRuntime = ( request: RuntimeCleanupRequest, ): Effect.Effect => - registration.withPermit( + coordination.withStackCleanup( + request.stackId, Effect.gen(function* () { let cleanupCause: Cause.Cause = Cause.empty; const attempt = (effect: Effect.Effect) => @@ -620,19 +640,20 @@ export const makeNativeRuntime = ( (resource) => resource.key.stackId === request.stackId, ); for (const resource of owned) { - yield* attempt(stopResource(resource)); - yield* attempt(removeResource(resource)); + yield* attempt(coordination.withKey(resource.key, stopResource(resource))); + yield* attempt(coordination.withKey(resource.key, removeResource(resource))); } if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); }), ); - const wipePersistentData = ( - key: RuntimeWorkloadKey, - ): Effect.Effect => - key.workloadId === "database:database" && options.wipeDatabaseData !== undefined - ? options.wipeDatabaseData - : Effect.void; + const wipePersistentData = (key: RuntimeWorkloadKey): Effect.Effect => + coordination.withKey( + key, + key.workloadId.endsWith(":database") && options.wipeDatabaseData !== undefined + ? options.wipeDatabaseData(key) + : Effect.void, + ); return { observe, diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 55e03591cd..6957cc7de0 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -19,7 +19,9 @@ const containerFormat = [ "{{.Names}}", templateLabel(CONTAINER_LABEL_KEYS.stackId), templateLabel(CONTAINER_LABEL_KEYS.ownerSessionId), + templateLabel(CONTAINER_LABEL_KEYS.instanceId), templateLabel(CONTAINER_LABEL_KEYS.workloadId), + templateLabel(CONTAINER_LABEL_KEYS.recipeId), templateLabel(CONTAINER_LABEL_KEYS.startup), templateLabel(CONTAINER_LABEL_KEYS.role), "{{.State}}", @@ -34,6 +36,7 @@ const networkFormat = [ const volumeFormat = [ "{{.Name}}", templateMapLabel(CONTAINER_LABEL_KEYS.stackId), + templateMapLabel(CONTAINER_LABEL_KEYS.instanceId), templateMapLabel(CONTAINER_LABEL_KEYS.workloadId), templateMapLabel(CONTAINER_LABEL_KEYS.role), ].join("\\t"); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index c5cf9a4a68..6fd4dc4674 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -13,6 +13,7 @@ import { runDatabaseBootstrap, } from "../model/DatabaseBootstrap.ts"; import type { PersistedStackState } from "../state/StackState.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; @@ -184,12 +185,15 @@ export const ensureInternalDatabase = ( /** Runs the initial bootstrap through the durable loopback database endpoint. */ export const bootstrapDatabaseAt = ( state: PersistedStackState, + instance: PersistedServiceInstance, ): Effect.Effect => Effect.gen(function* () { - const plan = yield* databaseBootstrapPlan(state); + const plan = yield* databaseBootstrapPlan(state, instance); const port = state.privatePorts.find( (assignment) => - assignment.workloadId === "database:database" && assignment.binding === "primary", + assignment.instanceId === instance.id && + assignment.workloadId.endsWith(":database") && + assignment.binding === "sql:internal", )?.port; if (port === undefined) return yield* new StackPreparationError({ diff --git a/packages/stack/src/runtime/PostgresInstanceRuntime.ts b/packages/stack/src/runtime/PostgresInstanceRuntime.ts new file mode 100644 index 0000000000..cdb37829b4 --- /dev/null +++ b/packages/stack/src/runtime/PostgresInstanceRuntime.ts @@ -0,0 +1,1148 @@ +import { + Cause, + Context, + Effect, + Exit, + FileSystem, + Path, + PlatformError, + Option, + Predicate, + Schema, + Stream, +} from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- tar-stream consumes a Node Readable. +import { createReadStream } from "node:fs"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { extract } from "tar-stream"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { + ServiceInitializationEvidence, + ServiceInitializationInputs, +} from "../model/ServiceRegistry.ts"; +import type { PersistedPendingOperation } from "../model/ServiceRegistry.ts"; +import type { InstanceRuntimeInput } from "../supervisor/Lifecycle.ts"; +import type { BackendEndpoint } from "../gateway/Gateway.ts"; +import type { SnapshotDescriptor } from "../public/Service.ts"; +import { ServiceKindSchema } from "../public/Service.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import type { StackError } from "../public/Errors.ts"; +import { + NoSnapshotDataError, + InitializationMismatchError, + SnapshotTargetInvalidError, + StackPreparationError, + StackCleanupError, + StackRuntimeError, + UnsupportedSnapshotError, + isStackError, +} from "../public/Errors.ts"; +import type { StackPaths } from "../state/Paths.ts"; +import type { RuntimeDriver, RuntimeWorkloadKey } from "./RuntimeDriver.ts"; +import type { RuntimeBindingPublication } from "./RuntimeBinding.ts"; +import type { PreparedWorkloadArtifact } from "../preparation/RuntimeArtifacts.ts"; + +const DATABASE_CAPABILITY = "database" as const; +const DATABASE_BINDING = "sql:internal"; +const ARCHIVE_FORMAT = "supabase-postgres-instance-v1"; +const ARCHIVE_MAJOR = 1; +const MANIFEST_NAME = "manifest.json"; + +const InstanceManifestSchema = Schema.Struct({ + format: Schema.Literal(ARCHIVE_FORMAT), + majorVersion: Schema.Literal(ARCHIVE_MAJOR), + sourceInstanceId: ServiceInstanceIdSchema, + lineageId: Schema.String, + profileId: Schema.NullOr(Schema.String), + artifactIdentity: Schema.String, + runtimeIdentity: Schema.String, + exportOperationId: Schema.String, + recipes: Schema.Array( + Schema.Struct({ + service: ServiceKindSchema, + recipeId: Schema.String, + artifactIdentity: Schema.String, + completed: Schema.Boolean, + }), + ), + dataFormat: Schema.Struct({ + provider: Schema.Literal("postgres"), + format: Schema.String, + majorVersion: Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))), + }), +}); +type InstanceManifest = Schema.Schema.Type; + +export interface CatalogInitializationRecipe { + readonly service: keyof ServiceInitializationInputs["catalog"]; + readonly recipeId: string; + readonly version: string; + readonly settings: unknown; +} + +export interface CatalogInitializationResult { + readonly artifactIdentity: string; +} + +interface PostgresSnapshotMetadata { + readonly artifactIdentity: string; + readonly runtimeIdentity: string; + readonly majorVersion: number; +} + +interface PostgresArtifactPreparer { + readonly prepare: ( + runtime: StackRuntime, + workload: PlannedWorkload, + ) => Effect.Effect; +} + +export interface PostgresInstanceRuntimeOptions { + readonly runtime: StackRuntime; + readonly paths: StackPaths; + readonly driver: RuntimeDriver; + readonly artifactPreparer: PostgresArtifactPreparer; + readonly context: Context.Context< + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >; + /** Archives the exact native PGDATA or container volume to the supplied staging directory. */ + readonly snapshotData: { + /** Confirms that the exact instance data store exists before export. */ + readonly exists: (input: InstanceRuntimeInput) => Effect.Effect; + /** Reads the PostgreSQL major version from the exact runtime-owned data store. */ + readonly readVersion: (input: InstanceRuntimeInput) => Effect.Effect; + /** Confirms that restore will publish into an empty exact instance data store. */ + readonly restoreTargetEmpty: ( + input: InstanceRuntimeInput, + ) => Effect.Effect; + readonly export: ( + input: InstanceRuntimeInput, + destination: string, + ) => Effect.Effect; + /** Must create the target parent, publish atomically, and leave it absent on failure. */ + readonly restore: ( + input: InstanceRuntimeInput, + source: string, + destination: string, + ) => Effect.Effect; + /** Removes a newly published restore target after a later publication step fails. */ + readonly rollbackRestore: (input: InstanceRuntimeInput) => Effect.Effect; + }; + /** Supplies the concrete resolved artifact/runtime identity persisted in snapshot metadata. */ + readonly snapshotMetadata: ( + input: InstanceRuntimeInput, + workload: PlannedWorkload, + ) => Effect.Effect; + /** Applies the managed Postgres roles/settings after the private endpoint is ready. */ + readonly reconcileManaged: ( + input: InstanceRuntimeInput, + endpoint: BackendEndpoint, + workload: PlannedWorkload, + artifact: PreparedWorkloadArtifact, + ) => Effect.Effect; + /** Applies one catalog recipe to the exact database instance being started. */ + readonly reconcileCatalogRecipe: ( + input: InstanceRuntimeInput, + recipe: CatalogInitializationRecipe, + endpoint: BackendEndpoint, + ) => Effect.Effect; + /** Publishes the completed same-profile receipt in the owning state transaction. */ + readonly publishInitialization: ( + input: InstanceRuntimeInput, + evidence: ServiceInitializationEvidence, + ) => Effect.Effect; + /** Publishes a fresh-data lineage after the first successful database start. */ + readonly publishFreshData: ( + input: InstanceRuntimeInput, + lineageId: string, + ) => Effect.Effect; + /** Publishes an operation-scoped incomplete-data marker before storage mutation. */ + readonly publishIncompleteData: (input: InstanceRuntimeInput) => Effect.Effect; + /** Publishes absent data after the runtime proves a failed mutation left no target data. */ + readonly publishAbsentData: (input: InstanceRuntimeInput) => Effect.Effect; + /** Journals helper/staging ownership before any snapshot helper is started. */ + readonly journal: ( + input: InstanceRuntimeInput, + phase: "admitted" | "running" | "settling" | "cleanup" | "complete", + patch?: Readonly<{ + readonly stagingPath?: string; + readonly outputPath?: string; + readonly helperId?: string; + }>, + ) => Effect.Effect; +} + +const missingWorkload = (input: InstanceRuntimeInput): StackRuntimeError => + new StackRuntimeError({ + message: `Database workload for instance ${input.instance.id} is missing from the execution plan`, + stackId: input.stackId, + workloadId: input.instance.id, + }); + +const keyFor = (input: InstanceRuntimeInput, workload: PlannedWorkload): RuntimeWorkloadKey => ({ + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, +}); + +const instanceRecipes = ( + inputs: ServiceInitializationInputs | null, +): ReadonlyArray => { + if (inputs === null) return []; + type CatalogService = keyof ServiceInitializationInputs["catalog"]; + const services: ReadonlyArray = [ + "auth", + "storage", + "realtime", + "analytics", + "pooler", + ]; + return services.flatMap((service) => { + const recipe = inputs.catalog[service]; + return recipe === undefined + ? [] + : [ + { + service, + recipeId: `${service}:${recipe.version}`, + version: recipe.version, + settings: recipe.settings, + }, + ]; + }); +}; + +const privateEndpoint = ( + input: InstanceRuntimeInput, + workload: PlannedWorkload, +): BackendEndpoint | undefined => { + const assignment = input.state.privatePorts.find( + (entry) => + entry.instanceId === input.instance.id && + entry.workloadId === workload.id && + entry.binding === DATABASE_BINDING, + ); + return assignment === undefined ? undefined : { host: "127.0.0.1", port: assignment.port }; +}; + +const archiveError = (message: string, cause?: unknown): StackPreparationError => + new StackPreparationError({ message, ...(cause === undefined ? {} : { cause }) }); + +const stackError = (cause: unknown): StackError => + isStackError(cause) + ? cause + : new StackRuntimeError({ + message: cause instanceof Error ? cause.message : "PostgreSQL instance runtime failed", + cause, + }); + +const cleanupError = (cause: unknown): StackCleanupError => + new StackCleanupError({ + message: cause instanceof Error ? cause.message : "PostgreSQL instance cleanup failed", + cause, + }); + +const runTar = ( + args: ReadonlyArray, +): Effect.Effect< + void, + StackPreparationError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("tar", args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { COPYFILE_DISABLE: "1" }, + extendEnv: true, + }).pipe( + Effect.mapError((cause) => archiveError("Unable to start snapshot archive helper", cause)), + ); + const drain = Effect.all([Stream.runDrain(handle.stdout), Stream.runDrain(handle.stderr)], { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.mapError((cause) => archiveError("Snapshot archive output failed", cause))); + const [exitCode] = yield* Effect.all([handle.exitCode, drain], { + concurrency: "unbounded", + }).pipe(Effect.mapError((cause) => archiveError("Snapshot archive helper failed", cause))); + if (Number(exitCode) !== 0) + return yield* archiveError(`Snapshot archive helper exited with ${String(exitCode)}`); + }), + ); + +const inspectArchive = ( + source: string, +): Effect.Effect, StackPreparationError> => + Effect.callback, StackPreparationError>((resume, signal) => { + const archive = extract(); + const input = createReadStream(source); + const paths: string[] = []; + let settled = false; + const onAbort = () => { + input.destroy(); + archive.destroy(); + }; + const fail = (cause: unknown) => { + if (settled) return; + settled = true; + input.destroy(); + archive.destroy(); + resume( + Effect.fail( + archiveError( + cause instanceof Error ? cause.message : "Unable to inspect snapshot archive", + cause, + ), + ), + ); + }; + const validate = (name: string, type: string | null | undefined) => { + if (name.length === 0 || name.includes("\\")) + throw new Error("Snapshot archive contains an invalid path"); + const isAppleDouble = name === "._manifest.json" || name.startsWith("postgres/._"); + if ( + name.startsWith("/") || + name + .split("/") + .some( + (part, index, parts) => + part === ".." || (part.length === 0 && index < parts.length - 1), + ) || + (!isAppleDouble && + name !== MANIFEST_NAME && + name !== "postgres" && + !name.startsWith("postgres/")) + ) + throw new Error("Snapshot archive contains an unsafe path"); + if (type !== "file" && type !== "directory") + throw new Error("Snapshot archive contains an unsupported link or special file"); + }; + archive.on("entry", (header, entry, next) => { + try { + validate(header.name, header.type); + paths.push(header.name); + entry.once("error", fail); + entry.once("end", next); + entry.resume(); + } catch (cause) { + archive.destroy(); + fail(cause); + } + }); + archive.once("error", fail); + archive.once("finish", () => { + if (settled) return; + settled = true; + resume(Effect.succeed(paths)); + }); + input.once("error", fail); + input.pipe(archive); + signal.addEventListener("abort", onAbort); + return Effect.sync(() => { + signal.removeEventListener("abort", onAbort); + input.destroy(); + archive.destroy(); + }); + }); + +const readManifest = ( + fs: FileSystem.FileSystem, + path: Path.Path, + archiveRoot: string, +): Effect.Effect => + fs.readFileString(path.join(archiveRoot, MANIFEST_NAME)).pipe( + Effect.mapError((cause) => archiveError("Snapshot manifest is missing", cause)), + Effect.flatMap((contents) => + Schema.decodeEffect(Schema.fromJsonString(InstanceManifestSchema))(contents).pipe( + Effect.mapError(() => archiveError("Snapshot manifest is invalid")), + ), + ), + ); + +const destinationParent = ( + fs: FileSystem.FileSystem, + path: Path.Path, + destination: string, +): Effect.Effect => + fs.makeDirectory(path.dirname(destination), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new SnapshotTargetInvalidError({ + message: "Unable to create snapshot destination directory", + path: destination, + cause, + }), + ), + ); + +/** Shared per-instance PostgreSQL lifecycle and snapshot implementation for native and containers. */ +export const makePostgresInstanceRuntime = ( + options: PostgresInstanceRuntimeOptions, +): { + readonly start: ( + input: InstanceRuntimeInput, + ) => Effect.Effect, StackError | StackPreparationError>; + readonly stop: (input: InstanceRuntimeInput) => Effect.Effect; + readonly destroy: (input: InstanceRuntimeInput) => Effect.Effect; + readonly exportSnapshot: ( + input: InstanceRuntimeInput, + options: { readonly destination: string }, + ) => Effect.Effect< + SnapshotDescriptor, + StackError | StackPreparationError | SnapshotTargetInvalidError + >; + readonly restoreSnapshot: ( + input: InstanceRuntimeInput, + options: { readonly source: string }, + ) => Effect.Effect; + readonly recoverSnapshot: ( + input: InstanceRuntimeInput, + operation: PersistedPendingOperation, + ) => Effect.Effect; +} => { + const provideContext = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ): Effect.Effect => effect.pipe(Effect.provideContext(options.context)); + + const workloadFor = ( + input: InstanceRuntimeInput, + ): Effect.Effect => { + const workload = input.plan.workloads.find( + (entry) => entry.instanceId === input.instance.id && entry.capability === DATABASE_CAPABILITY, + ); + return workload === undefined ? Effect.fail(missingWorkload(input)) : Effect.succeed(workload); + }; + + const start = (input: InstanceRuntimeInput) => + provideContext( + Effect.suspend(() => { + let started = false; + let cleanupKey: RuntimeWorkloadKey | undefined; + return Effect.gen(function* () { + const result = yield* Effect.exit( + Effect.gen(function* () { + const workload = yield* workloadFor(input); + const key = keyFor(input, workload); + cleanupKey = key; + const artifact = yield* options.artifactPreparer.prepare(options.runtime, workload); + const endpoint = privateEndpoint(input, workload); + if (endpoint === undefined) + return yield* new StackRuntimeError({ + message: `Private database port for instance ${input.instance.id} is missing`, + stackId: input.stackId, + workloadId: workload.id, + }); + const initialization = input.instance.initializationInputs; + if ( + initialization !== null && + input.instance.initialization?.profileId !== undefined && + input.instance.initialization.profileId !== initialization.profileId + ) + return yield* new InitializationMismatchError({ + instanceId: input.instance.id, + profileId: initialization.profileId, + message: "Database initialization profile does not match durable receipts", + }); + if ( + input.instance.data.origin === "absent" || + input.instance.data.origin === "incomplete" + ) + yield* options.publishIncompleteData(input); + yield* options.driver.start(key, workload); + started = true; + yield* journal(options, input, "running"); + yield* options.reconcileManaged(input, endpoint, workload, artifact); + const receipts: ServiceInitializationEvidence["recipes"][number][] = []; + const prior = + initialization !== null && + input.instance.initialization?.profileId === initialization.profileId + ? (input.instance.initialization?.recipes ?? []).filter( + (recipe) => recipe.completed, + ) + : []; + receipts.push(...prior); + const completed = new Set(prior.map((recipe) => recipe.recipeId)); + for (const recipe of instanceRecipes(input.instance.initializationInputs)) { + if (completed.has(recipe.recipeId)) continue; + const result = yield* options.reconcileCatalogRecipe(input, recipe, endpoint); + receipts.push({ + service: recipe.service, + recipeId: recipe.recipeId, + artifactIdentity: result.artifactIdentity, + completed: true, + }); + if (input.instance.initializationInputs !== null) + yield* options.publishInitialization(input, { + profileId: input.instance.initializationInputs.profileId, + recipes: receipts, + }); + } + if (input.instance.initializationInputs !== null) { + yield* options.publishInitialization(input, { + profileId: input.instance.initializationInputs.profileId, + recipes: receipts, + }); + } + if ( + input.instance.data.origin === "absent" || + input.instance.data.origin === "incomplete" + ) + yield* options.publishFreshData(input, input.operation.id); + yield* journal(options, input, "complete"); + return [ + { + workloadId: workload.id, + recipeId: workload.recipeId, + binding: DATABASE_BINDING, + endpoint, + } satisfies RuntimeBindingPublication, + ]; + }).pipe(Effect.mapError(stackError)), + ); + if (started && Exit.isFailure(result) && cleanupKey !== undefined) { + const cleanupResult = yield* Effect.exit( + options.driver + .stop(cleanupKey) + .pipe( + Effect.andThen(options.driver.remove(cleanupKey)), + Effect.mapError(stackError), + ), + ); + if (Exit.isFailure(cleanupResult)) + return yield* cleanupError({ operation: result.cause, cleanup: cleanupResult.cause }); + } + return yield* result; + }); + }).pipe(Effect.mapError(stackError)), + ); + + const stop = (input: InstanceRuntimeInput) => + provideContext( + Effect.gen(function* () { + const workload = yield* workloadFor(input); + yield* options.driver.stop(keyFor(input, workload)); + }).pipe(Effect.mapError(stackError)), + ); + + const destroy = (input: InstanceRuntimeInput) => + provideContext( + Effect.gen(function* () { + const workload = yield* workloadFor(input); + const key = keyFor(input, workload); + const stopped = yield* Effect.exit(options.driver.stop(key)); + const removed = yield* Effect.exit(options.driver.remove(key)); + const wiped = yield* Effect.exit(options.driver.wipePersistentData(key)); + const failures = [stopped, removed, wiped].flatMap((result) => + Exit.isFailure(result) ? [result.cause] : [], + ); + if (failures.length > 0) return yield* cleanupError(failures); + }).pipe(Effect.mapError(stackError)), + ); + + const exportSnapshot = ( + input: InstanceRuntimeInput, + snapshot: { readonly destination: string }, + ) => { + const destination = snapshot.destination; + return provideContext( + Effect.suspend(() => { + let ownsStage = false; + const operation = Effect.gen(function* () { + const workload = yield* workloadFor(input); + const instancePaths = yield* resolvePaths(options.paths, input.instance.id); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (!(yield* options.snapshotData.exists(input))) + return yield* new NoSnapshotDataError({ + message: "PostgreSQL instance data is absent", + instanceId: input.instance.id, + }); + if (yield* fs.exists(destination)) + return yield* new SnapshotTargetInvalidError({ + message: "Snapshot destination already exists", + path: destination, + }); + const metadata = yield* options.snapshotMetadata(input, workload); + const manifest = yield* manifestFor(input, undefined, metadata); + const staging = path.join(instancePaths.snapshotStaging, `export-${input.operation.id}`); + const stagedArchive = `${destination}.stage.${input.operation.id}`; + const stageClaim = `${stagedArchive}.claim`; + if (yield* fs.exists(stagedArchive)) + return yield* new SnapshotTargetInvalidError({ + message: "Snapshot staging destination already exists", + path: stagedArchive, + }); + if (yield* fs.exists(stageClaim)) + return yield* new SnapshotTargetInvalidError({ + message: "Snapshot staging claim already exists", + path: stageClaim, + }); + yield* destinationParent(fs, path, destination); + yield* fs.writeFileString(stageClaim, "", { flag: "wx", mode: 0o600 }).pipe( + Effect.mapError( + (cause) => + new SnapshotTargetInvalidError({ + message: "Unable to claim snapshot staging destination", + path: stageClaim, + cause, + }), + ), + ); + ownsStage = true; + yield* journal(options, input, "admitted", { + stagingPath: stagedArchive, + outputPath: destination, + helperId: `tar-export-${input.operation.id}`, + }); + yield* fs + .makeDirectory(path.join(staging, "postgres"), { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError((cause) => + archiveError("Unable to create snapshot staging directory", cause), + ), + ); + const encodedManifest = yield* encodeManifest(manifest); + yield* fs + .writeFileString(path.join(staging, MANIFEST_NAME), encodedManifest) + .pipe( + Effect.mapError((cause) => archiveError("Unable to write snapshot manifest", cause)), + ); + yield* options.snapshotData.export(input, path.join(staging, "postgres")); + yield* runTar(["-C", staging, "-cf", stagedArchive, MANIFEST_NAME, "postgres"]); + yield* fs.link(stagedArchive, destination).pipe( + Effect.mapError( + (cause) => + new SnapshotTargetInvalidError({ + message: "Unable to publish snapshot archive atomically", + path: destination, + cause, + }), + ), + ); + return descriptorFor(manifest); + }); + return withCleanup(() => + ownsStage + ? cleanupStaging( + options, + input, + "export", + `${destination}.stage.${input.operation.id}`, + `${destination}.stage.${input.operation.id}.claim`, + ) + : Effect.void, + )(operation.pipe(Effect.mapError(stackError))).pipe( + Effect.flatMap((descriptor) => + journal(options, input, "complete").pipe( + Effect.mapError((cause) => cleanupError(cause)), + Effect.as(descriptor), + ), + ), + Effect.mapError(stackError), + ); + }), + ); + }; + + const settleFailedRestore = (input: InstanceRuntimeInput, cause: Cause.Cause) => + Effect.gen(function* () { + const restoreError = Cause.findErrorOption(cause); + if ( + Cause.hasDies(cause) || + Cause.hasInterrupts(cause) || + Option.isNone(restoreError) || + restoreError.value instanceof StackCleanupError + ) + return yield* Effect.failCause(cause); + const targetEmpty = yield* Effect.exit(options.snapshotData.restoreTargetEmpty(input)); + if (Exit.isFailure(targetEmpty)) + return yield* Effect.failCause( + Cause.combine( + Cause.fail( + new StackCleanupError({ message: "Unable to prove failed restore target cleanup" }), + ), + Cause.combine(cause, targetEmpty.cause), + ), + ); + if (!targetEmpty.value) + return yield* Effect.failCause( + Cause.combine( + Cause.fail(new StackCleanupError({ message: "Failed restore target is not empty" })), + cause, + ), + ); + const absent = yield* Effect.exit(options.publishAbsentData(input)); + if (Exit.isFailure(absent)) + return yield* Effect.failCause( + Cause.combine( + Cause.fail( + new StackCleanupError({ message: "Unable to publish absent restore data state" }), + ), + Cause.combine(cause, absent.cause), + ), + ); + return yield* Effect.failCause(cause); + }); + + const restoreSnapshot = (input: InstanceRuntimeInput, snapshot: { readonly source: string }) => { + return provideContext( + Effect.suspend(() => { + let ownsStaging = false; + const operation = Effect.gen(function* () { + const workload = yield* workloadFor(input); + const source = snapshot.source; + const instancePaths = yield* resolvePaths(options.paths, input.instance.id); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (!(yield* fs.exists(source))) + return yield* new NoSnapshotDataError({ + message: "Snapshot source is absent", + instanceId: input.instance.id, + }); + if (!(yield* options.snapshotData.restoreTargetEmpty(input))) + return yield* new SnapshotTargetInvalidError({ + message: "Snapshot restore target is not empty", + path: instancePaths.data, + }); + const staging = path.join(instancePaths.snapshotStaging, `restore-${input.operation.id}`); + if (yield* fs.exists(staging)) + return yield* new SnapshotTargetInvalidError({ + message: "Snapshot restore staging directory already exists", + path: staging, + }); + yield* journal(options, input, "admitted", { + stagingPath: staging, + helperId: `tar-restore-${input.operation.id}`, + }); + yield* fs + .makeDirectory(staging, { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError((cause) => + archiveError("Unable to create restore staging directory", cause), + ), + ); + ownsStaging = true; + const archiveEntries = yield* inspectArchive(source); + if ( + !archiveEntries.includes(MANIFEST_NAME) || + (!archiveEntries.includes("postgres") && !archiveEntries.includes("postgres/")) + ) + return yield* archiveError("Snapshot archive is incomplete"); + yield* runTar(["-xf", source, "-C", staging, "--no-same-owner"]); + const manifest = yield* readManifest(fs, path, staging); + const pgVersion = yield* fs + .readFileString(path.join(staging, "postgres", "PG_VERSION")) + .pipe(Effect.mapError(() => archiveError("Snapshot PostgreSQL version is missing"))); + const expectedProfile = input.instance.initializationInputs?.profileId ?? null; + const metadata = yield* options.snapshotMetadata(input, workload); + if (manifest.profileId !== expectedProfile) + return yield* new InitializationMismatchError({ + instanceId: input.instance.id, + profileId: expectedProfile ?? "none", + message: "Snapshot initialization profile does not match the target instance", + }); + if ( + manifest.dataFormat.provider !== "postgres" || + manifest.majorVersion !== ARCHIVE_MAJOR || + manifest.dataFormat.format !== "pgdata" || + manifest.dataFormat.majorVersion !== metadata.majorVersion || + manifest.artifactIdentity !== metadata.artifactIdentity || + manifest.runtimeIdentity !== metadata.runtimeIdentity || + pgVersion.trim() !== String(metadata.majorVersion) + ) + return yield* new UnsupportedSnapshotError({ + message: "Snapshot format is unsupported", + instanceId: input.instance.id, + }); + yield* fs + .makeDirectory(path.dirname(instancePaths.manifest), { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError((cause) => + archiveError("Unable to create instance manifest directory", cause), + ), + ); + yield* options.publishIncompleteData(input); + const restored = yield* Effect.exit( + options.snapshotData.restore( + input, + path.join(staging, "postgres"), + instancePaths.postgresData, + ), + ); + if (Exit.isFailure(restored)) { + return yield* settleFailedRestore(input, restored.cause); + } + // A settling journal proves the restore helper published this operation's target. If + // the owner dies before the manifest receipt is committed, recovery may roll back + // that exact target through the driver's ownership-aware hook. + const settling = yield* Effect.exit(journal(options, input, "settling")); + if (Exit.isFailure(settling)) + return yield* new StackCleanupError({ + message: "Unable to journal restored PostgreSQL data publication", + cause: settling.cause, + }); + const encoded = yield* Effect.exit(encodeManifest(manifest)); + if (Exit.isFailure(encoded)) + return yield* new StackCleanupError({ + message: "Unable to encode restored PostgreSQL manifest", + cause: encoded.cause, + }); + const encodedManifest = encoded.value; + const published = yield* Effect.exit( + fs + .writeFileString(instancePaths.manifest, encodedManifest) + .pipe( + Effect.mapError((cause) => + archiveError("Unable to publish instance manifest", cause), + ), + ), + ); + if (Exit.isFailure(published)) { + const rollback = yield* Effect.exit(options.snapshotData.rollbackRestore(input)); + if (Exit.isFailure(rollback)) + return yield* cleanupError({ operation: published.cause, cleanup: rollback.cause }); + return yield* settleFailedRestore(input, published.cause); + } + if (manifest.profileId !== null && manifest.recipes.length > 0) { + const initialized = yield* Effect.exit( + options.publishInitialization(input, { + profileId: manifest.profileId, + recipes: manifest.recipes, + }), + ); + if (Exit.isFailure(initialized)) + return yield* new StackCleanupError({ + message: "Unable to publish restored initialization evidence", + cause: initialized.cause, + }); + } + return descriptorFor(manifest); + }); + return withCleanup(() => + ownsStaging ? cleanupStaging(options, input, "restore") : Effect.void, + )( + operation.pipe( + Effect.catchCause((cause) => Effect.failCause(Cause.map(cause, stackError))), + ), + ).pipe( + Effect.flatMap((descriptor) => + journal(options, input, "complete").pipe( + Effect.mapError((cause) => cleanupError(cause)), + Effect.as(descriptor), + ), + ), + Effect.catchCause((cause) => Effect.failCause(Cause.map(cause, stackError))), + ); + }), + ); + }; + + const recoverSnapshot = ( + input: InstanceRuntimeInput, + operation: PersistedPendingOperation, + ): Effect.Effect => + provideContext( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instancePaths = yield* resolvePaths(options.paths, input.instance.id); + const expectedStaging = path.join( + instancePaths.snapshotStaging, + `${operation.kind === "exportSnapshot" ? "export" : "restore"}-${operation.id}`, + ); + const expectedArchive = + operation.kind === "exportSnapshot" && operation.outputPath !== undefined + ? `${operation.outputPath}.stage.${operation.id}` + : undefined; + const recoveryFence = (message: string) => + new StackRuntimeError({ + message, + stackId: input.stackId, + workloadId: input.instance.id, + }); + if ( + operation.stagingPath !== undefined && + operation.stagingPath !== expectedStaging && + operation.stagingPath !== expectedArchive + ) + return yield* recoveryFence( + `Snapshot operation ${operation.id} has an unexpected staging path; refusing recovery`, + ); + + const cleanup = () => + cleanupStaging( + options, + input, + operation.kind === "exportSnapshot" ? "export" : "restore", + operation.kind === "exportSnapshot" + ? (expectedArchive ?? operation.stagingPath) + : undefined, + expectedArchive === undefined ? undefined : `${expectedArchive}.claim`, + ); + + if (operation.kind === "exportSnapshot") { + if (operation.outputPath === undefined || !(yield* fs.exists(operation.outputPath))) { + if (operation.phase === "complete") + return yield* recoveryFence( + `Snapshot operation ${operation.id} has no committed archive receipt`, + ); + yield* cleanup(); + return undefined; + } + const inspected = yield* Effect.exit(inspectArchive(operation.outputPath)); + if (Exit.isFailure(inspected)) { + if (operation.phase === "complete") + return yield* recoveryFence( + `Snapshot operation ${operation.id} has an unreadable committed archive`, + ); + // Publication uses an atomic link, so an unreadable or malformed destination was + // already present when this operation tried to publish. Preserve it and settle only + // the operation-owned staging paths. + yield* cleanup(); + return undefined; + } + const entries = inspected.value; + if ( + !entries.includes(MANIFEST_NAME) || + (!entries.includes("postgres") && !entries.includes("postgres/")) + ) { + if (operation.phase === "complete") + return yield* recoveryFence( + `Snapshot operation ${operation.id} has an invalid committed archive`, + ); + // A destination may have been created by another actor after this operation was + // admitted. Preserve it and remove only this operation's private staging paths. + yield* cleanup(); + return undefined; + } + yield* fs.makeDirectory(expectedStaging, { recursive: true, mode: 0o700 }); + yield* runTar(["-xf", operation.outputPath, "-C", expectedStaging, "--no-same-owner"]); + const manifest = yield* readManifest(fs, path, expectedStaging).pipe( + Effect.mapError(stackError), + ); + if ( + manifest.sourceInstanceId !== input.instance.id || + manifest.exportOperationId !== operation.id + ) { + if (operation.phase === "complete") + return yield* recoveryFence( + `Snapshot operation ${operation.id} has an unrelated committed archive`, + ); + // The atomic link cannot have replaced a preexisting destination. Keep that archive + // intact while settling the abandoned operation's own stage and claim. + yield* cleanup(); + return undefined; + } + yield* cleanup(); + return undefined; + } + + // The manifest is persisted by the stack, while the database data store belongs to the + // selected runtime. Container data lives in a volume and is intentionally invisible to + // the host filesystem. + const dataExists = yield* options.snapshotData.exists(input); + const manifestExists = yield* fs.exists(instancePaths.manifest); + if (!dataExists && !manifestExists) { + yield* cleanup(); + return undefined; + } + if (!dataExists || !manifestExists) { + if (dataExists && !manifestExists && operation.phase === "settling") { + yield* options.snapshotData.rollbackRestore(input); + yield* cleanup(); + return undefined; + } + return yield* recoveryFence( + `Restore operation ${operation.id} has incomplete committed data; refusing cleanup`, + ); + } + const contents = yield* fs + .readFileString(instancePaths.manifest) + .pipe(Effect.mapError((cause) => archiveError("Snapshot manifest is missing", cause))); + const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(InstanceManifestSchema))( + contents, + ).pipe(Effect.mapError(() => archiveError("Snapshot manifest is invalid"))); + const workload = yield* workloadFor(input); + const metadata = yield* options.snapshotMetadata(input, workload); + const pgVersion = yield* options.snapshotData.readVersion(input); + const expectedProfile = input.instance.initializationInputs?.profileId ?? null; + if (manifest.profileId !== expectedProfile) + return yield* recoveryFence( + `Restore operation ${operation.id} has a mismatched initialization profile`, + ); + if ( + manifest.dataFormat.provider !== "postgres" || + manifest.majorVersion !== ARCHIVE_MAJOR || + manifest.dataFormat.format !== "pgdata" || + manifest.dataFormat.majorVersion !== metadata.majorVersion || + manifest.artifactIdentity !== metadata.artifactIdentity || + manifest.runtimeIdentity !== metadata.runtimeIdentity || + pgVersion !== metadata.majorVersion + ) + return yield* recoveryFence( + `Restore operation ${operation.id} has unverified committed snapshot data`, + ); + yield* cleanup(); + return descriptorFor(manifest); + }).pipe(Effect.mapError(stackError)), + ); + + return { start, stop, destroy, exportSnapshot, restoreSnapshot, recoverSnapshot }; +}; + +const resolvePaths = (stack: StackPaths, instanceId: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceRoot = path.join(stack.runtime, "instances", instanceId); + const data = path.join(stack.data, "instances", instanceId); + return { + instanceRoot, + data, + postgresData: path.join(data, "postgres"), + manifest: path.join(data, MANIFEST_NAME), + snapshotStaging: path.join(instanceRoot, "snapshots"), + }; + }); + +const cleanupStaging = ( + options: PostgresInstanceRuntimeOptions, + input: InstanceRuntimeInput, + operation: "export" | "restore", + stagedArchive?: string, + stageClaim?: string, +) => + Effect.gen(function* () { + const paths = yield* resolvePaths(options.paths, input.instance.id); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + yield* removeIfPresent( + fs, + path.join(paths.snapshotStaging, `${operation}-${input.operation.id}`), + true, + ); + if (stagedArchive !== undefined) yield* removeIfPresent(fs, stagedArchive); + if (stageClaim !== undefined) yield* removeIfPresent(fs, stageClaim); + }).pipe(Effect.mapError(cleanupError)); + +const withCleanup = + ( + cleanup: () => Effect.Effect< + void, + StackCleanupError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ) => + ( + operation: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ): Effect.Effect< + A, + E | StackCleanupError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const result = yield* Effect.exit(restore(operation)); + const cleanupResult = yield* Effect.exit(cleanup()); + if (Exit.isFailure(result) && Exit.isFailure(cleanupResult)) + return yield* cleanupError({ operation: result.cause, cleanup: cleanupResult.cause }); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + if (Exit.isFailure(cleanupResult)) return yield* Effect.failCause(cleanupResult.cause); + return result.value; + }), + ); + +const removeIfPresent = ( + fs: FileSystem.FileSystem, + target: string, + recursive = false, +): Effect.Effect => + fs + .remove(target, { recursive }) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.void : Effect.fail(error), + ), + ); + +const journal = ( + options: PostgresInstanceRuntimeOptions, + input: InstanceRuntimeInput, + phase: "admitted" | "running" | "settling" | "cleanup" | "complete", + patch?: Readonly<{ + readonly stagingPath?: string; + readonly outputPath?: string; + readonly helperId?: string; + }>, +): Effect.Effect => options.journal(input, phase, patch); + +const manifestFor = ( + input: InstanceRuntimeInput, + profileId: string | undefined, + metadata: PostgresSnapshotMetadata, +): Effect.Effect => { + if (!Number.isInteger(metadata.majorVersion) || metadata.majorVersion <= 0) + return Effect.fail( + new UnsupportedSnapshotError({ + message: "PostgreSQL workload has no resolvable major version", + instanceId: input.instance.id, + }), + ); + const lineage = + input.instance.data.origin === "fresh" + ? input.instance.data.lineageId + : input.instance.data.origin === "restored" + ? input.instance.data.snapshot.lineageId + : undefined; + if (lineage === undefined) + return Effect.fail( + new UnsupportedSnapshotError({ + message: "PostgreSQL instance has no published data lineage", + instanceId: input.instance.id, + }), + ); + return Effect.succeed({ + format: ARCHIVE_FORMAT, + majorVersion: ARCHIVE_MAJOR, + sourceInstanceId: input.instance.id, + exportOperationId: input.operation.id, + lineageId: lineage, + profileId: + profileId ?? + input.instance.initializationInputs?.profileId ?? + (input.instance.data.origin === "restored" + ? input.instance.data.snapshot.initializationProfileId + : null), + artifactIdentity: metadata.artifactIdentity, + runtimeIdentity: metadata.runtimeIdentity, + recipes: input.instance.initialization?.recipes ?? [], + dataFormat: { provider: "postgres", format: "pgdata", majorVersion: metadata.majorVersion }, + }); +}; + +const descriptorFor = (manifest: InstanceManifest): SnapshotDescriptor => ({ + lineageId: manifest.lineageId, + initializationProfileId: manifest.profileId, + artifactIdentity: manifest.artifactIdentity, + runtimeIdentity: manifest.runtimeIdentity, + dataFormat: manifest.dataFormat, + provenance: { + sourceInstanceId: manifest.sourceInstanceId, + exportOperationId: manifest.exportOperationId, + }, +}); + +const encodeManifest = (manifest: InstanceManifest): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(InstanceManifestSchema))(manifest).pipe( + Effect.mapError(() => archiveError("Unable to encode snapshot manifest")), + ); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index a1c4551fdb..9cc99b1ceb 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -15,22 +15,32 @@ import { Scope, Schedule, Semaphore, + Predicate, + Redacted, + PlatformError, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { rebuildExecutionPlan, type StackDefinition } from "../model/Compiler.ts"; +import type { RuntimeArtifactInput } from "../preparation/RuntimeArtifacts.ts"; +import { createExecutionPlan } from "../model/Compiler.ts"; +import { CAPABILITY_MODULES } from "../model/ExecutionPlan.ts"; import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, } from "../functions/FunctionsBootstrap.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; +import { + resolveServiceInstancePaths, + resolveStackPaths, + type ServiceInstancePaths, +} from "../state/Paths.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; import { privateBindingKey, type PersistedStackState } from "../state/StackState.ts"; import type { PersistedSecretValues } from "../state/StackState.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; import type { StackId } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; -import type { ArtifactPreparationStatus } from "../public/Status.ts"; +import type { InstanceArtifactPreparationStatus } from "../public/Status.ts"; import type { CapabilityName } from "../public/Capability.ts"; import { GatewayActivationError, @@ -38,7 +48,12 @@ import { ContainerEngineError, PortUnavailableError, StackRuntimeMismatchError, + StackRuntimeError, + StackCleanupError, + StackLifecycleConflictError, StackStateInvalidError, + UnsupportedSnapshotError, + isStackError, type StackError, } from "../public/Errors.ts"; import { makeSupervisorIngress, type SupervisorIngress } from "../supervisor/Ingress.ts"; @@ -49,7 +64,15 @@ import { type LogStore, type LogRecord, } from "../supervisor/LogStore.ts"; -import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; +import type { InstanceRuntimeInput, LifecycleInput } from "../supervisor/Lifecycle.ts"; +import type { BackendEndpoint } from "../gateway/Gateway.ts"; +import type { RuntimeBindingPublication } from "./RuntimeBinding.ts"; +import type { PrepareResult } from "../public/Service.ts"; +import { makePostgresInstanceRuntime } from "./PostgresInstanceRuntime.ts"; +import type { + CatalogInitializationRecipe, + CatalogInitializationResult, +} from "./PostgresInstanceRuntime.ts"; import type { SupervisorRuntime } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeArtifactPreparer, @@ -70,24 +93,58 @@ import { runtimeSpecFor, validatePrivateAssignments, validateWorkloadRuntimeInputs, + functionOverridesForSettings, type WorkloadRuntimeInputs, } from "./WorkloadRuntimeSpec.ts"; import { makeNativeRuntime } from "./NativeRuntime.ts"; -import { makeContainerRuntime, type ContainerWorkloadResolution } from "./ContainerRuntime.ts"; -import { DEFAULT_READINESS_DEADLINE, probeReadiness } from "./ReadinessProbe.ts"; +import { + makeContainerRuntime, + type ContainerWorkloadResolution, + catalogInitContainerName, + workloadVolumeName, +} from "./ContainerRuntime.ts"; +import { + DEFAULT_READINESS_DEADLINE, + probeReadiness, + type ReadinessTarget, +} from "./ReadinessProbe.ts"; import { parseGoDuration } from "../model/capabilities/database.ts"; import type { ContainerEngine, + ContainerEngineFailure, ContainerEngineKind, ContainerHostRoute, + ContainerResource, + ContainerVolumeLabels, } from "./ContainerEngine.ts"; +import { ContainerCommandError } from "./ContainerEngine.ts"; import { resolveContainerEngine } from "./ContainerEngineResolver.ts"; -import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; +import { bootstrapDatabaseAt, bootstrapManagedPostgres } from "./PostgresDatabaseSession.ts"; +import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; +import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; +import { + rewriteCatalogDatabaseEnvironment, + runCatalogNativeProcess, +} from "./CatalogInitialization.ts"; +import { runContainerStartupProcess } from "./ContainerRuntime.ts"; import { validateMaterializedSecrets } from "../state/MaterializedSettings.ts"; +import { valueAt } from "../state/MaterializedSettings.ts"; +import { isRecord, settingValue, settingsForInstance } from "../state/MaterializedSettings.ts"; +import { + planFunctionFiles, + type FunctionFile, + type FunctionFilesPlan, +} from "../functions/FunctionFiles.ts"; +import { + resolveFunctionConfigs, + type FunctionFileSystem, + FunctionFileSystemError, +} from "../functions/serve-main-resolver.ts"; import { RuntimeDriverError, type RuntimeDriver, + type RuntimeStartOptions, type RuntimeWorkloadKey, } from "./RuntimeDriver.ts"; @@ -110,6 +167,12 @@ export interface ProductionRuntimeOptions { readonly bootstrapDatabase?: ( state: PersistedStackState, ) => Effect.Effect; + /** Runs one configured catalog recipe against the supplied instance endpoint. */ + readonly reconcileCatalogRecipe?: ( + input: InstanceRuntimeInput, + recipe: CatalogInitializationRecipe, + endpoint: BackendEndpoint, + ) => Effect.Effect; } const preparationError = (message: string, cause?: unknown): StackPreparationError => @@ -122,14 +185,16 @@ const unavailableLogStore = (error: LogStoreError, path: string): LogStore => ({ }); const driverError = ( - key: Pick, + key: Pick & + Partial>, message: string, cause?: unknown, ): RuntimeDriverError => new RuntimeDriverError({ message, stackId: key.stackId, - workloadId: key.workloadId, + ...(key.instanceId === undefined ? {} : { instanceId: key.instanceId }), + ...(key.workloadId === undefined ? {} : { workloadId: key.workloadId }), ...(cause === undefined ? {} : { cause }), }); @@ -153,8 +218,8 @@ const currentStateReader = (options: ProductionRuntimeOptions) => ), ); -const artifactKey = (runtime: StackRuntime, workload: PlannedWorkload): string => - `${runtime.kind}:${runtime.kind === "container" ? runtime.engine : ""}:${workload.id}:${workload.selected.kind === "native" ? workload.selected.release : workload.selected.image}`; +const artifactKey = (runtime: StackRuntime, workload: RuntimeArtifactInput): string => + `${runtime.kind}:${runtime.kind === "container" ? runtime.engine : ""}:${workload.recipeId}:${workload.selected.kind === "native" ? workload.selected.release : workload.selected.image}`; const runtimeMatches = (left: StackRuntime, right: StackRuntime): boolean => { if (left.kind !== right.kind) return false; @@ -167,7 +232,6 @@ const urlHost = (host: string): string => { return normalized.includes(":") && !normalized.startsWith("[") ? `[${normalized}]` : normalized; }; -const DATABASE_WORKLOAD_ID = "database:database"; // Native cold starts can spend more than 30 seconds loading shared libraries before serving. const NATIVE_READINESS_DEADLINE = Duration.minutes(2); const checkNativeDatabaseLockEvidence = ( @@ -223,13 +287,11 @@ const checkNativeDatabaseLockEvidence = ( ); }); const isDatabaseWorkload = (workload: PlannedWorkload): boolean => - workload.id === DATABASE_WORKLOAD_ID; + workload.capability === "database"; const configuredDatabaseReadinessDeadline = ( - definition: StackDefinition | undefined, + state: PersistedStackState, ): Effect.Effect => { - if (definition === undefined) - return Effect.fail(preparationError("Persisted database definition is missing")); - const configured = definition.capabilities.database.settings.health_timeout; + const configured = valueAt(state, "database", "health_timeout"); if (configured === undefined || configured === null) return Effect.fail(preparationError("Persisted database health_timeout is missing")); return Effect.try({ @@ -253,17 +315,17 @@ export const readinessDeadlineFor = ( workload: PlannedWorkload, ): Effect.Effect => isDatabaseWorkload(workload) - ? configuredDatabaseReadinessDeadline(state.definition) + ? configuredDatabaseReadinessDeadline(state) : Effect.succeed( state.runtime.kind === "native" ? NATIVE_READINESS_DEADLINE : DEFAULT_READINESS_DEADLINE, ); const validateDatabaseReadinessBudget = ( - definition: StackDefinition, + state: PersistedStackState, workloads: ReadonlyArray, ): Effect.Effect => workloads.some(isDatabaseWorkload) - ? configuredDatabaseReadinessDeadline(definition).pipe(Effect.asVoid) + ? configuredDatabaseReadinessDeadline(state).pipe(Effect.asVoid) : Effect.void; const redactEntry = ( @@ -334,14 +396,63 @@ const readinessFor = ( ); }; -declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; +const hasInspectorTarget = (value: unknown): boolean => + Array.isArray(value) && + value.some( + (entry) => + typeof entry === "object" && + entry !== null && + "webSocketDebuggerUrl" in entry && + typeof entry.webSocketDebuggerUrl === "string" && + entry.webSocketDebuggerUrl.length > 0, + ); + +const probeInspectorReadiness = ( + target: ReadinessTarget, + deadline: Duration.Duration, +): Effect.Effect => { + const attempt = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get( + `http://${urlHost(target.host)}:${target.port}${target.path ?? "/"}`, + ); + const body = yield* response.json; + if (response.status < 200 || response.status >= 300 || !hasInspectorTarget(body)) + return yield* new RuntimeDriverError({ + message: "Inspector target is not ready", + target, + }); + }).pipe( + Effect.mapError((error) => + error instanceof RuntimeDriverError + ? error + : new RuntimeDriverError({ + message: "Inspector readiness request failed", + target, + cause: error, + }), + ), + ); + return Effect.timeoutOrElse(Effect.retry(attempt, Schedule.spaced("100 millis")), { + duration: deadline, + orElse: () => + Effect.fail( + new RuntimeDriverError({ + message: "Inspector target readiness deadline exceeded", + target, + }), + ), + }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)); +}; + +declare const SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; // Release builds inject the already-bundled Edge Runtime entrypoint. The // source-only fallback keeps local development/tests convenient while keeping // esbuild out of the shipped supervisor's runtime dependency graph. const bootstrapContent = - typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" - ? Effect.succeed(SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE) + typeof SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" + ? Effect.succeed(SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE) : Effect.tryPromise({ try: () => import("../functions/serve-main-bundler.ts"), catch: (cause) => preparationError("Unable to bundle functions bootstrap", cause), @@ -356,11 +467,61 @@ const bootstrapContent = ); const mapDriverError = ( - key: Pick, + key: Pick, error: unknown, ): RuntimeDriverError => driverError(key, error instanceof Error ? error.message : "Runtime operation failed", error); +const isNotSymbolicLink = (error: PlatformError.PlatformError): boolean => { + if (!(error.reason instanceof PlatformError.SystemError) || error.reason._tag !== "Unknown") + return false; + const cause = error.reason.cause; + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EINVAL"; +}; + +const makeFunctionFileSystem = (fs: FileSystem.FileSystem): FunctionFileSystem => ({ + lstat: (pathname) => + Effect.gen(function* () { + const info = yield* fs.stat(pathname); + const isSymbolicLink = yield* fs.readLink(pathname).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => + isNotSymbolicLink(error) ? Effect.succeed(false) : Effect.fail(error), + ), + ); + return { + isDirectory: info.type === "Directory", + isFile: info.type === "File", + isSymbolicLink, + }; + }).pipe(Effect.mapError((cause) => new FunctionFileSystemError({ cause }))), + realPath: (pathname) => + fs.realPath(pathname).pipe(Effect.mapError((cause) => new FunctionFileSystemError({ cause }))), + readDirectory: (pathname) => + fs + .readDirectory(pathname) + .pipe(Effect.mapError((cause) => new FunctionFileSystemError({ cause }))), +}); + +const resolveGitRoot = ( + fs: FileSystem.FileSystem, + path: Path.Path, + projectRoot: string, +): Effect.Effect => + Effect.gen(function* () { + let current = path.resolve(projectRoot); + while (true) { + const marker = path.join(current, ".git"); + const exists = yield* fs + .exists(marker) + .pipe(Effect.mapError((cause) => preparationError("Unable to inspect Git root", cause))); + if (exists) return current; + const parent = path.dirname(current); + if (parent === current) return path.resolve(projectRoot); + current = parent; + } + }); + /** Ensures owner files are attempted even when the selected runtime cleanup fails. */ export const withOwnedRuntimeFileCleanup = ( driver: RuntimeDriver, @@ -371,7 +532,7 @@ export const withOwnedRuntimeFileCleanup = ( ): RuntimeDriver => { const cleanupFiles = (stackId: StackId): Effect.Effect => Effect.gen(function* () { - const key = { stackId, workloadId: "" }; + const key = { stackId }; let cleanupCause: Cause.Cause = Cause.empty; const attempts: ReadonlyArray> = [ ...(preparationCleanup === undefined @@ -419,6 +580,26 @@ export const withOwnedRuntimeFileCleanup = ( }; }; +/** Removes the durable and runtime roots owned by one destroyed service instance. */ +export const removeOwnedInstancePaths = ( + fileSystem: FileSystem.FileSystem, + instancePaths: Pick, +): Effect.Effect => + Effect.forEach( + [instancePaths.data, instancePaths.runtime], + (ownedPath) => + fileSystem.remove(ownedPath, { recursive: true, force: true }).pipe( + Effect.mapError( + (error) => + new StackCleanupError({ + message: `Unable to remove destroyed instance path ${ownedPath}`, + cause: error, + }), + ), + ), + { discard: true }, + ); + /** Composes concrete runtime owners around one persisted stack identity. */ export const makeProductionRuntime = ( options: ProductionRuntimeOptions, @@ -433,6 +614,12 @@ export const makeProductionRuntime = ( > => Effect.gen(function* () { const state = yield* currentStateReader(options); + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeContext = Context.add( + options.context, + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const paths = yield* resolveStackPaths({ @@ -517,27 +704,55 @@ export const makeProductionRuntime = ( })); const serveTemplate = yield* Effect.cached(bootstrapContent); const bootstrapDatabase = - options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); + options.bootstrapDatabase ?? + ((state: PersistedStackState) => { + const instanceId = state.registry.defaultInstanceIds.database; + const instance = state.registry.instances.find( + (entry) => entry.id === instanceId && entry.service === "database", + ); + return instance === undefined + ? Effect.fail( + new StackPreparationError({ message: "Default database instance is missing" }), + ) + : bootstrapDatabaseAt(state, instance); + }); const artifacts = new Map(); - const preparationStatuses = new Map(); - const recordPreparationProgress = (progress: RuntimeArtifactPreparationProgress): void => { + const preparationStatuses = new Map(); + const recordPreparationProgress = ( + progress: RuntimeArtifactPreparationProgress, + workload: PlannedWorkload, + artifactIdentity?: string, + ): void => { preparationStatuses.set(progress.workloadId, { workloadId: progress.workloadId, + instanceId: workload.instanceId, capability: progress.capability, state: progress.state, + ...(artifactIdentity === undefined ? {} : { artifactIdentity }), ...(progress.error === undefined ? {} : { error: progress.error }), }); }; const queuePreparation = (workload: PlannedWorkload): void => { - if (artifacts.has(artifactKey(state.runtime, workload))) return; + const cached = artifacts.get(artifactKey(state.runtime, workload)); + if (cached !== undefined) { + recordPreparationProgress( + { workloadId: workload.id, capability: workload.capability, state: "ready" }, + workload, + cached.image ?? `${workload.recipeId}@${cached.version}`, + ); + return; + } const current = preparationStatuses.get(workload.id); if (current?.state === "preparing" || current?.state === "downloading") return; - recordPreparationProgress({ - workloadId: workload.id, - capability: workload.capability, - state: "queued", - }); + recordPreparationProgress( + { + workloadId: workload.id, + capability: workload.capability, + state: "queued", + }, + workload, + ); }; const preparationGate = yield* Semaphore.make(1); const parentScope = yield* Scope.Scope; @@ -555,8 +770,7 @@ export const makeProductionRuntime = ( // Runtime input materialization writes shared files and populates completed caches. Keep // that short preparation boundary serialized while allowing the actual workloads to start // concurrently after their inputs are ready. - const runtimeInputGate = yield* Semaphore.make(1); - const freshState = (key: Pick) => + const freshState = (key: Pick) => currentStateReader(options).pipe( Effect.mapError((error) => mapDriverError(key, error)), Effect.flatMap((fresh) => @@ -571,35 +785,56 @@ export const makeProductionRuntime = ( const cached = artifacts.get(key); return cached === undefined ? Effect.sync(() => - recordPreparationProgress({ - workloadId: workload.id, - capability: workload.capability, - state: "preparing", - }), + recordPreparationProgress( + { + workloadId: workload.id, + capability: workload.capability, + state: "preparing", + }, + workload, + ), ).pipe( - Effect.andThen(preparer.prepare(runtime, workload, recordPreparationProgress)), + Effect.andThen( + preparer.prepare(runtime, workload, (progress) => + recordPreparationProgress(progress, workload), + ), + ), Effect.tap((prepared) => Effect.sync(() => { artifacts.set(key, prepared); - recordPreparationProgress({ - workloadId: workload.id, - capability: workload.capability, - state: "ready", - }); + recordPreparationProgress( + { + workloadId: workload.id, + capability: workload.capability, + state: "ready", + }, + workload, + prepared.image ?? `${workload.recipeId}@${prepared.version}`, + ); }), ), Effect.tapError((error) => Effect.sync(() => - recordPreparationProgress({ - workloadId: workload.id, - capability: workload.capability, - state: "failed", - error: error.message, - }), + recordPreparationProgress( + { + workloadId: workload.id, + capability: workload.capability, + state: "failed", + error: error.message, + }, + workload, + ), ), ), ) - : Effect.succeed(cached); + : Effect.sync(() => { + recordPreparationProgress( + { workloadId: workload.id, capability: workload.capability, state: "ready" }, + workload, + cached.image ?? `${workload.recipeId}@${cached.version}`, + ); + return cached; + }); }); }; const prepare = (runtime: StackRuntime, workload: PlannedWorkload) => @@ -625,7 +860,29 @@ export const makeProductionRuntime = ( }), ), ); - return yield* Fiber.join(joined); + const prepared = yield* Fiber.join(joined).pipe( + Effect.tapError((error) => + Effect.sync(() => + recordPreparationProgress( + { + workloadId: workload.id, + capability: workload.capability, + state: "failed", + error: error.message, + }, + workload, + ), + ), + ), + ); + yield* Effect.sync(() => + recordPreparationProgress( + { workloadId: workload.id, capability: workload.capability, state: "ready" }, + workload, + prepared.image ?? `${workload.recipeId}@${prepared.version}`, + ), + ); + return prepared; }); const prepareArtifacts = (runtime: StackRuntime, workloads: ReadonlyArray) => Effect.forEach(workloads, (workload) => prepare(runtime, workload), { @@ -646,16 +903,18 @@ export const makeProductionRuntime = ( logs.append({ source: "supervisor", stream: "internal", message }).pipe(Effect.ignore); const prefetch = (persisted: PersistedStackState): Effect.Effect => Effect.gen(function* () { - if (persisted.definition === undefined || persisted.definition.preparation === "on-demand") - return; - const plan = yield* rebuildExecutionPlan(persisted.runtime, persisted.definition).pipe( + if (persisted.preparation === "on-demand") return; + const plan = yield* createExecutionPlan(persisted.runtime, persisted.registry).pipe( Effect.mapError((error) => preparationError("Unable to plan background preparation", error), ), ); const workloads = plan.workloads.filter( (workload) => - persisted.definition?.capabilities[workload.capability].activation === "lazy" && + persisted.registry.instances.find( + (instance) => + instance.id === workload.instanceId && instance.service === workload.capability, + )?.config.activation === "lazy" && !artifacts.has(artifactKey(persisted.runtime, workload)), ); for (const workload of workloads) queuePreparation(workload); @@ -694,55 +953,166 @@ export const makeProductionRuntime = ( artifacts.clear(); }); }).pipe(Effect.uninterruptible); - const functionsPath = (): Effect.Effect => - serveTemplate.pipe(Effect.flatMap((content) => functionsBootstrap.write({ content }))); + const functionsPath = ( + instanceId: PlannedWorkload["instanceId"], + ): Effect.Effect => + serveTemplate.pipe( + Effect.flatMap((content) => functionsBootstrap.write({ instanceId, content })), + ); const runtimeInputs = ( workload: PlannedWorkload, fresh: PersistedStackState, host: ContainerHostRoute | undefined, ): Effect.Effect => - runtimeInputGate.withPermit( - Effect.gen(function* () { - const material = yield* inputOwner.resolve(fresh, workload.id); - const templates = material.auth?.templates; - const apiListener = fresh.definition?.listeners.api; - const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); - const templateBaseUrl = - workload.id !== "auth:auth" || templates === undefined || templates.length === 0 - ? undefined - : apiListener?.enabled !== true || apiAssignment === undefined - ? yield* preparationError( - "Configured Auth email templates require a public API listener", - ) - : `http://${urlHost(host?.host ?? apiListener.address)}:${apiAssignment.port}`; - const auth = - material.auth === undefined - ? undefined - : { - ...material.auth, - ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), - }; - const functions = - workload.id === "functions:edge-runtime" - ? { - bootstrapPath: yield* functionsPath(), - ...(material.functions?.secrets === undefined - ? {} - : { secrets: material.functions.secrets }), - } - : undefined; - return { - ...(auth === undefined ? {} : { auth }), - ...(workload.id.startsWith("analytics:") && material.analytics !== undefined - ? { analytics: material.analytics } - : {}), - database: { dataPath: pathService.join(paths.data, "database") }, - storage: { dataPath: pathService.join(paths.data, "storage") }, - ...(functions === undefined ? {} : { functions }), - ...(host === undefined ? {} : { hostRoute: host }), - }; - }), - ); + Effect.gen(function* () { + const material = yield* inputOwner.resolve(fresh, workload.instanceId, workload.id); + const instancePaths = yield* resolveServiceInstancePaths(paths, workload.instanceId).pipe( + Effect.provideService(Path.Path, pathService), + Effect.mapError((cause) => + preparationError("Unable to resolve service instance runtime paths", cause), + ), + ); + const templates = material.auth?.templates; + const apiListener = fresh.listeners.api; + const apiAssignment = fresh.ports.find( + (assignment) => assignment.owner === "stack" && assignment.binding === "api", + ); + const templateBaseUrl = + workload.recipeId !== "auth:auth" || templates === undefined || templates.length === 0 + ? undefined + : apiListener?.enabled !== true || apiAssignment === undefined + ? yield* preparationError( + "Configured Auth email templates require a public API listener", + ) + : `http://${urlHost(host?.host ?? apiListener.address ?? "127.0.0.1")}:${apiAssignment.port}`; + const auth = + material.auth === undefined + ? undefined + : { + ...material.auth, + ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), + }; + const functions = + workload.recipeId === "functions:edge-runtime" + ? { + bootstrapPath: yield* functionsPath(workload.instanceId), + files: yield* ((): Effect.Effect => { + const functionSettings = settingsForInstance( + fresh, + workload.instanceId, + "functions", + ); + const root = + isRecord(functionSettings) && functionSettings.functions_root !== undefined + ? settingValue(fresh, functionSettings.functions_root) + : ""; + if (root.length === 0) + return Effect.succeed({ files: [], warnings: [], allowedRoots: [] }); + const projectRoot = fresh.identity.projectRoot; + const functionRoot = pathService.isAbsolute(root) + ? root + : pathService.resolve(projectRoot, root); + return Effect.gen(function* () { + const overrides = functionOverridesForSettings(fresh, workload.instanceId); + const functionFileSystem = makeFunctionFileSystem(fileSystem); + const resolved = yield* resolveFunctionConfigs({ + root: functionRoot, + overrides, + fs: functionFileSystem, + }); + const sourceRoot = yield* resolveGitRoot(fileSystem, pathService, projectRoot); + const additionalModuleRoots = resolved + .map(({ config }) => pathService.dirname(config.entrypointPath)) + .filter((entrypointRoot, index, roots) => { + if (roots.indexOf(entrypointRoot) !== index) return false; + const relative = pathService.relative(sourceRoot, entrypointRoot); + return ( + pathService.isAbsolute(relative) || + relative === ".." || + relative.startsWith(`..${pathService.sep}`) + ); + }); + const plans = yield* Effect.forEach(resolved, ({ config }) => + planFunctionFiles({ + projectRoot, + sourceRoot, + entrypoint: config.entrypointPath, + importMap: config.importMapPath, + staticFiles: config.staticFiles, + additionalModuleRoots, + skipMissingImportMapTargets: true, + }).pipe( + Effect.mapError((cause) => + preparationError("Unable to plan Functions runtime files", cause), + ), + ), + ); + const externalStaticFiles = yield* Effect.forEach( + resolved.flatMap(({ config }) => + config.staticFiles.filter((pathname) => { + const relative = pathService.relative(sourceRoot, pathname); + return ( + !/[*?[{]/u.test(relative) && + (pathService.isAbsolute(relative) || + relative === ".." || + relative.startsWith(`..${pathService.sep}`)) + ); + }), + ), + (pathname) => + functionFileSystem.lstat(pathname).pipe( + Effect.catchTag("FunctionFileSystemError", () => Effect.void), + Effect.map((info) => + info?.isFile + ? { + hostPath: pathname, + targetPath: pathname, + kind: "file" as const, + externalScope: true, + } + : undefined, + ), + ), + ); + const filesByTarget = new Map(); + for (const file of [ + ...plans.flatMap((plan) => plan.files), + ...externalStaticFiles, + ]) { + if (file !== undefined && !filesByTarget.has(file.targetPath)) + filesByTarget.set(file.targetPath, file); + } + return { + files: [...filesByTarget.values()], + warnings: plans.flatMap((plan) => plan.warnings), + allowedRoots: [...new Set(plans.flatMap((plan) => plan.allowedRoots))], + }; + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.mapError((cause) => + cause instanceof StackPreparationError + ? cause + : preparationError("Unable to plan Functions runtime files", cause), + ), + ); + })(), + ...(material.functions?.secrets === undefined + ? {} + : { secrets: material.functions.secrets }), + } + : undefined; + return { + ...(auth === undefined ? {} : { auth }), + ...(workload.recipeId.startsWith("analytics:") && material.analytics !== undefined + ? { analytics: material.analytics } + : {}), + database: { dataPath: instancePaths.postgresData }, + storage: { dataPath: pathService.join(instancePaths.data, "storage") }, + ...(functions === undefined ? {} : { functions }), + ...(host === undefined ? {} : { hostRoute: host }), + }; + }); const preflight = (input: LifecycleInput): Effect.Effect => Effect.gen(function* () { @@ -760,12 +1130,8 @@ export const makeProductionRuntime = ( yield* rememberSecrets(knownSecrets, input.secrets); // Eagerly validate the candidate before any engine/artifact work. Start-time checks // below revalidate the fresh persisted definition because it is runtime authority. - yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); - const candidateState: PersistedStackState = { - ...input.state, - definition: input.definition, - secrets: input.secrets, - }; + yield* validateDatabaseReadinessBudget(input.state, input.plan.workloads); + const candidateState: PersistedStackState = input.state; yield* Effect.forEach(input.plan.workloads, (workload) => validateMaterializedSecrets(candidateState, workload.capability), ); @@ -794,22 +1160,24 @@ export const makeProductionRuntime = ( if (input.state.runtime.kind === "native") { const usableListeners = new Set(input.plan.routes.map(({ listener }) => listener)); for (const assignment of input.state.ports) { - const listener = input.definition.listeners[assignment.field]; + if (assignment.owner !== "stack" || assignment.binding !== "api") continue; + const listener = input.state.listeners.api; if ( - !usableListeners.has(assignment.field) || - !listener.enabled || - (listener.port === "automatic" + listener === undefined || + !usableListeners.has("api") || + listener.enabled !== true || + (listener.port === undefined ? assignment.intent !== "automatic" : listener.port !== assignment.port) ) continue; - yield* checkHostPort(listener.address, assignment.port, assignment.field).pipe( + yield* checkHostPort(listener.address ?? "127.0.0.1", assignment.port, "api").pipe( Effect.mapError( (error) => new PortUnavailableError({ - field: assignment.field, + field: "api", port: assignment.port, - message: `Persisted ${assignment.field} port is unavailable`, + message: "Persisted api port is unavailable", cause: error, }), ), @@ -826,9 +1194,21 @@ export const makeProductionRuntime = ( `${assignment.workloadId}:${assignment.binding}`, ); } - yield* checkNativeDatabaseLockEvidence( - fileSystem, - pathService.join(paths.data, "database", "postmaster.pid"), + yield* Effect.forEach( + input.plan.workloads.filter((workload) => workload.capability === "database"), + (workload) => + resolveServiceInstancePaths(paths, workload.instanceId).pipe( + Effect.provideService(Path.Path, pathService), + Effect.mapError((error) => + preparationError("Unable to resolve database runtime paths", error), + ), + Effect.flatMap((instancePaths) => + checkNativeDatabaseLockEvidence( + fileSystem, + pathService.join(instancePaths.postgresData, "postmaster.pid"), + ), + ), + ), ); } }); @@ -850,6 +1230,7 @@ export const makeProductionRuntime = ( }); const fresh = yield* freshState({ stackId: options.stackId, + instanceId: workload.instanceId, workloadId: workload.id, }).pipe(Effect.mapError((error) => new GatewayActivationError({ message: error.message }))); const spec = runtimeSpecFor(workload); @@ -881,7 +1262,7 @@ export const makeProductionRuntime = ( Effect.flatMap((fresh) => readinessDeadlineFor(fresh, workload).pipe( Effect.flatMap((deadline) => - Effect.timeout( + Effect.timeoutOrElse( Effect.retry( Effect.suspend(() => bootstrapDatabase(fresh)), { @@ -890,7 +1271,17 @@ export const makeProductionRuntime = ( error instanceof DatabaseBootstrapError && error.retryable === true, }, ), - deadline, + { + duration: deadline, + orElse: () => + Effect.fail( + new StackRuntimeError({ + stackId: key.stackId, + workloadId: key.workloadId, + message: `Database bootstrap deadline exceeded for ${workload.id}`, + }), + ), + }, ), ), ), @@ -898,6 +1289,75 @@ export const makeProductionRuntime = ( Effect.mapError((error) => mapDriverError(key, error)), ) : Effect.void; + const reconcileContainerDatabasePassword = ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + resource: ContainerResource, + ): Effect.Effect => { + if (containerEngine === undefined) + return Effect.fail(driverError(key, "Container engine is unavailable")); + return freshState(key).pipe( + Effect.flatMap((fresh) => + Effect.all({ + deadline: readinessDeadlineFor(fresh, workload), + plan: Effect.gen(function* () { + const instance = fresh.registry.instances.find( + (entry) => entry.id === key.instanceId, + ); + return instance === undefined + ? yield* driverError(key, "Database instance is missing from the registry") + : yield* databaseBootstrapPlan(fresh, instance).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + }), + }), + ), + Effect.flatMap(({ deadline, plan }) => + Effect.timeoutOrElse( + Effect.gen(function* () { + yield* Effect.retry( + containerEngine.execContainer(resource.id, [ + "pg_isready", + "--host=/tmp", + "--username=supabase_admin", + "--dbname=postgres", + ]), + { + schedule: Schedule.spaced("100 millis"), + while: (error) => + error instanceof ContainerCommandError && + (error.exitCode === 1 || error.exitCode === 2), + }, + ); + yield* containerEngine.execContainer( + resource.id, + [ + "psql", + "--host=/tmp", + "--username=supabase_admin", + "--dbname=postgres", + "--no-psqlrc", + "--set", + "ON_ERROR_STOP=1", + ], + `SET standard_conforming_strings = on;\nALTER ROLE supabase_admin PASSWORD '${Redacted.value(plan.databasePassword).replaceAll("'", "''")}';\n`, + ); + }), + { + duration: deadline, + orElse: () => + Effect.fail( + driverError( + key, + `Database socket readiness deadline exceeded for ${workload.id}`, + ), + ), + }, + ), + ), + Effect.mapError((error) => mapDriverError(key, error)), + ); + }; let driver: RuntimeDriver; if (state.runtime.kind === "native") { @@ -965,29 +1425,38 @@ export const makeProductionRuntime = ( ), ), waitForReadiness, - bootstrapDatabase: bootstrapWorkloadDatabase, + // PostgreSQL reconciliation is owned by PostgresInstanceRuntime so it can use the + // admitted instance's private endpoint rather than the stack default. + bootstrapDatabase: (key, workload) => + workload.capability === "database" + ? Effect.void + : bootstrapWorkloadDatabase(key, workload), logStore: logs, knownSecrets: Ref.get(knownSecrets).pipe(Effect.map((values) => [...values])), - wipeDatabaseData: Effect.gen(function* () { - const dataPath = pathService.join(paths.data, "database"); - const key = { stackId: options.stackId, workloadId: "database:database" }; - const exists = yield* fileSystem.exists(dataPath).pipe(Effect.orElseSucceed(() => false)); - if (exists) + wipeDatabaseData: (key) => + Effect.gen(function* () { + const instancePaths = yield* resolveServiceInstancePaths(paths, key.instanceId).pipe( + Effect.provideService(Path.Path, pathService), + Effect.mapError((error) => + driverError(key, "Unable to resolve native database data path", error), + ), + ); + const dataPath = instancePaths.postgresData; yield* fileSystem - .remove(dataPath, { recursive: true }) + .remove(dataPath, { recursive: true, force: true }) .pipe( Effect.mapError((error) => driverError(key, "Unable to wipe native database data", error), ), ); - yield* fileSystem - .makeDirectory(dataPath, { recursive: true, mode: 0o700 }) - .pipe( - Effect.mapError((error) => - driverError(key, "Unable to recreate native database data directory", error), - ), - ); - }), + yield* fileSystem + .remove(instancePaths.manifest, { force: true }) + .pipe( + Effect.mapError((error) => + driverError(key, "Unable to remove native database manifest", error), + ), + ); + }), }).pipe( Effect.mapError((error) => preparationError("Unable to initialize native runtime", error)), ); @@ -1047,20 +1516,21 @@ export const makeProductionRuntime = ( ); const envFile = yield* envFiles .write({ + instanceId: workload.instanceId, workloadId: workload.id, values: resolution.env, }) .pipe(Effect.mapError((error) => mapDriverError(key, error))); const volume = - workload.id === "database:database" + workload.recipeId === "database:database" ? { target: "/var/lib/postgresql/data", readOnly: false } - : workload.id === "storage:storage" + : workload.recipeId === "storage:storage" ? { target: "/mnt", readOnly: false, ownerWorkloadId: "storage:storage", } - : workload.id === "storage:imgproxy" + : workload.recipeId === "storage:imgproxy" ? { target: "/mnt", readOnly: true, @@ -1077,7 +1547,10 @@ export const makeProductionRuntime = ( ), ), waitForReadiness, - bootstrapDatabase: bootstrapWorkloadDatabase, + bootstrapDatabase: (key, workload, resource) => + workload.capability === "database" + ? reconcileContainerDatabasePassword(key, workload, resource) + : bootstrapWorkloadDatabase(key, workload), onNetworkReady: (network) => { const resolveGateway = containerEngine.resolveNetworkGateway; if (resolveGateway === undefined) return Effect.succeed(false); @@ -1111,10 +1584,1075 @@ export const makeProductionRuntime = ( inputOwner, cleanupPreparation, ); + const instanceWorkloads = (input: InstanceRuntimeInput): ReadonlyArray => + input.plan.workloads.filter((workload) => workload.instanceId === input.instance.id); + const instanceFailure = (input: InstanceRuntimeInput, error: unknown): StackError => + isStackError(error) + ? error + : new StackRuntimeError({ + stackId: input.stackId, + message: error instanceof Error ? error.message : "Instance runtime operation failed", + cause: error, + }); + const instanceCleanupFailure = ( + input: InstanceRuntimeInput, + error: unknown, + ): StackCleanupError => + error instanceof StackCleanupError + ? error + : new StackCleanupError({ + message: + error instanceof Error ? error.message : "Unable to clean up instance runtime data", + cause: error, + }); + const endpointForBinding = ( + input: InstanceRuntimeInput, + workload: PlannedWorkload, + binding: string, + ): BackendEndpoint | undefined => { + const assignment = input.state.privatePorts.find( + (entry) => + entry.instanceId === input.instance.id && + entry.workloadId === workload.id && + entry.binding === binding, + ); + return assignment === undefined ? undefined : { host: "127.0.0.1", port: assignment.port }; + }; + const publicationsFor = ( + input: InstanceRuntimeInput, + workload: PlannedWorkload, + ): ReadonlyArray => + input.state.privatePorts + .filter( + (entry) => entry.instanceId === input.instance.id && entry.workloadId === workload.id, + ) + .flatMap((entry) => { + const endpoint = endpointForBinding(input, workload, entry.binding); + return endpoint === undefined + ? [] + : [ + { + workloadId: workload.id, + recipeId: workload.recipeId, + binding: entry.binding, + endpoint, + } satisfies RuntimeBindingPublication, + ]; + }); + const startWorkloads = ( + input: InstanceRuntimeInput, + ): Effect.Effect, StackError> => + Effect.gen(function* () { + const publications: RuntimeBindingPublication[] = []; + for (const workload of instanceWorkloads(input)) { + yield* prepare(input.state.runtime, workload); + const startupPublications = publicationsFor(input, workload).filter( + (publication) => publication.binding === "inspector", + ); + const startOptions: RuntimeStartOptions = + input.publishStartupBindings === undefined || startupPublications.length === 0 + ? {} + : { + onStarted: Effect.forEach(startupPublications, (publication) => + readinessDeadlineFor(input.state, workload).pipe( + Effect.flatMap((deadline) => + probeInspectorReadiness( + { + mode: "http", + host: publication.endpoint.host, + port: publication.endpoint.port, + path: "/json/list", + }, + deadline, + ), + ), + ), + ).pipe( + Effect.andThen(input.publishStartupBindings(startupPublications)), + Effect.mapError((error) => + driverError( + { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + }, + "Unable to publish startup bindings", + error, + ), + ), + ), + }; + yield* baseDriver.start( + { stackId: input.stackId, instanceId: input.instance.id, workloadId: workload.id }, + workload, + startOptions, + ); + publications.push(...publicationsFor(input, workload)); + } + return publications; + }).pipe(Effect.mapError((error) => instanceFailure(input, error))); + const stopWorkloads = (input: InstanceRuntimeInput): Effect.Effect => + Effect.forEach([...instanceWorkloads(input)].reverse(), (workload) => + baseDriver.stop({ + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + }), + ).pipe( + Effect.asVoid, + Effect.mapError((error) => instanceFailure(input, error)), + ); + const destroyWorkloads = (input: InstanceRuntimeInput): Effect.Effect => + Effect.gen(function* () { + for (const workload of [...instanceWorkloads(input)].reverse()) { + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + yield* baseDriver.stop(key); + yield* baseDriver.remove(key); + yield* baseDriver.wipePersistentData(key); + } + }).pipe(Effect.mapError((error) => instanceFailure(input, error))); + const instancePrepare = ( + input: InstanceRuntimeInput, + ): Effect.Effect => + Effect.gen(function* () { + const prepared = yield* Effect.forEach(instanceWorkloads(input), (workload) => + prepare(input.state.runtime, workload), + ); + return { + instances: [ + { + id: input.instance.id, + service: input.instance.service, + artifacts: prepared.map((artifact) => ({ + identity: + artifact.image ?? + `${input.plan.workloads.find((workload) => workload.id === artifact.workloadId)?.recipeId ?? artifact.workloadId}@${artifact.version}`, + outcome: artifact.outcome, + })), + }, + ], + } satisfies PrepareResult; + }).pipe(Effect.mapError((error) => instanceFailure(input, error))); + const journalInstance = ( + input: InstanceRuntimeInput, + phase: "admitted" | "running" | "settling" | "cleanup" | "complete", + patch?: Readonly<{ + readonly stagingPath?: string; + readonly outputPath?: string; + readonly helperId?: string; + }>, + ): Effect.Effect => + options.stateStore + .update(options.stackId, (current) => { + const instance = current.registry.instances.find( + (entry) => entry.id === input.instance.id, + ); + if ( + instance === undefined || + instance.pendingOperation?.id !== input.operation.id || + instance.pendingOperation.generation !== input.operation.generation + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Instance operation ${input.operation.id} is no longer current`, + }), + ); + const pendingOperation = { + ...instance.pendingOperation, + phase, + ...(patch?.stagingPath === undefined ? {} : { stagingPath: patch.stagingPath }), + ...(patch?.outputPath === undefined ? {} : { outputPath: patch.outputPath }), + ...(patch?.helperId === undefined ? {} : { helperId: patch.helperId }), + }; + return Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === input.instance.id ? { ...entry, pendingOperation } : entry, + ), + }, + }); + }) + .pipe( + Effect.provideContext(options.context), + Effect.asVoid, + Effect.mapError((error) => + error instanceof StackLifecycleConflictError + ? error + : new StackStateInvalidError({ + stackId: options.stackId, + message: "Unable to journal instance runtime operation", + cause: error, + }), + ), + ); + const publishData = ( + input: InstanceRuntimeInput, + data: PersistedServiceInstance["data"], + shouldPublish: (instance: PersistedServiceInstance) => boolean = () => true, + ): Effect.Effect => + options.stateStore + .update(options.stackId, (current) => { + const instance = current.registry.instances.find( + (entry) => entry.id === input.instance.id, + ); + if ( + instance === undefined || + instance.pendingOperation?.id !== input.operation.id || + instance.pendingOperation.generation !== input.operation.generation + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Instance operation ${input.operation.id} is no longer current`, + }), + ); + return Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === input.instance.id && shouldPublish(entry) ? { ...entry, data } : entry, + ), + }, + }); + }) + .pipe(Effect.provideContext(options.context), Effect.asVoid); + const defaultCatalogReconcile = ( + input: InstanceRuntimeInput, + recipe: CatalogInitializationRecipe, + endpoint: BackendEndpoint, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + // Catalog recipes are required even when their service instance is disabled. In that + // case the execution plan has no long-lived workload, so materialize the one-shot + // recipe against this database instance while retaining its catalog artifact identity. + const workload = + input.plan.workloads.find( + (entry) => + entry.instanceId === input.instance.id && + entry.recipeId.startsWith(`${recipe.service}:`), + ) ?? + (() => { + const release = CAPABILITY_MODULES[recipe.service].releases[recipe.version]; + const entry = release?.workloads.find( + (candidate) => candidate.capability === recipe.service, + ); + if (entry === undefined) return undefined; + const selected = + input.state.runtime.kind === "native" + ? entry.artifacts.native + : entry.artifacts.container; + return { + id: `${input.instance.id}:catalog:${recipe.service}:${entry.name}`, + instanceId: input.instance.id, + recipeId: `${recipe.service}:${entry.name}`, + capability: entry.capability, + ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), + dependencies: [], + readiness: entry.readiness, + artifacts: entry.artifacts, + selected, + } satisfies PlannedWorkload; + })(); + if (workload === undefined) + return yield* new StackPreparationError({ + message: `Catalog workload is missing for ${recipe.service}`, + workload: input.instance.id, + }); + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* new StackPreparationError({ + message: `Runtime specification is missing for ${workload.recipeId}`, + workload: workload.id, + }); + const route = yield* Ref.get(hostRoute); + const inputs = { + ...(yield* runtimeInputs(workload, input.state, route)), + catalog: { capability: recipe.service, settings: recipe.settings }, + } satisfies WorkloadRuntimeInputs; + yield* validateWorkloadRuntimeInputs(input.state, workload, inputs); + const artifact = yield* prepare(input.state.runtime, workload); + const databaseInstance = + input.instance.service === "database" ? input.instance : undefined; + const passwordSlot = databaseInstance?.config.passwordSecretRef; + if (passwordSlot === undefined) + return yield* new StackPreparationError({ + message: + "Database instance password secret is unavailable for catalog initialization", + workload: input.instance.id, + }); + const password = input.state.secrets[passwordSlot]?.value; + if (password === undefined || password.length === 0) + return yield* new StackPreparationError({ + message: "Database instance password is unavailable for catalog initialization", + workload: input.instance.id, + }); + const target = + input.state.runtime.kind === "container" + ? { + host: `${catalogEntryFor("database:database").containerAlias}-${input.instance.id}`, + port: 5432, + } + : endpoint; + const environment = rewriteCatalogDatabaseEnvironment( + spec.env(input.state, workload, spec.containerPort, input.state.runtime.kind, inputs), + { ...target, password }, + ); + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + if (input.state.runtime.kind === "native") { + if (artifact.artifactRoot === undefined) + return yield* new StackPreparationError({ + message: "Native catalog artifact root is unavailable", + workload: workload.id, + }); + const startups = spec.nativeStartupProcesses( + artifact.artifactRoot, + input.state, + workload, + endpoint.port, + inputs, + ); + if (startups.length === 0) + return yield* new StackPreparationError({ + message: `Catalog workload has no initialization process for ${recipe.service}`, + workload: workload.id, + }); + yield* Effect.forEach( + startups, + (startup) => + runCatalogNativeProcess( + { + ...startup, + env: { ...startup.env, ...environment }, + timeout: "5 minutes", + }, + key, + stateSecrets(input.state), + ), + { discard: true }, + ); + } else { + if (containerEngine === undefined) + return yield* new StackPreparationError({ + message: "Container engine is unavailable for catalog initialization", + workload: workload.id, + }); + const network = (yield* containerEngine.listResources(input.stackId)).find( + (resource) => resource.kind === "network", + ); + if (network === undefined) + return yield* new StackPreparationError({ + message: "Stack network is unavailable for catalog initialization", + workload: workload.id, + }); + if (artifact.image === undefined) + return yield* new StackPreparationError({ + message: "Container catalog artifact image is unavailable", + workload: workload.id, + }); + const initWorkloadId = `${workload.id}:init:${input.operation.id}`; + const startups = spec.containerStartupProcesses(input.state, workload, inputs); + if (startups.length === 0) + return yield* new StackPreparationError({ + message: `Catalog workload has no initialization process for ${recipe.service}`, + workload: workload.id, + }); + const image = artifact.image; + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const startup = Effect.gen(function* () { + const envFile = yield* envFiles.write({ + instanceId: input.instance.id, + workloadId: initWorkloadId, + values: environment, + }); + yield* Effect.forEach( + startups, + (process) => + runContainerStartupProcess({ + engine: containerEngine, + key, + timeout: "5 minutes", + specification: { + name: catalogInitContainerName( + key, + `${input.operation.id}-${recipe.recipeId}`, + ), + image, + labels: { + stackId: input.stackId, + ownerSessionId: options.ownerSessionId, + instanceId: input.instance.id, + workloadId: workload.id, + recipeId: workload.recipeId, + role: "workload", + startup: true, + }, + network: network.id, + mounts: spec.containerMounts?.(input.state, workload, inputs) ?? [], + volumeMounts: [], + publications: [], + role: "workload", + entrypoint: process.entrypoint, + command: process.command, + envFile, + }, + }), + { discard: true }, + ); + }); + const startupResult = yield* Effect.exit(restore(startup)); + const cleanupResult = yield* Effect.exit( + envFiles.cleanupFile({ + instanceId: input.instance.id, + workloadId: initWorkloadId, + }), + ); + if (Exit.isFailure(startupResult) && Exit.isFailure(cleanupResult)) + return yield* Effect.failCause( + Cause.combine(startupResult.cause, cleanupResult.cause), + ); + if (Exit.isFailure(startupResult)) + return yield* Effect.failCause(startupResult.cause); + if (Exit.isFailure(cleanupResult)) + return yield* Effect.failCause(cleanupResult.cause); + }), + ); + } + return { + artifactIdentity: artifact.image ?? `${workload.recipeId}@${artifact.version}`, + }; + }).pipe( + Effect.mapError((error) => instanceFailure(input, error)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + ); + const snapshotVolume = (input: InstanceRuntimeInput, workload: PlannedWorkload) => { + if (containerEngine === undefined) + return Effect.fail( + new StackPreparationError({ + message: "Container engine is unavailable for PostgreSQL snapshot transfer", + workload: input.instance.id, + }), + ); + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + const name = workloadVolumeName(key); + const labels = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + role: "volume", + } satisfies ContainerVolumeLabels; + return containerEngine.listResources(input.stackId).pipe( + Effect.flatMap((resources) => { + const volume = resources.find( + (resource) => + resource.kind === "volume" && + resource.name === name && + resource.labels.role === "volume" && + resource.labels.instanceId === input.instance.id && + resource.labels.workloadId === workload.id, + ); + return volume === undefined + ? containerEngine.createVolume({ name, labels }) + : Effect.succeed(volume); + }), + ); + }; + const withSnapshotContainer = ( + input: InstanceRuntimeInput, + workload: PlannedWorkload, + action: (containerId: string) => Effect.Effect, + readOnly = false, + ) => { + if (containerEngine === undefined) + return Effect.fail( + new StackPreparationError({ + message: "Container engine is unavailable for PostgreSQL snapshot transfer", + workload: input.instance.id, + }), + ); + const copyImage = prepare(input.state.runtime, workload).pipe( + Effect.flatMap((artifact) => + artifact.image === undefined + ? Effect.fail( + new StackPreparationError({ + message: "PostgreSQL snapshot helper image is unavailable", + workload: workload.id, + }), + ) + : Effect.succeed(artifact.image), + ), + ); + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + const volume = workloadVolumeName(key); + return Effect.gen(function* () { + const image = yield* copyImage; + return yield* Effect.acquireUseRelease( + containerEngine.createContainer({ + name: `${volume}-snapshot-${input.operation.id}`.replace(/[^A-Za-z0-9_.-]/g, "-"), + image, + labels: { + stackId: input.stackId, + ownerSessionId: options.ownerSessionId, + instanceId: input.instance.id, + workloadId: workload.id, + recipeId: workload.recipeId, + role: "workload", + startup: true, + }, + network: "none", + mounts: [], + volumeMounts: [{ volume, target: "/var/lib/postgresql/data", readOnly }], + publications: [], + role: "workload", + entrypoint: "/bin/sh", + command: [ + "-c", + readOnly + ? "tail -f /dev/null" + : "chmod 700 /var/lib/postgresql/data && tail -f /dev/null", + ], + }), + (helper) => + containerEngine.startContainer(helper.id).pipe(Effect.andThen(action(helper.id))), + (helper) => + Effect.gen(function* () { + const stopped = yield* Effect.exit(containerEngine.stopContainer(helper.id)); + const removed = yield* Effect.exit(containerEngine.removeContainer(helper.id)); + if (Exit.isFailure(stopped) && Exit.isFailure(removed)) + return yield* new StackCleanupError({ + message: "Unable to stop and remove PostgreSQL snapshot helper", + cause: Cause.combine(stopped.cause, removed.cause), + }); + if (Exit.isFailure(stopped)) + return yield* new StackCleanupError({ + message: "Unable to stop PostgreSQL snapshot helper", + cause: stopped.cause, + }); + if (Exit.isFailure(removed)) + return yield* new StackCleanupError({ + message: "Unable to remove PostgreSQL snapshot helper", + cause: removed.cause, + }); + }), + ); + }); + }; + const restoreContainerOwnership = ( + input: InstanceRuntimeInput, + workload: PlannedWorkload, + ): Effect.Effect => { + if (containerEngine === undefined) + return Effect.fail( + new StackPreparationError({ + message: "Container engine is unavailable for PostgreSQL snapshot ownership", + workload: input.instance.id, + }), + ); + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + const volume = workloadVolumeName(key); + return prepare(input.state.runtime, workload).pipe( + Effect.flatMap((artifact): Effect.Effect => { + if (artifact.image === undefined) + return Effect.fail( + new StackPreparationError({ + message: "PostgreSQL snapshot helper image is unavailable", + workload: workload.id, + }), + ); + return runContainerStartupProcess({ + engine: containerEngine, + key, + timeout: "5 minutes", + specification: { + name: `${volume}-ownership-${input.operation.id}`.replace(/[^A-Za-z0-9_.-]/g, "-"), + image: artifact.image, + labels: { + stackId: input.stackId, + ownerSessionId: options.ownerSessionId, + instanceId: input.instance.id, + workloadId: workload.id, + recipeId: workload.recipeId, + role: "workload", + startup: true, + }, + network: "none", + mounts: [], + volumeMounts: [{ volume, target: "/var/lib/postgresql/data", readOnly: false }], + publications: [], + role: "workload", + entrypoint: "/bin/sh", + command: [ + "-c", + "chown -R postgres:postgres /var/lib/postgresql/data && chmod 700 /var/lib/postgresql/data", + ], + }, + }).pipe(Effect.mapError((error) => instanceFailure(input, error))); + }), + ); + }; + const postgres = makePostgresInstanceRuntime({ + runtime: state.runtime, + paths, + driver: baseDriver, + artifactPreparer: { + prepare: (runtime, workload) => + prepare(runtime, workload).pipe( + Effect.mapError( + (error) => + new StackPreparationError({ + message: error.message, + workload: workload.id, + cause: error, + }), + ), + ), + }, + context: runtimeContext, + snapshotData: { + exists: (input) => + state.runtime.kind === "native" + ? resolveServiceInstancePaths(paths, input.instance.id).pipe( + Effect.provideService(Path.Path, pathService), + Effect.flatMap((instancePaths) => fileSystem.exists(instancePaths.postgresData)), + Effect.mapError((error) => instanceFailure(input, error)), + ) + : Effect.gen(function* () { + const workload = input.plan.workloads.find( + (entry) => + entry.instanceId === input.instance.id && entry.capability === "database", + ); + if (workload === undefined) return false; + if (containerEngine === undefined) return false; + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + const name = workloadVolumeName(key); + const resources = yield* containerEngine.listResources(input.stackId); + return resources.some( + (resource) => + resource.kind === "volume" && + resource.name === name && + resource.labels.role === "volume" && + resource.labels.instanceId === input.instance.id && + resource.labels.workloadId === workload.id, + ); + }).pipe(Effect.mapError((error) => instanceFailure(input, error))), + readVersion: (input) => + state.runtime.kind === "native" + ? resolveServiceInstancePaths(paths, input.instance.id).pipe( + Effect.provideService(Path.Path, pathService), + Effect.flatMap((instancePaths) => + fileSystem.readFileString( + pathService.join(instancePaths.postgresData, "PG_VERSION"), + ), + ), + Effect.flatMap((value) => { + const version = Number.parseInt(value.trim(), 10); + return Number.isSafeInteger(version) && version > 0 + ? Effect.succeed(version) + : Effect.fail( + new StackPreparationError({ + message: "PostgreSQL PG_VERSION is invalid", + workload: input.instance.id, + }), + ); + }), + Effect.mapError((error) => instanceFailure(input, error)), + ) + : Effect.gen(function* () { + const workload = input.plan.workloads.find( + (entry) => + entry.instanceId === input.instance.id && entry.capability === "database", + ); + if (workload === undefined) + return yield* new StackPreparationError({ + message: "Database workload is missing", + workload: input.instance.id, + }); + const copy = containerEngine?.copyFromContainer; + if (copy === undefined) + return yield* new StackPreparationError({ + message: "Container engine cannot read PostgreSQL PG_VERSION", + workload: input.instance.id, + }); + return yield* Effect.acquireUseRelease( + fileSystem.makeTempDirectory({ + prefix: `supabase-pg-version-${input.instance.id}-`, + }), + (temporary) => { + const target = pathService.join(temporary, "PG_VERSION"); + return withSnapshotContainer( + input, + workload, + (helperId) => copy(helperId, "/var/lib/postgresql/data/PG_VERSION", target), + true, + ).pipe(Effect.andThen(fileSystem.readFileString(target))); + }, + (temporary) => + fileSystem.remove(temporary, { recursive: true }).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((value) => { + const version = Number.parseInt(value.trim(), 10); + return Number.isSafeInteger(version) && version > 0 + ? Effect.succeed(version) + : Effect.fail( + new StackPreparationError({ + message: "PostgreSQL PG_VERSION is invalid", + workload: input.instance.id, + }), + ); + }), + ); + }).pipe(Effect.mapError((error) => instanceFailure(input, error))), + restoreTargetEmpty: (input) => + state.runtime.kind === "native" + ? resolveServiceInstancePaths(paths, input.instance.id).pipe( + Effect.provideService(Path.Path, pathService), + Effect.flatMap((instancePaths) => + fileSystem + .exists(instancePaths.data) + .pipe( + Effect.flatMap((exists) => + exists + ? fileSystem + .readDirectory(instancePaths.data) + .pipe(Effect.map((entries) => entries.length === 0)) + : Effect.succeed(true), + ), + ), + ), + Effect.mapError((error) => instanceFailure(input, error)), + ) + : Effect.gen(function* () { + const workload = input.plan.workloads.find( + (entry) => + entry.instanceId === input.instance.id && entry.capability === "database", + ); + if (workload === undefined || containerEngine === undefined) return false; + const key = { + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + } satisfies RuntimeWorkloadKey; + const resources = yield* containerEngine.listResources(input.stackId); + return !resources.some( + (resource) => + resource.kind === "volume" && resource.name === workloadVolumeName(key), + ); + }).pipe(Effect.mapError((error) => instanceFailure(input, error))), + export: (input, destination) => + Effect.gen(function* () { + const instancePaths = yield* resolveServiceInstancePaths(paths, input.instance.id).pipe( + Effect.provideService(Path.Path, pathService), + ); + if (state.runtime.kind === "native") { + yield* fileSystem.copy(instancePaths.postgresData, destination, { overwrite: false }); + return; + } + const copy = containerEngine?.copyFromContainer; + if (copy === undefined) + return yield* new StackPreparationError({ + message: "Container engine does not support PostgreSQL volume export", + workload: input.instance.id, + }); + const workload = input.plan.workloads.find( + (entry) => entry.instanceId === input.instance.id && entry.capability === "database", + ); + if (workload === undefined) + return yield* new StackPreparationError({ message: "Database workload is missing" }); + yield* withSnapshotContainer(input, workload, (helperId) => + copy(helperId, "/var/lib/postgresql/data/.", destination), + ); + }).pipe(Effect.mapError((error) => instanceFailure(input, error))), + restore: (input, source, destination) => + Effect.gen(function* () { + if (state.runtime.kind === "container") { + const engine = containerEngine; + const copy = engine?.copyToContainer; + const workload = input.plan.workloads.find( + (entry) => + entry.instanceId === input.instance.id && entry.capability === "database", + ); + if (engine === undefined || copy === undefined || workload === undefined) + return yield* new StackPreparationError({ + message: "Container engine cannot restore PostgreSQL volume", + workload: input.instance.id, + }); + const volume = yield* snapshotVolume(input, workload); + const restored = yield* Effect.exit( + Effect.gen(function* () { + yield* withSnapshotContainer(input, workload, (helperId) => + copy(helperId, `${source}/.`, "/var/lib/postgresql/data/."), + ); + yield* restoreContainerOwnership(input, workload); + }), + ); + if (Exit.isFailure(restored)) { + const removed = yield* Effect.exit(engine.removeVolume(volume.id)); + if (Exit.isFailure(removed)) + return yield* new StackCleanupError({ + message: "Unable to remove PostgreSQL snapshot volume after restore failure", + cause: Cause.combine(restored.cause, removed.cause), + }); + return yield* Effect.failCause(restored.cause); + } + return; + } + const parent = pathService.dirname(destination); + yield* fileSystem.makeDirectory(parent, { recursive: true }); + const temporary = `${destination}.restore-${input.operation.id}`; + yield* Effect.acquireUseRelease( + Effect.succeed(temporary), + (staging) => + fileSystem + .copy(source, staging, { overwrite: false }) + .pipe(Effect.andThen(fileSystem.rename(staging, destination))), + (staging) => + fileSystem.remove(staging, { recursive: true }).pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.void + : Effect.fail( + new StackCleanupError({ + message: "Unable to clean up native PostgreSQL restore staging", + cause: error, + }), + ), + ), + ), + ); + }).pipe(Effect.mapError((error) => instanceFailure(input, error))), + rollbackRestore: (input) => + state.runtime.kind === "native" + ? resolveServiceInstancePaths(paths, input.instance.id).pipe( + Effect.provideService(Path.Path, pathService), + Effect.flatMap((instancePaths) => + fileSystem + .remove(instancePaths.postgresData, { recursive: true }) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.void + : Effect.fail(error), + ), + ), + ), + Effect.mapError((error) => instanceCleanupFailure(input, error)), + ) + : Effect.gen(function* () { + const workload = input.plan.workloads.find( + (entry) => + entry.instanceId === input.instance.id && entry.capability === "database", + ); + if (containerEngine === undefined || workload === undefined) + return yield* new StackPreparationError({ + message: "Container engine cannot roll back PostgreSQL volume", + workload: input.instance.id, + }); + const resources = yield* containerEngine.listResources(input.stackId); + const volume = resources.find( + (resource) => + resource.kind === "volume" && + resource.name === + workloadVolumeName({ + stackId: input.stackId, + instanceId: input.instance.id, + workloadId: workload.id, + }), + ); + if (volume !== undefined) yield* containerEngine.removeVolume(volume.id); + }).pipe(Effect.mapError((error) => instanceCleanupFailure(input, error))), + }, + snapshotMetadata: (input, workload) => + prepare(input.state.runtime, workload).pipe( + Effect.flatMap((artifact) => + input.state.runtime.kind === "container" && artifact.image === undefined + ? Effect.fail( + new StackPreparationError({ + message: "Container PostgreSQL artifact image is unavailable", + workload: workload.id, + }), + ) + : Effect.succeed({ + artifactIdentity: + input.state.runtime.kind === "container" + ? `container:${artifact.image}` + : `native:${artifact.version}`, + runtimeIdentity: + input.state.runtime.kind === "native" + ? `native:${workload.capability}:${input.instance.config.version}` + : `container:${workload.capability}:${input.instance.config.version}`, + majorVersion: Number.parseInt( + input.instance.config.version.match(/^(\d+)/u)?.[1] ?? "0", + 10, + ), + }), + ), + Effect.mapError((error) => instanceFailure(input, error)), + ), + reconcileManaged: (input, endpoint, workload) => + (options.bootstrapDatabase === undefined + ? databaseBootstrapPlan(input.state, input.instance).pipe( + Effect.flatMap((plan) => + readinessDeadlineFor(input.state, workload).pipe( + Effect.flatMap((deadline) => + Effect.timeoutOrElse( + Effect.retry( + Effect.suspend(() => + bootstrapManagedPostgres({ + ...plan, + host: endpoint.host, + port: endpoint.port, + }), + ), + { + schedule: Schedule.spaced("100 millis"), + while: (error) => + error instanceof DatabaseBootstrapError && error.retryable === true, + }, + ), + { + duration: deadline, + orElse: () => + Effect.fail( + new StackRuntimeError({ + stackId: input.stackId, + workloadId: workload.id, + message: `Database credential reconciliation deadline exceeded for ${workload.id}`, + }), + ), + }, + ), + ), + ), + ), + ) + : options.bootstrapDatabase(input.state) + ).pipe(Effect.mapError((error) => instanceFailure(input, error))), + reconcileCatalogRecipe: (input, recipe, endpoint) => { + return ( + options.reconcileCatalogRecipe?.(input, recipe, endpoint) ?? + defaultCatalogReconcile(input, recipe, endpoint) + ); + }, + publishInitialization: (input, evidence) => + options.stateStore + .update(options.stackId, (current) => { + const instance = current.registry.instances.find( + (entry) => entry.id === input.instance.id, + ); + if ( + instance === undefined || + instance.pendingOperation?.id !== input.operation.id || + instance.pendingOperation.generation !== input.operation.generation + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Instance operation ${input.operation.id} is no longer current`, + }), + ); + return Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === input.instance.id ? { ...entry, initialization: evidence } : entry, + ), + }, + }); + }) + .pipe(Effect.provideContext(options.context), Effect.asVoid), + publishFreshData: (input, lineageId) => + publishData( + input, + { origin: "fresh", lineageId }, + (entry) => entry.data.origin === "absent" || entry.data.origin === "incomplete", + ), + publishIncompleteData: (input) => + publishData(input, { origin: "incomplete", operationId: input.operation.id }), + publishAbsentData: (input) => publishData(input, { origin: "absent" }), + journal: journalInstance, + }); + const instanceStart = (input: InstanceRuntimeInput) => + input.instance.service === "database" + ? postgres.start(input).pipe(Effect.mapError((error) => instanceFailure(input, error))) + : startWorkloads(input); + const instanceStop = (input: InstanceRuntimeInput) => + input.instance.service === "database" ? postgres.stop(input) : stopWorkloads(input); + const instanceDestroy = (input: InstanceRuntimeInput) => + Effect.gen(function* () { + const runtime = + input.instance.service === "database" ? postgres.destroy(input) : destroyWorkloads(input); + const runtimeResult = yield* Effect.exit(runtime); + if (Exit.isFailure(runtimeResult)) return yield* Effect.failCause(runtimeResult.cause); + const instancePaths = yield* resolveServiceInstancePaths(paths, input.instance.id).pipe( + Effect.provideService(Path.Path, pathService), + Effect.mapError( + (error) => + new StackCleanupError({ + message: "Unable to resolve destroyed instance paths", + cause: error, + }), + ), + ); + yield* removeOwnedInstancePaths(fileSystem, instancePaths); + }); + const unsupportedSnapshot = (input: InstanceRuntimeInput) => + Effect.fail( + new UnsupportedSnapshotError({ + instanceId: input.instance.id, + message: `Snapshots are unsupported for ${input.instance.service} instances`, + }), + ); return { driver: baseDriver, preflight, - prepare: prepareFor, + prepare: instancePrepare, + prepareArtifacts: prepareFor, + start: instanceStart, + stop: instanceStop, + destroy: instanceDestroy, + exportSnapshot: (input, snapshot) => + input.instance.service === "database" + ? postgres.exportSnapshot(input, snapshot) + : unsupportedSnapshot(input), + restoreSnapshot: (input, snapshot) => + input.instance.service === "database" + ? postgres.restoreSnapshot(input, snapshot) + : unsupportedSnapshot(input), + recoverSnapshot: (input, operation) => + input.instance.service === "database" + ? postgres.recoverSnapshot(input, operation) + : Effect.map(Effect.void, () => undefined), prefetch, artifacts: Effect.sync(() => [...preparationStatuses.values()]), activate, diff --git a/packages/stack/src/runtime/RuntimeBinding.ts b/packages/stack/src/runtime/RuntimeBinding.ts new file mode 100644 index 0000000000..93f1956386 --- /dev/null +++ b/packages/stack/src/runtime/RuntimeBinding.ts @@ -0,0 +1,9 @@ +import type { BackendEndpoint } from "../gateway/Gateway.ts"; + +/** A concrete private endpoint published for one workload binding. */ +export interface RuntimeBindingPublication { + readonly workloadId: string; + readonly recipeId: string; + readonly binding: string; + readonly endpoint: BackendEndpoint; +} diff --git a/packages/stack/src/runtime/RuntimeCoordination.ts b/packages/stack/src/runtime/RuntimeCoordination.ts new file mode 100644 index 0000000000..ecfa6b17fd --- /dev/null +++ b/packages/stack/src/runtime/RuntimeCoordination.ts @@ -0,0 +1,107 @@ +import { Effect, Option, Semaphore } from "effect"; +import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; + +/** Coordinates exact runtime workloads without serializing unrelated slow operations. */ +export interface RuntimeCoordination { + /** Runs an operation under the exact workload's exclusion fence. */ + readonly withKey: ( + key: RuntimeWorkloadKey, + operation: Effect.Effect, + ) => Effect.Effect; + /** Commits a short registration/publication section unless its stack is fenced. */ + readonly withKeyCommit: ( + key: RuntimeWorkloadKey, + operation: Effect.Effect, + ) => Effect.Effect, E, R>; + /** Runs a short publication section under map admission without acquiring a workload lock. */ + readonly withMapCommit: ( + stackId: RuntimeWorkloadKey["stackId"], + operation: Effect.Effect, + ) => Effect.Effect, E, R>; + /** Serializes cleanup executions for one stack while leaving other stacks independent. */ + readonly withStackCleanup: ( + stackId: RuntimeWorkloadKey["stackId"], + operation: Effect.Effect, + ) => Effect.Effect; +} + +const keyFor = (key: RuntimeWorkloadKey): string => + JSON.stringify([key.stackId, key.instanceId, key.workloadId]); + +/** Creates runtime coordination with short map admission and one mutex per exact workload. */ +export const makeRuntimeCoordination: Effect.Effect = Effect.suspend(() => + Effect.gen(function* () { + const mapAdmission = yield* Semaphore.make(1); + const workloadLocks = new Map(); + const stackCleanupLocks = new Map(); + const fencedStacks = new Set(); + + const lockFor = (key: RuntimeWorkloadKey): Effect.Effect => + mapAdmission.withPermit( + Effect.sync(() => { + const id = keyFor(key); + const existing = workloadLocks.get(id); + if (existing !== undefined) return existing; + const created = Semaphore.makeUnsafe(1); + workloadLocks.set(id, created); + return created; + }), + ); + const cleanupLockFor = ( + stackId: RuntimeWorkloadKey["stackId"], + ): Effect.Effect => + mapAdmission.withPermit( + Effect.sync(() => { + const existing = stackCleanupLocks.get(stackId); + if (existing !== undefined) return existing; + const created = Semaphore.makeUnsafe(1); + stackCleanupLocks.set(stackId, created); + return created; + }), + ); + + const withMapCommit = ( + stackId: RuntimeWorkloadKey["stackId"], + operation: Effect.Effect, + ): Effect.Effect, E, R> => + mapAdmission.withPermit( + Effect.gen(function* () { + if (fencedStacks.has(stackId)) return Option.none(); + return Option.some(yield* operation); + }), + ); + + return { + withKey: (key, operation) => + Effect.flatMap(lockFor(key), (lock) => lock.withPermit(operation)), + withKeyCommit: (key, operation) => + Effect.flatMap(lockFor(key), (lock) => + lock.withPermit(withMapCommit(key.stackId, operation)), + ), + withMapCommit, + withStackCleanup: (stackId, operation) => + Effect.flatMap(cleanupLockFor(stackId), (lock) => + lock.withPermit( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* mapAdmission.withPermit( + Effect.sync(() => { + fencedStacks.add(stackId); + }), + ); + return yield* restore(operation).pipe( + Effect.ensuring( + mapAdmission.withPermit( + Effect.sync(() => { + fencedStacks.delete(stackId); + }), + ), + ), + ); + }), + ), + ), + ), + } satisfies RuntimeCoordination; + }), +); diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 30f4285303..7295e55253 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -1,11 +1,13 @@ import { Data, Effect } from "effect"; import type { StackId } from "../public/StackId.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ReadinessTarget } from "./ReadinessProbe.ts"; /** The exact identity used when touching a private runtime resource. */ export interface RuntimeWorkloadKey { readonly stackId: StackId; + readonly instanceId: ServiceInstanceId; readonly workloadId: string; } @@ -24,6 +26,11 @@ export interface ObservedWorkload extends RuntimeWorkloadKey { readonly error?: string; } +/** Work performed once by the winning start operation after live bindings exist. */ +export interface RuntimeStartOptions { + readonly onStarted?: Effect.Effect; +} + export interface RuntimeDriver { /** Enumerates only private resources owned by this exact stack identity. */ readonly observe: ( @@ -36,6 +43,7 @@ export interface RuntimeDriver { readonly start: ( key: RuntimeWorkloadKey, workload: PlannedWorkload, + options?: RuntimeStartOptions, ) => Effect.Effect; /** Stops one exact resource; no other stack may be touched. */ readonly stop: (key: RuntimeWorkloadKey) => Effect.Effect; @@ -56,6 +64,7 @@ export interface RuntimeDriver { export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ readonly message: string; readonly stackId?: StackId; + readonly instanceId?: ServiceInstanceId; readonly workloadId?: string; /** Private endpoint used by readiness failures, when applicable. */ readonly target?: ReadinessTarget; diff --git a/packages/stack/src/runtime/RuntimeEnvFile.ts b/packages/stack/src/runtime/RuntimeEnvFile.ts index a89f25fffd..4e8804db8a 100644 --- a/packages/stack/src/runtime/RuntimeEnvFile.ts +++ b/packages/stack/src/runtime/RuntimeEnvFile.ts @@ -1,14 +1,21 @@ import { Crypto, Effect, FileSystem, Path, PlatformError } from "effect"; import { StackPreparationError } from "../public/Errors.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; +import { resolveServiceInstancePaths, resolveStackPaths } from "../state/Paths.ts"; import type { StackId } from "../public/StackId.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; export interface RuntimeEnvFileOwner { /** Writes one workload file and returns its exact owned path. */ readonly write: (input: { + readonly instanceId: ServiceInstanceId; readonly workloadId: string; readonly values: Readonly>; }) => Effect.Effect; + /** Removes one exact workload file owned by this instance. */ + readonly cleanupFile: (input: { + readonly instanceId: ServiceInstanceId; + readonly workloadId: string; + }) => Effect.Effect; /** Removes only this owner's env-file directory; safe when already absent. */ readonly cleanupAll: Effect.Effect; } @@ -55,7 +62,7 @@ export const encodeRuntimeEnvFile = ( }; /** - * Owns container env files under `/runtime/env`. Native workloads use + * Owns container env files under `/runtime/instances//env`. Native workloads use * their fd4 environment and never pass through this owner. */ export const makeRuntimeEnvFileOwner = ( @@ -72,15 +79,22 @@ export const makeRuntimeEnvFileOwner = ( const paths = yield* resolveStackPaths(options).pipe( Effect.mapError((cause) => error("Unable to resolve runtime environment path", { cause })), ); - const envRoot = path.join(paths.runtime, "env"); - + const envRoot = path.join(paths.runtime, "instances"); const write = (input: { + readonly instanceId: ServiceInstanceId; readonly workloadId: string; readonly values: Readonly>; }): Effect.Effect => { if (!validWorkloadId(input.workloadId)) return Effect.fail(error("Invalid runtime environment workload identity")); return Effect.gen(function* () { + const instancePaths = yield* resolveServiceInstancePaths(paths, input.instanceId).pipe( + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => + error("Unable to resolve runtime environment path", { cause }), + ), + ); + const envRoot = path.join(instancePaths.runtime, "env"); const text = yield* encodeRuntimeEnvFile(input.values); const target = path.join(envRoot, `${encodeWorkloadId(input.workloadId)}.env`); const token = yield* crypto.randomUUIDv4.pipe( @@ -127,10 +141,38 @@ export const makeRuntimeEnvFileOwner = ( }); }; + const cleanupFile = (input: { + readonly instanceId: ServiceInstanceId; + readonly workloadId: string; + }) => { + if (!validWorkloadId(input.workloadId)) + return Effect.fail(error("Invalid runtime environment workload identity")); + return resolveServiceInstancePaths(paths, input.instanceId).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap((instancePaths) => { + const target = path.join( + instancePaths.runtime, + "env", + `${encodeWorkloadId(input.workloadId)}.env`, + ); + return mapFile( + target, + "clean runtime environment file", + fs.remove(target, { force: true }), + ); + }), + Effect.mapError((cause) => + cause instanceof StackPreparationError + ? cause + : error("Unable to resolve runtime environment path", { cause }), + ), + ); + }; + const cleanupAll = mapFile( envRoot, "clean runtime environment files", fs.remove(envRoot, { recursive: true, force: true }), ); - return { write, cleanupAll }; + return { write, cleanupFile, cleanupAll }; }); diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 8d80ec9a28..14a1b7f083 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -1,16 +1,27 @@ -import { Crypto, Effect, FileSystem, Path, PlatformError, Schema } from "effect"; +import { + Crypto, + Deferred, + Effect, + FileSystem, + Fiber, + Path, + PlatformError, + Schema, + Scope, +} from "effect"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackId } from "../public/StackId.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; import { StackPreparationError } from "../public/Errors.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; -import { isRecord, settingValue, settingsFor } from "../state/MaterializedSettings.ts"; +import { resolveServiceInstancePaths, resolveStackPaths } from "../state/Paths.ts"; +import { isRecord, settingValue, settingsForInstance } from "../state/MaterializedSettings.ts"; import { base64UrlEncode, resolveSigningKeyMaterial, type ResolvedSigningKeyMaterial, } from "../state/SecretStore.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; -import { canonicalize } from "../model/Compiler.ts"; +import { canonical } from "../model/Compiler.ts"; /** A parsed JSON document fetched by the owner for OIDC discovery. */ export type RuntimeJsonFetcher = (url: string) => Effect.Effect; @@ -44,12 +55,12 @@ export interface RuntimeInputOwner { /** * Resolves stack-owned inputs needed before a workload is created. * - * The Supervisor serializes workload startup and runtime cleanup. This owner therefore keeps - * only completed material in its caches; the caller owns an in-progress resolution and its - * interruption. + * Completed material is cached by state and workload identity. In-progress resolutions are + * shared by callers and are interrupted by cleanupAll. */ readonly resolve: ( state: PersistedStackState, + instanceId: ServiceInstanceId, workloadId: string, ) => Effect.Effect; /** Resolves one configured project-relative regular file without copying it. */ @@ -124,11 +135,33 @@ const symmetricJwk = (secret: string): Readonly> => ({ k: base64UrlEncode(new TextEncoder().encode(secret)), }); +const settingsForService = (state: PersistedStackState, service: string): unknown => { + const instanceId = state.registry.defaultInstanceIds[service]; + return state.registry.instances.find( + (instance) => instance.id === instanceId && instance.service === service, + )?.config.settings; +}; + +const authInstanceIdFor = ( + state: PersistedStackState, + requestedInstanceId?: ServiceInstanceId, +): string | undefined => { + if ( + requestedInstanceId !== undefined && + state.registry.instances.some( + (instance) => instance.id === requestedInstanceId && instance.service === "auth", + ) + ) + return requestedInstanceId; + return state.registry.defaultInstanceIds.auth; +}; + /** Resolves materialized Edge Runtime secrets to their caller-visible names. */ const resolveFunctionsEdgeRuntimeSecrets = ( state: PersistedStackState, + instanceId: ServiceInstanceId, ): Effect.Effect>, StackPreparationError> => { - const settings = settingsFor(state, "functions"); + const settings = settingsForInstance(state, instanceId, "functions"); const edgeRuntime = isRecord(settings) && isRecord(settings.edge_runtime) ? settings.edge_runtime : {}; const configured = isRecord(edgeRuntime.secrets) ? edgeRuntime.secrets : {}; @@ -200,16 +233,17 @@ export const makeRuntimeInputOwner = ( ): Effect.Effect< RuntimeInputOwner, StackPreparationError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto + FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + const ownerScope = yield* Effect.scope; const stackPaths = yield* resolveStackPaths(options).pipe( Effect.mapError((cause) => failure("Unable to resolve runtime input paths", { cause })), ); - const vectorRoot = path.join(stackPaths.runtime, "inputs", "vector"); + const vectorRoot = path.join(stackPaths.runtime, "instances"); const resolveProjectFile = ( state: PersistedStackState, @@ -243,8 +277,14 @@ export const makeRuntimeInputOwner = ( const ensureFunctionsRoot = ( state: PersistedStackState, + instanceId: ServiceInstanceId, ): Effect.Effect => { - const settings = settingsFor(state, "functions"); + const functionsInstance = state.registry.instances.find( + (instance) => + instance.id === instanceId && instance.service === "functions" && instance.config.enabled, + ); + if (functionsInstance === undefined) return Effect.void; + const settings: unknown = functionsInstance.config.settings; const root = isRecord(settings) ? settingValue(state, settings.functions_root) : ""; if (root.length === 0) return Effect.fail(failure("Persisted Functions root is missing")); return mapFile( @@ -254,11 +294,15 @@ export const makeRuntimeInputOwner = ( ).pipe(Effect.asVoid); }; - const resolveAuthTemplates = ( + const resolveAuthTemplatesForInstance = ( state: PersistedStackState, + authInstanceId: string | undefined, ): Effect.Effect, StackPreparationError> => Effect.gen(function* () { - const auth = settingsFor(state, "auth"); + const auth = + authInstanceId === undefined + ? settingsForService(state, "auth") + : settingsForInstance(state, authInstanceId, "auth"); const email = isRecord(auth) && isRecord(auth.email) ? auth.email : {}; const result: RuntimeAuthTemplate[] = []; const ids = new Set(); @@ -296,6 +340,11 @@ export const makeRuntimeInputOwner = ( return result; }); + const resolveAuthTemplates = ( + state: PersistedStackState, + ): Effect.Effect, StackPreparationError> => + resolveAuthTemplatesForInstance(state, authInstanceIdFor(state)); + const resolveRemoteKeys = ( issuer: string, ): Effect.Effect, StackPreparationError> => { @@ -362,9 +411,10 @@ export const makeRuntimeInputOwner = ( const resolveAuth = ( state: PersistedStackState, + authInstanceId: string | undefined, ): Effect.Effect, StackPreparationError> => Effect.gen(function* () { - const signing = state.definition?.security.jwt.signing; + const signing = state.security.jwt?.signing; let local: ResolvedSigningKeyMaterial | undefined; if (signing?.kind === "jwks-file") { local = yield* resolveSigningKeyMaterial({ @@ -379,7 +429,11 @@ export const makeRuntimeInputOwner = ( ), ); } - const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); + const authSettings = + authInstanceId === undefined + ? settingsForService(state, "auth") + : settingsForInstance(state, authInstanceId, "auth"); + const thirdParty = resolveThirdPartyIssuer(authSettings); if (!thirdParty.ok) return yield* failure("Unable to resolve Auth third-party issuer", { provider: thirdParty.provider, @@ -391,16 +445,19 @@ export const makeRuntimeInputOwner = ( signing?.kind === "jwks-file" ? [] : (() => { - const secret = state.secrets["secret:auth.settings.jwt_secret"]?.value ?? ""; + const slot = signing?.kind === "symmetric" ? signing.secret.slot : undefined; + const secret = slot === undefined ? "" : (state.secrets[slot]?.value ?? ""); return secret.length === 0 ? [] : [symmetricJwk(secret)]; })(); if (signing?.kind !== "jwks-file" && symmetric.length === 0) return yield* failure("Persisted Auth JWT secret is missing"); const publicKeys = [...remote, ...localPublic, ...symmetric]; const templates = - state.definition?.capabilities.auth.enabled !== true + state.registry.instances.some( + (instance) => instance.service === "auth" && instance.config.enabled, + ) !== true ? [] - : yield* resolveAuthTemplates(state); + : yield* resolveAuthTemplatesForInstance(state, authInstanceId); return { ...(local === undefined ? {} : { jwtKeys: local.privateKeysJson }), jwks: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ @@ -412,24 +469,33 @@ export const makeRuntimeInputOwner = ( const writeVectorConfig = ( state: PersistedStackState, + instanceId: ServiceInstanceId, ): Effect.Effect => { const assignment = state.privatePorts.find( - (entry) => entry.workloadId === "analytics:vector" && entry.binding === "primary", + (entry) => + entry.instanceId === instanceId && + entry.workloadId.endsWith(":vector") && + entry.binding === "primary", ); if (state.runtime.kind === "native" && assignment === undefined) return Effect.fail(failure("Persisted native Vector assignment is missing")); - const target = path.join(vectorRoot, "vector.yaml"); return Effect.gen(function* () { + const instancePaths = yield* resolveServiceInstancePaths(stackPaths, instanceId).pipe( + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => failure("Unable to resolve Vector input path", { cause })), + ); + const targetRoot = path.join(instancePaths.runtime, "inputs", "vector"); + const target = path.join(targetRoot, "vector.yaml"); yield* mapFile( - vectorRoot, + targetRoot, "create Vector config directory", - fs.makeDirectory(vectorRoot, { recursive: true, mode: 0o700 }), + fs.makeDirectory(targetRoot, { recursive: true, mode: 0o700 }), ); - yield* mapFile(vectorRoot, "secure Vector config directory", fs.chmod(vectorRoot, 0o700)); + yield* mapFile(targetRoot, "secure Vector config directory", fs.chmod(targetRoot, 0o700)); const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError(() => failure("Unable to allocate Vector config file")), ); - const temporary = path.join(vectorRoot, `.vector.yaml.${token}.tmp`); + const temporary = path.join(targetRoot, `.vector.yaml.${token}.tmp`); yield* Effect.ensuring( Effect.scoped( Effect.gen(function* () { @@ -461,36 +527,52 @@ export const makeRuntimeInputOwner = ( const commonCompleted = new Map(); const authCompleted = new Map>(); const keyFor = (state: PersistedStackState): string => - `${options.stackId}\u0000${canonicalize(state.runtime)}\u0000${canonicalize(state.definition ?? {})}`; - const needsAuthMaterial = (state: PersistedStackState, workloadId: string): boolean => - (["rest", "auth", "realtime", "storage", "functions"] as const).some( - (capability) => - state.definition?.capabilities[capability].enabled === true && - (workloadId === `${capability}:${capability}` || - (capability === "functions" && workloadId === "functions:edge-runtime")), + `${options.stackId}\u0000${canonical(state.runtime)}\u0000${canonical(state.registry)}`; + const needsAuthMaterial = ( + state: PersistedStackState, + instanceId: ServiceInstanceId, + ): boolean => { + const instance = state.registry.instances.find((entry) => entry.id === instanceId); + return ( + instance !== undefined && + instance.config.enabled && + (instance.service === "rest" || + instance.service === "auth" || + instance.service === "realtime" || + instance.service === "storage") ); + }; const materializeCommon = ( state: PersistedStackState, + instanceId: ServiceInstanceId, workloadId: string, authMaterial: NonNullable | undefined, ): Effect.Effect => Effect.gen(function* () { - if (workloadId === "studio:studio" || workloadId === "functions:edge-runtime") - yield* ensureFunctionsRoot(state); - const auth = needsAuthMaterial(state, workloadId) ? authMaterial : undefined; + if (workloadId.endsWith(":edge-runtime")) yield* ensureFunctionsRoot(state, instanceId); + if (workloadId.endsWith(":studio")) { + const functionsId = state.registry.defaultInstanceIds.functions; + if (functionsId !== undefined) yield* ensureFunctionsRoot(state, functionsId); + } + const auth = needsAuthMaterial(state, instanceId) ? authMaterial : undefined; const resolvesAnalyticsMaterial = - state.definition?.capabilities.analytics.enabled === true && - workloadId.startsWith("analytics:"); + state.registry.instances.some( + (instance) => + instance.id === instanceId && + instance.service === "analytics" && + instance.config.enabled, + ) && workloadId.includes(":vector"); const analytics = !resolvesAnalyticsMaterial ? undefined : yield* Effect.gen(function* () { - const analyticsSettings = settingsFor(state, "analytics"); + const analyticsSettings = settingsForInstance(state, instanceId, "analytics"); const gcpPath = isRecord(analyticsSettings) ? settingValue(state, analyticsSettings.gcp_jwt_path) : ""; - const vectorConfigPath = - workloadId === "analytics:vector" ? yield* writeVectorConfig(state) : undefined; + const vectorConfigPath = workloadId.endsWith(":vector") + ? yield* writeVectorConfig(state, instanceId) + : undefined; return gcpPath.length === 0 && vectorConfigPath === undefined ? undefined : { @@ -501,10 +583,15 @@ export const makeRuntimeInputOwner = ( }; }); const resolvesFunctionsMaterial = - workloadId === "functions:edge-runtime" && - state.definition?.capabilities.functions.enabled === true; + workloadId.endsWith(":edge-runtime") && + state.registry.instances.some( + (instance) => + instance.id === instanceId && + instance.service === "functions" && + instance.config.enabled, + ); const functions = resolvesFunctionsMaterial - ? { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) } + ? { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state, instanceId) } : undefined; return { ...(auth === undefined ? {} : { auth }), @@ -513,35 +600,79 @@ export const makeRuntimeInputOwner = ( }; }); + const commonInFlight = new Map< + string, + Fiber.Fiber + >(); + const authInFlight = new Map< + string, + Fiber.Fiber, StackPreparationError> + >(); const resolveCached = ( key: string, completed: Map, + inFlight: Map>, materialize: Effect.Effect, ): Effect.Effect => - Effect.suspend(() => { - const ready = completed.get(key); - return ready === undefined - ? materialize.pipe(Effect.tap((value) => Effect.sync(() => completed.set(key, value)))) - : Effect.succeed(ready); - }); + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const ready = completed.get(key); + if (ready !== undefined) return ready; + const current = inFlight.get(key); + if (current !== undefined) return yield* restore(Fiber.join(current)); + const start = yield* Deferred.make(); + const owned = Effect.gen(function* () { + yield* Deferred.await(start); + return yield* materialize; + }).pipe( + Effect.tap((value) => Effect.sync(() => completed.set(key, value))), + Effect.ensuring(Effect.sync(() => inFlight.delete(key))), + ); + const fiber = yield* Effect.forkIn(owned, ownerScope, { + startImmediately: true, + uninterruptible: false, + }); + inFlight.set(key, fiber); + yield* Deferred.succeed(start, undefined); + return yield* restore(Fiber.join(fiber)); + }), + ); const resolve = ( state: PersistedStackState, + instanceId: ServiceInstanceId, workloadId: string, ): Effect.Effect => Effect.gen(function* () { - const key = `${keyFor(state)}\u0000${workloadId}`; - const auth = needsAuthMaterial(state, workloadId) - ? yield* resolveCached(keyFor(state), authCompleted, resolveAuth(state)) + const key = `${keyFor(state)}\u0000${instanceId}\u0000${workloadId}`; + const authInstanceId = authInstanceIdFor(state, instanceId); + const auth = needsAuthMaterial(state, instanceId) + ? yield* resolveCached( + `${keyFor(state)}\u0000auth\u0000${authInstanceId ?? "default"}`, + authCompleted, + authInFlight, + resolveAuth(state, authInstanceId), + ) : undefined; const common = yield* resolveCached( key, commonCompleted, - materializeCommon(state, workloadId, auth), + commonInFlight, + materializeCommon(state, instanceId, workloadId, auth), ); return common; }); const cleanupAll = Effect.gen(function* () { + const interruptAndJoin = (fibers: ReadonlyArray>) => + Effect.gen(function* () { + for (const fiber of fibers) yield* Fiber.interrupt(fiber); + for (const fiber of fibers) + yield* Fiber.join(fiber).pipe(Effect.catchCause(() => Effect.void)); + }); + yield* interruptAndJoin([...commonInFlight.values()]); + yield* interruptAndJoin([...authInFlight.values()]); + commonInFlight.clear(); + authInFlight.clear(); commonCompleted.clear(); authCompleted.clear(); yield* mapFile( diff --git a/packages/stack/src/runtime/SchemaInit.ts b/packages/stack/src/runtime/SchemaInit.ts deleted file mode 100644 index dfb5b59704..0000000000 --- a/packages/stack/src/runtime/SchemaInit.ts +++ /dev/null @@ -1,705 +0,0 @@ -import { - Crypto, - Duration, - Effect, - FileSystem, - Option, - Path, - Redacted, - Schema, - Scope, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { compileStack } from "../model/Compiler.ts"; -import { excludeStackCapabilities, type ExcludableCapabilityName } from "../model/Exclusions.ts"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import type { - SchemaInitCapabilityName, - SchemaInitOptions, - SchemaInitTarget, -} from "../public/SchemaInit.ts"; -import type { SchemaInitError } from "../public/Errors.ts"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; -import { - ContainerEngineError, - InvalidStackConfigError, - InvalidStackIdentityError, - RequiresActivatedProcessError, - StackPreparationError, - StackRuntimeError, -} from "../public/Errors.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; -import { - AUTH_JWT_SECRET_SLOT, - DATABASE_INTERNAL_PASSWORD_SLOT, - resolveSecrets, - type SecretDeclaration, -} from "../state/SecretStore.ts"; -import { - STACK_STATE_FORMAT, - type PersistedSecretValues, - type PersistedStackState, -} from "../state/StackState.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { resolveRuntimeEnvironment } from "../supervisor/Launcher.ts"; -import { makeProductionRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; -import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; -import { resolveContainerEngine } from "./ContainerEngineResolver.ts"; -import { runContainerStartupProcess, schemaInitContainerName } from "./ContainerRuntime.ts"; -import { - defaultNativeProcessLauncher, - spawnNativeProcess, - type NativeProcessSpec, -} from "./NativeProcess.ts"; -import { makeProcessOutputTail } from "./Diagnostics.ts"; -import { makeRuntimeInputOwner } from "./RuntimeInputOwner.ts"; -import { encodeRuntimeEnvFile } from "./RuntimeEnvFile.ts"; -import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; -import { - runtimeSpecFor, - validateWorkloadRuntimeInputs, - type WorkloadRuntimeInputs, - type WorkloadRuntimeSpec, -} from "./WorkloadRuntimeSpec.ts"; - -const STARTUP_TIMEOUT = "5 minutes" satisfies Duration.Input; -const AUTH_TEMPLATE_BASE_URL = "http://127.0.0.1"; - -const PRIMARY_WORKLOAD: Record = { - auth: "auth:auth", - storage: "storage:storage", - realtime: "realtime:realtime", - analytics: "analytics:analytics", - pooler: "pooler:pooler", -}; - -const SCHEMA_INIT_BINDINGS = [ - "primary", - "admin", - "ui", - "smtp", - "pop3", - "inspector", - "rpc", -] as const; - -const schemaInitCompileConfig = ( - config: SchemaInitTarget["config"], - names: ReadonlyArray, -) => { - const requested = new Set(names); - return excludeStackCapabilities( - config, - CAPABILITY_NAMES.filter( - (name): name is ExcludableCapabilityName => name !== "database" && !requested.has(name), - ), - ); -}; - -// Live schema-init compiles a capability subset. Stamp declared managed values from -// persisted state; passing the full map into resolveSecrets fails on extra pass-through -// slots while the stack is running. -const overlayManagedSecrets = ( - declarations: ReadonlyArray, - persisted: PersistedSecretValues | undefined, -): ReadonlyArray => { - if (persisted === undefined) return declarations; - return declarations.map((entry) => { - if (entry.value !== undefined) return entry; - const existing = persisted[entry.slot]; - if (existing === undefined || existing.policy !== "managed") return entry; - return { ...entry, value: Redacted.make(existing.value) }; - }); -}; - -/** Catalog version and image for a schema-init one-shot; undefined when the pin is unknown. */ -export const schemaInitArtifactIdentity = ( - name: SchemaInitCapabilityName, - version?: string, -): string | undefined => { - const release = catalogReleaseFor(PRIMARY_WORKLOAD[name], version); - return release === undefined ? undefined : `${release.version}:${release.containerImage}`; -}; - -const schemaInitPrivatePorts = ( - databasePort: number, - workloadId: string, - bindings: WorkloadRuntimeSpec["bindings"], -): PersistedStackState["privatePorts"] => [ - { workloadId: "database:database", binding: "primary", port: databasePort }, - ...SCHEMA_INIT_BINDINGS.flatMap((binding) => { - const bound = bindings[binding]; - return bound === undefined ? [] : [{ workloadId, binding, port: bound.containerPort }]; - }), -]; - -/** Linux Engine extra hosts so `host.docker.internal` resolves (DNS even when URLs stay on the alias). */ -export const schemaInitHostGatewayExtraHosts = ( - platform: string, - host: string, -): ReadonlyArray => - platform === "linux" && host === "host.docker.internal" - ? ["host.docker.internal:host-gateway"] - : []; - -const DATABASE_HOST_KEYS = new Set([ - "DB_HOST", - "GOTRUE_DB_HOST", - "POSTGRES_HOST", - "PGHOST", - "PG_META_DB_HOST", -]); -const DATABASE_PORT_KEYS = new Set([ - "DB_PORT", - "GOTRUE_DB_PORT", - "POSTGRES_PORT", - "PGPORT", - "PG_META_DB_PORT", -]); -const DATABASE_PASSWORD_KEYS = new Set([ - "DB_PASSWORD", - "GOTRUE_DB_PASSWORD", - "POSTGRES_PASSWORD", - "PGPASSWORD", - "PG_META_DB_PASSWORD", -]); -const CONNECTION_PROTOCOLS = new Set(["postgres:", "postgresql:", "ecto:"]); - -export interface ParsedDatabaseUrl { - readonly host: string; - readonly port: number; - readonly password: string; - readonly database: string; -} - -export const parseSchemaInitDatabaseUrl = (url: string): ParsedDatabaseUrl | undefined => { - try { - const parsed = new URL(url); - if (!CONNECTION_PROTOCOLS.has(parsed.protocol)) return undefined; - const port = Number.parseInt(parsed.port === "" ? "5432" : parsed.port, 10); - if (!Number.isInteger(port) || parsed.hostname.length === 0) return undefined; - const database = decodeURIComponent(parsed.pathname.replace(/^\//, "")); - return { - host: parsed.hostname, - port, - password: decodeURIComponent(parsed.password), - database, - }; - } catch { - return undefined; - } -}; - -const rewriteConnectionUrl = ( - value: string, - target: { readonly host: string; readonly port: number; readonly password: string }, -): string | undefined => { - if (!value.includes("://")) return undefined; - try { - const parsed = new URL(value); - if (!CONNECTION_PROTOCOLS.has(parsed.protocol)) return undefined; - parsed.hostname = target.host; - parsed.port = String(target.port); - parsed.password = target.password; - return parsed.toString(); - } catch { - return undefined; - } -}; - -/** Rewrites only host/port/password on database connection settings; keeps user and database name. */ -export const rewriteDatabaseEnvironment = ( - env: Readonly>, - target: { readonly host: string; readonly port: number; readonly password: string }, -): Record => { - const rewritten: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (DATABASE_HOST_KEYS.has(key)) { - rewritten[key] = target.host; - continue; - } - if (DATABASE_PORT_KEYS.has(key)) { - rewritten[key] = String(target.port); - continue; - } - if (DATABASE_PASSWORD_KEYS.has(key)) { - rewritten[key] = target.password; - continue; - } - rewritten[key] = rewriteConnectionUrl(value, target) ?? value; - } - return rewritten; -}; - -const loopbackHost = (host: string): boolean => host === "127.0.0.1" || host === "localhost"; - -const schemaInitIdentity = ( - crypto: Crypto.Crypto, -): Effect.Effect => - Effect.gen(function* () { - const first = yield* crypto.randomUUIDv4; - const second = yield* crypto.randomUUIDv4; - return yield* Schema.decodeEffect(StackIdSchema)(`${first}${second}`.replaceAll("-", "")); - }).pipe( - Effect.mapError( - (cause) => - new InvalidStackIdentityError({ - message: "Unable to allocate schema-init identity", - cause, - }), - ), - ); - -const runtimeError = ( - key: Pick, - message: string, - cause?: unknown, -): StackRuntimeError => - new StackRuntimeError({ - message, - stackId: key.stackId, - workloadId: key.workloadId, - ...(cause === undefined ? {} : { cause }), - }); - -const mapContainerEngineError = ( - engine: StackRuntime & { readonly kind: "container" }, - message: string, - cause: unknown, -): ContainerEngineError => - new ContainerEngineError({ - engine: engine.engine, - message, - cause, - }); - -const resolveEngine = ( - target: SchemaInitTarget, - options: SchemaInitOptions, -): Effect.Effect< - Option.Option, - ContainerEngineError, - ChildProcessSpawner.ChildProcessSpawner -> => { - if (target.runtime.kind !== "container") return Effect.succeed(Option.none()); - if (options.containerEngine !== undefined) - return Effect.succeed(Option.some(options.containerEngine)); - const runtime = target.runtime; - return resolveContainerEngine(runtime.engine).pipe( - Effect.map(Option.some), - Effect.mapError((cause) => - mapContainerEngineError( - runtime, - `Unable to configure ${runtime.engine} for schema init`, - cause, - ), - ), - ); -}; - -const resolveLiveNetwork = ( - engine: ContainerEngine, - stackId: StackId, - runtime: StackRuntime & { readonly kind: "container" }, -): Effect.Effect => - engine.listResources(stackId).pipe( - Effect.mapError((cause) => - mapContainerEngineError(runtime, "Unable to list stack resources for schema init", cause), - ), - Effect.flatMap((resources) => { - const network = resources.find((entry) => entry.kind === "network"); - return network === undefined - ? Effect.fail( - runtimeError( - { stackId, workloadId: "" }, - "Stack network is unavailable for schema init", - ), - ) - : Effect.succeed(network.id); - }), - ); - -const acquireEphemeralNetwork = ( - engine: ContainerEngine, - schemaInitId: StackId, - runtime: StackRuntime & { readonly kind: "container" }, -): Effect.Effect => - Effect.gen(function* () { - const name = `supabase-${schemaInitId.slice(0, 16)}-schema-init-net`; - yield* Effect.addFinalizer(() => engine.removeNetwork(name).pipe(Effect.ignore)); - const created = yield* engine - .createNetwork({ - name, - labels: { - stackId: schemaInitId, - ownerSessionId: schemaInitId.slice(0, 32), - role: "network", - }, - }) - .pipe( - Effect.mapError((cause) => - mapContainerEngineError(runtime, "Unable to create schema-init network", cause), - ), - ); - return created.id; - }); - -const runNativeStartup = ( - spec: NativeProcessSpec, - key: RuntimeWorkloadKey, - knownSecrets: ReadonlyArray = [], -): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const process = yield* spawnNativeProcess(spec, defaultNativeProcessLauncher(), key).pipe( - Effect.mapError((error) => runtimeError(key, error.message, error)), - ); - const tail = makeProcessOutputTail(); - const drain = Effect.all( - [ - Stream.runForEach(process.stdout, (bytes) => - Effect.sync(() => tail.pushBytes("stdout", bytes)), - ), - Stream.runForEach(process.stderr, (bytes) => - Effect.sync(() => tail.pushBytes("stderr", bytes)), - ), - ], - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.mapError((error) => runtimeError(key, error.message, error))); - const exit = process.exitCode.pipe( - Effect.mapError((error) => runtimeError(key, error.message, error)), - ); - const [exitCode] = yield* Effect.all([exit, drain], { concurrency: "unbounded" }).pipe( - Effect.timeoutOrElse({ - duration: spec.timeout ?? STARTUP_TIMEOUT, - orElse: () => - Effect.fail(runtimeError(key, `Native schema init timed out for ${key.workloadId}`)), - }), - ); - if (exitCode !== 0) { - const diagnostic = tail.finish(knownSecrets); - const message = `Native schema init exited with code ${String(exitCode)} for ${key.workloadId}`; - return yield* runtimeError( - key, - diagnostic.length === 0 ? message : `${message}\n${diagnostic}`, - ); - } - }), - ); - -const capabilityInputs = ( - material: WorkloadRuntimeInputs, - hostRoute: ContainerHostRoute | undefined, -): WorkloadRuntimeInputs => { - const templateBaseUrl = material.auth?.templateBaseUrl ?? AUTH_TEMPLATE_BASE_URL; - const auth = { - ...material.auth, - templateBaseUrl, - }; - return { - ...material, - auth, - ...(hostRoute === undefined ? {} : { hostRoute }), - }; -}; - -export const schemaInitWorkloads = ( - names: ReadonlyArray, - target: SchemaInitTarget, - options: SchemaInitOptions = {}, -): Effect.Effect< - void, - SchemaInitError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner -> => - Effect.scoped( - Effect.gen(function* () { - const parsed = parseSchemaInitDatabaseUrl(target.databaseUrl); - if (parsed === undefined) - return yield* new InvalidStackConfigError({ - message: "Schema init requires a valid PostgreSQL URL", - }); - const password = Redacted.value(target.secrets.databasePassword); - const connection = { host: parsed.host, port: parsed.port, password }; - const compiled = yield* compileStack({ - projectRoot: target.projectRoot, - runtime: target.runtime, - config: schemaInitCompileConfig(target.config, names), - }); - const shared = yield* resolveRuntimeEnvironment; - let persistedSecrets: PersistedSecretValues | undefined; - if (target.kind === "live") { - const liveState = yield* (yield* makeStackStateStore({ stateRoot: shared.stateRoot })).read( - target.stackId, - ); - persistedSecrets = liveState?.secrets; - } - const declarations = overlayManagedSecrets( - compiled.secrets.map((entry) => { - if (entry.slot === DATABASE_INTERNAL_PASSWORD_SLOT) - return { ...entry, value: target.secrets.databasePassword }; - if (entry.slot === AUTH_JWT_SECRET_SLOT && target.secrets.jwtSecret !== undefined) - return { ...entry, value: target.secrets.jwtSecret }; - return entry; - }), - persistedSecrets, - ); - const resolved = yield* resolveSecrets({ declarations }, undefined, "unconfigured"); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const schemaInitId = yield* schemaInitIdentity(crypto); - const tempRoot = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-schema-init-" }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to create schema-init workspace", - cause, - }), - ), - ); - const engine = Option.getOrUndefined(yield* resolveEngine(target, options)); - const preparer = - options.artifactPreparer ?? - (yield* makeProductionRuntimeArtifactPreparer({ - stateRoot: shared.stateRoot, - ...(shared.artifactCacheRoot === undefined - ? {} - : { artifactCacheRoot: shared.artifactCacheRoot }), - runtime: target.runtime, - ...(engine === undefined ? {} : { containerEngine: engine }), - })); - const inputOwner = yield* makeRuntimeInputOwner({ - stateRoot: tempRoot, - stackId: schemaInitId, - }); - yield* Effect.addFinalizer(() => inputOwner.cleanupAll.pipe(Effect.ignore)); - const joinPostgresNetwork = target.kind === "ephemeral" && target.networkId !== undefined; - const envKind = - target.runtime.kind === "container" && (target.kind === "live" || joinPostgresNetwork) - ? "container" - : "native"; - const privatePorts: PersistedStackState["privatePorts"] = [ - { workloadId: "database:database", binding: "primary", port: connection.port }, - ]; - const state: PersistedStackState = { - format: STACK_STATE_FORMAT, - identity: { - projectRoot: target.projectRoot, - branchContext: "schema-init", - stackName: "schema-init", - }, - runtime: target.runtime, - desiredLifecycle: "stopped", - definition: compiled.definition, - ports: [], - privatePorts, - secrets: resolved.persisted, - }; - let hostRoute: ContainerHostRoute | undefined; - let networkId: string | undefined; - if (target.runtime.kind === "container") { - const runtime = target.runtime; - if (engine === undefined) - return yield* new ContainerEngineError({ - engine: runtime.engine, - message: "Container engine is unavailable for schema init", - }); - hostRoute = yield* engine.preflight.pipe( - Effect.mapError((cause) => - mapContainerEngineError(runtime, "Container host route preflight failed", cause), - ), - ); - networkId = - target.kind === "live" - ? yield* resolveLiveNetwork(engine, target.stackId, runtime) - : target.networkId !== undefined - ? target.networkId - : yield* acquireEphemeralNetwork(engine, schemaInitId, runtime); - } - const rewriteTarget = - target.runtime.kind === "container" && target.kind === "ephemeral" && !joinPostgresNetwork - ? { - host: - hostRoute !== undefined && loopbackHost(connection.host) - ? hostRoute.host - : connection.host, - port: connection.port, - password, - } - : undefined; - const extraHosts = - target.runtime.kind === "container" && target.kind === "ephemeral" - ? schemaInitHostGatewayExtraHosts( - options.platform ?? process.platform, - hostRoute?.host ?? "host.docker.internal", - ) - : []; - yield* Effect.forEach( - names, - (name) => - Effect.gen(function* () { - if (compiled.definition.capabilities[name].enabled !== true) { - yield* Effect.logWarning(`Skipping schema init for disabled capability ${name}`); - return; - } - const workloadId = PRIMARY_WORKLOAD[name]; - const workload = compiled.executionPlan.workloads.find( - (entry) => entry.id === workloadId, - ); - if (workload === undefined) - return yield* runtimeError( - { stackId: schemaInitId, workloadId }, - `Missing planned workload for ${name} schema init`, - ); - const spec = runtimeSpecFor(workload); - if (spec === undefined) - return yield* new StackPreparationError({ - message: `Unknown runtime specification for ${workload.id}`, - workload: workload.id, - }); - const key: RuntimeWorkloadKey = { stackId: schemaInitId, workloadId }; - const dummyPort = spec.containerPort; - const envState: PersistedStackState = { - ...state, - privatePorts: schemaInitPrivatePorts(connection.port, workloadId, spec.bindings), - }; - const material = yield* inputOwner.resolve(envState, workload.id); - const inputs = capabilityInputs(material, hostRoute); - yield* validateWorkloadRuntimeInputs(envState, workload, inputs); - const environment = yield* Effect.try({ - try: () => spec.env(envState, workload, dummyPort, envKind, inputs), - catch: (cause) => - runtimeError(key, cause instanceof Error ? cause.message : String(cause), cause), - }); - const env = - rewriteTarget === undefined - ? environment - : rewriteDatabaseEnvironment(environment, rewriteTarget); - if (target.runtime.kind === "native") { - const preview = spec.nativeStartupProcesses( - "", - envState, - workload, - dummyPort, - inputs, - ); - if (preview.length === 0) - return yield* new RequiresActivatedProcessError({ - capability: name, - message: `${name} schema init requires the activated ${name} process`, - }); - const prepared = yield* preparer.prepare(target.runtime, workload); - if (prepared.artifactRoot === undefined) - return yield* runtimeError( - key, - `Native artifact root is unavailable for ${workload.id}`, - ); - const startups = spec.nativeStartupProcesses( - prepared.artifactRoot, - envState, - workload, - dummyPort, - inputs, - ); - const knownSecrets = Object.values(resolved.persisted).flatMap((entry) => { - const value = entry.value; - if (value.length === 0) return []; - const encoded = encodeURIComponent(value); - return encoded === value ? [value] : [value, encoded]; - }); - yield* Effect.forEach( - startups, - (startup) => { - // Native prepare seeds when SEED_SELF_HOST is set; this one-shot must only migrate. - const nativeEnv = { ...env, ...startup.env }; - delete nativeEnv.SEED_SELF_HOST; - return runNativeStartup( - { - ...startup, - timeout: STARTUP_TIMEOUT, - env: nativeEnv, - }, - key, - knownSecrets, - ); - }, - { discard: true }, - ); - return; - } - const startups = spec.containerStartupProcesses(envState, workload, inputs); - if (startups.length === 0) - return yield* new RequiresActivatedProcessError({ - capability: name, - message: `${name} schema init requires the activated ${name} process`, - }); - if (engine === undefined || networkId === undefined) - return yield* new ContainerEngineError({ - engine: target.runtime.kind === "container" ? target.runtime.engine : "docker", - message: "Container engine is unavailable for schema init", - }); - const prepared = yield* preparer.prepare(target.runtime, workload); - if (prepared.image === undefined) - return yield* runtimeError(key, `Container image is unavailable for ${workload.id}`); - const encoded = yield* encodeRuntimeEnvFile(env); - const envFile = path.join(tempRoot, `${encodeURIComponent(workload.id)}.env`); - yield* fs.writeFileString(envFile, encoded, { mode: 0o600 }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to write schema-init environment file", - path: envFile, - cause, - }), - ), - ); - yield* fs.chmod(envFile, 0o600).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to secure schema-init environment file", - path: envFile, - cause, - }), - ), - ); - const image = prepared.image; - const mounts = spec.containerMounts?.(envState, workload, inputs) ?? []; - yield* Effect.forEach( - startups, - (startup) => - runContainerStartupProcess({ - engine, - key, - timeout: STARTUP_TIMEOUT, - specification: { - name: schemaInitContainerName(key), - image, - labels: { - stackId: schemaInitId, - ownerSessionId: schemaInitId.slice(0, 32), - workloadId, - startup: true, - role: "workload", - }, - network: networkId, - mounts, - volumeMounts: [], - publications: [], - role: "workload", - entrypoint: startup.entrypoint, - command: startup.command, - envFile, - ...(extraHosts.length === 0 ? {} : { extraHosts }), - }, - }).pipe(Effect.mapError((error) => runtimeError(key, error.message, error))), - { discard: true }, - ); - }), - { discard: true }, - ); - }), - ); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 1bd5f03435..49f88f5a89 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -6,7 +6,7 @@ import { isRecord, secret, settingValue, - settingsFor, + settingsForInstance, valueAt, } from "../state/MaterializedSettings.ts"; import type { @@ -14,25 +14,34 @@ import type { ContainerMount, ContainerStartupProcess, } from "./ContainerEngine.ts"; -import { catalogEntryFor, containerAliasFor } from "../model/WorkloadCatalog.ts"; +import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; import { Effect, type Duration } from "effect"; import { StackPreparationError, StackStateInvalidError } from "../public/Errors.ts"; import { FUNCTIONS_CONTAINER_ROOT } from "../functions/serve-main-deps.ts"; -import { DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; -export { FUNCTIONS_CONTAINER_ROOT } from "../functions/serve-main-deps.ts"; +import type { FunctionFilesPlan } from "../functions/FunctionFiles.ts"; +import type { FunctionOverride, FunctionOverrides } from "../functions/serve-main-resolver.ts"; type WorkloadRuntimeKind = "native" | "container"; /** Closed set of private ports a workload may expose to the host gateway. */ -type WorkloadBindingName = "primary" | "admin" | "ui" | "smtp" | "pop3" | "inspector" | "rpc"; +type WorkloadBindingName = + | "sql:internal" + | "primary" + | "admin" + | "ui" + | "smtp" + | "pop3" + | "inspector" + | "rpc"; interface WorkloadBinding { readonly containerPort: number; } interface WorkloadBindings { + readonly "sql:internal"?: WorkloadBinding; readonly primary?: WorkloadBinding; readonly admin?: WorkloadBinding; readonly ui?: WorkloadBinding; @@ -42,15 +51,21 @@ interface WorkloadBindings { readonly rpc?: WorkloadBinding; } -type BindingSelectionState = Pick; +type BindingSelectionState = Pick; export interface WorkloadBindingIntent { + readonly instanceId: string; readonly workloadId: string; readonly binding: WorkloadBindingName; } /** Inputs resolved by the owner before a process/container is created. */ export interface WorkloadRuntimeInputs { + /** Materialized settings for a one-shot catalog recipe on its database instance. */ + readonly catalog?: Readonly<{ + readonly capability: CapabilityName; + readonly settings: unknown; + }>; /** Resolved GoTrue signing key JSON and public JWKS. */ readonly auth?: Readonly<{ readonly jwtKeys?: string; @@ -68,6 +83,8 @@ export interface WorkloadRuntimeInputs { readonly functions?: Readonly<{ readonly bootstrapPath?: string; readonly secrets?: Readonly>; + /** Files outside the managed functions tree that must be visible to containers. */ + readonly files?: FunctionFilesPlan; }>; /** Stack-owned native persistent data paths. Containers use their named volumes instead. */ readonly database?: Readonly<{ readonly dataPath?: string }>; @@ -193,19 +210,42 @@ export const validateWorkloadRuntimeInputs = ( inputs: WorkloadRuntimeInputs = {}, ): Effect.Effect => Effect.gen(function* () { - const signing = state.definition?.security.jwt.signing; - const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); + const signing = state.security.jwt?.signing; + const thirdParty = resolveThirdPartyIssuer( + settingsForWorkload(state, workload.instanceId, "auth", inputs.catalog), + ); if (!thirdParty.ok) return yield* new StackPreparationError({ message: thirdParty.message, workload: workload.id, }); + if (workload.recipeId === "realtime:realtime") { + const dbEncryptionKey = valueAtInstance( + state, + workload.instanceId, + "realtime", + "db_enc_key", + inputs, + ); + const secretKeyBase = valueAtInstance( + state, + workload.instanceId, + "realtime", + "secret_key_base", + inputs, + ); + if (dbEncryptionKey.length === 0 || secretKeyBase.length === 0) + return yield* new StackPreparationError({ + message: "Resolved Realtime secret settings are required", + workload: workload.id, + }); + } const jwksConsumer = - workload.id === "rest:rest" || - workload.id === "auth:auth" || - workload.id === "realtime:realtime" || - workload.id === "storage:storage" || - workload.id === "functions:edge-runtime"; + workload.recipeId === "rest:rest" || + workload.recipeId === "auth:auth" || + workload.recipeId === "realtime:realtime" || + workload.recipeId === "storage:storage" || + workload.recipeId === "functions:edge-runtime"; if ( jwksConsumer && (signing?.kind === "jwks-file" || thirdParty.value !== undefined) && @@ -225,11 +265,12 @@ export const validateWorkloadRuntimeInputs = ( message: "Managed JWT signing secret is required for the configured auth mode", workload: workload.id, }); - if (workload.id === "analytics:analytics") { - const backend = valueAt(state, "analytics", "backend"); + if (workload.recipeId === "analytics:analytics") { + const backend = valueAtInstance(state, workload.instanceId, "analytics", "backend", inputs); if ( backend === "bigquery" && - valueAt(state, "analytics", "gcp_jwt_path").length > 0 && + valueAtInstance(state, workload.instanceId, "analytics", "gcp_jwt_path", inputs).length > + 0 && (inputs.analytics?.gcpJwtPath === undefined || inputs.analytics.gcpJwtPath.length === 0) ) return yield* new StackPreparationError({ @@ -237,7 +278,7 @@ export const validateWorkloadRuntimeInputs = ( workload: workload.id, }); } - if (workload.id === "auth:auth") { + if (workload.recipeId === "auth:auth") { if ( signing?.kind === "jwks-file" && (inputs.auth?.jwtKeys === undefined || inputs.auth.jwtKeys.length === 0) @@ -246,7 +287,7 @@ export const validateWorkloadRuntimeInputs = ( message: "Resolved JWT signing keys are required for Auth", workload: workload.id, }); - const email = settingsFor(state, "auth"); + const email = settingsForWorkload(state, workload.instanceId, "auth", inputs.catalog); const emailSettings = isRecord(email) && isRecord(email.email) ? email.email : undefined; const templates = emailSettings?.template; const notifications = emailSettings?.notification; @@ -270,42 +311,77 @@ export const validateWorkloadRuntimeInputs = ( } }); -export const FUNCTIONS_BOOTSTRAP_CONTAINER_PATH = "/root"; +const FUNCTIONS_BOOTSTRAP_CONTAINER_PATH = "/root"; const compactEnvironment = ( environment: Readonly>, ): Readonly> => Object.fromEntries(Object.entries(environment).filter(([, value]) => value.length > 0)); -const capabilityEnabled = (state: PersistedStackState, capability: CapabilityName): boolean => - state.definition?.capabilities[capability].enabled ?? true; +const capabilityEnabled = ( + state: PersistedStackState, + capability: CapabilityName, + instanceId: string, +): boolean => + state.registry.instances.find( + (instance) => instance.id === instanceId && instance.service === capability, + )?.config.enabled ?? false; const capabilityEnv = ( state: PersistedStackState, capability: CapabilityName, prefix: string, + instanceId: string, omit: (key: string) => boolean = () => false, + inputs: WorkloadRuntimeInputs = {}, ): Record => { const out: Record = {}; - const settings = settingsFor(state, capability); + const settings = settingsForWorkload(state, instanceId, capability, inputs.catalog); if (settings !== undefined) flattenSettings(state, settings, prefix, out); for (const key of Object.keys(out)) if (omit(key)) delete out[key]; return out; }; -const dbPort = (state: PersistedStackState): number => - state.privatePorts.find( +const valueAtInstance = ( + state: PersistedStackState, + instanceId: string, + capability: CapabilityName, + path: string, + inputs: WorkloadRuntimeInputs = {}, +): string => { + let current: unknown = settingsForWorkload(state, instanceId, capability, inputs.catalog); + for (const segment of path.split(".")) { + if (!isRecord(current)) return ""; + current = current[segment]; + } + return settingValue(state, current); +}; + +const dbPort = (state: PersistedStackState, instanceId: string): number => { + const port = state.privatePorts.find( (assignment) => - assignment.workloadId === "database:database" && assignment.binding === "primary", - )?.port ?? 5432; + assignment.instanceId === instanceId && + assignment.workloadId.endsWith(":database") && + assignment.binding === "sql:internal", + )?.port; + if (port === undefined) throw new Error(`Missing database port for instance ${instanceId}`); + return port; +}; + +const databaseInstanceIdFor = (state: PersistedStackState, instanceId: string): string => + serviceInstanceIdFor(state, instanceId, "database"); const privatePortFor = ( state: PersistedStackState, workloadId: string, binding: WorkloadBindingName, + instanceId: string, ): number | undefined => state.privatePorts.find( - (assignment) => assignment.workloadId === workloadId && assignment.binding === binding, + (assignment) => + assignment.instanceId === instanceId && + assignment.workloadId === workloadId && + assignment.binding === binding, )?.port; const bindingFor = ( @@ -319,38 +395,162 @@ const workloadPort = ( binding: WorkloadBindingName, runtime: WorkloadRuntimeKind, containerPort: number, + instanceId: string, ): number => runtime === "container" ? containerPort - : (privatePortFor(state, workloadId, binding) ?? containerPort); + : (privatePortFor(state, workloadId, binding, instanceId) ?? + (() => { + throw new Error(`Missing ${binding} port for workload ${workloadId}`); + })()); const containerPortFor = ( state: PersistedStackState, workloadId: string, binding: WorkloadBindingName, fallback: number, + instanceId: string, ): number => { - if (workloadId === "pooler:pooler" && binding === "primary") - return valueAt(state, "pooler", "pool_mode") === "session" ? 5432 : 6543; + if (workloadId.endsWith(":pooler") && binding === "primary") + return valueAtInstance(state, instanceId, "pooler", "pool_mode") === "session" ? 5432 : 6543; return fallback; }; -const dbHost = (runtime: WorkloadRuntimeKind): string => - runtime === "container" ? containerAliasFor("database:database") : "127.0.0.1"; +const dbHost = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + instanceId: string, +): string => { + if (runtime === "native") return "127.0.0.1"; + const databaseId = serviceInstanceIdFor(state, instanceId, "database"); + const catalog = catalogEntryFor("database:database"); + if (catalog === undefined) throw new Error("Database catalog entry is missing"); + return `${catalog.containerAlias}-${databaseId}`; +}; + +const serviceInstanceIdFor = ( + state: PersistedStackState, + currentInstanceId: string, + service: string, +): string => { + const current = state.registry.instances.find((entry) => entry.id === currentInstanceId); + if (current === undefined) throw new Error(`Instance ${currentInstanceId} is missing`); + const dependencies: Readonly> = current.dependencies; + if (service === "database" && current.service === "database") return current.id; + const dependency = dependencies[service]; + if (dependency === undefined) + throw new Error(`Dependency ${service} is missing for instance ${currentInstanceId}`); + return dependency; +}; + +const workloadIdFor = ( + state: PersistedStackState, + currentInstanceId: string, + recipeId: string, +): string => { + const separator = recipeId.indexOf(":"); + const service = recipeId.slice(0, separator); + const suffix = recipeId.slice(separator + 1); + const current = state.registry.instances.find((entry) => entry.id === currentInstanceId); + if (current === undefined) throw new Error(`Instance ${currentInstanceId} is missing`); + const target = + current.service === service + ? current.id + : serviceInstanceIdFor(state, currentInstanceId, service); + return `${target}:${suffix}`; +}; + +const databasePassword = (state: PersistedStackState, currentInstanceId: string): string => { + const databaseId = serviceInstanceIdFor(state, currentInstanceId, "database"); + const database = state.registry.instances.find( + (entry) => entry.id === databaseId && entry.service === "database", + ); + if ( + database === undefined || + database.service !== "database" || + database.config.passwordSecretRef === undefined + ) + throw new Error(`Database password slot is missing for instance ${databaseId}`); + return secret(state, database.config.passwordSecretRef); +}; + +const settingsForWorkload = ( + state: PersistedStackState, + currentInstanceId: string, + capability: CapabilityName, + catalog?: WorkloadRuntimeInputs["catalog"], +): unknown => { + if (catalog?.capability === capability) return catalog.settings; + const current = state.registry.instances.find((entry) => entry.id === currentInstanceId); + const dependencies = current?.dependencies; + const targetId = + current?.service === capability + ? current.id + : dependencies === undefined || dependencies === null + ? undefined + : Object.entries(dependencies).find(([name]) => name === capability)?.[1]; + return targetId === undefined ? undefined : settingsForInstance(state, targetId, capability); +}; + +const serviceAlias = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + currentInstanceId: string, + recipeId: PlannedWorkload["recipeId"], +): string => { + const catalog = catalogEntryFor(recipeId); + if (catalog === undefined) throw new Error(`Catalog entry is missing for ${recipeId}`); + if (runtime === "native") return "127.0.0.1"; + const service = recipeId.slice(0, recipeId.indexOf(":")); + return `${catalog.containerAlias}-${serviceInstanceIdFor(state, currentInstanceId, service)}`; +}; const dbUrl = ( state: PersistedStackState, role: string, runtime: WorkloadRuntimeKind, + instanceId: string, database = "postgres", ): string => { - const port = runtime === "container" ? 5432 : dbPort(state); - return `postgresql://${role}:${secret(state, DATABASE_INTERNAL_PASSWORD_SLOT)}@${dbHost(runtime)}:${port}/${database}`; + const port = + runtime === "container" ? 5432 : dbPort(state, databaseInstanceIdFor(state, instanceId)); + return `postgresql://${role}:${databasePassword(state, instanceId)}@${dbHost(state, runtime, instanceId)}:${port}/${database}`; }; -const usesResolvedJwks = (state: PersistedStackState): boolean => { - const signing = state.definition?.security.jwt.signing; - const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); +const functionsDatabaseUrl = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + instanceId: string, + inputs: WorkloadRuntimeInputs, +): string | undefined => { + const instance = state.registry.instances.find((entry) => entry.id === instanceId); + const dependencies: Readonly> = instance?.dependencies ?? {}; + const databaseId = dependencies.database ?? state.registry.defaultInstanceIds.database; + if (databaseId === undefined) return undefined; + const database = state.registry.instances.find( + (entry) => entry.id === databaseId && entry.service === "database", + ); + if ( + database === undefined || + database.service !== "database" || + database.config.enabled === false || + database.config.passwordSecretRef === undefined + ) + return undefined; + const port = state.ports.find( + (assignment) => + assignment.owner === "instance" && + assignment.instanceId === database.id && + assignment.binding === "sql", + )?.port; + const host = runtime === "native" ? "127.0.0.1" : inputs.hostRoute?.host; + if (port === undefined || host === undefined) return undefined; + return `postgresql://supabase_admin:${secret(state, database.config.passwordSecretRef)}@${host}:${port}/postgres`; +}; + +const usesResolvedJwks = (state: PersistedStackState, instanceId: string): boolean => { + const signing = state.security.jwt?.signing; + const thirdParty = resolveThirdPartyIssuer(settingsForWorkload(state, instanceId, "auth")); return signing?.kind === "jwks-file" || (thirdParty.ok && thirdParty.value !== undefined); }; @@ -363,7 +563,8 @@ const edgeRuntimeJwtEnvironment = ( SUPABASE_INTERNAL_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.publishable_key"), SUPABASE_INTERNAL_SECRET_KEY: secret(state, "secret:auth.settings.secret_key"), SUPABASE_INTERNAL_HOST_PORT: String( - state.ports.find((assignment) => assignment.field === "api")?.port ?? "", + state.ports.find((assignment) => assignment.owner === "stack" && assignment.binding === "api") + ?.port ?? "", ), SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', }); @@ -372,17 +573,25 @@ const edgeRuntimeJwtEnvironment = ( return { ...inputs.functions?.secrets, ...fixed }; }; -const functionsConfigEnvironment = (state: PersistedStackState): string => { - const settings = settingsFor(state, "functions"); +export const functionOverridesForSettings = ( + state: PersistedStackState, + instanceId: string, +): FunctionOverrides => { + const settings = settingsForInstance(state, instanceId, "functions"); const edgeRuntime = isRecord(settings) && isRecord(settings.edge_runtime) ? settings.edge_runtime : {}; const configured = isRecord(settings) && isRecord(settings.functions) ? settings.functions : {}; - const result: Record = {}; - const defaults: Record = {}; - if (typeof edgeRuntime.verify_jwt_default === "boolean") - defaults.verify_jwt = edgeRuntime.verify_jwt_default; - if (typeof edgeRuntime.import_map_default === "string") - defaults.import_map_root = edgeRuntime.import_map_default; + const result: Record = {}; + const defaults: FunctionOverride = { + ...(typeof edgeRuntime.verify_jwt_default === "boolean" + ? { verifyJWT: edgeRuntime.verify_jwt_default } + : {}), + ...(typeof edgeRuntime.import_map_default === "string" + ? { + importMapRoot: edgeRuntime.import_map_default, + } + : {}), + }; if (Object.keys(defaults).length > 0) result.$default = defaults; for (const [slug, value] of Object.entries(configured)) { if (!isRecord(value)) continue; @@ -391,26 +600,87 @@ const functionsConfigEnvironment = (state: PersistedStackState): string => { Object.entries(value.env).map(([key, entry]) => [key, settingValue(state, entry)]), ) : {}; - result[slug] = { - enabled: value.enabled ?? true, - verify_jwt: value.verify_jwt ?? true, - import_map: settingValue(state, value.import_map), - entrypoint: settingValue(state, value.entrypoint), - static_files: Array.isArray(value.static_files) - ? value.static_files.map((entry) => settingValue(state, entry)) - : [], + const functionConfig: FunctionOverride = { + enabled: value.enabled !== false, env, + ...(typeof value.verify_jwt === "boolean" ? { verifyJWT: value.verify_jwt } : {}), + ...(value.import_map === undefined + ? {} + : { + importMapPath: settingValue(state, value.import_map), + }), + ...(value.entrypoint === undefined + ? {} + : { + entrypointPath: settingValue(state, value.entrypoint), + }), + ...(Array.isArray(value.static_files) + ? { + staticFiles: value.static_files.map((entry) => settingValue(state, entry)), + } + : {}), }; + result[slug] = functionConfig; } - return JSON.stringify(result); + return result; +}; + +const functionsConfigEnvironment = (state: PersistedStackState, instanceId: string): string => + JSON.stringify(functionOverridesForSettings(state, instanceId)); + +const functionsInstanceIdFor = ( + state: PersistedStackState, + instanceId: string, +): string | undefined => { + const instance = state.registry.instances.find((entry) => entry.id === instanceId); + if (instance?.service === "functions") return instance.id; + if (instance?.service !== "studio") return undefined; + const functionsId = state.registry.defaultInstanceIds.functions; + const functions = state.registry.instances.find( + (entry) => entry.id === functionsId && entry.service === "functions", + ); + return functions?.config.enabled === true ? functions.id : undefined; }; -const functionsRoot = (state: PersistedStackState): string => - valueAt(state, "functions", "functions_root"); +const functionsRoot = (state: PersistedStackState, instanceId: string): string => { + const functionsId = functionsInstanceIdFor(state, instanceId); + const settings = + functionsId === undefined ? undefined : settingsForInstance(state, functionsId, "functions"); + return isRecord(settings) ? settingValue(state, settings.functions_root) : ""; +}; + +const pathWithin = (root: string, candidate: string): boolean => { + const normalizedRoot = root.replaceAll("\\", "/").replace(/\/+$/u, ""); + const normalizedCandidate = candidate.replaceAll("\\", "/"); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +}; + +const functionsContainerMounts = ( + state: PersistedStackState, + workload: PlannedWorkload, + inputs: WorkloadRuntimeInputs, +): ReadonlyArray => { + const root = functionsRoot(state, workload.instanceId); + if (root.length === 0) return []; + const filesByTarget = new Map(); + for (const file of inputs.functions?.files?.files ?? []) { + if (!pathWithin(root, file.hostPath) && !filesByTarget.has(file.targetPath)) + filesByTarget.set(file.targetPath, file); + } + const extra = [...filesByTarget.values()].map((file) => ({ + source: file.hostPath, + target: file.targetPath, + readOnly: true, + })); + return [{ source: root, target: root, readOnly: true }, ...extra]; +}; const privateEndpointFor = ( state: PersistedStackState, workloadId: string, + instanceId: string, bindings: WorkloadBindings, binding: WorkloadBindingName, runtime: WorkloadRuntimeKind, @@ -420,8 +690,8 @@ const privateEndpointFor = ( if (declared === undefined) return undefined; const port = runtime === "container" - ? containerPortFor(state, workloadId, binding, declared.containerPort) - : privatePortFor(state, workloadId, binding); + ? containerPortFor(state, workloadId, binding, declared.containerPort, instanceId) + : privatePortFor(state, workloadId, binding, instanceId); return port === undefined ? undefined : { @@ -438,34 +708,49 @@ const nativeArgsFor = ( args: ReadonlyArray, inputs: WorkloadRuntimeInputs, ): ReadonlyArray => { - if (workload.id === "analytics:vector" && inputs.analytics?.vectorConfigPath !== undefined) + if (workload.recipeId === "analytics:vector" && inputs.analytics?.vectorConfigPath !== undefined) return ["--config", inputs.analytics.vectorConfigPath]; - if (workload.id !== "functions:edge-runtime") return args; + if (workload.recipeId !== "functions:edge-runtime") return args; return args.map((arg) => (arg.startsWith("--main-service=") ? "--main-service=." : arg)); }; const nativeFunctionsDirectory = ( state: PersistedStackState, + instanceId: string, inputs: WorkloadRuntimeInputs, ): string => { const bootstrapPath = inputs.functions?.bootstrapPath; - if (bootstrapPath === undefined) return functionsRoot(state); + if (bootstrapPath === undefined) return functionsRoot(state, instanceId); return bootstrapPath.slice(0, bootstrapPath.lastIndexOf("/")) || bootstrapPath; }; -const functionsInspectorRequested = (state: Pick): boolean => { - const inspectorSettings = state.definition?.capabilities.functions.settings.inspector; +const functionsInspectorRequested = ( + state: Pick, + instanceId: string, +): boolean => { + const functions = state.registry.instances.find( + (instance) => instance.id === instanceId && instance.service === "functions", + ); + const functionSettings: unknown = functions?.config.settings; + const inspectorSettings = isRecord(functionSettings) ? functionSettings.inspector : undefined; + const functionEndpoints: unknown = functions?.config.endpoints; return ( - isRecord(inspectorSettings) || state.definition?.listeners.functionsInspector.enabled === true + isRecord(inspectorSettings) || + (isRecord(functionEndpoints) && + isRecord(functionEndpoints.inspector) && + functionEndpoints.inspector.enabled === true) ); }; const functionsInspectorArgs = ( state: PersistedStackState, runtime: WorkloadRuntimeKind, + instanceId: string, ): ReadonlyArray => { - const configuredMode = valueAt(state, "functions", "inspector.mode"); - const inspectorRequested = functionsInspectorRequested(state); + const settings = settingsForInstance(state, instanceId, "functions"); + const inspector = isRecord(settings) && isRecord(settings.inspector) ? settings.inspector : {}; + const configuredMode = settingValue(state, inspector.mode); + const inspectorRequested = functionsInspectorRequested(state, instanceId); const mode = configuredMode === "run" || configuredMode === "brk" || configuredMode === "wait" ? configuredMode @@ -474,14 +759,16 @@ const functionsInspectorArgs = ( : ""; if (mode !== "run" && mode !== "brk" && mode !== "wait") return []; const port = - runtime === "container" ? 9229 : privatePortFor(state, "functions:edge-runtime", "inspector"); + runtime === "container" + ? 9229 + : privatePortFor(state, `${instanceId}:edge-runtime`, "inspector", instanceId); if (port === undefined) throw new Error("Functions inspector private port assignment is required"); const address = runtime === "container" ? "0.0.0.0" : "127.0.0.1"; const flag = mode === "brk" ? "--inspect-brk" : mode === "wait" ? "--inspect-wait" : "--inspect"; return [ `${flag}=${address}:${port}`, - ...(valueAt(state, "functions", "inspector.main") === "true" ? ["--inspect-main"] : []), + ...(settingValue(state, inspector.main) === "true" ? ["--inspect-main"] : []), ]; }; @@ -493,9 +780,15 @@ const nativeProcessFor = ( spec: WorkloadRuntimeSpecDefinition, inputs: WorkloadRuntimeInputs = {}, ): NativeProcessResolution => { - const catalog = catalogEntryFor(workload.id); + const catalog = catalogEntryFor(workload.recipeId); const executablePath = catalog?.executablePath; - const resolvedPort = privatePortFor(state, workload.id, "primary") ?? port; + const resolvedPort = + privatePortFor( + state, + workload.id, + workload.recipeId === "database:database" ? "sql:internal" : "primary", + workload.instanceId, + ) ?? port; const args = spec .args(state, workload, resolvedPort, "native") .map((arg) => @@ -508,10 +801,10 @@ const nativeProcessFor = ( executablePath === undefined ? artifactRoot : artifactPath(artifactRoot, executablePath), args: nativeArgs, cwd: - workload.id === "functions:edge-runtime" - ? nativeFunctionsDirectory(state, inputs) + workload.recipeId === "functions:edge-runtime" + ? nativeFunctionsDirectory(state, workload.instanceId, inputs) : (spec.cwd?.(state, workload) ?? state.identity.projectRoot), - ...(workload.id === "database:database" + ...(workload.recipeId === "database:database" ? { gracefulStopSignal: "SIGINT", gracefulStopTimeout: "15 seconds" } : {}), }; @@ -525,7 +818,7 @@ const nativeStartupProcessesFor = ( ): ReadonlyArray => { const artifact = (relative: string): string => artifactPath(artifactRoot, relative); const cwd = artifactRoot; - switch (workload.id) { + switch (workload.recipeId) { case "auth:auth": return [{ executable: artifact("bin/auth"), args: ["migrate"], cwd }]; case "storage:storage": @@ -546,31 +839,54 @@ const nativeStartupProcessesFor = ( const withRestSettings = ( state: PersistedStackState, + instanceId: string, runtime: WorkloadRuntimeKind, port: number, inputs: WorkloadRuntimeInputs = {}, ): Record => compactEnvironment({ - PGRST_DB_URI: dbUrl(state, "authenticator", runtime), - PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas"), - PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path"), + PGRST_DB_URI: dbUrl(state, "authenticator", runtime, instanceId), + PGRST_DB_SCHEMAS: valueAtInstance(state, instanceId, "rest", "schemas", inputs), + PGRST_DB_EXTRA_SEARCH_PATH: valueAtInstance( + state, + instanceId, + "rest", + "extra_search_path", + inputs, + ), PGRST_DB_ANON_ROLE: "anon", - PGRST_JWT_SECRET: usesResolvedJwks(state) + PGRST_JWT_SECRET: usesResolvedJwks(state, instanceId) ? (inputs.auth?.jwks ?? "") : secret(state, "secret:auth.settings.jwt_secret"), PGRST_SERVER_PORT: String(port), - PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows"), - PGRST_ADMIN_SERVER_PORT: String(workloadPort(state, "rest:rest", "admin", runtime, 3001)), - PGRST_OPENAPI_SERVER_PROXY_URI: valueAt(state, "rest", "external_url"), + PGRST_DB_MAX_ROWS: valueAtInstance(state, instanceId, "rest", "max_rows", inputs), + PGRST_ADMIN_SERVER_PORT: String( + workloadPort( + state, + workloadIdFor(state, instanceId, "rest:rest"), + "admin", + runtime, + 3001, + instanceId, + ), + ), + PGRST_OPENAPI_SERVER_PROXY_URI: valueAtInstance( + state, + instanceId, + "rest", + "external_url", + inputs, + ), }); const authNestedEnvironment = ( state: PersistedStackState, + instanceId: string, jwtIssuer: string, inputs: WorkloadRuntimeInputs, ): Record => { const out: Record = {}; - const settings = settingsFor(state, "auth"); + const settings = settingsForWorkload(state, instanceId, "auth", inputs.catalog); if (!isRecord(settings)) return out; const external = settings.external; if (isRecord(external)) @@ -650,12 +966,16 @@ const authNestedEnvironment = ( }; const authExternalUrl = (state: PersistedStackState): string => { - const apiPort = state.ports.find((assignment) => assignment.field === "api")?.port; + const apiPort = state.ports.find( + (assignment) => assignment.owner === "stack" && assignment.binding === "api", + )?.port; return `http://127.0.0.1${apiPort === undefined ? "" : `:${apiPort}`}/auth/v1`; }; const apiListenerUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInputs): string => { - const apiPort = state.ports.find((assignment) => assignment.field === "api")?.port; + const apiPort = state.ports.find( + (assignment) => assignment.owner === "stack" && assignment.binding === "api", + )?.port; const host = inputs?.hostRoute?.host ?? "127.0.0.1"; return `http://${host}${apiPort === undefined ? "" : `:${apiPort}`}`; }; @@ -665,8 +985,12 @@ const apiGatewayUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInput return valueAt(state, "studio", "api_url") || apiListenerUrl(state); }; -const authSmsProvider = (state: PersistedStackState): string => { - const sms = settingsFor(state, "auth"); +const authSmsProvider = ( + state: PersistedStackState, + instanceId: string, + inputs: WorkloadRuntimeInputs, +): string => { + const sms = settingsForWorkload(state, instanceId, "auth", inputs.catalog); if (!isRecord(sms) || !isRecord(sms.sms)) return ""; // Providers are checked in this fixed order; if multiple are enabled, the first one wins // and only its credentials are passed to GoTrue. @@ -678,18 +1002,26 @@ const authSmsProvider = (state: PersistedStackState): string => { return ""; }; -const authSmsTestOtp = (state: PersistedStackState): string => { - const value = valueAt(state, "auth", "sms.test_otp"); +const authSmsTestOtp = ( + state: PersistedStackState, + instanceId: string, + inputs: WorkloadRuntimeInputs, +): string => { + const value = valueAtInstance(state, instanceId, "auth", "sms.test_otp", inputs); if (value.length === 0) return ""; - const settings = settingsFor(state, "auth"); + const settings = settingsForWorkload(state, instanceId, "auth", inputs.catalog); if (!isRecord(settings) || !isRecord(settings.sms) || !isRecord(settings.sms.test_otp)) return ""; return Object.entries(settings.sms.test_otp) .map(([phone, otp]) => `${phone}:${settingValue(state, otp)}`) .join(","); }; -const passwordRequiredCharacters = (state: PersistedStackState): string => { - const requirements = valueAt(state, "auth", "password_requirements"); +const passwordRequiredCharacters = ( + state: PersistedStackState, + instanceId: string, + inputs: WorkloadRuntimeInputs, +): string => { + const requirements = valueAtInstance(state, instanceId, "auth", "password_requirements", inputs); if (requirements === "letters_digits") return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"; if (requirements === "lower_upper_letters_digits") @@ -701,6 +1033,7 @@ const passwordRequiredCharacters = (state: PersistedStackState): string => { const withAuthSettings = ( state: PersistedStackState, + instanceId: string, runtime: WorkloadRuntimeKind, port: number, inputs: WorkloadRuntimeInputs = {}, @@ -710,22 +1043,25 @@ const withAuthSettings = ( state, "auth", "GOTRUE", + instanceId, (key) => key === "GOTRUE_SIGNING_KEYS_PATH" || key.startsWith("GOTRUE_THIRD_PARTY_") || key.startsWith("GOTRUE_EXTERNAL_") || key.startsWith("GOTRUE_MFA_PHONE_"), + inputs, ), ...authNestedEnvironment( state, - valueAt(state, "auth", "jwt_issuer") || authExternalUrl(state), + instanceId, + valueAtInstance(state, instanceId, "auth", "jwt_issuer", inputs) || authExternalUrl(state), inputs, ), - GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime), + GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime, instanceId), GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: valueAt(state, "auth", "site_url"), + GOTRUE_SITE_URL: valueAtInstance(state, instanceId, "auth", "site_url", inputs), GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - GOTRUE_JWT_EXP: valueAt(state, "auth", "jwt_expiry"), + GOTRUE_JWT_EXP: valueAtInstance(state, instanceId, "auth", "jwt_expiry", inputs), GOTRUE_JWT_AUD: "authenticated", GOTRUE_JWT_ADMIN_ROLES: "service_role", GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", @@ -740,16 +1076,62 @@ const withAuthSettings = ( GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl(state)}/verify`, GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl(state)}/verify`, GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl(state)}/verify`, - GOTRUE_URI_ALLOW_LIST: valueAt(state, "auth", "additional_redirect_urls"), - GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED: valueAt(state, "auth", "enable_refresh_token_rotation"), - GOTRUE_REFRESH_TOKEN_REUSE_INTERVAL: valueAt(state, "auth", "refresh_token_reuse_interval"), - GOTRUE_DISABLE_SIGNUP: valueAt(state, "auth", "enable_signup") === "false" ? "true" : "false", - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: valueAt(state, "auth", "enable_anonymous_sign_ins"), - GOTRUE_PASSWORD_MIN_LENGTH: valueAt(state, "auth", "minimum_password_length"), - GOTRUE_PASSWORD_REQUIREMENTS: valueAt(state, "auth", "password_requirements"), - GOTRUE_PASSWORD_REQUIRED_CHARACTERS: passwordRequiredCharacters(state), - GOTRUE_JWT_ISSUER: valueAt(state, "auth", "jwt_issuer") || authExternalUrl(state), - GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: valueAt(state, "auth", "enable_manual_linking"), + GOTRUE_URI_ALLOW_LIST: valueAtInstance( + state, + instanceId, + "auth", + "additional_redirect_urls", + inputs, + ), + GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "enable_refresh_token_rotation", + inputs, + ), + GOTRUE_REFRESH_TOKEN_REUSE_INTERVAL: valueAtInstance( + state, + instanceId, + "auth", + "refresh_token_reuse_interval", + inputs, + ), + GOTRUE_DISABLE_SIGNUP: + valueAtInstance(state, instanceId, "auth", "enable_signup", inputs) === "false" + ? "true" + : "false", + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "enable_anonymous_sign_ins", + inputs, + ), + GOTRUE_PASSWORD_MIN_LENGTH: valueAtInstance( + state, + instanceId, + "auth", + "minimum_password_length", + inputs, + ), + GOTRUE_PASSWORD_REQUIREMENTS: valueAtInstance( + state, + instanceId, + "auth", + "password_requirements", + inputs, + ), + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: passwordRequiredCharacters(state, instanceId, inputs), + GOTRUE_JWT_ISSUER: + valueAtInstance(state, instanceId, "auth", "jwt_issuer", inputs) || authExternalUrl(state), + GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "enable_manual_linking", + inputs, + ), GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: valueAt( state, "auth", @@ -760,35 +1142,157 @@ const withAuthSettings = ( "auth", "refresh_token_reuse_interval", ), - GOTRUE_RATE_LIMIT_EMAIL_SENT: valueAt(state, "auth", "rate_limit.email_sent"), - GOTRUE_RATE_LIMIT_SMS_SENT: valueAt(state, "auth", "rate_limit.sms_sent"), - GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: valueAt(state, "auth", "rate_limit.anonymous_users"), - GOTRUE_RATE_LIMIT_TOKEN_REFRESH: valueAt(state, "auth", "rate_limit.token_refresh"), - GOTRUE_RATE_LIMIT_VERIFY: valueAt(state, "auth", "rate_limit.token_verifications"), - GOTRUE_RATE_LIMIT_OTP: valueAt(state, "auth", "rate_limit.sign_in_sign_ups"), - GOTRUE_RATE_LIMIT_WEB3: valueAt(state, "auth", "rate_limit.web3"), - GOTRUE_SECURITY_CAPTCHA_ENABLED: valueAt(state, "auth", "captcha.enabled"), - GOTRUE_SECURITY_CAPTCHA_PROVIDER: valueAt(state, "auth", "captcha.provider"), - GOTRUE_SECURITY_CAPTCHA_SECRET: valueAt(state, "auth", "captcha.secret"), - GOTRUE_MFA_TOTP_ENROLL_ENABLED: valueAt(state, "auth", "mfa.totp.enroll_enabled"), - GOTRUE_MFA_TOTP_VERIFY_ENABLED: valueAt(state, "auth", "mfa.totp.verify_enabled"), - GOTRUE_MFA_PHONE_ENROLL_ENABLED: valueAt(state, "auth", "mfa.phone.enroll_enabled"), - GOTRUE_MFA_PHONE_VERIFY_ENABLED: valueAt(state, "auth", "mfa.phone.verify_enabled"), - ...(valueAt(state, "auth", "mfa.phone.enroll_enabled") === "true" || - valueAt(state, "auth", "mfa.phone.verify_enabled") === "true" + GOTRUE_RATE_LIMIT_EMAIL_SENT: valueAtInstance( + state, + instanceId, + "auth", + "rate_limit.email_sent", + inputs, + ), + GOTRUE_RATE_LIMIT_SMS_SENT: valueAtInstance( + state, + instanceId, + "auth", + "rate_limit.sms_sent", + inputs, + ), + GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: valueAtInstance( + state, + instanceId, + "auth", + "rate_limit.anonymous_users", + inputs, + ), + GOTRUE_RATE_LIMIT_TOKEN_REFRESH: valueAtInstance( + state, + instanceId, + "auth", + "rate_limit.token_refresh", + inputs, + ), + GOTRUE_RATE_LIMIT_VERIFY: valueAtInstance( + state, + instanceId, + "auth", + "rate_limit.token_verifications", + inputs, + ), + GOTRUE_RATE_LIMIT_OTP: valueAtInstance( + state, + instanceId, + "auth", + "rate_limit.sign_in_sign_ups", + inputs, + ), + GOTRUE_RATE_LIMIT_WEB3: valueAtInstance(state, instanceId, "auth", "rate_limit.web3", inputs), + GOTRUE_SECURITY_CAPTCHA_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "captcha.enabled", + inputs, + ), + GOTRUE_SECURITY_CAPTCHA_PROVIDER: valueAtInstance( + state, + instanceId, + "auth", + "captcha.provider", + inputs, + ), + GOTRUE_SECURITY_CAPTCHA_SECRET: valueAtInstance( + state, + instanceId, + "auth", + "captcha.secret", + inputs, + ), + GOTRUE_MFA_TOTP_ENROLL_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "mfa.totp.enroll_enabled", + inputs, + ), + GOTRUE_MFA_TOTP_VERIFY_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "mfa.totp.verify_enabled", + inputs, + ), + GOTRUE_MFA_PHONE_ENROLL_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "mfa.phone.enroll_enabled", + inputs, + ), + GOTRUE_MFA_PHONE_VERIFY_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "mfa.phone.verify_enabled", + inputs, + ), + ...(valueAtInstance(state, instanceId, "auth", "mfa.phone.enroll_enabled", inputs) === "true" || + valueAtInstance(state, instanceId, "auth", "mfa.phone.verify_enabled", inputs) === "true" ? { - GOTRUE_MFA_PHONE_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), - GOTRUE_MFA_PHONE_TEMPLATE: valueAt(state, "auth", "mfa.phone.template"), - GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAt(state, "auth", "mfa.phone.max_frequency"), + GOTRUE_MFA_PHONE_OTP_LENGTH: valueAtInstance( + state, + instanceId, + "auth", + "mfa.phone.otp_length", + inputs, + ), + GOTRUE_MFA_PHONE_TEMPLATE: valueAtInstance( + state, + instanceId, + "auth", + "mfa.phone.template", + inputs, + ), + GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAtInstance( + state, + instanceId, + "auth", + "mfa.phone.max_frequency", + inputs, + ), } : {}), - GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: valueAt(state, "auth", "mfa.web_authn.enroll_enabled"), - GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: valueAt(state, "auth", "mfa.web_authn.verify_enabled"), - GOTRUE_MFA_MAX_ENROLLED_FACTORS: valueAt(state, "auth", "mfa.max_enrolled_factors"), - GOTRUE_SESSIONS_TIMEBOX: valueAt(state, "auth", "sessions.timebox"), - GOTRUE_SESSIONS_INACTIVITY_TIMEOUT: valueAt(state, "auth", "sessions.inactivity_timeout"), + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "mfa.web_authn.enroll_enabled", + inputs, + ), + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "mfa.web_authn.verify_enabled", + inputs, + ), + GOTRUE_MFA_MAX_ENROLLED_FACTORS: valueAtInstance( + state, + instanceId, + "auth", + "mfa.max_enrolled_factors", + inputs, + ), + GOTRUE_SESSIONS_TIMEBOX: valueAtInstance(state, instanceId, "auth", "sessions.timebox", inputs), + GOTRUE_SESSIONS_INACTIVITY_TIMEOUT: valueAtInstance( + state, + instanceId, + "auth", + "sessions.inactivity_timeout", + inputs, + ), GOTRUE_MAILER_AUTOCONFIRM: - valueAt(state, "auth", "email.enable_confirmations") === "false" ? "true" : "false", + valueAtInstance(state, instanceId, "auth", "email.enable_confirmations", inputs) === "false" + ? "true" + : "false", GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED: "true", GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: valueAt( state, @@ -800,76 +1304,176 @@ const withAuthSettings = ( "auth", "email.secure_password_change", ), - GOTRUE_MAILER_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), - GOTRUE_SMTP_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), - GOTRUE_MAILER_OTP_LENGTH: valueAt(state, "auth", "email.otp_length"), - GOTRUE_MAILER_OTP_EXP: valueAt(state, "auth", "email.otp_expiry"), - ...(valueAt(state, "auth", "email.smtp.enabled") === "true" + GOTRUE_MAILER_MAX_FREQUENCY: valueAtInstance( + state, + instanceId, + "auth", + "email.max_frequency", + inputs, + ), + GOTRUE_SMTP_MAX_FREQUENCY: valueAtInstance( + state, + instanceId, + "auth", + "email.max_frequency", + inputs, + ), + GOTRUE_MAILER_OTP_LENGTH: valueAtInstance( + state, + instanceId, + "auth", + "email.otp_length", + inputs, + ), + GOTRUE_MAILER_OTP_EXP: valueAtInstance(state, instanceId, "auth", "email.otp_expiry", inputs), + ...(valueAtInstance(state, instanceId, "auth", "email.smtp.enabled", inputs) === "true" ? { - GOTRUE_SMTP_HOST: valueAt(state, "auth", "email.smtp.host"), - GOTRUE_SMTP_PORT: valueAt(state, "auth", "email.smtp.port"), - GOTRUE_SMTP_USER: valueAt(state, "auth", "email.smtp.user"), - GOTRUE_SMTP_PASS: valueAt(state, "auth", "email.smtp.pass"), - GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "auth", "email.smtp.admin_email"), - GOTRUE_SMTP_SENDER_NAME: valueAt(state, "auth", "email.smtp.sender_name"), + GOTRUE_SMTP_HOST: valueAtInstance(state, instanceId, "auth", "email.smtp.host", inputs), + GOTRUE_SMTP_PORT: valueAtInstance(state, instanceId, "auth", "email.smtp.port", inputs), + GOTRUE_SMTP_USER: valueAtInstance(state, instanceId, "auth", "email.smtp.user", inputs), + GOTRUE_SMTP_PASS: valueAtInstance(state, instanceId, "auth", "email.smtp.pass", inputs), + GOTRUE_SMTP_ADMIN_EMAIL: valueAtInstance( + state, + instanceId, + "auth", + "email.smtp.admin_email", + inputs, + ), + GOTRUE_SMTP_SENDER_NAME: valueAtInstance( + state, + instanceId, + "auth", + "email.smtp.sender_name", + inputs, + ), } - : capabilityEnabled(state, "mail") + : capabilityEnabled(state, "mail", instanceId) ? { - GOTRUE_SMTP_HOST: - runtime === "container" ? containerAliasFor("mail:mail") : "127.0.0.1", - GOTRUE_SMTP_PORT: String(workloadPort(state, "mail:mail", "smtp", runtime, 1025)), - GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "mail", "admin_email"), - GOTRUE_SMTP_SENDER_NAME: valueAt(state, "mail", "sender_name"), + GOTRUE_SMTP_HOST: serviceAlias(state, runtime, instanceId, "mail:mail"), + GOTRUE_SMTP_PORT: String( + workloadPort( + state, + workloadIdFor(state, instanceId, "mail:mail"), + "smtp", + runtime, + 1025, + instanceId, + ), + ), + GOTRUE_SMTP_ADMIN_EMAIL: valueAtInstance( + state, + instanceId, + "mail", + "admin_email", + inputs, + ), + GOTRUE_SMTP_SENDER_NAME: valueAtInstance( + state, + instanceId, + "mail", + "sender_name", + inputs, + ), } : {}), GOTRUE_SMS_AUTOCONFIRM: - valueAt(state, "auth", "sms.enable_confirmations") === "false" ? "true" : "false", - GOTRUE_SMS_MAX_FREQUENCY: valueAt(state, "auth", "sms.max_frequency"), + valueAtInstance(state, instanceId, "auth", "sms.enable_confirmations", inputs) === "false" + ? "true" + : "false", + GOTRUE_SMS_MAX_FREQUENCY: valueAtInstance( + state, + instanceId, + "auth", + "sms.max_frequency", + inputs, + ), GOTRUE_SMS_OTP_EXP: "6000", GOTRUE_SMS_OTP_LENGTH: "6", - GOTRUE_SMS_TEMPLATE: valueAt(state, "auth", "sms.template"), - GOTRUE_SMS_PROVIDER: authSmsProvider(state), - GOTRUE_SMS_TEST_OTP: authSmsTestOtp(state), - GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED: valueAt(state, "auth", "web3.solana.enabled"), - GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED: valueAt(state, "auth", "web3.ethereum.enabled"), - GOTRUE_EXTERNAL_EMAIL_ENABLED: valueAt(state, "auth", "email.enable_signup"), - GOTRUE_EXTERNAL_PHONE_ENABLED: valueAt(state, "auth", "sms.enable_signup"), - GOTRUE_OAUTH_SERVER_ENABLED: valueAt(state, "auth", "oauth_server.enabled"), - GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH: valueAt( + GOTRUE_SMS_TEMPLATE: valueAtInstance(state, instanceId, "auth", "sms.template", inputs), + GOTRUE_SMS_PROVIDER: authSmsProvider(state, instanceId, inputs), + GOTRUE_SMS_TEST_OTP: authSmsTestOtp(state, instanceId, inputs), + GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "web3.solana.enabled", + inputs, + ), + GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "web3.ethereum.enabled", + inputs, + ), + GOTRUE_EXTERNAL_EMAIL_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "email.enable_signup", + inputs, + ), + GOTRUE_EXTERNAL_PHONE_ENABLED: valueAtInstance( + state, + instanceId, + "auth", + "sms.enable_signup", + inputs, + ), + GOTRUE_OAUTH_SERVER_ENABLED: valueAtInstance( state, + instanceId, + "auth", + "oauth_server.enabled", + inputs, + ), + GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH: valueAtInstance( + state, + instanceId, "auth", "oauth_server.authorization_url_path", + inputs, ), - GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: valueAt( + GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: valueAtInstance( state, + instanceId, "auth", "oauth_server.allow_dynamic_registration", + inputs, ), }); const withStorageSettings = ( state: PersistedStackState, + instanceId: string, runtime: WorkloadRuntimeKind, port: number, inputs: WorkloadRuntimeInputs = {}, ): Record => { - const fileSizeLimit = parseFileSize(valueAt(state, "storage", "file_size_limit")); + const fileSizeLimit = parseFileSize( + valueAtInstance(state, instanceId, "storage", "file_size_limit", inputs), + ); + const imageTransformationEnabled = + valueAtInstance(state, instanceId, "storage", "image_transformation.enabled", inputs) === + "true"; return compactEnvironment({ ...capabilityEnv( state, "storage", "STORAGE", + instanceId, (key) => key === "STORAGE_FILE_SIZE_LIMIT" || key.startsWith("STORAGE_S3_PROTOCOL_") || key.startsWith("STORAGE_VECTOR_") || key.startsWith("STORAGE_IMAGE_TRANSFORMATION_"), + inputs, ), PORT: String(port), ANON_KEY: secret(state, "secret:auth.settings.anon_key"), SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), + DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime, instanceId), ...(fileSizeLimit === undefined ? {} : { FILE_SIZE_LIMIT: fileSizeLimit }), STORAGE_BACKEND: "file", FILE_STORAGE_BACKEND_PATH: @@ -880,11 +1484,35 @@ const withStorageSettings = ( runtime === "container" ? "/mnt" : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), - ENABLE_IMAGE_TRANSFORMATION: valueAt(state, "storage", "image_transformation.enabled"), - S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled"), - S3_PROTOCOL_ACCESS_KEY_ID: valueAt(state, "storage", "s3_protocol.access_key_id"), - S3_PROTOCOL_ACCESS_KEY_SECRET: valueAt(state, "storage", "s3_protocol.secret_access_key"), - STORAGE_S3_REGION: valueAt(state, "storage", "s3_protocol.region"), + ENABLE_IMAGE_TRANSFORMATION: valueAtInstance( + state, + instanceId, + "storage", + "image_transformation.enabled", + inputs, + ), + S3_PROTOCOL_ENABLED: valueAtInstance( + state, + instanceId, + "storage", + "s3_protocol.enabled", + inputs, + ), + S3_PROTOCOL_ACCESS_KEY_ID: valueAtInstance( + state, + instanceId, + "storage", + "s3_protocol.access_key_id", + inputs, + ), + S3_PROTOCOL_ACCESS_KEY_SECRET: valueAtInstance( + state, + instanceId, + "storage", + "s3_protocol.secret_access_key", + inputs, + ), + STORAGE_S3_REGION: valueAtInstance(state, instanceId, "storage", "s3_protocol.region", inputs), GLOBAL_S3_BUCKET: "stub", TENANT_ID: "stub", S3_PROTOCOL_PREFIX: "/storage/v1", @@ -894,16 +1522,20 @@ const withStorageSettings = ( PGRST_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), ...(inputs.auth?.jwks === undefined ? {} : { JWT_JWKS: inputs.auth.jwks }), TUS_URL_PATH: "/storage/v1/upload/resumable", - IMGPROXY_URL: - runtime === "container" - ? `http://${containerAliasFor("storage:imgproxy")}:5001` - : `http://127.0.0.1:${workloadPort(state, "storage:imgproxy", "primary", runtime, 5001)}`, - ...(valueAt(state, "storage", "vector.enabled") === "true" + ...(imageTransformationEnabled && inputs.catalog === undefined + ? { + IMGPROXY_URL: + runtime === "container" + ? `http://${serviceAlias(state, runtime, instanceId, "storage:imgproxy")}:5001` + : `http://127.0.0.1:${workloadPort(state, workloadIdFor(state, instanceId, "storage:imgproxy"), "primary", runtime, 5001, instanceId)}`, + } + : {}), + ...(valueAtInstance(state, instanceId, "storage", "vector.enabled", inputs) === "true" ? { VECTOR_ENABLED: "true", VECTOR_BUCKET_PROVIDER: "pgvector", VECTOR_STORE_MIGRATIONS_ENABLED: "true", - VECTOR_DATABASE_URL: dbUrl(state, "postgres", runtime), + VECTOR_DATABASE_URL: dbUrl(state, "postgres", runtime, instanceId), } : {}), }); @@ -911,10 +1543,11 @@ const withStorageSettings = ( const databaseArgs = ( state: PersistedStackState, + instanceId: string, port: number, runtime: WorkloadRuntimeKind, ): ReadonlyArray => { - const settings = settingsFor(state, "database"); + const settings = settingsForInstance(state, instanceId, "database"); const postgresSettings = isRecord(settings) ? settings.settings : undefined; const tuning = isRecord(postgresSettings) ? postgresSettings : {}; const tuned = Object.entries(tuning).flatMap(([key, value]) => { @@ -926,31 +1559,48 @@ const databaseArgs = ( String(port), "-c", runtime === "container" ? "listen_addresses=*" : "listen_addresses=127.0.0.1", + ...(runtime === "container" ? ["-c", "unix_socket_directories=/tmp"] : []), ...tuned, ]; }; const analyticsEnv = ( state: PersistedStackState, + instanceId: string, runtime: WorkloadRuntimeKind, port: number, inputs: WorkloadRuntimeInputs = {}, ): Record => { - const backend = valueAt(state, "analytics", "backend"); + const backend = valueAtInstance(state, instanceId, "analytics", "backend", inputs); const gcpJwtPath = inputs.analytics?.gcpJwtPath ?? ""; return compactEnvironment({ - ...capabilityEnv(state, "analytics", "ANALYTICS", (key) => key === "ANALYTICS_GCP_JWT_PATH"), + ...capabilityEnv( + state, + "analytics", + "ANALYTICS", + instanceId, + (key) => key === "ANALYTICS_GCP_JWT_PATH", + inputs, + ), PORT: String(port), PHX_HTTP_PORT: String(port), - DB_HOSTNAME: dbHost(runtime), - DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), + DB_HOSTNAME: dbHost(state, runtime, instanceId), + DB_PORT: String( + runtime === "container" ? 5432 : dbPort(state, databaseInstanceIdFor(state, instanceId)), + ), DB_DATABASE: "_supabase", DB_SCHEMA: "_analytics", DB_USERNAME: "supabase_admin", - DB_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), + DB_PASSWORD: databasePassword(state, instanceId), LOGFLARE_SUPABASE_MODE: "true", LOGFLARE_SINGLE_TENANT: "true", - LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), + LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAtInstance( + state, + instanceId, + "analytics", + "api_key", + inputs, + ), LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", LOGFLARE_MIN_CLUSTER_SIZE: "1", LOGFLARE_LOG_LEVEL: "warn", @@ -958,13 +1608,25 @@ const analyticsEnv = ( RELEASE_COOKIE: "cookie", ...(backend === "postgres" ? { - POSTGRES_BACKEND_URL: dbUrl(state, "postgres", runtime, "_supabase"), + POSTGRES_BACKEND_URL: dbUrl(state, "postgres", runtime, instanceId, "_supabase"), POSTGRES_BACKEND_SCHEMA: "_analytics", } : { GOOGLE_DATASET_ID_APPEND: "_prod", - GOOGLE_PROJECT_ID: valueAt(state, "analytics", "gcp_project_id"), - GOOGLE_PROJECT_NUMBER: valueAt(state, "analytics", "gcp_project_number"), + GOOGLE_PROJECT_ID: valueAtInstance( + state, + instanceId, + "analytics", + "gcp_project_id", + inputs, + ), + GOOGLE_PROJECT_NUMBER: valueAtInstance( + state, + instanceId, + "analytics", + "gcp_project_number", + inputs, + ), GOOGLE_APPLICATION_CREDENTIALS: runtime === "container" && gcpJwtPath.length > 0 ? "/opt/app/rel/logflare/bin/gcloud.json" @@ -975,9 +1637,9 @@ const analyticsEnv = ( const specs: Readonly> = { "database:database": { - bindings: { primary: { containerPort: 5432 } }, - args: (state, _workload, port) => databaseArgs(state, port, "native"), - env: (state, _workload, _port, runtime = "native", inputs = {}) => + bindings: { "sql:internal": { containerPort: 5432 } }, + args: (state, workload, port) => databaseArgs(state, workload.instanceId, port, "native"), + env: (state, workload, _port, runtime = "native", inputs = {}) => compactEnvironment({ PGDATA: runtime === "container" @@ -985,25 +1647,29 @@ const specs: Readonly> = { : (inputs.database?.dataPath ?? `${state.identity.projectRoot}/.supabase/db/data`), POSTGRES_USER: "supabase_admin", POSTGRES_DB: "postgres", - POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), + POSTGRES_PASSWORD: databasePassword(state, workload.instanceId), + ...(runtime === "native" + ? { PGPORT: String(dbPort(state, databaseInstanceIdFor(state, workload.instanceId))) } + : {}), TZDIR: "/var/db/timezone/zoneinfo", }), - containerArgs: (state, _workload, port) => databaseArgs(state, port, "container"), + containerArgs: (state, workload, port) => + databaseArgs(state, workload.instanceId, port, "container"), readiness: { protocol: "tcp" }, }, "rest:rest": { bindings: { primary: { containerPort: 3000 }, admin: { containerPort: 3001 } }, args: () => [], - env: (state, _workload, port, runtime = "native", inputs = {}) => - withRestSettings(state, runtime, port, inputs), + env: (state, workload, port, runtime = "native", inputs = {}) => + withRestSettings(state, workload.instanceId, runtime, port, inputs), containerArgs: () => [], readiness: { protocol: "http", path: "/" }, }, "auth:auth": { bindings: { primary: { containerPort: 9999 } }, args: () => [], - env: (state, _workload, port, runtime = "native", inputs = {}) => - withAuthSettings(state, runtime, port, inputs), + env: (state, workload, port, runtime = "native", inputs = {}) => + withAuthSettings(state, workload.instanceId, runtime, port, inputs), containerArgs: () => [], containerStartupProcesses: () => [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], readiness: { protocol: "http", path: "/health" }, @@ -1011,33 +1677,49 @@ const specs: Readonly> = { "realtime:realtime": { bindings: { primary: { containerPort: 4000 }, rpc: { containerPort: 5369 } }, args: () => [], - env: (state, _workload, port, runtime = "native", inputs = {}) => { + env: (state, workload, port, runtime = "native", inputs = {}) => { // Production Realtime defaults both gen_rpc TCP ports to 5369. Native stacks // need a unique host binding; container netns already owns 5369 in-container. const rpcPort = state.runtime.kind === "native" - ? privatePortFor(state, "realtime:realtime", "rpc") + ? privatePortFor(state, workload.id, "rpc", workload.instanceId) : undefined; return compactEnvironment({ - ...capabilityEnv(state, "realtime", "REALTIME"), + ...capabilityEnv(state, "realtime", "REALTIME", workload.instanceId, undefined, inputs), PORT: String(port), - DB_HOST: dbHost(runtime), - DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), + DB_HOST: dbHost(state, runtime, workload.instanceId), + DB_PORT: String( + runtime === "container" + ? 5432 + : dbPort(state, databaseInstanceIdFor(state, workload.instanceId)), + ), DB_USER: "supabase_admin", - DB_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), + DB_PASSWORD: databasePassword(state, workload.instanceId), DB_NAME: "postgres", DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), ...(inputs.auth?.jwks === undefined ? {} : { API_JWT_JWKS: inputs.auth.jwks }), METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DB_ENC_KEY: secret(state, "secret:realtime.settings.db_enc_key"), - SECRET_KEY_BASE: secret(state, "secret:realtime.settings.secret_key_base"), + DB_ENC_KEY: valueAtInstance(state, workload.instanceId, "realtime", "db_enc_key", inputs), + SECRET_KEY_BASE: valueAtInstance( + state, + workload.instanceId, + "realtime", + "secret_key_base", + inputs, + ), DNS_NODES: "''", APP_NAME: "realtime", SEED_SELF_HOST: "true", - MAX_HEADER_LENGTH: valueAt(state, "realtime", "max_header_length"), + MAX_HEADER_LENGTH: valueAtInstance( + state, + workload.instanceId, + "realtime", + "max_header_length", + inputs, + ), ERL_AFLAGS: - valueAt(state, "realtime", "ip_version") === "IPv6" + valueAtInstance(state, workload.instanceId, "realtime", "ip_version", inputs) === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", RUN_JANITOR: "true", @@ -1058,8 +1740,8 @@ const specs: Readonly> = { "storage:storage": { bindings: { primary: { containerPort: 5000 } }, args: () => [], - env: (state, _workload, port, runtime = "native", inputs = {}) => - withStorageSettings(state, runtime, port, inputs), + env: (state, workload, port, runtime = "native", inputs = {}) => + withStorageSettings(state, workload.instanceId, runtime, port, inputs), containerArgs: () => [], containerStartupProcesses: () => [{ entrypoint: "/slim-runtime/bin/prepare", command: [] }], readiness: { protocol: "http", path: "/status" }, @@ -1076,91 +1758,120 @@ const specs: Readonly> = { }, "functions:edge-runtime": { bindings: { primary: { containerPort: 9000 }, inspector: { containerPort: 9229 } }, - cwd: functionsRoot, - args: (state, _workload, port, runtime = "native") => [ + cwd: (state, workload) => functionsRoot(state, workload.instanceId), + args: (state, workload, port, runtime = "native") => [ "start", - `--main-service=${functionsRoot(state)}`, + `--main-service=${functionsRoot(state, workload.instanceId)}`, `--port=${port}`, - `--policy=${valueAt(state, "functions", "edge_runtime.policy")}`, - ...functionsInspectorArgs(state, runtime), + `--policy=${valueAtInstance(state, workload.instanceId, "functions", "edge_runtime.policy")}`, + ...functionsInspectorArgs(state, runtime, workload.instanceId), ], - env: (state, _workload, port, runtime = "native", inputs = {}) => ({ + env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...edgeRuntimeJwtEnvironment(state, inputs), EDGE_RUNTIME_PORT: String(port), FUNCTIONS_CONTAINER_ROOT, - SUPABASE_INTERNAL_FUNCTIONS_ROOT: - runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), - SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfigEnvironment(state), + SUPABASE_INTERNAL_FUNCTIONS_ROOT: functionsRoot(state, workload.instanceId), + SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfigEnvironment(state, workload.instanceId), SUPABASE_URL: apiListenerUrl(state, runtime === "container" ? inputs : undefined), - EDGE_RUNTIME_POLICY: valueAt(state, "functions", "edge_runtime.policy"), - EDGE_RUNTIME_DENO_VERSION: valueAt(state, "functions", "edge_runtime.deno_version"), - INSPECTOR_MODE: valueAt(state, "functions", "inspector.mode"), - INSPECTOR_MAIN: valueAt(state, "functions", "inspector.main"), + ...(functionsDatabaseUrl(state, runtime, workload.instanceId, inputs) === undefined + ? {} + : { + SUPABASE_DB_URL: functionsDatabaseUrl(state, runtime, workload.instanceId, inputs), + }), + EDGE_RUNTIME_POLICY: valueAtInstance( + state, + workload.instanceId, + "functions", + "edge_runtime.policy", + ), + EDGE_RUNTIME_DENO_VERSION: valueAtInstance( + state, + workload.instanceId, + "functions", + "edge_runtime.deno_version", + ), + INSPECTOR_MODE: valueAtInstance(state, workload.instanceId, "functions", "inspector.mode"), + INSPECTOR_MAIN: valueAtInstance(state, workload.instanceId, "functions", "inspector.main"), }), - containerArgs: (state, _workload, port) => [ + containerArgs: (state, workload, port) => [ "start", `--main-service=${FUNCTIONS_BOOTSTRAP_CONTAINER_PATH}`, `--port=${port}`, - `--policy=${valueAt(state, "functions", "edge_runtime.policy")}`, - ...functionsInspectorArgs(state, "container"), - ], - containerMounts: (state) => [ - { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, + `--policy=${valueAtInstance(state, workload.instanceId, "functions", "edge_runtime.policy")}`, + ...functionsInspectorArgs(state, "container", workload.instanceId), ], + containerMounts: (state, workload, inputs = {}) => + functionsContainerMounts(state, workload, inputs), readiness: { protocol: "http", path: "/_internal/health" }, }, "studio:studio": { bindings: { primary: { containerPort: 3000 } }, args: () => [], - env: (state, _workload, port, runtime = "native", inputs = {}) => - compactEnvironment({ - ...capabilityEnv(state, "studio", "STUDIO"), + env: (state, workload, port, runtime = "native", inputs = {}) => { + const analyticsInstanceId = serviceInstanceIdFor(state, workload.instanceId, "analytics"); + return compactEnvironment({ + ...capabilityEnv(state, "studio", "STUDIO", workload.instanceId), PORT: String(port), HOSTNAME: "0.0.0.0", STUDIO_PG_META_URL: runtime === "container" - ? `http://${containerAliasFor("studio:pgmeta")}:8080` - : `http://127.0.0.1:${workloadPort(state, "studio:pgmeta", "primary", runtime, 8080)}`, + ? `http://${serviceAlias(state, runtime, workload.instanceId, "studio:pgmeta")}:8080` + : `http://127.0.0.1:${workloadPort(state, workloadIdFor(state, workload.instanceId, "studio:pgmeta"), "primary", runtime, 8080, workload.instanceId)}`, LOGFLARE_URL: runtime === "container" - ? `http://${containerAliasFor("analytics:analytics")}:4000` - : `http://127.0.0.1:${workloadPort(state, "analytics:analytics", "primary", runtime, 4000)}`, - LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), - NEXT_PUBLIC_ENABLE_LOGS: capabilityEnabled(state, "analytics") ? "true" : "false", - NEXT_ANALYTICS_BACKEND_PROVIDER: valueAt(state, "analytics", "backend"), + ? `http://${serviceAlias(state, runtime, workload.instanceId, "analytics:analytics")}:4000` + : `http://127.0.0.1:${workloadPort(state, `${analyticsInstanceId}:analytics`, "primary", runtime, 4000, analyticsInstanceId)}`, + LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAtInstance( + state, + analyticsInstanceId, + "analytics", + "api_key", + ), + NEXT_PUBLIC_ENABLE_LOGS: capabilityEnabled(state, "analytics", analyticsInstanceId) + ? "true" + : "false", + NEXT_ANALYTICS_BACKEND_PROVIDER: valueAtInstance( + state, + analyticsInstanceId, + "analytics", + "backend", + ), SUPABASE_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), SUPABASE_PUBLIC_URL: apiListenerUrl(state), SUPABASE_ANON_KEY: secret(state, "secret:auth.settings.anon_key"), SUPABASE_SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), SUPABASE_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.publishable_key"), SUPABASE_SECRET_KEY: secret(state, "secret:auth.settings.secret_key"), - EDGE_FUNCTIONS_MANAGEMENT_FOLDER: - runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: functionsRoot(state, workload.instanceId), OPENAI_API_KEY: secret(state, "secret:studio.settings.openai_api_key"), CURRENT_CLI_VERSION: "local", - POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), + POSTGRES_PASSWORD: databasePassword(state, workload.instanceId), POSTGRES_USER_READ_WRITE: "postgres", PGRST_DB_SCHEMAS: "public,graphql_public", PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", PGRST_DB_MAX_ROWS: "1000", - }), + }); + }, containerArgs: () => [], - containerMounts: (state) => [ - { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, - ], + containerMounts: (state, workload, inputs = {}) => + functionsContainerMounts(state, workload, inputs), readiness: { protocol: "http", path: "/api/platform/profile" }, }, "studio:pgmeta": { bindings: { primary: { containerPort: 8080 } }, args: () => [], - env: (state, _workload, port, runtime = "native") => ({ - ...capabilityEnv(state, "studio", "PG_META"), + env: (state, workload, port, runtime = "native") => ({ + ...capabilityEnv(state, "studio", "PG_META", workload.instanceId), PG_META_PORT: String(port), - PG_META_DB_HOST: dbHost(runtime), - PG_META_DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), + PG_META_DB_HOST: dbHost(state, runtime, workload.instanceId), + PG_META_DB_PORT: String( + runtime === "container" + ? 5432 + : dbPort(state, databaseInstanceIdFor(state, workload.instanceId)), + ), PG_META_DB_NAME: "postgres", PG_META_DB_USER: "postgres", - PG_META_DB_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), + PG_META_DB_PASSWORD: databasePassword(state, workload.instanceId), }), containerArgs: () => [], readiness: { protocol: "http", path: "/health" }, @@ -1172,11 +1883,11 @@ const specs: Readonly> = { pop3: { containerPort: 1110 }, }, args: () => [], - env: (state, _workload, _port, runtime = "native") => ({ - ...capabilityEnv(state, "mail", "MAIL"), - MP_UI_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "ui", runtime, 8025)}`, - MP_SMTP_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "smtp", runtime, 1025)}`, - MP_POP3_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "pop3", runtime, 1110)}`, + env: (state, workload, _port, runtime = "native") => ({ + ...capabilityEnv(state, "mail", "MAIL", workload.instanceId), + MP_UI_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, workloadIdFor(state, workload.instanceId, "mail:mail"), "ui", runtime, 8025, workload.instanceId)}`, + MP_SMTP_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, workloadIdFor(state, workload.instanceId, "mail:mail"), "smtp", runtime, 1025, workload.instanceId)}`, + MP_POP3_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, workloadIdFor(state, workload.instanceId, "mail:mail"), "pop3", runtime, 1110, workload.instanceId)}`, MP_SMTP_DISABLE_RDNS: "true", }), containerArgs: () => [], @@ -1185,12 +1896,12 @@ const specs: Readonly> = { "analytics:analytics": { bindings: { primary: { containerPort: 4000 } }, args: () => ["start"], - env: (state, _workload, port, runtime = "native", inputs = {}) => - analyticsEnv(state, runtime, port, inputs), + env: (state, workload, port, runtime = "native", inputs = {}) => + analyticsEnv(state, workload.instanceId, runtime, port, inputs), // The slim container entrypoint performs Logflare migrations before start. containerArgs: () => [], - containerMounts: (state, _workload, inputs = {}) => { - const backend = valueAt(state, "analytics", "backend"); + containerMounts: (state, workload, inputs = {}) => { + const backend = valueAtInstance(state, workload.instanceId, "analytics", "backend", inputs); const source = inputs.analytics?.gcpJwtPath ?? ""; return backend === "bigquery" && source.length > 0 ? [ @@ -1207,15 +1918,20 @@ const specs: Readonly> = { "analytics:vector": { bindings: { primary: { containerPort: 9001 } }, args: (_state, _workload, _port) => ["--config", "share/doc/vector/config/vector.yaml"], - env: (state, _workload, port, runtime = "native") => ({ - ...capabilityEnv(state, "analytics", "VECTOR"), + env: (state, workload, port, runtime = "native") => ({ + ...capabilityEnv(state, "analytics", "VECTOR", workload.instanceId), VECTOR_API_ADDRESS: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, VECTOR_API_PORT: String(port), LOGFLARE_URL: runtime === "container" - ? `http://${containerAliasFor("analytics:analytics")}:4000` - : `http://127.0.0.1:${workloadPort(state, "analytics:analytics", "primary", runtime, 4000)}`, - LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), + ? `http://${serviceAlias(state, runtime, workload.instanceId, "analytics:analytics")}:4000` + : `http://127.0.0.1:${workloadPort(state, workloadIdFor(state, workload.instanceId, "analytics:analytics"), "primary", runtime, 4000, workload.instanceId)}`, + LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAtInstance( + state, + workload.instanceId, + "analytics", + "api_key", + ), }), containerArgs: (_state, _workload, _port, inputs = {}) => inputs.analytics?.vectorConfigPath === undefined @@ -1236,39 +1952,71 @@ const specs: Readonly> = { "pooler:pooler": { bindings: { primary: { containerPort: 6543 }, admin: { containerPort: 4000 } }, args: () => ["start"], - env: (state, _workload, _port, runtime = "native") => { + env: (state, workload, _port, runtime = "native", inputs = {}) => { const adminPort = - runtime === "container" ? 4000 : privatePortFor(state, "pooler:pooler", "admin"); - const primaryPort = privatePortFor(state, "pooler:pooler", "primary"); + runtime === "container" + ? 4000 + : privatePortFor(state, workload.id, "admin", workload.instanceId); + const primaryPort = privatePortFor(state, workload.id, "primary", workload.instanceId); if (adminPort === undefined || primaryPort === undefined) throw new Error("Pooler private port assignments must be validated before env resolution"); return { - ...capabilityEnv(state, "pooler", "POOLER"), + ...capabilityEnv(state, "pooler", "POOLER", workload.instanceId, undefined, inputs), // `port` is the readiness binding (admin); proxy listeners use the primary SQL binding. // validatePrivateAssignments guarantees both native bindings are assigned. PORT: String(adminPort), PROXY_PORT_SESSION: - runtime === "native" && valueAt(state, "pooler", "pool_mode") === "session" + runtime === "native" && + valueAtInstance(state, workload.instanceId, "pooler", "pool_mode", inputs) === "session" ? String(primaryPort) : "5432", PROXY_PORT_TRANSACTION: - runtime === "native" && valueAt(state, "pooler", "pool_mode") !== "session" + runtime === "native" && + valueAtInstance(state, workload.instanceId, "pooler", "pool_mode", inputs) !== "session" ? String(primaryPort) : "6543", - DATABASE_URL: `ecto://postgres:${secret(state, DATABASE_INTERNAL_PASSWORD_SLOT)}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, - POSTGRES_HOST: dbHost(runtime), - POSTGRES_PORT: String(runtime === "container" ? 5432 : dbPort(state)), - POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), + DATABASE_URL: `ecto://postgres:${databasePassword(state, workload.instanceId)}@${dbHost(state, runtime, workload.instanceId)}:${runtime === "container" ? 5432 : dbPort(state, databaseInstanceIdFor(state, workload.instanceId))}/_supabase`, + POSTGRES_HOST: dbHost(state, runtime, workload.instanceId), + POSTGRES_PORT: String( + runtime === "container" + ? 5432 + : dbPort(state, databaseInstanceIdFor(state, workload.instanceId)), + ), + POSTGRES_PASSWORD: databasePassword(state, workload.instanceId), API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), REGION: "local", - TENANT_ID: valueAt(state, "pooler", "tenant_id"), + TENANT_ID: valueAtInstance(state, workload.instanceId, "pooler", "tenant_id", inputs), CLUSTER_POSTGRES: "true", - SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), - VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), + SECRET_KEY_BASE: valueAtInstance( + state, + workload.instanceId, + "pooler", + "secret_key_base", + inputs, + ), + VAULT_ENC_KEY: valueAtInstance( + state, + workload.instanceId, + "pooler", + "encryption_key", + inputs, + ), METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size"), - MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn"), - POOL_MODE: valueAt(state, "pooler", "pool_mode"), + DEFAULT_POOL_SIZE: valueAtInstance( + state, + workload.instanceId, + "pooler", + "default_pool_size", + inputs, + ), + MAX_CLIENT_CONN: valueAtInstance( + state, + workload.instanceId, + "pooler", + "max_client_conn", + inputs, + ), + POOL_MODE: valueAtInstance(state, workload.instanceId, "pooler", "pool_mode", inputs), }; }, // Mirror native convergence explicitly: prepare and provision before the main server. @@ -1283,6 +2031,7 @@ const specs: Readonly> = { }; const WORKLOAD_BINDING_NAMES: ReadonlyArray = [ + "sql:internal", "primary", "admin", "ui", @@ -1303,9 +2052,10 @@ const declaredBindings = ( const selectedBindings = ( state: BindingSelectionState, bindings: WorkloadBindings, + instanceId: string, ): ReadonlyArray => { return declaredBindings(bindings).filter(([binding]) => { - if (binding === "inspector") return functionsInspectorRequested(state); + if (binding === "inspector") return functionsInspectorRequested(state, instanceId); // Docker already owns 5369 in the container netns; publishing it on the host // collides when two stacks share a host. if (binding === "rpc") return state.runtime.kind === "native"; @@ -1319,9 +2069,10 @@ export const privateBindingIntentsFor = ( state: BindingSelectionState, ): ReadonlyArray => plan.workloads.flatMap((workload) => { - const spec = specs[workload.id]; + const spec = specs[workload.recipeId]; if (spec === undefined) return []; - return selectedBindings(state, spec.bindings).map(([binding]) => ({ + return selectedBindings(state, spec.bindings, workload.instanceId).map(([binding]) => ({ + instanceId: workload.instanceId, workloadId: workload.id, binding, })); @@ -1331,13 +2082,16 @@ export const validatePrivateAssignments = ( state: PersistedStackState, workload: PlannedWorkload, ): Effect.Effect => { - const spec = specs[workload.id]; + const spec = specs[workload.recipeId]; if (spec === undefined) return Effect.void; // Runtime env resolution assumes every declared binding was assigned here. - for (const [binding] of selectedBindings(state, spec.bindings)) { + for (const [binding] of selectedBindings(state, spec.bindings, workload.instanceId)) { if ( !state.privatePorts.some( - (assignment) => assignment.workloadId === workload.id && assignment.binding === binding, + (assignment) => + assignment.instanceId === workload.instanceId && + assignment.workloadId === workload.id && + assignment.binding === binding, ) ) return Effect.fail( @@ -1351,10 +2105,11 @@ export const validatePrivateAssignments = ( }; export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | undefined => { - const spec = specs[workload.id]; - const catalog = catalogEntryFor(workload.id); + const spec = specs[workload.recipeId]; + const catalog = catalogEntryFor(workload.recipeId); if (spec === undefined || catalog === undefined) return undefined; const primary = + spec.bindings["sql:internal"] ?? spec.bindings.primary ?? spec.bindings.admin ?? spec.bindings.ui ?? @@ -1370,11 +2125,17 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | env: spec.env, containerStartupProcesses: (state, currentWorkload, inputs = {}) => spec.containerStartupProcesses?.(state, currentWorkload, inputs) ?? [], - readiness: { ...spec.readiness, binding: spec.readiness.binding ?? "primary" }, + readiness: { + ...spec.readiness, + binding: + spec.readiness.binding ?? + (workload.recipeId === "database:database" ? "sql:internal" : "primary"), + }, privateEndpoint: (state, binding = "primary", runtime = "native") => privateEndpointFor( state, workload.id, + workload.instanceId, spec.bindings, binding, runtime, @@ -1395,46 +2156,53 @@ export const containerResolutionFor = ( ): ContainerWorkloadResolution | undefined => { const spec = runtimeSpecFor(workload); if (spec === undefined) return undefined; - const catalog = catalogEntryFor(workload.id); + const catalog = catalogEntryFor(workload.recipeId); if (catalog === undefined) return undefined; return { ...(spec.containerEntrypoint === undefined ? {} : { entrypoint: spec.containerEntrypoint }), command: spec.containerArgs( state, workload, - containerPortFor(state, workload.id, "primary", spec.containerPort), + containerPortFor(state, workload.id, "primary", spec.containerPort, workload.instanceId), inputs, ), startup: spec.containerStartupProcesses(state, workload, inputs), env: spec.env( state, workload, - containerPortFor(state, workload.id, "primary", spec.containerPort), + containerPortFor(state, workload.id, "primary", spec.containerPort, workload.instanceId), "container", inputs, ), mounts: spec.containerMounts?.(state, workload, inputs) ?? [], - networkAliases: [catalog.containerAlias], - publications: selectedBindings(state, spec.bindings).flatMap(([binding, definition]) => { - const assignment = state.privatePorts.find( - (entry) => entry.workloadId === workload.id && entry.binding === binding, - ); - return assignment === undefined - ? [] - : [ - { - address: "127.0.0.1" as const, - hostPort: assignment.port, - containerPort: containerPortFor( - state, - workload.id, - binding, - definition.containerPort, - ), - }, - ]; - }), - ...(workload.id === "functions:edge-runtime" && inputs.functions?.bootstrapPath !== undefined + networkAliases: [`${catalog.containerAlias}-${workload.instanceId}`], + publications: selectedBindings(state, spec.bindings, workload.instanceId).flatMap( + ([binding, definition]) => { + const assignment = state.privatePorts.find( + (entry) => + entry.instanceId === workload.instanceId && + entry.workloadId === workload.id && + entry.binding === binding, + ); + return assignment === undefined + ? [] + : [ + { + address: "127.0.0.1" as const, + hostPort: assignment.port, + containerPort: containerPortFor( + state, + workload.id, + binding, + definition.containerPort, + workload.instanceId, + ), + }, + ]; + }, + ), + ...(workload.recipeId === "functions:edge-runtime" && + inputs.functions?.bootstrapPath !== undefined ? { bootstrap: { source: inputs.functions.bootstrapPath, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index a83d2f90d6..c6b2d2694b 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Crypto, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Option, - Path, - Stream, - Sink, -} from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Stream, Sink } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { NodeServices } from "@effect/platform-node"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import { StackIdSchema } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import { ContainerCommandError, ContainerEngineProtocolError, @@ -39,10 +28,6 @@ import { makeContainerRuntime } from "./ContainerRuntime.ts"; import { RuntimeDriverError, type RuntimeWorkloadKey } from "./RuntimeDriver.ts"; import { LogStoreError, type LogRecord, type LogStore } from "../supervisor/LogStore.ts"; import { ContainerEngineError } from "../public/Errors.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { makeSupervisor, type SupervisorRuntime } from "../supervisor/Supervisor.ts"; -import type { SupervisorIngress } from "../supervisor/Ingress.ts"; -import { deriveStackId } from "../identity/Identity.ts"; const makeControlledCommandRunner = ( options: Pick & Partial>, @@ -52,8 +37,10 @@ const makeControlledCommandRunner = ( }); const stackId = StackIdSchema.make("a".repeat(64)); +const instanceId = ServiceInstanceIdSchema.make("primary"); const key: RuntimeWorkloadKey = { stackId, + instanceId, workloadId: "database:database", }; @@ -64,6 +51,8 @@ const containerArtifact: ContainerArtifact = { const workload = (selected: PlannedWorkload["selected"] = containerArtifact): PlannedWorkload => ({ id: key.workloadId, + instanceId, + recipeId: key.workloadId, capability: key.workloadId.startsWith("functions:") ? "functions" : "database", dependencies: [], readiness: {}, @@ -173,6 +162,10 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { : Effect.sync(() => { state.calls.push(`copy:${resourceId}:${source}:${destination}`); }), + execContainer: (resourceId: string, command: ReadonlyArray) => + Effect.sync(() => { + state.calls.push(`exec:${resourceId}:${command.join(" ")}`); + }), startContainer: (resourceId: string) => Effect.sync(() => { state.calls.push(`start:${resourceId}`); @@ -338,7 +331,9 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner", + instanceId, workloadId: "auth:auth", + recipeId: "auth:auth", role: "workload", }, network: "private", @@ -538,6 +533,38 @@ describe("container runtime", () => { }), ); + it.live("runs a startup publication after the container starts and before readiness", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const published = yield* Deferred.make(); + let readinessObservedPublication = false; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => Effect.succeed({ publications: [] }), + waitForReadiness: () => + Deferred.isDone(published).pipe( + Effect.tap((done) => Effect.sync(() => (readinessObservedPublication = done))), + Effect.asVoid, + ), + }); + const ready = yield* runtime.start(key, workload(), { + onStarted: Effect.sync(() => { + expect(state.calls.some((call) => call.startsWith("start:"))).toBe(true); + }).pipe(Effect.andThen(Deferred.succeed(published, undefined))), + }); + expect(ready.state).toBe("ready"); + expect(readinessObservedPublication).toBe(true); + yield* runtime.cleanup({ stackId, destroy: true }); + }), + ); + it.live("resolves workload inputs again after the network gateway is ready", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -1496,7 +1523,9 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner-session", + instanceId, workloadId: key.workloadId, + recipeId: key.workloadId, role: "workload", }, }; @@ -1526,13 +1555,15 @@ describe("container runtime", () => { Effect.gen(function* () { const stale: ContainerResource = { id: "stale-startup", - name: `supabase-${stackId.slice(0, 16)}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, + name: `supabase-${stackId.slice(0, 16)}-${key.instanceId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, kind: "workload", state: "stopped", labels: { stackId, ownerSessionId: "owner-session", + instanceId, workloadId: key.workloadId, + recipeId: key.workloadId, startup: true, role: "workload", }, @@ -1900,7 +1931,9 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner-session", + instanceId, workloadId: "database:database", + recipeId: "database:database", role: "workload", }, network: "private", @@ -1930,13 +1963,13 @@ describe("container runtime", () => { ? "not-json\n" : "" : request.args[0] === "ps" - ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", key.workloadId, "false", "workload", "running"])}\n` + ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", key.instanceId, key.workloadId, key.workloadId, "false", "workload", "running"])}\n` : request.args[0] === "network" && request.args[1] === "create" ? "created-id\nsecond\n" : request.args[0] === "network" ? `${dockerJsonRow(["network-id", "private", stackId, "owner", "network"])}\n` : request.args[0] === "volume" - ? `${dockerJsonRow(["volume-name", stackId, key.workloadId, "volume"])}\n` + ? `${dockerJsonRow(["volume-name", stackId, key.instanceId, key.workloadId, "volume"])}\n` : request.args[0] === "version" ? '"27.0.0"\n' : "created-id\n", @@ -1974,7 +2007,9 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner", + instanceId, workloadId: "backend", + recipeId: "backend", role: "workload" as const, }, network: "private", @@ -2082,6 +2117,33 @@ describe("container runtime", () => { }), ); + it.live("serializes owner credential reconciliation through the container socket", () => + Effect.sync(() => { + expect( + serializeDockerCommand({ + operation: "exec-container", + id: "container-id", + command: ["psql", "--host=/tmp", "--username=supabase_admin"], + stdin: + "SET standard_conforming_strings = on;\nALTER ROLE supabase_admin PASSWORD 'secret';\n", + }), + ).toEqual({ + args: [ + "exec", + "--interactive", + "--user", + "postgres", + "container-id", + "psql", + "--host=/tmp", + "--username=supabase_admin", + ], + stdin: + "SET standard_conforming_strings = on;\nALTER ROLE supabase_admin PASSWORD 'secret';\n", + }); + }), + ); + it.live("serializes Podman network inspection templates", () => Effect.sync(() => { expect( @@ -2115,11 +2177,11 @@ describe("container runtime", () => { ? "bad\trow\n" : "" : request.args[0] === "ps" - ? `container-id\tbackend\t${stackId}\towner\t${key.workloadId}\tfalse\tworkload\trunning\n` + ? `container-id\tbackend\t${stackId}\towner\t${key.instanceId}\t${key.workloadId}\t${key.workloadId}\tfalse\tworkload\trunning\n` : request.args[0] === "network" ? `network-id\tprivate\t${stackId}\towner\tnetwork\n` : request.args[0] === "volume" - ? `volume-name\t${stackId}\t${key.workloadId}\tvolume\n` + ? `volume-name\t${stackId}\t${key.instanceId}\t${key.workloadId}\tvolume\n` : "created-id\n", stderr: "", exitCode: 0, @@ -2341,7 +2403,7 @@ describe("container runtime", () => { Effect.gen(function* () { const foreignStackId = StackIdSchema.make("c".repeat(64)); const foreignKey = { ...key, stackId: foreignStackId, workloadId: "api:api" }; - const foreignName = `supabase-${stackId.slice(0, 16)}-api-api-workload`; + const foreignName = `supabase-${stackId.slice(0, 16)}-${key.instanceId}-api-api-workload`; const state: FakeContainerState = { resources: [ { @@ -2352,7 +2414,9 @@ describe("container runtime", () => { labels: { stackId: foreignStackId, ownerSessionId: "other-session", + instanceId, workloadId: foreignKey.workloadId, + recipeId: foreignKey.workloadId, role: "workload", }, }, @@ -2406,7 +2470,7 @@ describe("container runtime", () => { resolveWorkload: () => Effect.succeed({ volume }), }); yield* runtime.start(key, workload()); - const physicalVolumeName = `supabase-${key.stackId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; + const physicalVolumeName = `supabase-${key.stackId}-${key.instanceId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; expect(state.createdSpecs[0]?.volumeMounts).toEqual([ { volume: physicalVolumeName, target: volume.target, readOnly: false }, ]); @@ -2495,7 +2559,7 @@ describe("container runtime", () => { yield* runtime.start(secondaryKey, secondaryWorkload); yield* runtime.start(ownerKey, ownerWorkload); - const expectedVolume = `supabase-${stackId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; + const expectedVolume = `supabase-${stackId}-${key.instanceId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; expect(state.resources.filter((resource) => resource.kind === "volume")).toHaveLength(1); expect(state.resources.find((resource) => resource.kind === "volume")?.name).toBe( expectedVolume, @@ -2702,6 +2766,7 @@ describe("container runtime", () => { nextId: 1, copyFailure: new ContainerCommandError({ operation: "copy-container", + exitCode: 1, message: "bootstrap copy failed", }), }; @@ -2747,6 +2812,7 @@ describe("container runtime", () => { nextId: 1, inspectImageFailure: new ContainerCommandError({ operation: "inspect-image", + exitCode: 1, message: "registry unavailable", }), }; @@ -2772,6 +2838,7 @@ describe("container runtime", () => { nextId: 1, inspectImageFailure: new ContainerCommandError({ operation: "inspect-image", + exitCode: 1, message: "daemon rejected image inspection", }), }; @@ -2788,99 +2855,120 @@ describe("container runtime", () => { }), ); - it.live("reports container engine identity when a log follower fails before readiness", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-container-follower-" }); - const testStackId = yield* deriveStackId({ - projectRoot: root, - branchContext: "ordinary-workspace", - stackName: "container-follower", - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(testStackId, { - format: "supabase-stack-state-v1", - identity: { - projectRoot: root, - branchContext: "ordinary-workspace", - stackName: "container-follower", - }, - runtime: { kind: "container", engine: "docker" }, - desiredLifecycle: "unconfigured", - ports: [], - privatePorts: [], - secrets: {}, - }); - const state: FakeContainerState = { - resources: [], - imagePresent: true, - calls: [], - createdSpecs: [], - nextId: 1, - }; - const engine: ContainerEngine = { - ...fakeContainerEngine(state), - waitContainer: () => Effect.never, - streamLogs: () => - Stream.fail( - new ContainerEngineProtocolError({ - operation: "logs", - message: "follower disconnected before readiness", - }), - ), - }; - const logStore = memoryLogStore([]); - const driver = yield* makeContainerRuntime({ - engine, - ownerSessionId: "owner-session", - logStore, - resolveWorkload: () => Effect.succeed({ waitForReadiness: () => Effect.never }), - }); - const ingress: SupervisorIngress = { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), + it.live("starts an unrelated workload while container stop is held at a barrier", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const stoppedEntered = yield* Deferred.make(); + const releaseStop = yield* Deferred.make(); + const firstKey = { ...key, workloadId: "database:first" }; + const unrelatedKey = { ...key, workloadId: "functions:edge-runtime" }; + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + stopContainer: (id) => + Effect.gen(function* () { + const entry = state.resources.find((resource) => resource.id === id); + if ( + entry?.labels.role === "workload" && + entry.labels.workloadId === firstKey.workloadId + ) { + yield* Deferred.succeed(stoppedEntered, undefined); + yield* Deferred.await(releaseStop); + } + yield* base.stopContainer(id); }), - open: () => Effect.void, - close: Effect.void, - }; - const runtime: SupervisorRuntime = { - driver, - preflight: () => Effect.void, - prepare: () => Effect.void, - prefetch: () => Effect.void, - artifacts: Effect.succeed([]), - activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), - ingress, - logStore, - }; - const context = yield* Effect.context(); - const supervisor = yield* makeSupervisor({ - stackId: testStackId, - ownerSessionId: "owner-session", - stateStore: store, - context, - runtime, - }); - const result = yield* supervisor.start().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(ContainerEngineError); - expect(failure.value.message).toContain("follower disconnected before readiness"); - } - } - expect((yield* supervisor.status).lifecycle).toBe("unconfigured"); - yield* supervisor.shutdownIfIdle; - yield* supervisor.shutdown; - }), - ).pipe(Effect.provide(NodeServices.layer)), + }, + ownerSessionId: "owner-session", + }); + yield* runtime.start(firstKey, workload()); + const stopping = yield* runtime + .stop(firstKey) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(stoppedEntered); + + const ready = yield* runtime.start(unrelatedKey, { + ...workload(), + id: unrelatedKey.workloadId, + capability: "functions", + }); + expect(ready).toEqual({ ...unrelatedKey, state: "ready" }); + + yield* Deferred.succeed(releaseStop, undefined); + yield* Fiber.join(stopping); + }), + ); + + it.live("keeps same-recipe instance resources independently addressable", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const primaryKey = { + ...key, + instanceId: ServiceInstanceIdSchema.make("primary-runtime"), + workloadId: "database:primary", + }; + const shadowKey = { + ...key, + instanceId: ServiceInstanceIdSchema.make("shadow-runtime"), + workloadId: "database:shadow", + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + }); + yield* runtime.start(primaryKey, { + ...workload(), + id: primaryKey.workloadId, + instanceId: primaryKey.instanceId, + recipeId: "database:database", + }); + yield* runtime.start(shadowKey, { + ...workload(), + id: shadowKey.workloadId, + instanceId: shadowKey.instanceId, + recipeId: "database:database", + }); + + const workloads = state.resources.filter( + (resource) => resource.kind === "workload" && resource.labels.role === "workload", + ); + expect(workloads).toHaveLength(2); + expect(new Set(workloads.map((resource) => resource.name)).size).toBe(2); + expect( + workloads.map((resource) => + "instanceId" in resource.labels ? resource.labels.instanceId : undefined, + ), + ).toEqual([primaryKey.instanceId, shadowKey.instanceId]); + + yield* runtime.remove(primaryKey); + expect( + state.resources.some( + (resource) => + resource.kind === "workload" && + resource.labels.role === "workload" && + resource.labels.instanceId === primaryKey.instanceId, + ), + ).toBe(false); + expect( + state.resources.some( + (resource) => + resource.kind === "workload" && + resource.labels.role === "workload" && + resource.labels.instanceId === shadowKey.instanceId, + ), + ).toBe(true); + }), ); }); diff --git a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts index d22e950ff4..62c2bdd93c 100644 --- a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts +++ b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts @@ -1,112 +1,123 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Redacted } from "effect"; -import { compileStack } from "../model/Compiler.ts"; +import { Cause, Effect, Exit, Option, Path, Redacted } from "effect"; +import { compileServiceInstance } from "../model/Compiler.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; -const stateFrom = (definition: PersistedStackState["definition"]): PersistedStackState => ({ - format: "supabase-stack-state-v1", - identity: { - projectRoot: "/tmp/project", - branchContext: "ordinary-workspace", - stackName: "default", - }, - runtime: { kind: "native" }, - desiredLifecycle: "stopped", - definition, - ports: [], - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 54_321 }], - secrets: { - "secret:database.internal.password": { policy: "managed", value: "database-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }, -}); - -const compileDefinition = compileStack({ - projectRoot: "/tmp/project", - runtime: { kind: "native" }, -}).pipe( - Effect.provide(NodeServices.layer), - Effect.map((result) => stateFrom(result.definition)), -); +const makeFixture = () => + Effect.gen(function* () { + const path = yield* Path.Path; + const compiled = yield* compileServiceInstance( + { + service: "database", + config: { password: Redacted.make("database-secret"), settings: {} }, + }, + { projectRoot: "/tmp/database-bootstrap", path, runtime: { kind: "native" } }, + ); + const instance = compiled.instance; + if (instance.service !== "database") return yield* Effect.die("database fixture missing"); + const passwordSlot = instance.config.passwordSecretRef; + if (passwordSlot === undefined) return yield* Effect.die("database password slot missing"); + const state: PersistedStackState = { + format: "supabase-stack-state-v2", + identity: { + projectRoot: "/tmp/database-bootstrap", + branchContext: "test", + stackName: "database-bootstrap", + }, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "secret:auth.jwt" } }, + }, + }, + listeners: {}, + registry: { + initialized: true, + instances: [instance], + defaultInstanceIds: { database: instance.id }, + }, + ports: [], + privatePorts: [ + { + instanceId: instance.id, + workloadId: `${instance.id}:database`, + binding: "sql:internal", + port: 5432, + }, + ], + secrets: { + [passwordSlot]: { policy: "managed", value: "database-secret" }, + "secret:auth.jwt": { policy: "managed", value: "jwt-secret" }, + }, + }; + return { state, instance }; + }); const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("database bootstrap catalog", () => { - it.live("returns the managed database material required for reconciliation", () => + it.live("returns managed material for the requested database instance", () => Effect.gen(function* () { - const state = yield* compileDefinition; - const plan = yield* databaseBootstrapPlan(state); + const { state, instance } = yield* makeFixture(); + const plan = yield* databaseBootstrapPlan(state, instance); expect(Redacted.value(plan.databasePassword)).toBe("database-secret"); expect(Redacted.value(plan.jwtSecret)).toBe("jwt-secret"); expect(plan.jwtExpiry).toBe(3600); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("rejects database bootstrap when the managed database password is absent", () => + it.live("rejects a missing instance password", () => Effect.gen(function* () { - const state = yield* compileDefinition; - const missing = yield* databaseBootstrapPlan({ - ...state, - secrets: Object.fromEntries( - Object.entries(state.secrets).filter( - ([slot]) => slot !== "secret:database.internal.password", - ), - ), - }).pipe(Effect.exit); - const missingError = errorOf(missing); - expect(missingError).toMatchObject({ + const { state, instance } = yield* makeFixture(); + const missing = yield* databaseBootstrapPlan( + { ...state, secrets: { "secret:auth.jwt": { policy: "managed", value: "jwt-secret" } } }, + instance, + ).pipe(Effect.exit); + const error = errorOf(missing); + expect(error).toBeInstanceOf(StackPreparationError); + expect(error).toMatchObject({ message: "Managed database password is unavailable for bootstrap", }); - expect(missingError).toBeInstanceOf(StackPreparationError); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("rejects database bootstrap when the managed JWT secret is absent", () => + it.live("rejects a missing shared JWT secret", () => Effect.gen(function* () { - const state = yield* compileDefinition; - const missing = yield* databaseBootstrapPlan({ - ...state, - secrets: Object.fromEntries( - Object.entries(state.secrets).filter( - ([slot]) => slot !== "secret:auth.settings.jwt_secret", + const { state, instance } = yield* makeFixture(); + const missing = yield* databaseBootstrapPlan( + { + ...state, + secrets: Object.fromEntries( + Object.entries(state.secrets).filter(([slot]) => slot !== "secret:auth.jwt"), ), - ), - }).pipe(Effect.exit); - const missingError = errorOf(missing); - expect(missingError).toMatchObject({ + }, + instance, + ).pipe(Effect.exit); + const error = errorOf(missing); + expect(error).toBeInstanceOf(StackPreparationError); + expect(error).toMatchObject({ message: "Managed JWT secret is unavailable for database bootstrap", }); - expect(missingError).toBeInstanceOf(StackPreparationError); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("rejects database bootstrap when the Auth JWT expiry is invalid", () => + it.live("rejects an invalid shared JWT expiry", () => Effect.gen(function* () { - const state = yield* compileDefinition; - if (state.definition === undefined) throw new Error("compiled state has no definition"); - const invalidDefinition = { - ...state.definition, - capabilities: { - ...state.definition.capabilities, - auth: { - ...state.definition.capabilities.auth, - settings: { ...state.definition.capabilities.auth.settings, jwt_expiry: 0 }, - }, - }, - }; - const invalid = yield* databaseBootstrapPlan({ - ...state, - definition: invalidDefinition, - }).pipe(Effect.exit); - const invalidError = errorOf(invalid); - expect(invalidError).toMatchObject({ - message: "Auth JWT expiry must be a finite positive integer", - }); - expect(invalidError).toBeInstanceOf(StackPreparationError); - }), + const { state, instance } = yield* makeFixture(); + const invalid = yield* databaseBootstrapPlan( + { ...state, security: { jwt: { ...state.security.jwt, expirySeconds: 0 } } }, + instance, + ).pipe(Effect.exit); + const error = errorOf(invalid); + expect(error).toBeInstanceOf(StackPreparationError); + expect(error).toMatchObject({ message: "Auth JWT expiry must be a finite positive integer" }); + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 0f2a24f928..801299539a 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url"; import { LogStoreError, makeLogStore, type LogStore } from "../supervisor/LogStore.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { StackIdSchema } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; import { RuntimeDriverError } from "./RuntimeDriver.ts"; import { makeNativeRuntime } from "./NativeRuntime.ts"; @@ -32,6 +33,7 @@ import { } from "./NativeProcess.ts"; const stackId = StackIdSchema.make("d".repeat(64)); +const instanceId = ServiceInstanceIdSchema.make("primary"); const encodeJson = (value: unknown): string => Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(value); @@ -41,6 +43,8 @@ class ProcessTreeTestError extends Data.TaggedError("ProcessTreeTestError")<{ const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ id, + instanceId, + recipeId: id, capability: "database", ...(bootstrap === undefined ? {} : { bootstrap }), dependencies: [], @@ -54,6 +58,7 @@ const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ const keyFor = (id: string): RuntimeWorkloadKey => ({ stackId, + instanceId, workloadId: `database:${id}`, }); @@ -171,6 +176,31 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("runs a startup publication after spawn and before readiness", () => + withPlatform( + Effect.gen(function* () { + const published = yield* Deferred.make(); + let readinessObservedPublication = false; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("ready"))), + waitForReadiness: () => + Deferred.isDone(published).pipe( + Effect.tap((done) => Effect.sync(() => (readinessObservedPublication = done))), + Effect.asVoid, + ), + }); + const key = keyFor("publication"); + const ready = yield* runtime.start(key, workload("publication"), { + onStarted: Deferred.succeed(published, undefined), + }); + expect(ready.state).toBe("ready"); + expect(readinessObservedPublication).toBe(true); + yield* runtime.stop(key); + yield* runtime.remove(key); + }), + ), + ); + it.live("publishes an unexpected native workload exit after readiness", () => withPlatform( Effect.gen(function* () { @@ -830,6 +860,76 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("starts another workload while a native process is stopping gracefully", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-stop-gate-" }); + const releasePath = path.join(root, "release"); + const firstReady = yield* Deferred.make(); + const stopEntered = yield* Deferred.make(); + const stopFinished = yield* Deferred.make(); + const secondReady = yield* Deferred.make(); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const signals = signalOnLog( + signalOnLog(signalOnLog(logStore, "first-ready", firstReady), "stopping", stopEntered), + "second-ready", + secondReady, + ); + const first = keyFor("graceful-gate"); + const second = keyFor("independent-start"); + const runtime = yield* makeNativeRuntime({ + logStore: signals, + resolveProcess: (key) => + Effect.succeed( + processPlan( + key.workloadId === first.workloadId + ? { + executable: process.execPath, + args: [ + "-e", + `const fs = require("node:fs"); +const release = ${JSON.stringify(releasePath)}; +const watcher = fs.watch(${JSON.stringify(root)}, () => { + if (fs.existsSync(release)) process.exit(0); +}); +process.on("SIGTERM", () => process.stdout.write("stopping\\n")); +process.stdout.write("first-ready\\n");`, + ], + gracefulStopSignal: "SIGTERM" as const, + gracefulStopTimeout: "1 minute" as const, + } + : fixtureProcess("second-ready"), + ), + ), + waitForReadiness: (key) => + Deferred.await(key.workloadId === first.workloadId ? firstReady : secondReady), + }); + yield* runtime.start(first, workload("graceful-gate")); + const stopping = yield* Effect.forkChild( + runtime.stop(first).pipe(Effect.tap(() => Deferred.succeed(stopFinished, undefined))), + ); + yield* Effect.gen(function* () { + yield* Deferred.await(stopEntered); + const ready = yield* runtime.start(second, workload("independent-start")); + expect(ready.state).toBe("ready"); + expect(yield* Deferred.isDone(stopFinished)).toBe(false); + }).pipe(Effect.ensuring(fs.writeFileString(releasePath, "release").pipe(Effect.orDie))); + yield* Fiber.join(stopping); + expect(yield* runtime.observe(stackId)).toEqual( + expect.arrayContaining([ + { ...first, state: "stopped" }, + { ...second, state: "ready" }, + ]), + ); + yield* runtime.remove(first); + yield* runtime.stop(second); + yield* runtime.remove(second); + }), + ), + ); + it.live("isolates exact workload identities while stopping one", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/postgres-client.ts b/packages/stack/src/runtime/postgres-client.ts index 8443ef8c8e..a26cb4201e 100644 --- a/packages/stack/src/runtime/postgres-client.ts +++ b/packages/stack/src/runtime/postgres-client.ts @@ -1,16 +1,13 @@ import { Config, Context, Crypto, Effect, FileSystem, Option, Path, Stream } from "effect"; import { ChildProcess } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { RuntimeArtifactInput } from "../preparation/RuntimeArtifacts.ts"; import { ContainerEngineError, PostgresClientError, type PostgresClientRunError, } from "../public/Errors.ts"; -import { - resolveEphemeralPostgresRelease, - type EphemeralPostgresRelease, -} from "../public/EphemeralPostgres.ts"; +import { resolvePostgresRelease, type PostgresRelease } from "../model/PostgresRelease.ts"; import type { StackRuntime, StackRuntimePreference } from "../public/Runtime.ts"; import { makeProductionRuntimeArtifactPreparer, @@ -60,7 +57,7 @@ export class PostgresClientPreparer extends Context.Service< { readonly prepare: ( runtime: StackRuntime, - release: EphemeralPostgresRelease, + release: PostgresRelease, ) => Effect.Effect; } >()("@supabase/stack/PostgresClientPreparer") {} @@ -69,12 +66,10 @@ const plannedWorkload = ( version: string, image: string, runtime: StackRuntime, -): PlannedWorkload => ({ +): RuntimeArtifactInput => ({ id: DATABASE_WORKLOAD_ID, + recipeId: DATABASE_WORKLOAD_ID, capability: "database", - bootstrap: "database", - dependencies: [], - readiness: { portField: "database" }, artifacts: { native: { kind: "native", release: version }, container: { kind: "container", image }, @@ -109,7 +104,7 @@ const resolvedRuntime = ( const defaultPrepare = ( runtime: StackRuntime, - release: EphemeralPostgresRelease, + release: PostgresRelease, ): Effect.Effect< PreparedPostgresClient, RuntimeArtifactPreparationError, @@ -137,7 +132,7 @@ const defaultPrepare = ( const prepareClient = ( runtime: StackRuntime, - release: EphemeralPostgresRelease, + release: PostgresRelease, ): Effect.Effect< PreparedPostgresClient, RuntimeArtifactPreparationError, @@ -321,7 +316,7 @@ export const runPostgresClient = ( Effect.gen(function* () { if (options.argv.length === 0) return yield* new PostgresClientError({ message: "Postgres client argv must not be empty." }); - const release = yield* resolveEphemeralPostgresRelease(options.version); + const release = yield* resolvePostgresRelease(options.version); const runtime = yield* resolvedRuntime(options.runtime); const prepared = yield* prepareClient(runtime, release); return runtime.kind === "native" diff --git a/packages/stack/src/runtime/postgres-instance.integration.test.ts b/packages/stack/src/runtime/postgres-instance.integration.test.ts new file mode 100644 index 0000000000..05e8677acf --- /dev/null +++ b/packages/stack/src/runtime/postgres-instance.integration.test.ts @@ -0,0 +1,1115 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + Cause, + Context, + Crypto, + Effect, + Exit, + FileSystem, + Option, + Path, + Ref, + Redacted, + Schema, +} from "effect"; +import { compileServiceInstance, createExecutionPlan } from "../model/Compiler.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { + type PersistedPendingOperation, + type PersistedServiceInstance, +} from "../model/ServiceRegistry.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackPaths } from "../state/Paths.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { StackCleanupError, StackPreparationError, StackRuntimeError } from "../public/Errors.ts"; +import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import { + RuntimeDriverError, + type ObservedWorkload, + type RuntimeDriver, + type RuntimeWorkloadKey, +} from "./RuntimeDriver.ts"; +import { makePostgresInstanceRuntime } from "./PostgresInstanceRuntime.ts"; +import { makeProductionRuntime } from "./ProductionRuntime.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { AUTH_JWT_SECRET_SLOT, resolveSecrets } from "../state/SecretStore.ts"; + +const runtime = { kind: "native" } as const satisfies StackRuntime; +const stackId = StackIdSchema.make("a".repeat(64)); + +const paths: StackPaths = { + stackRoot: "/tmp/postgres-instance", + stateDocument: "/tmp/postgres-instance/state.json", + data: "/tmp/postgres-instance/data", + logs: "/tmp/postgres-instance/logs", + runtime: "/tmp/postgres-instance/runtime", + controlMetadata: "/tmp/postgres-instance/control.json", +}; + +const makeInput = ( + state: PersistedStackState, + instance: PersistedServiceInstance, + plan: ExecutionPlan, + operationId: string, +) => ({ + stackId, + state, + instance, + plan, + operation: { id: operationId, generation: 1 }, +}); + +const makeDriver = ( + started: Array<{ readonly instanceId: ServiceInstanceId; readonly workloadId: string }>, + remove?: () => Effect.Effect, + onStart?: (key: RuntimeWorkloadKey) => Effect.Effect, +) => + ({ + observe: () => Effect.succeed([]), + start: (key: RuntimeWorkloadKey, _workload: PlannedWorkload) => + Effect.gen(function* () { + if (onStart !== undefined) yield* onStart(key); + started.push({ instanceId: key.instanceId, workloadId: key.workloadId }); + return { ...key, state: "ready" as const } satisfies ObservedWorkload; + }), + stop: () => Effect.void, + remove: remove ?? (() => Effect.void), + cleanup: () => Effect.void, + wipePersistentData: () => Effect.void, + }) satisfies RuntimeDriver; + +const makeState = ( + instances: ReadonlyArray, + privatePorts: PersistedStackState["privatePorts"], + selectedRuntime: StackRuntime = runtime, +): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity: { + projectRoot: "/tmp/postgres-instance", + branchContext: "test", + stackName: "postgres-instance", + }, + runtime: selectedRuntime, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances, defaultInstanceIds: {} }, + ports: [], + privatePorts: privatePorts, + secrets: {}, +}); + +const EventsSchema = Schema.Array( + Schema.Struct({ + url: Schema.String, + password: Schema.String, + site: Schema.String, + }), +); + +describe("postgres instance runtime", () => { + it.live("reconciles requested catalog recipes on the exact instance and skips its receipt", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const context = Context.empty().pipe( + Context.add(FileSystem.FileSystem, fileSystem), + Context.add(Path.Path, path), + Context.add(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + const first = yield* compileServiceInstance( + { + service: "database", + config: { password: Redacted.make("first-password"), settings: {} }, + initialization: { catalog: { auth: { settings: {} } } }, + }, + { projectRoot: paths.stackRoot, path, runtime }, + ); + const second = yield* compileServiceInstance( + { + service: "database", + config: { password: Redacted.make("second-password"), settings: {} }, + initialization: { catalog: { auth: { settings: {} } } }, + }, + { projectRoot: paths.stackRoot, path, runtime }, + ); + const instances = [first.instance, second.instance]; + const privatePorts = instances.map((instance, index) => ({ + instanceId: instance.id, + workloadId: `${instance.id}:database`, + binding: "sql:internal", + port: 55432 + index, + })); + const initial = makeState(instances, privatePorts); + const plan = yield* createExecutionPlan(runtime, initial.registry); + const current = yield* Ref.make(initial); + const started: Array<{ + readonly instanceId: ServiceInstanceId; + readonly workloadId: string; + }> = []; + const recipes: Array<{ readonly instanceId: string; readonly recipeId: string }> = []; + const originsAtDriverStart: string[] = []; + let failCatalog = false; + let failRemove = false; + const driver = makeDriver( + started, + () => + failRemove + ? Effect.fail( + new RuntimeDriverError({ + message: "injected remove failure", + stackId, + }), + ) + : Effect.void, + (key) => + Ref.get(current).pipe( + Effect.tap((state) => + Effect.sync(() => { + const instance = state.registry.instances.find( + (entry) => entry.id === key.instanceId, + ); + if (instance !== undefined) originsAtDriverStart.push(instance.data.origin); + }), + ), + Effect.asVoid, + ), + ); + const artifactPreparer: RuntimeArtifactPreparer = { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "v2.196.0", + outcome: "cached" as const, + artifactRoot: "/tmp/postgres-instance/auth", + }), + }; + const provider = makePostgresInstanceRuntime({ + runtime, + paths, + driver, + artifactPreparer, + context, + snapshotData: { + exists: () => Effect.succeed(false), + readVersion: () => Effect.succeed(17), + restoreTargetEmpty: () => Effect.succeed(true), + export: () => Effect.void, + restore: () => Effect.void, + rollbackRestore: () => Effect.void, + }, + snapshotMetadata: () => + Effect.succeed({ + artifactIdentity: "postgres@17", + runtimeIdentity: "native", + majorVersion: 17, + }), + reconcileManaged: () => Effect.void, + reconcileCatalogRecipe: (input, recipe) => + failCatalog + ? Effect.fail(new StackRuntimeError({ message: "injected catalog failure", stackId })) + : Effect.sync(() => { + recipes.push({ instanceId: input.instance.id, recipeId: recipe.recipeId }); + return { artifactIdentity: `${recipe.service}@${recipe.version}` }; + }), + publishInitialization: (input, evidence) => + Ref.modify(current, (state) => [ + undefined, + { + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((instance) => + instance.id === input.instance.id + ? { ...instance, initialization: evidence } + : instance, + ), + }, + }, + ]), + publishFreshData: (input, lineageId) => + Ref.update(current, (state) => ({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((instance) => + instance.id === input.instance.id + ? { ...instance, data: { origin: "fresh" as const, lineageId } } + : instance, + ), + }, + })), + publishIncompleteData: (input) => + Ref.update(current, (state) => ({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((instance) => + instance.id === input.instance.id + ? { + ...instance, + data: { origin: "incomplete" as const, operationId: input.operation.id }, + } + : instance, + ), + }, + })), + publishAbsentData: (input) => + Ref.update(current, (state) => ({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((instance) => + instance.id === input.instance.id + ? { ...instance, data: { origin: "absent" as const } } + : instance, + ), + }, + })), + journal: () => Effect.void, + }); + + for (const instance of instances) { + const state = yield* Ref.get(current); + const currentInstance = state.registry.instances.find(({ id }) => id === instance.id); + if (currentInstance === undefined) throw new Error(`Missing ${instance.id}`); + yield* provider.start(makeInput(state, currentInstance, plan, `start-${instance.id}`)); + } + const firstState = yield* Ref.get(current); + const firstCurrent = firstState.registry.instances.find(({ id }) => id === first.id); + if (firstCurrent === undefined) throw new Error("Missing first database instance"); + yield* provider.start(makeInput(firstState, firstCurrent, plan, "restart-first")); + + expect(started.map(({ instanceId }) => instanceId)).toEqual([first.id, second.id, first.id]); + expect(recipes).toEqual([ + { instanceId: first.id, recipeId: "auth:v2.196.0" }, + { instanceId: second.id, recipeId: "auth:v2.196.0" }, + ]); + failCatalog = true; + failRemove = true; + const failed = yield* Effect.exit( + provider.start(makeInput(initial, first.instance, plan, "cleanup-failure")), + ); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) { + const error = Option.getOrUndefined(Cause.findErrorOption(failed.cause)); + expect(error).toBeInstanceOf(StackCleanupError); + } + expect(originsAtDriverStart.at(-1)).toBe("incomplete"); + const failedState = yield* Ref.get(current); + const failedInstance = failedState.registry.instances.find(({ id }) => id === first.id); + expect(failedInstance?.data).toEqual({ + origin: "incomplete", + operationId: "cleanup-failure", + }); + + failCatalog = false; + failRemove = false; + if (failedInstance === undefined) throw new Error("Missing failed database instance"); + yield* provider.start(makeInput(failedState, failedInstance, plan, "retry-success")); + const retriedState = yield* Ref.get(current); + const retriedInstance = retriedState.registry.instances.find(({ id }) => id === first.id); + expect(retriedInstance?.data).toEqual({ origin: "fresh", lineageId: "retry-success" }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("round trips a native instance archive with source provenance", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const context = Context.empty().pipe( + Context.add(FileSystem.FileSystem, fileSystem), + Context.add(Path.Path, path), + Context.add(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "postgres-snapshot-" }); + const snapshotPaths: StackPaths = { + stackRoot: root, + stateDocument: path.join(root, "state.json"), + data: path.join(root, "data"), + logs: path.join(root, "logs"), + runtime: path.join(root, "runtime"), + controlMetadata: path.join(root, "control.json"), + }; + const compiled = yield* compileServiceInstance( + { + service: "database", + config: { password: Redacted.make("snapshot-password"), settings: {} }, + initialization: { catalog: { auth: { settings: {} } } }, + }, + { projectRoot: root, path, runtime }, + ); + const source = { + ...compiled.instance, + data: { origin: "fresh" as const, lineageId: "lineage-source" }, + }; + const target = { + ...compiled.instance, + id: ServiceInstanceIdSchema.make("target-db"), + data: { origin: "fresh" as const, lineageId: "lineage-target" }, + }; + const instances = [source, target]; + const privatePorts = instances.map((instance, index) => ({ + instanceId: instance.id, + workloadId: `${instance.id}:database`, + binding: "sql:internal", + port: 55532 + index, + })); + const state = makeState(instances, privatePorts); + const plan = yield* createExecutionPlan(runtime, state.registry); + const workload = plan.workloads.find(({ instanceId }) => instanceId === source.id); + if (workload === undefined) throw new Error("Missing source database workload"); + const sourceData = path.join(snapshotPaths.data, "instances", source.id, "postgres"); + yield* fileSystem.makeDirectory(sourceData, { recursive: true, mode: 0o700 }); + const sourceVersion = path.join(sourceData, "PG_VERSION"); + yield* fileSystem.writeFileString(sourceVersion, "17\n"); + yield* fileSystem.chmod(sourceVersion, 0o600); + const driver = makeDriver([]); + let failJournalComplete = false; + let failRestoreBeforeCopy = false; + let failRestoreEmptyProbe = false; + let restoreFailureObserved = false; + let failRestore = false; + let failManifestWrite = false; + let publishedData: PersistedServiceInstance["data"] = { origin: "absent" }; + let restoreEntryData: PersistedServiceInstance["data"] | undefined; + const artifactPreparer: RuntimeArtifactPreparer = { + prepare: () => + Effect.succeed({ + workloadId: workload.id, + capability: "database" as const, + version: "17.6.1.168", + outcome: "cached" as const, + }), + }; + const provider = makePostgresInstanceRuntime({ + runtime, + paths: snapshotPaths, + driver, + artifactPreparer, + context, + snapshotData: { + exists: (input) => + fileSystem + .exists(path.join(snapshotPaths.data, "instances", input.instance.id, "postgres")) + .pipe( + Effect.mapError((cause) => new StackPreparationError({ message: "exists", cause })), + ), + readVersion: (input) => + fileSystem + .readFileString( + path.join( + snapshotPaths.data, + "instances", + input.instance.id, + "postgres", + "PG_VERSION", + ), + ) + .pipe( + Effect.map((contents) => Number(contents.trim())), + Effect.mapError( + (cause) => new StackPreparationError({ message: "version", cause }), + ), + ), + restoreTargetEmpty: (input) => + failRestoreEmptyProbe && restoreFailureObserved + ? Effect.die("injected restore empty probe defect") + : fileSystem + .exists(path.join(snapshotPaths.data, "instances", input.instance.id)) + .pipe( + Effect.flatMap((exists) => + exists + ? fileSystem + .readDirectory( + path.join(snapshotPaths.data, "instances", input.instance.id), + ) + .pipe(Effect.map((entries) => entries.length === 0)) + : Effect.succeed(true), + ), + Effect.mapError( + (cause) => new StackPreparationError({ message: "empty", cause }), + ), + ), + export: (_input, destination) => + fileSystem + .copy(sourceData, destination, { overwrite: false }) + .pipe( + Effect.mapError((cause) => new StackPreparationError({ message: "export", cause })), + ), + restore: (_input, sourcePath, destination) => + Effect.gen(function* () { + restoreEntryData = publishedData; + restoreFailureObserved = true; + if (failRestoreBeforeCopy) + return yield* new StackPreparationError({ + message: "injected empty restore failure", + }); + yield* fileSystem + .copy(sourcePath, destination, { overwrite: false }) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "restore", cause }), + ), + ); + if (failManifestWrite) + yield* fileSystem + .makeDirectory(path.join(path.dirname(destination), "manifest.json")) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "manifest", cause }), + ), + ); + if (failRestore) + return yield* new StackPreparationError({ message: "injected restore failure" }); + }), + rollbackRestore: (input) => + fileSystem + .remove(path.join(snapshotPaths.data, "instances", input.instance.id, "postgres"), { + recursive: true, + }) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "rollback", cause }), + ), + ), + }, + snapshotMetadata: () => + Effect.succeed({ + artifactIdentity: "postgres@17.6.1.168", + runtimeIdentity: "native", + majorVersion: 17, + }), + reconcileManaged: () => Effect.void, + reconcileCatalogRecipe: () => + Effect.fail(new StackRuntimeError({ message: "catalog is not used in snapshot test" })), + publishInitialization: () => Effect.void, + publishFreshData: () => Effect.void, + publishIncompleteData: (input) => + Effect.sync(() => { + publishedData = { origin: "incomplete", operationId: input.operation.id }; + }), + publishAbsentData: () => + Effect.sync(() => { + publishedData = { origin: "absent" }; + }), + journal: (_input, phase) => + failJournalComplete && phase === "complete" + ? Effect.fail(new StackRuntimeError({ message: "injected completion journal failure" })) + : Effect.void, + }); + const sourceInput = makeInput(state, source, plan, "export-operation"); + const archive = path.join(root, "source.snapshot.tar"); + const descriptor = yield* provider.exportSnapshot(sourceInput, { destination: archive }); + expect(descriptor.provenance).toEqual({ + sourceInstanceId: source.id, + exportOperationId: "export-operation", + }); + const targetInput = makeInput(state, target, plan, "restore-operation"); + const restored = yield* provider.restoreSnapshot(targetInput, { source: archive }); + expect(restored.provenance).toEqual(descriptor.provenance); + expect( + yield* fileSystem.readFileString( + path.join(snapshotPaths.data, "instances", target.id, "postgres", "PG_VERSION"), + ), + ).toBe("17\n"); + const restoredVersion = yield* fileSystem.stat( + path.join(snapshotPaths.data, "instances", target.id, "postgres", "PG_VERSION"), + ); + expect(Number(restoredVersion.mode) & 0o777).toBe(0o600); + const restoredData = yield* fileSystem.stat( + path.join(snapshotPaths.data, "instances", target.id, "postgres"), + ); + expect(Number(restoredData.mode) & 0o777).toBe(0o700); + expect( + yield* fileSystem.exists( + path.join( + snapshotPaths.runtime, + "instances", + source.id, + "snapshots", + "export-export-operation", + ), + ), + ).toBe(false); + yield* fileSystem.remove(path.join(snapshotPaths.data, "instances", target.id), { + recursive: true, + }); + failRestoreBeforeCopy = true; + failRestoreEmptyProbe = true; + restoreFailureObserved = false; + const uncertainFailure = yield* Effect.exit( + provider.restoreSnapshot(targetInput, { source: archive }), + ); + expect(Exit.isFailure(uncertainFailure)).toBe(true); + if (Exit.isFailure(uncertainFailure)) { + expect(Cause.hasDies(uncertainFailure.cause)).toBe(true); + expect( + uncertainFailure.cause.reasons.some( + (reason) => Cause.isFailReason(reason) && reason.error instanceof StackCleanupError, + ), + ).toBe(true); + } + yield* fileSystem.remove(path.join(snapshotPaths.data, "instances", target.id), { + recursive: true, + }); + failRestoreEmptyProbe = false; + const cleanFailure = yield* Effect.exit( + provider.restoreSnapshot(targetInput, { source: archive }), + ); + expect(Exit.isFailure(cleanFailure)).toBe(true); + expect(publishedData).toEqual({ origin: "absent" }); + expect( + yield* fileSystem.exists(path.join(snapshotPaths.data, "instances", target.id, "postgres")), + ).toBe(false); + failRestoreBeforeCopy = false; + failRestore = true; + const failedMutation = yield* Effect.exit( + provider.restoreSnapshot(targetInput, { source: archive }), + ); + expect(Exit.isFailure(failedMutation)).toBe(true); + if (Exit.isFailure(failedMutation)) { + const error = Option.getOrUndefined(Cause.findErrorOption(failedMutation.cause)); + expect(error).toBeInstanceOf(StackCleanupError); + } + expect(restoreEntryData).toEqual({ + origin: "incomplete", + operationId: "restore-operation", + }); + expect(publishedData).toEqual({ + origin: "incomplete", + operationId: "restore-operation", + }); + yield* fileSystem.remove(path.join(snapshotPaths.data, "instances", target.id), { + recursive: true, + }); + failRestore = false; + failManifestWrite = true; + const failedManifest = yield* Effect.exit( + provider.restoreSnapshot(targetInput, { source: archive }), + ); + expect(Exit.isFailure(failedManifest)).toBe(true); + if (Exit.isFailure(failedManifest)) { + const error = Option.getOrUndefined(Cause.findErrorOption(failedManifest.cause)); + expect(error).toBeInstanceOf(StackCleanupError); + } + expect(publishedData).toEqual({ + origin: "incomplete", + operationId: "restore-operation", + }); + expect( + yield* fileSystem.exists( + path.join(snapshotPaths.data, "instances", target.id, "manifest.json"), + ), + ).toBe(true); + expect( + yield* fileSystem.exists(path.join(snapshotPaths.data, "instances", target.id, "postgres")), + ).toBe(false); + yield* fileSystem.remove(path.join(snapshotPaths.data, "instances", target.id), { + recursive: true, + }); + failManifestWrite = false; + failJournalComplete = true; + const failedRestore = yield* Effect.exit( + provider.restoreSnapshot(targetInput, { source: archive }), + ); + expect(Exit.isFailure(failedRestore)).toBe(true); + if (Exit.isFailure(failedRestore)) { + const error = Option.getOrUndefined(Cause.findErrorOption(failedRestore.cause)); + expect(error).toBeInstanceOf(StackCleanupError); + } + expect(publishedData).toEqual({ + origin: "incomplete", + operationId: "restore-operation", + }); + expect( + yield* fileSystem.exists(path.join(snapshotPaths.data, "instances", target.id, "postgres")), + ).toBe(true); + const recoveryOperation: PersistedPendingOperation = { + id: "restore-operation", + kind: "restoreSnapshot", + generation: 1, + ownerSessionId: "snapshot-test", + phase: "complete", + }; + const recovered = yield* provider.recoverSnapshot(targetInput, recoveryOperation); + expect(recovered).toEqual(restored); + + yield* fileSystem.remove( + path.join(snapshotPaths.data, "instances", target.id, "manifest.json"), + ); + const rolledBack = yield* provider.recoverSnapshot(targetInput, { + ...recoveryOperation, + phase: "settling", + }); + expect(rolledBack).toBeUndefined(); + expect( + yield* fileSystem.exists(path.join(snapshotPaths.data, "instances", target.id, "postgres")), + ).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("recovers a container volume without reading host PGDATA", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const context = Context.empty().pipe( + Context.add(FileSystem.FileSystem, fileSystem), + Context.add(Path.Path, path), + Context.add(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "postgres-volume-snapshot-", + }); + const containerRuntime = { + kind: "container", + engine: "docker", + } as const satisfies StackRuntime; + const snapshotPaths: StackPaths = { + stackRoot: root, + stateDocument: path.join(root, "state.json"), + data: path.join(root, "data"), + logs: path.join(root, "logs"), + runtime: path.join(root, "runtime"), + controlMetadata: path.join(root, "control.json"), + }; + const compiled = yield* compileServiceInstance( + { service: "database", config: { password: Redacted.make("volume-password") } }, + { projectRoot: root, path, runtime: containerRuntime }, + ); + const source = { + ...compiled.instance, + id: ServiceInstanceIdSchema.make("volume-source"), + data: { origin: "fresh" as const, lineageId: "volume-lineage" }, + }; + const target = { + ...compiled.instance, + id: ServiceInstanceIdSchema.make("volume-target"), + data: { origin: "fresh" as const, lineageId: "target-lineage" }, + }; + const instances = [source, target]; + const privatePorts = instances.map((instance, index) => ({ + instanceId: instance.id, + workloadId: `${instance.id}:database`, + binding: "sql:internal", + port: 56532 + index, + })); + const state = makeState(instances, privatePorts, containerRuntime); + const plan = yield* createExecutionPlan(containerRuntime, state.registry); + const sourceWorkload = plan.workloads.find(({ instanceId }) => instanceId === source.id); + if (sourceWorkload === undefined) throw new Error("Missing source database workload"); + const volumes = new Map([[source.id, 17]]); + const targetHostData = path.join(snapshotPaths.data, "instances", target.id); + yield* fileSystem.makeDirectory(targetHostData, { recursive: true, mode: 0o700 }); + const provider = makePostgresInstanceRuntime({ + runtime: containerRuntime, + paths: snapshotPaths, + driver: makeDriver([]), + artifactPreparer: { + prepare: () => + Effect.succeed({ + workloadId: sourceWorkload.id, + capability: "database" as const, + version: "17.6.1.168", + outcome: "cached" as const, + }), + }, + context, + snapshotData: { + exists: (input) => Effect.succeed(volumes.has(input.instance.id)), + readVersion: (input) => { + const version = volumes.get(input.instance.id); + return version === undefined + ? Effect.fail( + new StackRuntimeError({ + message: "Container volume is absent", + stackId, + workloadId: input.instance.id, + }), + ) + : Effect.succeed(version); + }, + restoreTargetEmpty: (input) => Effect.succeed(!volumes.has(input.instance.id)), + export: (_input, destination) => + fileSystem.makeDirectory(destination, { recursive: true, mode: 0o700 }).pipe( + Effect.andThen( + fileSystem.writeFileString(path.join(destination, "PG_VERSION"), "17\n"), + ), + Effect.mapError((cause) => new StackPreparationError({ message: "export", cause })), + ), + restore: (input) => Effect.sync(() => volumes.set(input.instance.id, 17)), + rollbackRestore: (input) => Effect.sync(() => volumes.delete(input.instance.id)), + }, + snapshotMetadata: () => + Effect.succeed({ + artifactIdentity: "postgres@17.6.1.168", + runtimeIdentity: "container:docker", + majorVersion: 17, + }), + reconcileManaged: () => Effect.void, + reconcileCatalogRecipe: () => + Effect.fail(new StackRuntimeError({ message: "catalog is not used in volume test" })), + publishInitialization: () => Effect.void, + publishFreshData: () => Effect.void, + publishIncompleteData: () => Effect.void, + publishAbsentData: () => Effect.void, + journal: () => Effect.void, + }); + const sourceInput = makeInput(state, source, plan, "volume-export-operation"); + const archive = path.join(root, "volume.snapshot.tar"); + const descriptor = yield* provider.exportSnapshot(sourceInput, { destination: archive }); + const targetInput = makeInput(state, target, plan, "volume-restore-operation"); + const restored = yield* provider.restoreSnapshot(targetInput, { source: archive }); + expect(restored.provenance).toEqual(descriptor.provenance); + expect(yield* fileSystem.exists(path.join(targetHostData, "postgres"))).toBe(false); + const recovered = yield* provider.recoverSnapshot(targetInput, { + id: "volume-restore-operation", + kind: "restoreSnapshot", + generation: 1, + ownerSessionId: "snapshot-test", + phase: "complete", + }); + expect(recovered).toEqual(restored); + + yield* fileSystem.remove(path.join(targetHostData, "manifest.json")); + const rolledBack = yield* provider.recoverSnapshot(targetInput, { + id: "volume-restore-operation", + kind: "restoreSnapshot", + generation: 1, + ownerSessionId: "snapshot-test", + phase: "settling", + }); + expect(rolledBack).toBeUndefined(); + expect(volumes.has(target.id)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "runs the default catalog adapter for a disabled live service on the requested database", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "postgres-production-", + }); + const db = yield* compileServiceInstance( + { + service: "database", + config: { + password: Redacted.make("database-password"), + settings: { health_timeout: "2m" }, + }, + initialization: { + catalog: { + auth: { settings: { site_url: "https://recipe.example" } }, + storage: { settings: {} }, + realtime: { settings: {} }, + }, + }, + }, + { + projectRoot: root, + path, + runtime, + instanceId: ServiceInstanceIdSchema.make("db-one"), + }, + ); + const authVersion = db.instance.initializationInputs?.catalog.auth?.version; + if (authVersion === undefined) throw new Error("Missing compiled auth catalog version"); + const catalogRecipeIds = Object.entries( + db.instance.initializationInputs?.catalog ?? {}, + ).map(([service, recipe]) => `${service}:${recipe.version}`); + const second = yield* compileServiceInstance( + { + service: "database", + config: { + password: Redacted.make("second-database-password"), + settings: { health_timeout: "2m" }, + }, + initialization: { + catalog: { + auth: { settings: { site_url: "https://recipe.example" } }, + storage: { settings: {} }, + realtime: { settings: {} }, + }, + }, + }, + { + projectRoot: root, + path, + runtime, + instanceId: ServiceInstanceIdSchema.make("db-two"), + }, + ); + const instances = [db.instance, second.instance]; + const privatePorts = instances.map((instance, index) => ({ + instanceId: instance.id, + workloadId: `${instance.id}:database`, + binding: "sql:internal" as const, + port: 55632 + index, + })); + const baseState = makeState(instances, privatePorts); + const firstSecrets = yield* resolveSecrets( + { declarations: db.secretSlots }, + undefined, + "unconfigured", + ); + const secondSecrets = yield* resolveSecrets( + { declarations: second.secretSlots }, + undefined, + "unconfigured", + ); + const state: PersistedStackState = { + ...baseState, + identity: { projectRoot: root, branchContext: "test", stackName: "production" }, + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + secrets: { + ...firstSecrets.persisted, + ...secondSecrets.persisted, + [AUTH_JWT_SECRET_SLOT]: { policy: "managed", value: "jwt-secret" }, + [`secret:${db.id}.settings.db_enc_key`]: { + policy: "managed", + value: "realtime-db-key", + }, + [`secret:${db.id}.settings.secret_key_base`]: { + policy: "managed", + value: "realtime-secret-key", + }, + [`secret:${second.id}.settings.db_enc_key`]: { + policy: "managed", + value: "second-realtime-db-key", + }, + [`secret:${second.id}.settings.secret_key_base`]: { + policy: "managed", + value: "second-realtime-secret-key", + }, + }, + registry: { + ...baseState.registry, + defaultInstanceIds: { database: db.instance.id }, + instances: instances.map((instance) => ({ + ...instance, + pendingOperation: { + id: `production-start-${instance.id}`, + kind: "start" as const, + generation: 1, + ownerSessionId: "production-test", + phase: "admitted" as const, + }, + })), + }, + }; + const plan = yield* createExecutionPlan(runtime, state.registry); + const eventsPath = path.join(root, "catalog-env.json"); + const dbScript = path.join(root, "bin", "supabase-postgres-start"); + const authScript = path.join(root, "bin", "auth"); + const scriptEventsPath = eventsPath.replaceAll("\\", "\\\\").replaceAll("'", "\\'"); + yield* fileSystem.makeDirectory(path.dirname(dbScript), { recursive: true }); + yield* fileSystem.writeFileString( + dbScript, + `#!/usr/bin/env node +const net = require("node:net"); +const port = Number(process.argv[process.argv.indexOf("-p") + 1]); +const server = net.createServer((socket) => socket.end()); +server.listen(port, "127.0.0.1"); +const stop = () => server.close(() => process.exit(0)); +process.on("SIGINT", stop); +process.on("SIGTERM", stop); +`, + ); + yield* fileSystem.writeFileString( + authScript, + `#!/usr/bin/env node +const fs = require("node:fs"); +const eventsPath = '${scriptEventsPath}'; +// The fixture is a standalone Node script and has no Effect Schema runtime. +// oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- standalone Node fixture has no Effect services. +const events = fs.existsSync(eventsPath) ? JSON.parse(fs.readFileSync(eventsPath, "utf8")) : []; +events.push({ url: process.env.GOTRUE_DB_DATABASE_URL, password: process.env.GOTRUE_JWT_SECRET, site: process.env.GOTRUE_SITE_URL }); +fs.writeFileSync(eventsPath, JSON.stringify(events)); +`, + ); + yield* fileSystem.writeFileString( + path.join(root, "bin", "prepare"), + "#!/usr/bin/env node\n", + ); + yield* fileSystem.chmod(dbScript, 0o755); + yield* fileSystem.chmod(authScript, 0o755); + yield* fileSystem.chmod(path.join(root, "bin", "prepare"), 0o755); + let current = state; + const stateStore: StackStateStore = { + read: (_id) => Effect.succeed(current), + initialize: () => Effect.die("unused"), + replace: (_id, next) => Effect.sync(() => void (current = next)), + replaceUnlocked: (_id, next) => Effect.sync(() => void (current = next)), + update: (_id, transform) => + Effect.gen(function* () { + const next = yield* transform(current); + current = next; + return next; + }), + cleanup: () => Effect.die("unused"), + recoverRuntimeRemnant: () => Effect.void, + }; + const artifactPreparer: RuntimeArtifactPreparer = { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: workload.recipeId.startsWith("auth:") ? authVersion : "17.6.1.168", + outcome: "cached" as const, + artifactRoot: root, + }), + }; + let failBootstrap = true; + const runtimeInstance = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "production-test", + stateStore, + context: Context.empty().pipe( + Context.add(FileSystem.FileSystem, fileSystem), + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ), + artifactPreparer, + bootstrapDatabase: () => + failBootstrap + ? Effect.fail(new StackPreparationError({ message: "injected bootstrap failure" })) + : Effect.void, + }); + const startedInputs = []; + for (const instance of current.registry.instances) { + const input = { + stackId, + state: current, + instance, + plan, + operation: { id: `production-start-${instance.id}`, generation: 1 }, + }; + if (instance.id === db.id) { + const failed = yield* Effect.exit(runtimeInstance.start(input)); + expect(Exit.isFailure(failed)).toBe(true); + const failedState = yield* stateStore.read(stackId); + if (failedState === undefined) throw new Error("Missing failed production state"); + const failedInstance = failedState.registry.instances.find( + (entry) => entry.id === db.id, + ); + expect(failedInstance?.data).toEqual({ + origin: "incomplete", + operationId: input.operation.id, + }); + if (failedInstance === undefined) throw new Error("Missing failed database instance"); + failBootstrap = false; + const retryInput = { ...input, state: failedState, instance: failedInstance }; + yield* runtimeInstance.start(retryInput); + startedInputs.push(retryInput); + } else { + yield* runtimeInstance.start(input); + startedInputs.push(input); + } + } + const firstInput = startedInputs.find((input) => input.instance.id === db.id); + if (firstInput === undefined) throw new Error("Missing started database input"); + yield* runtimeInstance.stop(firstInput); + const freshState = yield* stateStore.read(stackId); + if (freshState === undefined) throw new Error("Missing fresh production state"); + const freshInstance = freshState.registry.instances.find(({ id }) => id === db.id); + if (freshInstance === undefined) throw new Error("Missing fresh database instance"); + const restartOperationId = "production-restart-db-one"; + yield* stateStore.update(stackId, (current) => + Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === db.id + ? { + ...entry, + pendingOperation: { + id: restartOperationId, + kind: "start" as const, + generation: 1, + ownerSessionId: "production-test", + phase: "admitted" as const, + }, + } + : entry, + ), + }, + }), + ); + const restartState = yield* stateStore.read(stackId); + if (restartState === undefined) throw new Error("Missing restart production state"); + const restartInstance = restartState.registry.instances.find(({ id }) => id === db.id); + if (restartInstance === undefined) throw new Error("Missing restart database instance"); + const restartInput = { + stackId, + state: restartState, + instance: restartInstance, + plan, + operation: { id: restartOperationId, generation: 1 }, + }; + yield* runtimeInstance.start(restartInput); + startedInputs.splice(startedInputs.indexOf(firstInput), 1, restartInput); + const preserved = yield* stateStore.read(stackId); + if (preserved === undefined) throw new Error("Missing preserved production state"); + expect(preserved.registry.instances.find(({ id }) => id === db.id)?.data).toEqual({ + origin: "fresh", + lineageId: firstInput.operation.id, + }); + const events = yield* Schema.decodeEffect(Schema.fromJsonString(EventsSchema))( + yield* fileSystem.readFileString(eventsPath), + ); + expect(events).toHaveLength(2); + expect(events.map((event) => event.url)).toEqual( + expect.arrayContaining([ + "postgresql://supabase_auth_admin:database-password@127.0.0.1:55632/postgres", + "postgresql://supabase_auth_admin:second-database-password@127.0.0.1:55633/postgres", + ]), + ); + expect(events.every((event) => event.password === "jwt-secret")).toBe(true); + expect(events.every((event) => event.site === "https://recipe.example")).toBe(true); + const updated = yield* stateStore.read(stackId); + if (updated === undefined) throw new Error("Missing updated state"); + expect(updated.registry.instances.map((instance) => instance.data.origin)).toEqual([ + "fresh", + "fresh", + ]); + expect( + updated.registry.instances.map((instance) => instance.initialization?.recipes), + ).toEqual( + instances.map(() => + catalogRecipeIds.map((recipeId) => + expect.objectContaining({ recipeId, completed: true }), + ), + ), + ); + for (const input of startedInputs) yield* runtimeInstance.stop(input); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); +}); diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 122188fb93..08454bfefb 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -1,2968 +1,128 @@ -import { NodeHttpServer, NodeHttpServerRequest, NodeServices } from "@effect/platform-node"; +import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Context, - Deferred, - Duration, - Effect, - Exit, - FileSystem, - Fiber, - Path, - Crypto, - Redacted, - Schema, - Option, - Predicate, - Stream, - Layer, -} from "effect"; -import * as TestClock from "effect/testing/TestClock"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.layer requires a native factory to bind loopback; layerTest does not expose a host option. -import { createServer as createHttpServer } from "node:http"; -import { createServer as createNetServer } from "node:net"; -import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import type { StackLogEntry } from "../public/Logs.ts"; -import type { CapabilityName } from "../public/Capability.ts"; +import { Duration, Effect, FileSystem, Path } from "effect"; import { StackIdSchema } from "../public/StackId.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; -import { NetworkPortSchema } from "../public/Status.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import type { StackStateStore } from "../state/StackStateStore.ts"; -import { resolveSecrets } from "../state/SecretStore.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; -import type { SupervisorIngress } from "../supervisor/Ingress.ts"; -import type { LogStore } from "../supervisor/LogStore.ts"; -import { LogStoreError } from "../supervisor/LogStore.ts"; -import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { RuntimeDriver } from "./RuntimeDriver.ts"; +import type { RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; +import type { FunctionsBootstrapOwner } from "../functions/FunctionsBootstrap.ts"; import { - makeProductionRuntime, readinessDeadlineFor, + removeOwnedInstancePaths, withOwnedRuntimeFileCleanup, } from "./ProductionRuntime.ts"; -import { makeSupervisor } from "../supervisor/Supervisor.ts"; -import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; -import { - InvalidStackConfigError, - PortUnavailableError, - StackPreparationError, - StackStateInvalidError, -} from "../public/Errors.ts"; -import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; -import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import { makeRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import { makeArtifactStore, type ArtifactSource } from "../preparation/ArtifactStore.ts"; -import { compileStack } from "../model/Compiler.ts"; -import type { - ContainerContainerSpec, - ContainerEngine, - ContainerResource, - ContainerNetworkSpec, - ContainerVolumeSpec, -} from "./ContainerEngine.ts"; -import { ContainerEngineProtocolError } from "./ContainerEngine.ts"; -import { - makeFunctionsBootstrapOwner, - type FunctionsBootstrapOwner, -} from "../functions/FunctionsBootstrap.ts"; -import type { RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; -import { makeRuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; -import { probeReadiness } from "./ReadinessProbe.ts"; - -const encodeJson = (value: unknown): string => - Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(value); const stackId = StackIdSchema.make("a".repeat(64)); - -const stateFor = ( - secrets: PersistedStackState["secrets"], - runtime: StackRuntime = { kind: "native" }, -): PersistedStackState => ({ - format: "supabase-stack-state-v1", +const instanceId = ServiceInstanceIdSchema.make("instance"); +const state = (runtime: PersistedStackState["runtime"]): PersistedStackState => ({ + format: "supabase-stack-state-v2", identity: { projectRoot: "/tmp/production-runtime", - branchContext: "ordinary-workspace", + branchContext: "test", stackName: "production-runtime", }, runtime, - desiredLifecycle: "stopped", + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "secret:auth.jwt" } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, ports: [], privatePorts: [], - secrets, -}); - -const stateStoreFor = ( - current: { value: PersistedStackState }, - onRead?: () => void, -): StackStateStore => ({ - read: () => - Effect.sync(() => { - onRead?.(); - return current.value; - }), - initialize: () => Effect.die("unused"), - replace: () => Effect.die("unused"), - replaceUnlocked: () => Effect.die("unused"), - cleanup: () => Effect.die("unused"), - recoverRuntimeRemnant: () => Effect.die("unused"), -}); - -const mutableStateStoreFor = (current: { - value: PersistedStackState | undefined; -}): StackStateStore => ({ - read: () => Effect.succeed(current.value), - initialize: () => Effect.die("unused"), - replace: (_stackId, state) => - Effect.sync(() => { - current.value = state; - }), - replaceUnlocked: (_stackId, state) => - Effect.sync(() => { - current.value = state; - }), - cleanup: () => - Effect.sync(() => { - current.value = undefined; - }), - recoverRuntimeRemnant: () => Effect.void, + secrets: {}, }); -const memoryLogStore = (entries: StackLogEntry[]): LogStore => ({ - path: "memory://production-runtime", - append: (record) => - Effect.sync(() => { - const entry: StackLogEntry = { - cursor: { opaque: `v1_${entries.length + 1}` }, - timestamp: record.timestamp ?? "2026-01-01T00:00:00.000Z", - source: record.source, - stream: record.stream, - message: record.message, - }; - entries.push(entry); - return entry; - }), - read: () => Effect.succeed(entries), -}); - -const ingress: SupervisorIngress = { - acquire: () => Effect.die("unused"), - open: () => Effect.die("unused"), - close: Effect.void, -}; - -const artifacts: RuntimeArtifactPreparer = { - prepare: () => Effect.die("unused"), +const workload: PlannedWorkload = { + id: `${instanceId}:rest`, + instanceId, + recipeId: "rest:rest", + capability: "rest", + dependencies: [], + readiness: { portField: "api" }, + artifacts: { + native: { kind: "native", release: "12.2.0" }, + container: { kind: "container", image: "postgrest/postgrest:v12.2.0" }, + }, + selected: { kind: "native", release: "12.2.0" }, }; -const writeNativeDatabaseFixture = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string, - eventsPath: string, -) => - Effect.gen(function* () { - const binDirectory = path.join(root, "bin"); - yield* fs.makeDirectory(binDirectory, { recursive: true }); - const main = path.join(binDirectory, "supabase-postgres-start"); - const helperScript = [ - 'const fs = require("node:fs");', - 'const net = require("node:net");', - "const args = process.argv.slice(1);", - 'const port = Number(args[args.indexOf("-p") + 1]);', - `fs.appendFileSync(${encodeJson(eventsPath)}, "postgres-start|data=" + process.env.PGDATA + "|user=" + process.env.POSTGRES_USER + "|db=" + process.env.POSTGRES_DB + "|password=" + process.env.POSTGRES_PASSWORD + "|args=" + args.join(" ") + "\\n");`, - "const server = net.createServer((socket) => socket.end());", - 'server.listen(port, "127.0.0.1");', - "const stop = () => server.close(() => process.exit(0));", - 'process.on("SIGTERM", stop);', - 'process.on("SIGINT", stop);', - ].join(""); - yield* fs.writeFileString( - main, - `#!/bin/sh -set -eu -exec ${encodeJson(process.execPath)} -e ${encodeJson(helperScript)} -- "$@" -`, - ); - yield* fs.chmod(main, 0o755); - return { main }; - }); - -const writeNativeRealtimeFixture = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string, - eventsPath: string, -) => - Effect.gen(function* () { - const bin = path.join(root, "bin"); - yield* fs.makeDirectory(bin, { recursive: true }); - const profile = path.join(bin, ".runtime-env.sh"); - yield* fs.writeFileString( - profile, - `if [ -z "\${RELEASE_DISTRIBUTION:-}" ]; then - export RELEASE_DISTRIBUTION=name -fi -`, - ); - const migrate = path.join(bin, "migrate"); - yield* fs.writeFileString( - migrate, - `#!/bin/sh -. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)/.runtime-env.sh" -printf 'realtime-migrate|RELEASE_DISTRIBUTION=%s\\n' "\${RELEASE_DISTRIBUTION:-missing}" >> ${encodeJson(eventsPath)} -`, - ); - yield* fs.chmod(migrate, 0o755); - const prepare = path.join(bin, "prepare"); - yield* fs.writeFileString( - prepare, - `#!/bin/sh -set -eu -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)" -"$SCRIPT_DIR/migrate" -if [ "\${SEED_SELF_HOST:-}" = true ]; then - "$SCRIPT_DIR/realtime" eval 'Realtime.Release.seeds(Realtime.Repo)' -fi -`, - ); - yield* fs.chmod(prepare, 0o755); - const realtime = path.join(bin, "realtime"); - yield* fs.writeFileString(realtime, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(realtime, 0o755); - const server = path.join(bin, "server"); - yield* fs.writeFileString( - server, - "#!/bin/sh\ntrap 'exit 0' TERM INT\nwhile :; do sleep 1; done\n", - ); - yield* fs.chmod(server, 0o755); - return { migrate, realtime, server }; - }); - -const listenForNativeReadiness = (server: ReturnType) => - Effect.acquireRelease( - Effect.callback((resume) => { - server.once("error", (error: Error) => resume(Effect.fail(error))); - server.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!server.listening) return resume(Effect.void); - server.close(() => resume(Effect.void)); - }), - ); - -const listenForHttpFixture = ( - handler: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect, -) => - Effect.gen(function* () { - const scope = yield* Effect.scope; - const context = yield* Layer.buildWithScope( - Layer.fresh(NodeHttpServer.layer(createHttpServer, { port: 0, host: "127.0.0.1" })), - scope, - ); - const server = Context.get(context, HttpServer.HttpServer); - yield* server.serve(Effect.flatMap(HttpServerRequest.HttpServerRequest, handler)); - return server; - }); - -const httpFixturePort = (server: HttpServer.HttpServer["Service"]): Effect.Effect => - Predicate.isTagged(server.address, "TcpAddress") - ? Effect.succeed(server.address.port) - : Effect.die("HTTP fixture did not expose TCP address"); - -const envFiles: RuntimeEnvFileOwner = { - write: () => Effect.die("unused"), - cleanupAll: Effect.void, -}; +const noOpDriver = (): RuntimeDriver => ({ + observe: () => Effect.succeed([]), + start: () => Effect.die("unused"), + stop: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + cleanup: () => Effect.void, + wipePersistentData: () => Effect.die("unused"), +}); -const bootstrap: FunctionsBootstrapOwner = { +const owner = (cleanupAll: Effect.Effect): RuntimeEnvFileOwner => ({ write: () => Effect.die("unused"), - cleanupAll: Effect.void, -}; - -const ownerInputContainerEngine = ( - createdSpecs: ContainerContainerSpec[], - copiedFiles: Array> = [], -): ContainerEngine => { - const resources: ContainerResource[] = []; - const oneShot = new Set(); - let nextId = 1; - const resource = ( - kind: ContainerResource["kind"], - name: string, - labels: ContainerResource["labels"], - ): ContainerResource => ({ - id: `${kind}-${nextId++}`, - name, - kind, - labels, - ...(kind === "workload" ? { state: "created" as const } : {}), - }); - const updateState = (id: string, state: "running" | "stopped") => { - const index = resources.findIndex((entry) => entry.id === id); - const entry = resources[index]; - if (entry !== undefined) resources[index] = { ...entry, state }; - }; - return { - kind: "docker", - preflight: Effect.sync(() => { - return { host: "host.docker.internal" }; - }), - probe: Effect.void, - inspectImage: () => Effect.succeed({ present: true }), - pullImage: () => Effect.void, - listResources: () => Effect.sync(() => [...resources]), - createNetwork: (spec: ContainerNetworkSpec) => - Effect.sync(() => { - const created = resource("network", spec.name, spec.labels); - resources.push(created); - return created; - }), - removeNetwork: (id) => - Effect.sync(() => { - const index = resources.findIndex((entry) => entry.id === id); - if (index >= 0) resources.splice(index, 1); - }), - createVolume: (spec: ContainerVolumeSpec) => - Effect.sync(() => { - const created = resource("volume", spec.name, spec.labels); - resources.push(created); - return created; - }), - removeVolume: (id) => - Effect.sync(() => { - const index = resources.findIndex((entry) => entry.id === id); - if (index >= 0) resources.splice(index, 1); - }), - createContainer: (spec) => - Effect.sync(() => { - createdSpecs.push(spec); - const created = resource("workload", spec.name, spec.labels); - if ( - spec.entrypoint?.startsWith("/app/bin/") || - spec.command?.includes("migrate") || - spec.command?.join(" ").includes("eval") || - spec.entrypoint === "/node/bin/node" - ) - oneShot.add(created.id); - resources.push(created); - return created; - }), - copyToContainer: (_id, source, destination) => - Effect.sync(() => { - copiedFiles.push({ source, destination }); - }), - startContainer: (id) => - Effect.sync(() => { - updateState(id, "running"); - }), - waitContainer: (id) => (oneShot.has(id) ? Effect.succeed(0) : Effect.never), - stopContainer: (id) => Effect.sync(() => updateState(id, "stopped")), - removeContainer: (id) => - Effect.sync(() => { - const index = resources.findIndex((entry) => entry.id === id); - if (index >= 0) resources.splice(index, 1); - }), - streamLogs: () => Stream.empty, - }; -}; + cleanupFile: () => Effect.die("unused"), + cleanupAll, +}); describe("production runtime", () => { - it.live("validates materialized secrets from the candidate definition and values", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-candidate-", - }); - const previous = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const previousSecrets = yield* resolveSecrets( - { declarations: previous.secrets }, - undefined, - "stopped", - ); - const candidate = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - storage: { - enabled: true, - settings: { - s3_protocol: { - secret_access_key: Redacted.make("candidate-secret"), - }, - }, - }, - }, - }, - }); - const candidateSecrets = yield* resolveSecrets( - { declarations: candidate.secrets }, - undefined, - "stopped", - ); - const missing = Object.fromEntries( - Object.entries(candidateSecrets.persisted).filter( - ([slot]) => slot !== "secret:storage.settings.s3_protocol.secret_access_key", - ), - ); - const current = { - value: { - ...stateFor({}, { kind: "native" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - definition: previous.definition, - secrets: previousSecrets.persisted, - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "candidate-secrets", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - }), - }, - logStore: memoryLogStore([]), - }); - const result = yield* runtime - .preflight({ - stackId, - state: current.value, - definition: candidate.definition, - secrets: missing, - plan: candidate.executionPlan, - }) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(error).toBeInstanceOf(StackStateInvalidError); - } - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("keeps cleanup available when retained logs are corrupted", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-bad-logs-" }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - }); - const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); - yield* fs.makeDirectory(path.dirname(paths.logs), { recursive: true }); - yield* fs.writeFileString(paths.logs, "not-json\n"); - const current = { - value: { - ...stateFor({}, { kind: "container", engine: "docker" }), - desiredLifecycle: "running" as const, - definition: compiled.definition, - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - }, - } satisfies { value: PersistedStackState | undefined }; - const store = mutableStateStoreFor(current); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "bad-logs-owner", - stateStore: store, - context, - ingress, - artifactPreparer: artifacts, - containerEngine: ownerInputContainerEngine([]), - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - }); - const supervisor = yield* makeSupervisor({ - stackId, - ownerSessionId: "bad-logs-owner", - stateStore: store, - context, - runtime, - }); - const failed = yield* supervisor.start().pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - if (Exit.isFailure(failed)) { - const error = Cause.findErrorOption(failed.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackPreparationError); - expect(error.value.cause).toBeInstanceOf(LogStoreError); - } - } - const logs = yield* supervisor.logs().pipe(Effect.exit); - expect(Exit.isFailure(logs)).toBe(true); - if (Exit.isFailure(logs)) { - const error = Cause.findErrorOption(logs.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackStateInvalidError); - expect(error.value.cause).toBeInstanceOf(LogStoreError); - } - } - expect(yield* fs.readFileString(paths.logs)).toBe("not-json\n"); - expect((yield* supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect(yield* fs.readFileString(paths.logs)).toBe("not-json\n"); - yield* supervisor.destroy; - expect(current.value).toBeUndefined(); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("retries a transient database bootstrap connection failure", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-bootstrap-", - }); - const readinessServer = createNetServer((socket) => socket.end()); - yield* listenForNativeReadiness(readinessServer); - const address = readinessServer.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("Database readiness server did not expose an address"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - }); - const current = { - value: { - ...stateFor( - { - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }, - { kind: "container", engine: "docker" }, - ), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: address.port }, - ], - }, - } satisfies { value: PersistedStackState }; - const database = compiled.executionPlan.workloads.find( - (workload) => workload.id === "database:database", - ); - if (database === undefined) return yield* Effect.die("Expected database workload"); - const createdSpecs: ContainerContainerSpec[] = []; - let attempts = 0; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: ownerInputContainerEngine(createdSpecs), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: () => { - attempts += 1; - return attempts === 1 - ? Effect.fail( - new DatabaseBootstrapError({ - message: "Database is still accepting connections", - retryable: true, - }), - ) - : Effect.void; - }, - }); - const ready = yield* runtime.driver.start( - { - stackId, - workloadId: database.id, - }, - database, - ); - expect(ready.state).toBe("ready"); - expect(attempts).toBe(2); - yield* runtime.driver.stop({ - stackId, - workloadId: database.id, - }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("defers unreachable Auth OIDC resolution until Auth activation", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-oidc-lazy-", - }); - const readinessServer = createNetServer((socket) => socket.end()); - yield* listenForNativeReadiness(readinessServer); - const address = readinessServer.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("Database readiness server did not expose an address"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - auth: { - enabled: true, - settings: { - third_party: { - workos: { enabled: true, issuer_url: "https://issuer.example" }, - }, - }, - }, - }, - }, - }); - const resolved = yield* resolveSecrets( - { declarations: compiled.secrets }, - undefined, - "stopped", - ); - const current = { - value: { - ...stateFor(resolved.persisted, { kind: "container", engine: "docker" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: address.port }, - ], - }, - } satisfies { value: PersistedStackState }; - const database = compiled.executionPlan.workloads.find( - ({ id }) => id === "database:database", - ); - const auth = compiled.executionPlan.workloads.find(({ id }) => id === "auth:auth"); - if (database === undefined || auth === undefined) - return yield* Effect.die("Expected database and Auth workloads"); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "oidc-lazy", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: ownerInputContainerEngine([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - logStore: memoryLogStore([]), - fetchJson: () => Effect.fail(new StackPreparationError({ message: "OIDC unavailable" })), - bootstrapDatabase: () => Effect.void, - }); - const databaseReady = yield* runtime.driver.start( - { stackId, workloadId: database.id }, - database, - ); - expect(databaseReady.state).toBe("ready"); - const authResult = yield* runtime.driver - .start({ stackId, workloadId: auth.id }, auth) - .pipe(Effect.exit); - expect(Exit.isFailure(authResult)).toBe(true); - if (Exit.isFailure(authResult)) { - const error = Option.getOrUndefined(Cause.findErrorOption(authResult.cause)); - expect(error).toBeInstanceOf(RuntimeDriverError); - expect(error?.message).toContain("OIDC discovery request failed"); - expect(error?.cause).toBeInstanceOf(StackPreparationError); - } - yield* runtime.driver.stop({ stackId, workloadId: database.id }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("follows redirects while resolving Auth OIDC metadata", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-oidc-lazy-", - }); - let issuer = ""; - const oidc = yield* listenForHttpFixture((request) => { - if (request.url === "/.well-known/openid-configuration") - return Effect.succeed(HttpServerResponse.redirect("/discovery")); - if (request.url === "/discovery") - return Effect.succeed( - HttpServerResponse.text(encodeJson({ jwks_uri: `${issuer}/keys` }), { - headers: { "content-type": "application/json" }, - }), - ); - if (request.url === "/keys") - return Effect.succeed(HttpServerResponse.redirect("/keys-final")); - return Effect.succeed( - HttpServerResponse.text(encodeJson({ keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }), { - headers: { "content-type": "application/json" }, - }), - ); - }); - const oidcPort = yield* httpFixturePort(oidc); - issuer = `http://127.0.0.1:${oidcPort}`; - const readinessServer = createNetServer((socket) => socket.end()); - yield* listenForNativeReadiness(readinessServer); - const address = readinessServer.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("Database readiness server did not expose an address"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - auth: { - enabled: true, - settings: { - third_party: { - workos: { enabled: true, issuer_url: issuer }, - }, - }, - }, - }, - }, - }); - const resolved = yield* resolveSecrets( - { declarations: compiled.secrets }, - undefined, - "stopped", - ); - const current = { - value: { - ...stateFor(resolved.persisted, { kind: "container", engine: "docker" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: address.port }, - { workloadId: "auth:auth", binding: "primary", port: oidcPort }, - ], - }, - } satisfies { value: PersistedStackState }; - const database = compiled.executionPlan.workloads.find( - ({ id }) => id === "database:database", - ); - const auth = compiled.executionPlan.workloads.find(({ id }) => id === "auth:auth"); - if (database === undefined || auth === undefined) - return yield* Effect.die("Expected database and Auth workloads"); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "oidc-lazy", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: ownerInputContainerEngine([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - const databaseReady = yield* runtime.driver.start( - { stackId, workloadId: database.id }, - database, - ); - expect(databaseReady.state).toBe("ready"); - const authResult = yield* runtime.driver.start({ stackId, workloadId: auth.id }, auth); - expect(authResult.state).toBe("ready"); - yield* runtime.driver.stop({ stackId, workloadId: database.id }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("shares background preparation with a concurrent studio activation closure", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-shared-preparation-", - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - preparation: "background", - capabilities: { - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - mail: { enabled: false }, - pooler: { enabled: false }, - rest: { activation: "eager" }, - analytics: { activation: "eager" }, - }, - }, - }); - const studioWorkloads = compiled.executionPlan.workloads.filter( - (workload) => workload.capability === "studio", - ); - const studioImages = new Set( - studioWorkloads.map((workload) => - workload.selected.kind === "container" ? workload.selected.image : "", - ), - ); - if (studioImages.has("") || studioImages.size === 0) - return yield* Effect.die("Expected container Studio workloads"); - const closure: ReadonlySet = new Set([ - "database", - "rest", - "analytics", - "studio", - ]); - const closureWorkloads = compiled.executionPlan.workloads.filter( - (workload) => - closure.has(workload.capability) && - !studioImages.has( - workload.selected.kind === "container" ? workload.selected.image : "", - ), - ); - if (closureWorkloads.length < 2) - return yield* Effect.die("Expected at least two non-Studio closure workloads"); - const blocked = yield* Deferred.make(); - const release = yield* Deferred.make(); - const releaseClosure = yield* Deferred.make(); - const newClosureWorkStarted = yield* Deferred.make(); - let blockedPullsStarted = 0; - let closurePullsStarted = 0; - const pulls = new Map(); - const createdSpecs: ContainerContainerSpec[] = []; - const baseEngine = ownerInputContainerEngine(createdSpecs); - const engine: ContainerEngine = { - ...baseEngine, - inspectImage: () => Effect.succeed({ present: false }), - pullImage: (image) => - Effect.gen(function* () { - pulls.set(image, (pulls.get(image) ?? 0) + 1); - if (studioImages.has(image)) { - blockedPullsStarted += 1; - if (blockedPullsStarted === studioImages.size) - yield* Deferred.succeed(blocked, undefined); - yield* Deferred.await(release); - } else { - closurePullsStarted += 1; - if (closurePullsStarted === closureWorkloads.length) - yield* Deferred.succeed(newClosureWorkStarted, undefined); - yield* Deferred.await(releaseClosure); - } - }), - }; - const current = { - value: { - ...stateFor({}, { kind: "container", engine: "docker" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "shared-preparation", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: engine, - artifactPreparer: makeRuntimeArtifactPreparer({ containerEngine: engine }), - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - }); - const input: LifecycleInput = { - stackId, - state: current.value, - definition: compiled.definition, - secrets: current.value.secrets, - plan: compiled.executionPlan, - }; - const background = yield* Effect.forkChild(runtime.prefetch(current.value), { - startImmediately: true, - }); - yield* Deferred.await(blocked); - expect(yield* runtime.artifacts).toEqual( - expect.arrayContaining( - studioWorkloads.map((workload) => - expect.objectContaining({ - workloadId: workload.id, - state: "downloading", - }), - ), - ), - ); - const foreground = yield* Effect.forkChild(runtime.prepare(input, closure), { - startImmediately: true, - }); - const interruptedWaiter = yield* Effect.forkChild(runtime.prepare(input, closure), { - startImmediately: true, - }); - yield* Deferred.await(newClosureWorkStarted); - const interrupted = yield* Fiber.interrupt(interruptedWaiter).pipe(Effect.exit); - expect(Exit.isSuccess(interrupted)).toBe(true); - const waiterExit = yield* Fiber.join(interruptedWaiter).pipe(Effect.exit); - expect(Exit.isFailure(waiterExit)).toBe(true); - expect(yield* Deferred.isDone(blocked)).toBe(true); - expect(closurePullsStarted).toBe(closureWorkloads.length); - yield* Deferred.succeed(releaseClosure, undefined); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(background); - yield* Fiber.join(foreground); - for (const workload of compiled.executionPlan.workloads.filter((entry) => - closure.has(entry.capability), - )) { - if (workload.selected.kind === "container") - expect(pulls.get(workload.selected.image)).toBe(1); - } - expect(createdSpecs).toHaveLength(0); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* runtime.artifacts).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live( - "keeps background preparation best effort and retries a failed artifact on activation", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-preparation-retry-", - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - preparation: "background", - capabilities: { - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - const rest = compiled.executionPlan.workloads.find( - (workload) => workload.capability === "rest", - ); - const analytics = compiled.executionPlan.workloads.find( - (workload) => workload.capability === "analytics", - ); - if ( - rest === undefined || - analytics === undefined || - rest.selected.kind !== "container" || - analytics.selected.kind !== "container" - ) - return yield* Effect.die("Expected REST and analytics container workloads"); - const restImage = rest.selected.image; - const pullAttempts = new Map(); - const logs: StackLogEntry[] = []; - const createdSpecs: ContainerContainerSpec[] = []; - const baseEngine = ownerInputContainerEngine(createdSpecs); - const engine: ContainerEngine = { - ...baseEngine, - inspectImage: () => Effect.succeed({ present: false }), - pullImage: (image) => - Effect.gen(function* () { - const attempt = pullAttempts.get(image) ?? 0; - pullAttempts.set(image, attempt + 1); - if (image === restImage && attempt === 0) - return yield* new ContainerEngineProtocolError({ - operation: "pull-image", - message: "temporary image registry failure", - }); - }), - }; - const current = { - value: { - ...stateFor({}, { kind: "container", engine: "docker" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context< - FileSystem.FileSystem | Path.Path | Crypto.Crypto - >(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "preparation-retry", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: engine, - artifactPreparer: makeRuntimeArtifactPreparer({ containerEngine: engine }), - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore(logs), - }); - const background = yield* runtime.prefetch(current.value); - expect(background).toBeUndefined(); - expect(pullAttempts.get(restImage)).toBe(1); - expect(pullAttempts.get(analytics.selected.image)).toBe(1); - const statusesAfterBackground = yield* runtime.artifacts; - expect(statusesAfterBackground).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - workloadId: rest.id, - state: "failed", - error: expect.stringContaining("Unable to pull container image"), - }), - expect.objectContaining({ workloadId: analytics.id, state: "ready" }), - ]), - ); - expect( - logs.some((entry) => - entry.message.includes(`Background preparation failed for ${rest.id}`), - ), - ).toBe(true); - const input: LifecycleInput = { - stackId, - state: current.value, - definition: compiled.definition, - secrets: current.value.secrets, - plan: compiled.executionPlan, - }; - yield* runtime.prepare(input, new Set(["rest"])); - expect(pullAttempts.get(restImage)).toBe(2); - expect( - (yield* runtime.artifacts).find(({ workloadId }) => workloadId === rest.id), - ).toEqual(expect.objectContaining({ workloadId: rest.id, state: "ready" })); - expect(createdSpecs).toHaveLength(0); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* runtime.artifacts).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("reports artifact progress while launch preparation is waiting for a pull", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-eager-preparation-", - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - preparation: "on-demand", - capabilities: { - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - const database = compiled.executionPlan.workloads.find( - (workload) => workload.capability === "database", - ); - if (database === undefined || database.selected.kind !== "container") - return yield* Effect.die("Expected container database workload"); - const databaseImage = database.selected.image; - const resolved = yield* resolveSecrets( - { declarations: compiled.secrets }, - undefined, - "stopped", - ); - const current = { - value: { - ...stateFor(resolved.persisted, { kind: "container", engine: "docker" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - definition: compiled.definition, - secrets: resolved.persisted, - }, - } satisfies { value: PersistedStackState }; - const pullStarted = yield* Deferred.make(); - const release = yield* Deferred.make(); - const createdSpecs: ContainerContainerSpec[] = []; - let pullCount = 0; - const baseEngine = ownerInputContainerEngine(createdSpecs); - const engine: ContainerEngine = { - ...baseEngine, - inspectImage: () => Effect.succeed({ present: false }), - pullImage: (image) => - image === databaseImage - ? Effect.gen(function* () { - pullCount += 1; - yield* Deferred.succeed(pullStarted, undefined); - yield* Deferred.await(release); - }) - : Effect.void, - }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "eager-preparation", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: engine, - artifactPreparer: makeRuntimeArtifactPreparer({ containerEngine: engine }), - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - }); - const input: LifecycleInput = { - stackId, - state: current.value, - definition: compiled.definition, - secrets: resolved.persisted, - plan: compiled.executionPlan, - }; - yield* runtime.preflight(input); - const launching = yield* Effect.forkChild( - runtime.driver.start({ stackId, workloadId: database.id }, database), - { startImmediately: true }, - ); - yield* Deferred.await(pullStarted); - expect(yield* runtime.artifacts).toEqual([ - expect.objectContaining({ workloadId: database.id, state: "downloading" }), - ]); - const preparing = yield* Effect.forkChild(runtime.prepare(input, new Set(["database"])), { - startImmediately: true, - }); - expect(yield* runtime.artifacts).toEqual([ - expect.objectContaining({ workloadId: database.id, state: "downloading" }), - ]); - yield* Fiber.interrupt(launching); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(preparing); - expect(yield* runtime.artifacts).toEqual([ - expect.objectContaining({ workloadId: database.id, state: "ready" }), - ]); - expect(createdSpecs).toHaveLength(0); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* runtime.artifacts).toEqual([]); - yield* runtime.prefetch(current.value); - expect(pullCount).toBe(1); - expect(yield* runtime.artifacts).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("cancels an in-flight native artifact transfer during runtime cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-native-artifact-stop-", - }); - const transferStarted = yield* Deferred.make(); - const transferInterrupted = yield* Deferred.make(); - const archiveSha256 = "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"; - const checksumFailure = new StackPreparationError({ - message: "published checksum lookup is temporarily unavailable", - }); - let checksumCalls = 0; - const source: ArtifactSource = { - checksum: () => - Effect.sync(() => { - checksumCalls += 1; - return checksumCalls; - }).pipe( - Effect.flatMap((calls) => - calls === 1 ? Effect.fail(checksumFailure) : Effect.succeed(archiveSha256), - ), - ), - materialize: (_request, destination, _expectedSha256, onProgress) => - Effect.gen(function* () { - const sourceFile = path.join(destination, "partial-source"); - onProgress?.("downloading"); - yield* fs.writeFileString(sourceFile, "partial"); - yield* Deferred.succeed(transferStarted, undefined); - return yield* Effect.never; - }).pipe( - Effect.ensuring(Deferred.succeed(transferInterrupted, undefined)), - Effect.mapError( - (cause) => new StackPreparationError({ message: "native transfer failed", cause }), - ), - ), - }; - const store = yield* makeArtifactStore({ cacheRoot: path.join(root, "artifacts"), source }); - const preparer = makeRuntimeArtifactPreparer({ - native: { - store, - platform: { os: "darwin", arch: "arm64" }, - }, - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const current = { - value: { - ...stateFor({}, { kind: "native" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - }, - } satisfies { value: PersistedStackState }; - const workload = compiled.executionPlan.workloads.find( - (entry) => entry.capability === "database", - ); - if (workload === undefined) return yield* Effect.die("Expected database workload"); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "native-artifact-stop", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - artifactPreparer: preparer, - logStore: memoryLogStore([]), - }); - const input: LifecycleInput = { - stackId, - state: current.value, - definition: compiled.definition, - secrets: current.value.secrets, - plan: compiled.executionPlan, - }; - const firstAttempt = yield* runtime - .prepare(input, new Set([workload.capability])) - .pipe(Effect.exit); - expect(Exit.isFailure(firstAttempt)).toBe(true); - if (Exit.isFailure(firstAttempt)) { - const error = Cause.findErrorOption(firstAttempt.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackPreparationError); - expect(error.value.message).toBe(checksumFailure.message); - } - } - expect(yield* runtime.artifacts).toEqual([ - expect.objectContaining({ - workloadId: workload.id, - state: "failed", - error: checksumFailure.message, - }), - ]); - const preparing = yield* Effect.forkChild( - runtime.prepare(input, new Set([workload.capability])), - { startImmediately: true }, - ); - yield* Deferred.await(transferStarted); - expect(yield* runtime.artifacts).toEqual([ - expect.objectContaining({ workloadId: workload.id, state: "downloading" }), - ]); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* Deferred.isDone(transferInterrupted)).toBe(true); - const result = yield* Fiber.join(preparing).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - const entries = yield* fs.readDirectory(root, { recursive: true }); - expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); - expect(yield* runtime.artifacts).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("cancels an in-flight container image pull during runtime cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-container-pull-stop-", - }); - const pullStarted = yield* Deferred.make(); - const pullInterrupted = yield* Deferred.make(); - const createdSpecs: ContainerContainerSpec[] = []; - const baseEngine = ownerInputContainerEngine(createdSpecs); - const engine: ContainerEngine = { - ...baseEngine, - inspectImage: () => Effect.succeed({ present: false }), - pullImage: () => - Effect.gen(function* () { - yield* Deferred.succeed(pullStarted, undefined); - return yield* Effect.never; - }).pipe(Effect.ensuring(Deferred.succeed(pullInterrupted, undefined))), - }; - const preparer = makeRuntimeArtifactPreparer({ containerEngine: engine }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - }); - const current = { - value: { - ...stateFor({}, { kind: "container", engine: "docker" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - }, - } satisfies { value: PersistedStackState }; - const workload = compiled.executionPlan.workloads.find( - (entry) => entry.capability === "database", - ); - if (workload === undefined) return yield* Effect.die("Expected database workload"); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "container-pull-stop", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: engine, - artifactPreparer: preparer, - logStore: memoryLogStore([]), - }); - const input: LifecycleInput = { - stackId, - state: current.value, - definition: compiled.definition, - secrets: current.value.secrets, - plan: compiled.executionPlan, - }; - const preparing = yield* Effect.forkChild( - runtime.prepare(input, new Set([workload.capability])), - { startImmediately: true }, - ); - yield* Deferred.await(pullStarted); - expect(yield* runtime.artifacts).toEqual([ - expect.objectContaining({ workloadId: workload.id, state: "downloading" }), - ]); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* Deferred.isDone(pullInterrupted)).toBe(true); - const result = yield* Fiber.join(preparing).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect(createdSpecs).toHaveLength(0); - expect(yield* runtime.artifacts).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("interrupts native input materialization before cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-oidc-stop-", - }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const interrupted = yield* Deferred.make(); - yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - auth: { - enabled: true, - settings: { - third_party: { - workos: { enabled: true, issuer_url: "https://issuer.example" }, - }, - }, - }, - }, - }, - }); - const resolved = yield* resolveSecrets( - { declarations: compiled.secrets }, - undefined, - "stopped", - ); - const current = { - value: { - ...stateFor(resolved.persisted, { kind: "native" }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - }, - } satisfies { value: PersistedStackState }; - const auth = compiled.executionPlan.workloads.find(({ id }) => id === "auth:auth"); - if (auth === undefined) return yield* Effect.die("Expected Auth workload"); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "oidc-stop", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - artifactPreparer: artifacts, - logStore: memoryLogStore([]), - fetchJson: (url) => - Effect.gen(function* () { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }).pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), - }); - const key = { stackId, workloadId: auth.id }; - const starting = yield* Effect.forkChild(runtime.driver.start(key, auth), { - startImmediately: true, - }); - yield* Deferred.await(started); - yield* runtime.driver.stop(key); - expect(yield* Deferred.isDone(interrupted)).toBe(true); - // The release is only a guard for the interrupted implementation under test. A correct - // owner propagates cancellation from NativeRuntime.startFiber without this handoff. - yield* Deferred.succeed(release, undefined); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - const startExit = yield* Fiber.join(starting).pipe(Effect.exit); - expect(Exit.isFailure(startExit)).toBe(true); - expect(yield* runtime.driver.observe(stackId)).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("does not retry non-retryable database bootstrap failures", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-bootstrap-fail-", - }); - const readinessServer = createNetServer((socket) => socket.end()); - yield* listenForNativeReadiness(readinessServer); - const address = readinessServer.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("Database readiness server did not expose an address"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - }); - const current = { - value: { - ...stateFor( - { - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }, - { kind: "container", engine: "docker" }, - ), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: address.port }, - ], - }, - } satisfies { value: PersistedStackState }; - const database = compiled.executionPlan.workloads.find( - (workload) => workload.id === "database:database", - ); - if (database === undefined) return yield* Effect.die("Expected database workload"); - let attempts = 0; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: ownerInputContainerEngine([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: () => { - attempts += 1; - return Effect.fail(new DatabaseBootstrapError({ message: "invalid password" })); - }, - }); - const result = yield* runtime.driver - .start( - { - stackId, - workloadId: database.id, - }, - database, - ) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect(attempts).toBe(1); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("activates a capability through its public listener workload", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-activation-endpoint-", - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - database: {}, - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { - enabled: true, - settings: { image_transformation: { enabled: true } }, - }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - const current = { - value: { - ...stateFor({}), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "storage:imgproxy", binding: "primary", port: 41_001 }, - { workloadId: "storage:storage", binding: "primary", port: 41_002 }, - ], - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - artifactPreparer: artifacts, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - const input = { - stackId, - state: current.value, - definition: compiled.definition, - secrets: current.value.secrets, - plan: compiled.executionPlan, - } satisfies LifecycleInput; - if (runtime.activate === undefined) return yield* Effect.die("activation seam missing"); - const endpoint = yield* runtime.activate("storage", input); - expect(endpoint).toEqual({ host: "127.0.0.1", port: 41_002 }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("loads BEAM defaults from the prepared realtime artifact", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-beam-" }); - const eventsPath = path.join(root, "events"); - const artifactRoot = path.join(root, "artifact"); - yield* writeNativeRealtimeFixture(fs, path, artifactRoot, eventsPath); - const readinessServer = yield* listenForHttpFixture(() => - Effect.succeed(HttpServerResponse.text("ok", { headers: { Connection: "close" } })), - ); - const port = yield* httpFixturePort(readinessServer); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - database: {}, - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: true }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - const current = { - value: { - ...stateFor({ - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "realtime:realtime", binding: "primary", port }, - { workloadId: "realtime:realtime", binding: "rpc", port: 41_069 }, - ], - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - artifactPreparer: { - prepare: () => - Effect.succeed({ - workloadId: "realtime:realtime", - capability: "realtime" as const, - version: compiled.definition.capabilities.realtime.version, - outcome: "cached" as const, - artifactRoot, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - const realtime = compiled.executionPlan.workloads.find( - (workload) => workload.id === "realtime:realtime", - ); - if (realtime === undefined) return yield* Effect.die("Expected Realtime workload"); - const key = { - stackId, - workloadId: realtime.id, - }; - const ready = yield* runtime.driver.start(key, realtime); - expect(ready.state).toBe("ready"); - expect(yield* fs.readFileString(eventsPath)).toContain( - "realtime-migrate|RELEASE_DISTRIBUTION=name", - ); - yield* runtime.driver.stop(key); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("starts the canonical PostgreSQL helper before bootstrap", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-pg-first-" }); - const eventsPath = path.join(root, "events"); - const artifactRoot = path.join(root, "artifact"); - yield* fs.writeFileString(eventsPath, ""); - yield* writeNativeDatabaseFixture(fs, path, artifactRoot, eventsPath); - const readinessServer = createNetServer(); - yield* listenForNativeReadiness(readinessServer); - const address = readinessServer.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("Native readiness server did not expose an address"); - yield* Effect.callback((resume) => { - readinessServer.close((error) => - error === undefined ? resume(Effect.void) : resume(Effect.fail(error)), - ); - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const current = { - value: { - ...stateFor({ - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: address.port }, - ], - }, - } satisfies { value: PersistedStackState }; - const runtimePaths = yield* resolveStackPaths({ stateRoot: root, stackId }); - const databaseDataPath = path.join(runtimePaths.data, "database"); - const bootstrap = () => - Effect.gen(function* () { - const previous = yield* fs.readFileString(eventsPath); - yield* fs.writeFileString(eventsPath, `${previous}bootstrap\n`); - }).pipe( - Effect.mapError( - (cause) => new StackPreparationError({ message: "bootstrap fixture failed", cause }), - ), - ); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - artifactPreparer: { - prepare: () => - Effect.succeed({ - workloadId: "database:database", - capability: "database" as const, - version: compiled.definition.capabilities.database.version, - outcome: "cached" as const, - artifactRoot, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: bootstrap, - }); - const database = compiled.executionPlan.workloads.find( - (workload) => workload.id === "database:database", - ); - if (database === undefined) return yield* Effect.die("Expected database workload"); - const ready = yield* runtime.driver.start( - { - stackId, - workloadId: database.id, - }, - database, - ); - expect(ready.state).toBe("ready"); - const events = yield* fs.readFileString(eventsPath); - expect(events).toContain(`postgres-start|data=${databaseDataPath}`); - expect(events).toContain("user=supabase_admin"); - expect(events).toContain("db=postgres"); - expect(events).toContain("password=db-secret"); - expect(events).toContain(`args=-p ${address.port}`); - expect(events.indexOf("postgres-start")).toBeLessThan(events.indexOf("bootstrap")); - yield* runtime.driver.stop({ - stackId, - workloadId: database.id, - }); - }), - ).pipe(Effect.provide(NodeServices.layer)), + it.effect("uses the native readiness budget for a native workload", () => + Effect.gen(function* () { + const deadline = yield* readinessDeadlineFor(state({ kind: "native" }), workload); + expect(Duration.toMillis(deadline)).toBe(120_000); + }), ); - it.live("wires persisted database and generic readiness deadlines", () => + it.effect("uses the container readiness budget for a container workload", () => Effect.gen(function* () { - const configured = yield* compileStack({ - projectRoot: "/tmp/production-runtime-readiness-budget", - runtime: { kind: "native" }, - config: { capabilities: { database: { settings: { health_timeout: "90s" } } } }, + const deadline = yield* readinessDeadlineFor(state({ kind: "container", engine: "docker" }), { + ...workload, + selected: { kind: "container", image: "postgrest/postgrest:v12.2.0" }, }); - const defaulted = yield* compileStack({ - projectRoot: "/tmp/production-runtime-readiness-budget-default", - runtime: { kind: "native" }, - }); - const configuredDatabase = configured.executionPlan.workloads.find( - ({ id }) => id === "database:database", - ); - const defaultDatabase = defaulted.executionPlan.workloads.find( - ({ id }) => id === "database:database", - ); - const generic = configured.executionPlan.workloads.find(({ id }) => id === "rest:rest"); - if ( - configuredDatabase === undefined || - defaultDatabase === undefined || - generic === undefined - ) - return; - const configuredState = { - ...stateFor({}), - definition: configured.definition, - }; - const defaultState = { - ...stateFor({}), - definition: defaulted.definition, - }; - expect( - Duration.toMillis(yield* readinessDeadlineFor(configuredState, configuredDatabase)), - ).toBe(90_000); - expect(Duration.toMillis(yield* readinessDeadlineFor(defaultState, defaultDatabase))).toBe( - 120_000, - ); - expect(Duration.toMillis(yield* readinessDeadlineFor(configuredState, generic))).toBe( - 120_000, - ); - const configuredContainerState = { - ...stateFor({}, { kind: "container", engine: "docker" }), - definition: configured.definition, - }; - expect( - Duration.toMillis(yield* readinessDeadlineFor(configuredContainerState, generic)), - ).toBe(30_000); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("allows cold native services to become ready after library loading", () => - Effect.scoped( - Effect.gen(function* () { - const root = "/tmp/production-runtime-native-readiness-delay"; - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const rest = compiled.executionPlan.workloads.find(({ id }) => id === "rest:rest"); - if (rest === undefined) return yield* Effect.die("Expected REST workload"); - const deadline = yield* readinessDeadlineFor( - { ...stateFor({}), definition: compiled.definition }, - rest, - ); - const received = yield* Deferred.make(); - const context = yield* Effect.context(); - let response: ReturnType | undefined; - const server = yield* listenForHttpFixture((request) => { - const nativeResponse = NodeHttpServerRequest.toServerResponse(request); - response = nativeResponse; - Effect.runSyncWith(context)(Deferred.succeed(received, undefined)); - return Effect.callback((resume) => { - let settled = false; - const onDone = () => { - if (settled) return; - settled = true; - nativeResponse.off("finish", onDone); - nativeResponse.off("close", onDone); - resume(Effect.succeed(HttpServerResponse.empty())); - }; - nativeResponse.once("finish", onDone); - nativeResponse.once("close", onDone); - return Effect.sync(() => { - settled = true; - nativeResponse.off("finish", onDone); - nativeResponse.off("close", onDone); - }); - }).pipe(Effect.interruptible); - }); - const port = yield* httpFixturePort(server); - const fiber = yield* Effect.forkChild( - probeReadiness({ mode: "http", host: "127.0.0.1", port }, { deadline }), - { startImmediately: true }, - ); - yield* Deferred.await(received); - yield* TestClock.adjust(Duration.seconds(52)); - const pending = yield* Effect.sync(() => fiber.pollUnsafe() === undefined); - response?.setHeader("Connection", "close"); - response?.end("ok"); - const result = yield* Fiber.join(fiber).pipe(Effect.exit); - expect(pending).toBe(true); - expect(Exit.isSuccess(result)).toBe(true); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("bounds native HTTP readiness at two minutes and cancels the request", () => - Effect.scoped( - Effect.gen(function* () { - const root = "/tmp/production-runtime-native-readiness-bound"; - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const rest = compiled.executionPlan.workloads.find(({ id }) => id === "rest:rest"); - if (rest === undefined) return yield* Effect.die("Expected REST workload"); - const deadline = yield* readinessDeadlineFor( - { ...stateFor({}), definition: compiled.definition }, - rest, - ); - expect(Duration.toMillis(deadline)).toBe(120_000); - const received = yield* Deferred.make(); - const closed = yield* Deferred.make(); - const context = yield* Effect.context(); - const server = yield* listenForHttpFixture((request) => { - const response = NodeHttpServerRequest.toServerResponse(request); - response.once("close", () => { - Effect.runSyncWith(context)(Deferred.succeed(closed, undefined)); - }); - Effect.runSyncWith(context)(Deferred.succeed(received, undefined)); - return Effect.callback((resume) => { - const onClose = () => resume(Effect.succeed(HttpServerResponse.empty())); - response.once("close", onClose); - return Effect.sync(() => response.off("close", onClose)); - }).pipe(Effect.interruptible); - }); - const port = yield* httpFixturePort(server); - const fiber = yield* Effect.forkChild( - probeReadiness({ mode: "http", host: "127.0.0.1", port }, { deadline }), - { startImmediately: true }, - ); - yield* Deferred.await(received); - yield* TestClock.adjust(Duration.seconds(121)); - const result = yield* Fiber.join(fiber).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(error).toMatchObject({ message: "Readiness deadline exceeded" }); - } - yield* Deferred.await(closed); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("rejects an invalid database readiness budget before creating a workload", () => - Effect.scoped( - Effect.gen(function* () { - const result = yield* compileStack({ - projectRoot: "/tmp/production-runtime-invalid-health-timeout", - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { database: { settings: { health_timeout: "not-a-duration" } } }, - }, - }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(cause).toBeInstanceOf(InvalidStackConfigError); - } - }).pipe(Effect.provide(NodeServices.layer)), - ), + expect(Duration.toMillis(deadline)).toBe(30_000); + }), ); - it.live("rejects a zero persisted database readiness budget", () => + it.effect("runs all owner file cleanup after runtime cleanup", () => Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: "/tmp/production-runtime-zero-health-timeout", - runtime: { kind: "native" }, - }); - const database = compiled.executionPlan.workloads.find( - ({ id }) => id === "database:database", - ); - if (database === undefined) return; - const state = { - ...stateFor({}), - definition: { - ...compiled.definition, - capabilities: { - ...compiled.definition.capabilities, - database: { - ...compiled.definition.capabilities.database, - settings: { - ...compiled.definition.capabilities.database.settings, - health_timeout: "0", - }, - }, - }, - }, + const events: string[] = []; + const files = owner(Effect.sync(() => void events.push("env"))); + const bootstrap: FunctionsBootstrapOwner = { + write: () => Effect.die("unused"), + cleanupAll: Effect.sync(() => void events.push("bootstrap")), }; - const result = yield* readinessDeadlineFor(state, database).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( - StackPreparationError, - ); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("does not create the Functions root during preflight", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-functions-root-", - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const secrets = Object.fromEntries( - compiled.secrets.map((entry) => [ - entry.slot, - { policy: entry.policy, value: "test-secret" }, - ]), - ); - const current = { - value: { - ...stateFor(secrets), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - definition: compiled.definition, - secrets, - }, - } satisfies { value: PersistedStackState }; - const functionsRoot = compiled.definition.capabilities.functions.settings.functions_root; - if (functionsRoot === null) return yield* Effect.die("Functions root was not materialized"); - expect(yield* fs.exists(functionsRoot)).toBe(false); - const prepared: string[] = []; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.sync(() => { - prepared.push(workload.id); - return { - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - }; - }), - }, - bootstrapDatabase: () => Effect.void, - }); - yield* runtime.preflight({ - stackId, - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }); - expect(yield* fs.exists(functionsRoot)).toBe(false); - expect(prepared).toEqual([]); - yield* runtime.prepare( - { - stackId, - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }, - new Set(["database"]), - ); - expect(prepared).toEqual(["database:database"]); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("rejects an occupied persisted native port during a cold preflight", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-preflight-" }); - const occupied = createNetServer(); - yield* listenForNativeReadiness(occupied); - const address = occupied.address(); - if (address === null || typeof address === "string") - return yield* Effect.die("occupied listener has no TCP address"); - const port = yield* Schema.decodeEffect(NetworkPortSchema)(address.port).pipe(Effect.orDie); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const secrets = Object.fromEntries( - compiled.secrets.map((entry) => [ - entry.slot, - { policy: entry.policy, value: "test-secret" }, - ]), - ); - const current = { - value: { - ...stateFor(secrets), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - definition: compiled.definition, - ports: [{ field: "database", port, intent: "automatic" }], - secrets, - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached", - }), - }, - bootstrapDatabase: () => Effect.void, - }); - const result = yield* runtime - .preflight({ - stackId, - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(error).toBeInstanceOf(PortUnavailableError); - } - const changedPort = port === 65_535 ? 65_534 : port + 1; - const candidates = [ - { - ...compiled.definition, - listeners: { - ...compiled.definition.listeners, - database: { ...compiled.definition.listeners.database, enabled: false }, - }, - }, - { - ...compiled.definition, - listeners: { - ...compiled.definition.listeners, - database: { ...compiled.definition.listeners.database, port: changedPort }, - }, - }, - ]; - for (const definition of candidates) { - const accepted = yield* runtime - .preflight({ - stackId, - state: current.value, - definition, - secrets, - plan: compiled.executionPlan, - }) - .pipe(Effect.exit); - expect(Exit.isSuccess(accepted)).toBe(true); - } - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("ignores obsolete inspector bindings while retaining requested bindings", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-native-private-preflight-", - }); - const occupied = createNetServer(); - yield* listenForNativeReadiness(occupied); - const address = occupied.address(); - if (address === null || typeof address === "string") - return yield* Effect.die("occupied listener has no TCP address"); - const port = yield* Schema.decodeEffect(NetworkPortSchema)(address.port).pipe(Effect.orDie); - const previous = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { capabilities: { functions: { settings: { inspector: { mode: "run" } } } } }, - }); - const disabled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const requestedLazy = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { capabilities: { functions: { settings: { inspector: { mode: "run" } } } } }, - }); - const secrets = Object.fromEntries( - disabled.secrets.map((entry) => [ - entry.slot, - { policy: entry.policy, value: "test-secret" }, - ]), - ); - const current = { - value: { - ...stateFor(secrets), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - definition: previous.definition, - privatePorts: [{ workloadId: "functions:edge-runtime", binding: "inspector", port }], - secrets, - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "private-port-preflight", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - }), - }, - bootstrapDatabase: () => Effect.void, - }); - const input = (candidate: typeof disabled) => ({ - stackId, - state: current.value, - definition: candidate.definition, - secrets, - plan: candidate.executionPlan, - }); - const obsolete = yield* runtime.preflight(input(disabled)).pipe(Effect.exit); - expect(Exit.isSuccess(obsolete)).toBe(true); - const requested = yield* runtime.preflight(input(requestedLazy)).pipe(Effect.exit); - expect(Exit.isFailure(requested)).toBe(true); - if (Exit.isFailure(requested)) { - const error = Option.getOrUndefined(Cause.findErrorOption(requested.cause)); - expect(error).toBeInstanceOf(PortUnavailableError); - } - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("rejects native database lock evidence owned by a live process", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-lock-" }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const secrets = Object.fromEntries( - compiled.secrets.map((entry) => [ - entry.slot, - { policy: entry.policy, value: "test-secret" }, - ]), - ); - const current = { - value: { - ...stateFor(secrets), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - definition: compiled.definition, - secrets, - }, - } satisfies { value: PersistedStackState }; - const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); - yield* fs.makeDirectory(path.join(paths.data, "database"), { recursive: true }); - yield* fs.writeFileString( - path.join(paths.data, "database", "postmaster.pid"), - `${process.pid}\n`, - ); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached", - }), - }, - bootstrapDatabase: () => Effect.void, - }); - const result = yield* runtime - .preflight({ - stackId, - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(error).toBeInstanceOf(StackPreparationError); - expect(error?.message).toContain("live process"); - } - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("redacts logs using secret slots materialized after factory creation", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-logs-" }); - const current = { value: stateFor({}) }; - const reads = { value: 0 }; - const entries: StackLogEntry[] = []; - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const completeSecrets = Object.fromEntries( - compiled.secrets.map((entry) => [ - entry.slot, - { policy: entry.policy, value: "placeholder" }, - ]), - ); - const candidate = { - ...stateFor({ - ...completeSecrets, - "secret:auth.settings.jwt_secret": { - policy: "managed" as const, - value: "rotated-secret", - }, - }), - desiredLifecycle: "running" as const, - definition: compiled.definition, - } satisfies PersistedStackState; - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current, () => reads.value++), - context, - ingress, - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - }), - }, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore(entries), - bootstrapDatabase: () => Effect.void, - }); - yield* runtime.preflight({ - stackId, - state: candidate, - definition: compiled.definition, - secrets: candidate.secrets, - plan: compiled.executionPlan, - }); - const logStore = runtime.logStore; - expect(logStore).toBeDefined(); - if (logStore === undefined) return; - yield* logStore.append({ - source: "auth", - stream: "stdout", - message: "token=rotated-secret", - }); - yield* logStore.append({ - source: "auth", - stream: "stdout", - message: "token=rotated-secret again", - }); - expect(entries[0]?.message).toBe("token=[REDACTED]"); - expect(entries[1]?.message).toBe("token=[REDACTED] again"); - expect(reads.value).toBe(1); - }).pipe(Effect.provide(NodeServices.layer)), - ), + const driver = withOwnedRuntimeFileCleanup(noOpDriver(), files, bootstrap); + yield* driver.cleanup({ stackId, destroy: false }); + expect(events).toEqual(["env", "bootstrap"]); + }), ); - const makeOwnerMaterialFixture = () => + it.live("removes only the destroyed instance data and runtime roots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-inputs-" }); - const template = path.join(root, "templates", "confirmation.html"); - yield* fs.makeDirectory(path.dirname(template), { recursive: true }); - yield* fs.writeFileString(template, "confirmation"); - const gcpCredentials = path.join(root, "gcp.json"); - yield* fs.writeFileString(gcpCredentials, "{}"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - auth: { - settings: { - email: { - template: { confirmation: { content_path: "templates/confirmation.html" } }, - }, - }, - }, - pooler: { enabled: true }, - rest: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { - enabled: true, - settings: { - edge_runtime: { secrets: { FACTORY_SECRET: Redacted.make("factory-secret") } }, - }, - }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { - enabled: true, - settings: { backend: "bigquery", gcp_jwt_path: "gcp.json" }, - }, - }, - listeners: { - database: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const authServer = yield* listenForHttpFixture(() => - Effect.succeed(HttpServerResponse.text("ok", { headers: { Connection: "close" } })), - ); - const authPort = yield* httpFixturePort(authServer); - const functionsServer = yield* listenForHttpFixture(() => - Effect.succeed(HttpServerResponse.text("ok", { headers: { Connection: "close" } })), - ); - const functionsPort = yield* httpFixturePort(functionsServer); - const analyticsServer = yield* listenForHttpFixture(() => - Effect.succeed(HttpServerResponse.text("ok", { headers: { Connection: "close" } })), + const root = yield* fs.makeTempDirectoryScoped({ prefix: "production-instance-cleanup-" }); + const targetData = path.join(root, "data", "instances", "target"); + const targetRuntime = path.join(root, "runtime", "instances", "target"); + const siblingData = path.join(root, "data", "instances", "sibling"); + const siblingRuntime = path.join(root, "runtime", "instances", "sibling"); + const external = path.join(root, "external-mount"); + yield* Effect.forEach( + [targetData, targetRuntime, siblingData, siblingRuntime, external], + (directory) => fs.makeDirectory(directory, { recursive: true }), + { discard: true }, ); - const analyticsPort = yield* httpFixturePort(analyticsServer); - const poolerServer = yield* listenForHttpFixture(() => - Effect.succeed(HttpServerResponse.empty({ status: 204 })), - ); - const poolerPort = yield* httpFixturePort(poolerServer); - const current = { - value: { - ...stateFor( - { - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - "secret:auth.settings.publishable_key": { - policy: "managed", - value: "sb_publishable_test", - }, - "secret:auth.settings.secret_key": { - policy: "managed", - value: "sb_secret_test", - }, - "secret:functions.settings.edge_runtime.secrets.FACTORY_SECRET": { - policy: "managed", - value: "factory-secret", - }, - }, - { kind: "container", engine: "docker" }, - ), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - ports: [{ field: "api" as const, port: 40_000, intent: "exact" as const }], - privatePorts: [ - { - workloadId: "auth:auth", - binding: "primary", - port: authPort, - }, - { - workloadId: "pooler:pooler", - binding: "primary", - port: poolerPort + 1, - }, - { - workloadId: "pooler:pooler", - binding: "admin", - port: poolerPort, - }, - { - workloadId: "functions:edge-runtime", - binding: "primary", - port: functionsPort, - }, - { - workloadId: "functions:edge-runtime", - binding: "inspector", - port: functionsPort + 1, - }, - { - workloadId: "analytics:analytics", - binding: "primary", - port: analyticsPort, - }, - ], - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const createdSpecs: ContainerContainerSpec[] = []; - const copiedFiles: Array> = []; - const engine = ownerInputContainerEngine(createdSpecs, copiedFiles); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: engine, - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - return { fs, runtime, createdSpecs, copiedFiles, compiled }; - }).pipe(Effect.provide(NodeServices.layer)); - - it.live("writes Auth owner material and confirmation template settings", () => - Effect.scoped( - Effect.gen(function* () { - const { fs, runtime, createdSpecs, compiled } = yield* makeOwnerMaterialFixture(); - const auth = compiled.executionPlan.workloads.find( - (workload) => workload.id === "auth:auth", - ); - if (auth === undefined) return yield* Effect.die("Expected Auth workload"); - - yield* runtime.driver.start({ stackId, workloadId: auth.id }, auth); - const authSpec = createdSpecs.find((spec) => spec.labels.workloadId === auth.id); - if (authSpec?.envFile === undefined) - return yield* Effect.die("Auth container was not captured"); - const authEnvironment = yield* fs.readFileString(authSpec.envFile); - expect(authEnvironment).toContain("GOTRUE_JWT_SECRET=jwt-secret"); - expect(authEnvironment).toContain( - "GOTRUE_MAILER_TEMPLATES_CONFIRMATION=http://host.docker.internal:40000/email/confirmation.html", - ); - }), - ), - ); - - it.live("writes Functions owner material and bootstrap source", () => - Effect.scoped( - Effect.gen(function* () { - const { fs, runtime, createdSpecs, copiedFiles, compiled } = - yield* makeOwnerMaterialFixture(); - const functions = compiled.executionPlan.workloads.find( - (workload) => workload.id === "functions:edge-runtime", - ); - if (functions === undefined) return yield* Effect.die("Expected Functions workload"); - - yield* runtime.driver.start({ stackId, workloadId: functions.id }, functions); - const functionsSpec = createdSpecs.find((spec) => spec.labels.workloadId === functions.id); - if (functionsSpec?.envFile === undefined) - return yield* Effect.die("Functions container was not captured"); - const firstBootstrap = copiedFiles.at(-1); - if (firstBootstrap === undefined) - return yield* Effect.die("Functions bootstrap was not captured"); - const functionsEnvironment = yield* fs.readFileString(functionsSpec.envFile); - - expect(functionsEnvironment).toContain("FACTORY_SECRET=factory-secret"); - expect(yield* fs.exists(firstBootstrap.source)).toBe(true); - }), - ), - ); - - it.live("mounts the Analytics credentials file for the owner", () => - Effect.scoped( - Effect.gen(function* () { - const { runtime, createdSpecs, compiled } = yield* makeOwnerMaterialFixture(); - const analytics = compiled.executionPlan.workloads.find( - (workload) => workload.id === "analytics:analytics", - ); - if (analytics === undefined) return yield* Effect.die("Expected Analytics workload"); + yield* removeOwnedInstancePaths(fs, { data: targetData, runtime: targetRuntime }); - yield* runtime.driver.start({ stackId, workloadId: analytics.id }, analytics); - const analyticsSpec = createdSpecs.find((spec) => spec.labels.workloadId === analytics.id); - - expect(analyticsSpec?.mounts).toContainEqual({ - source: expect.stringContaining("gcp.json"), - target: "/opt/app/rel/logflare/bin/gcloud.json", - readOnly: true, - }); - }), - ), - ); - - it.live("starts Pooler with its owner startup entrypoints", () => - Effect.scoped( - Effect.gen(function* () { - const { runtime, createdSpecs, compiled } = yield* makeOwnerMaterialFixture(); - const pooler = compiled.executionPlan.workloads.find( - (workload) => workload.id === "pooler:pooler", - ); - if (pooler === undefined) return yield* Effect.die("Expected Pooler workload"); - - yield* runtime.driver.start({ stackId, workloadId: pooler.id }, pooler); - const poolerSpec = createdSpecs.find( - (spec) => spec.labels.workloadId === pooler.id && spec.labels.startup !== true, - ); - const poolerStartupSpecs = createdSpecs.filter( - (spec) => spec.labels.workloadId === pooler.id && spec.labels.startup === true, - ); - - expect(poolerSpec?.mounts).toEqual([]); - expect(poolerStartupSpecs.map((spec) => spec.entrypoint)).toEqual([ - "/app/bin/prepare", - "/app/bin/provision-tenant", - ]); - }), - ), - ); - - it.live("removes and recreates the Functions bootstrap across container cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const { fs, runtime, copiedFiles, compiled } = yield* makeOwnerMaterialFixture(); - const functions = compiled.executionPlan.workloads.find( - (workload) => workload.id === "functions:edge-runtime", - ); - if (functions === undefined) return yield* Effect.die("Expected Functions workload"); - - yield* runtime.driver.start({ stackId, workloadId: functions.id }, functions); - const firstBootstrap = copiedFiles.at(-1); - if (firstBootstrap === undefined) - return yield* Effect.die("Functions bootstrap was not captured"); - expect(yield* fs.exists(firstBootstrap.source)).toBe(true); - - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* fs.exists(firstBootstrap.source)).toBe(false); - - yield* runtime.driver.start({ stackId, workloadId: functions.id }, functions); - const restartedBootstrap = copiedFiles.at(-1); - if (restartedBootstrap === undefined || restartedBootstrap === firstBootstrap) - return yield* Effect.die("Restarted Functions bootstrap was not captured"); - expect(yield* fs.exists(restartedBootstrap.source)).toBe(true); - }), - ), - ); - - it.live("scopes Auth template URL requirements to the Auth workload", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-auth-scope-", - }); - const template = path.join(root, "confirmation.html"); - yield* fs.writeFileString(template, "confirmation"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - auth: { - settings: { - email: { template: { confirmation: { content_path: "confirmation.html" } } }, - }, - }, - rest: { enabled: true }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - listeners: { - api: { enabled: false }, - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const restServer = yield* listenForHttpFixture(() => - Effect.succeed(HttpServerResponse.text("ok", { headers: { Connection: "close" } })), - ); - const port = yield* httpFixturePort(restServer); - const current = { - value: { - ...stateFor( - { - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }, - { kind: "container", engine: "docker" }, - ), - identity: { - ...stateFor({}).identity, - projectRoot: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "rest:rest", binding: "primary", port }, - { workloadId: "rest:rest", binding: "admin", port: port + 1 }, - { workloadId: "auth:auth", binding: "primary", port: port + 2 }, - ], - }, - } satisfies { value: PersistedStackState }; - const context = yield* Effect.context(); - const createdSpecs: ContainerContainerSpec[] = []; - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: ownerInputContainerEngine(createdSpecs), - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "cached" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - const rest = compiled.executionPlan.workloads.find( - (workload) => workload.id === "rest:rest", - ); - const auth = compiled.executionPlan.workloads.find( - (workload) => workload.id === "auth:auth", - ); - if (rest === undefined || auth === undefined) - return yield* Effect.die("Expected REST and Auth workloads"); - yield* runtime.driver.start({ stackId, workloadId: rest.id }, rest); - expect(createdSpecs.some((spec) => spec.labels.workloadId === rest.id)).toBe(true); - const authStart = yield* runtime.driver - .start({ stackId, workloadId: auth.id }, auth) - .pipe(Effect.exit); - expect(Exit.isFailure(authStart)).toBe(true); - expect(createdSpecs.some((spec) => spec.labels.workloadId === auth.id)).toBe(false); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("cleans owner env and Functions files only during stack cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-cleanup-" }); - const current = { value: stateFor({}) }; - const envOwner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); - const functionsOwner = yield* makeFunctionsBootstrapOwner({ - stateRoot: root, - stackId, - }); - const envPath = yield* envOwner.write({ - workloadId: "database:database", - values: { TOKEN: "stale" }, - }); - const bootstrapPath = yield* functionsOwner.write({ - content: "export default 1", - }); - const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - artifactPreparer: artifacts, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - yield* runtime.driver.stop({ - stackId, - workloadId: "database:database", - }); - expect(yield* fs.exists(envPath)).toBe(true); - expect(yield* fs.exists(bootstrapPath)).toBe(true); - yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* fs.exists(envPath)).toBe(false); - expect(yield* fs.exists(bootstrapPath)).toBe(false); - }).pipe(Effect.provide(NodeServices.layer)), - ), + expect(yield* fs.exists(targetData)).toBe(false); + expect(yield* fs.exists(targetRuntime)).toBe(false); + expect(yield* fs.exists(siblingData)).toBe(true); + expect(yield* fs.exists(siblingRuntime)).toBe(true); + expect(yield* fs.exists(external)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), ); - - it("attempts both owner cleanups when runtime cleanup fails", () => { - const calls: string[] = []; - const runtimeFailure = new RuntimeDriverError({ - message: "runtime cleanup failed", - stackId, - }); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: () => Effect.die("unused"), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.fail(runtimeFailure), - wipePersistentData: () => Effect.void, - }; - const envOwner: RuntimeEnvFileOwner = { - write: () => Effect.die("unused"), - cleanupAll: Effect.sync(() => { - calls.push("env"); - }), - }; - const functionsOwner: FunctionsBootstrapOwner = { - write: () => Effect.die("unused"), - cleanupAll: Effect.sync(() => { - calls.push("functions"); - }), - }; - const wrapped = withOwnedRuntimeFileCleanup(driver, envOwner, functionsOwner); - const result = Effect.runSyncExit(wrapped.cleanup({ stackId, destroy: false })); - expect(Exit.isFailure(result)).toBe(true); - expect(calls.sort()).toEqual(["env", "functions"]); - }); - - it("continues owner cleanup when one file owner fails", () => { - const calls: string[] = []; - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: () => Effect.die("unused"), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const envOwner: RuntimeEnvFileOwner = { - write: () => Effect.die("unused"), - cleanupAll: Effect.gen(function* () { - calls.push("env"); - return yield* new StackPreparationError({ message: "env cleanup failed" }); - }), - }; - const functionsOwner: FunctionsBootstrapOwner = { - write: () => Effect.die("unused"), - cleanupAll: Effect.sync(() => { - calls.push("functions"); - }), - }; - const wrapped = withOwnedRuntimeFileCleanup(driver, envOwner, functionsOwner); - const result = Effect.runSyncExit(wrapped.cleanup({ stackId, destroy: false })); - expect(Exit.isFailure(result)).toBe(true); - expect(calls).toEqual(["env", "functions"]); - }); }); diff --git a/packages/stack/src/runtime/runtime-env-file.integration.test.ts b/packages/stack/src/runtime/runtime-env-file.integration.test.ts index ef200b1903..fd95fb99d8 100644 --- a/packages/stack/src/runtime/runtime-env-file.integration.test.ts +++ b/packages/stack/src/runtime/runtime-env-file.integration.test.ts @@ -2,9 +2,11 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Path } from "effect"; import { StackIdSchema } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import { makeRuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; const stackId = StackIdSchema.make("e".repeat(64)); +const instanceId = ServiceInstanceIdSchema.make("primary"); const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -24,6 +26,7 @@ describe("runtime environment file owner", () => { const path = yield* Path.Path; const { fs, owner } = yield* setupEnvOwner("stack-env-"); const first = yield* owner.write({ + instanceId, workloadId: "database:database", values: { Z_LAST: "two", A_FIRST: "one" }, }); @@ -39,10 +42,12 @@ describe("runtime environment file owner", () => { Effect.gen(function* () { const { fs, owner } = yield* setupEnvOwner("stack-env-replace-"); const first = yield* owner.write({ + instanceId, workloadId: "database:database", values: { A_FIRST: "one" }, }); const second = yield* owner.write({ + instanceId, workloadId: "database:database", values: { A_FIRST: "updated" }, }); @@ -59,6 +64,7 @@ describe("runtime environment file owner", () => { const secret = "very-secret-value"; const invalid = yield* owner .write({ + instanceId, workloadId: "database:database", values: { VALID: `${secret}\nINJECTED` }, }) @@ -75,7 +81,7 @@ describe("runtime environment file owner", () => { Effect.gen(function* () { const { owner } = yield* setupEnvOwner("stack-env-invalid-name-"); const invalid = yield* owner - .write({ workloadId: "database:database", values: { "BAD-NAME": "ok" } }) + .write({ instanceId, workloadId: "database:database", values: { "BAD-NAME": "ok" } }) .pipe(Effect.exit); expect(Exit.isFailure(invalid)).toBe(true); }), @@ -87,7 +93,7 @@ describe("runtime environment file owner", () => { Effect.gen(function* () { const { owner } = yield* setupEnvOwner("stack-env-invalid-workload-"); const invalid = yield* owner - .write({ workloadId: "../escape", values: { SAFE: "ok" } }) + .write({ instanceId, workloadId: "../escape", values: { SAFE: "ok" } }) .pipe(Effect.exit); expect(Exit.isFailure(invalid)).toBe(true); }), @@ -100,6 +106,7 @@ describe("runtime environment file owner", () => { const path = yield* Path.Path; const { fs, root, owner } = yield* setupEnvOwner("stack-env-cleanup-"); const file = yield* owner.write({ + instanceId, workloadId: "rest:rest", values: { X: "y" }, }); @@ -115,15 +122,49 @@ describe("runtime environment file owner", () => { ), ); + it.live("cleans one exact workload file", () => + withPlatform( + Effect.gen(function* () { + const { fs, owner } = yield* setupEnvOwner("stack-env-file-cleanup-"); + const first = yield* owner.write({ + instanceId, + workloadId: "rest:rest:init-operation", + values: { X: "first" }, + }); + const second = yield* owner.write({ + instanceId, + workloadId: "auth:auth:init-operation", + values: { X: "second" }, + }); + + yield* owner.cleanupFile({ + instanceId, + workloadId: "rest:rest:init-operation", + }); + + expect(yield* fs.exists(first)).toBe(false); + expect(yield* fs.exists(second)).toBe(true); + }), + ), + ); + it.live("recreates a workload file after cleanup", () => withPlatform( Effect.gen(function* () { const { fs, owner } = yield* setupEnvOwner("stack-env-recreate-"); - const original = yield* owner.write({ workloadId: "rest:rest", values: { X: "y" } }); + const original = yield* owner.write({ + instanceId, + workloadId: "rest:rest", + values: { X: "y" }, + }); expect(yield* fs.exists(original)).toBe(true); yield* owner.cleanupAll; expect(yield* fs.exists(original)).toBe(false); - const recreated = yield* owner.write({ workloadId: "rest:rest", values: { X: "z" } }); + const recreated = yield* owner.write({ + instanceId, + workloadId: "rest:rest", + values: { X: "z" }, + }); expect(yield* fs.readFileString(recreated)).toBe("X=z\n"); }), ), @@ -135,8 +176,8 @@ describe("runtime environment file owner", () => { const { fs, owner } = yield* setupEnvOwner("stack-env-concurrent-"); const files = yield* Effect.all( [ - owner.write({ workloadId: "rest:rest", values: { A: "one" } }), - owner.write({ workloadId: "auth:auth", values: { B: "two" } }), + owner.write({ instanceId, workloadId: "rest:rest", values: { A: "one" } }), + owner.write({ instanceId, workloadId: "auth:auth", values: { B: "two" } }), ], { concurrency: "unbounded" }, ); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index d4988e945b..93dcf4b7d0 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -1,819 +1,468 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted, Schema } from "effect"; -import { generateKeyPairSync } from "node:crypto"; -import { compileStack, type CompiledStack } from "../model/Compiler.ts"; -import { StackPreparationError } from "../public/Errors.ts"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option, Path, Schema } from "effect"; +import { AuthModule } from "../model/capabilities/auth.ts"; import { StackIdSchema } from "../public/StackId.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import { StackPreparationError } from "../public/Errors.ts"; +import { PersistedServiceInstanceSchema } from "../model/ServiceRegistry.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { resolveSecrets } from "../state/SecretStore.ts"; import { makeRuntimeInputOwner } from "./RuntimeInputOwner.ts"; -import { resolveContainerResolutionFor } from "./WorkloadRuntimeSpec.ts"; const stackId = StackIdSchema.make("f".repeat(64)); -const jsonSchema = Schema.fromJsonString(Schema.Unknown); - -const encodeJson = (value: unknown): string => Schema.encodeSync(jsonSchema)(value); -const decodeJson = (value: string): unknown => Schema.decodeSync(jsonSchema)(value); -const decodeJwks = (value: string): { readonly keys: ReadonlyArray } => - Schema.decodeUnknownSync(Schema.Struct({ keys: Schema.Array(Schema.Unknown) }))( - decodeJson(value), - ); - -const withPlatform = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -const identityFor = (projectRoot: string): PersistedStackState["identity"] => ({ - projectRoot, - branchContext: "ordinary-workspace", - stackName: "runtime-input-owner", -}); -const stateFor = ( - root: string, - compiled: CompiledStack, - secrets: PersistedStackState["secrets"], - runtime: StackRuntime = { kind: "native" }, -): PersistedStackState => ({ - format: "supabase-stack-state-v1", - identity: identityFor(root), - runtime, - desiredLifecycle: "stopped", - definition: compiled.definition, +const stateFor = (root: string): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity: { projectRoot: root, branchContext: "test", stackName: "runtime-input-owner" }, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "secret:auth.jwt" } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, ports: [], privatePorts: [], - secrets, + secrets: { "secret:auth.jwt": { policy: "managed", value: "auth-jwt" } }, }); -const compiledState = (root: string, config: Parameters[0]["config"] = {}) => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config, - }); - const resolved = yield* resolveSecrets( - { declarations: compiled.secrets }, - undefined, - "stopped", - ); - return stateFor(root, compiled, resolved.persisted); - }); - -const vectorFixture = () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-vector-" }); - const base = yield* compiledState(root, { - capabilities: { analytics: { settings: {} } }, - }); - const state: PersistedStackState = { - ...base, - privatePorts: [{ workloadId: "analytics:vector", binding: "primary", port: 30_008 }], - }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const resolveConfigPath = () => - Effect.gen(function* () { - const material = yield* owner.resolve(state, "analytics:vector"); - const configPath = material.analytics?.vectorConfigPath; - if (configPath === undefined) return yield* Effect.die("Vector config path is missing"); - return configPath; - }); - return { fs, owner, resolveConfigPath }; - }); - const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; -describe("runtime input owner", () => { - it.live("resolves contained regular files and rejects escapes, symlinks, and directories", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-files-" }); - const outside = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-outside-" }); - yield* fs.makeDirectory(path.join(root, "nested"), { recursive: true }); - yield* fs.writeFileString(path.join(root, "nested", "config.json"), "{}"); - yield* fs.writeFileString(path.join(outside, "secret.json"), "secret"); - yield* fs.symlink(path.join(outside, "secret.json"), path.join(root, "linked.json")); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const state = yield* compiledState(root); - const canonicalRoot = yield* fs.realPath(root); - expect(yield* owner.resolveProjectFile(state, "nested/config.json")).toBe( - path.join(canonicalRoot, "nested", "config.json"), - ); - for (const configured of ["/etc/passwd", "../outside.json", "linked.json"] as const) { - const failed = yield* owner.resolveProjectFile(state, configured).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - } - const directory = yield* owner.resolveProjectFile(state, "nested").pipe(Effect.exit); - expect(Exit.isFailure(directory)).toBe(true); - }), - ), - ); - - it.live("returns all private local keys and a public-only JWKS", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-jwks-" }); - const first = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ - format: "jwk", - }); - const second = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ - format: "jwk", - }); - yield* fs.writeFileString( - path.join(root, "keys.json"), - encodeJson([ - { ...first, alg: "ES256", kid: "ec-key" }, - { ...second, alg: "RS256", kid: "rsa-key" }, - ]), - ); - const base = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { - workos: { enabled: true, issuer_url: "https://issuer.example" }, - }, - }, - }, - }, - }); - if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); - const state: PersistedStackState = { - ...base, - definition: { - ...base.definition, - security: { - ...base.definition.security, - jwt: { - ...base.definition.security.jwt, - signing: { kind: "jwks-file", path: "keys.json" }, - }, - }, - }, - }; - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => - Effect.succeed( - url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, - ), - }); - const material = yield* owner.resolve(state, "auth:auth"); - expect(decodeJson(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); - const jwks = decodeJwks(material.auth?.jwks ?? '{"keys":[]}'); - expect(jwks.keys).toHaveLength(3); - expect( - jwks.keys.every( - (key) => typeof key === "object" && key !== null && !Object.hasOwn(key, "d"), - ), - ).toBe(true); - }), - ), - ); - - it.live("rejects a JWKS file when any configured key is invalid", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-jwks-invalid-" }); - const valid = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ - format: "jwk", - }); - yield* fs.writeFileString( - path.join(root, "keys.json"), - encodeJson([ - { ...valid, alg: "ES256" }, - { kty: "EC", alg: "ES256", d: "bad" }, - ]), - ); - const base = yield* compiledState(root); - if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); - const state: PersistedStackState = { - ...base, - definition: { - ...base.definition, - security: { - ...base.definition.security, - jwt: { - ...base.definition.security.jwt, - signing: { kind: "jwks-file", path: "keys.json" }, - }, - }, - }, - }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - }), - ), - ); - - it.live("merges injected third-party keys with the canonical symmetric key", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-oidc-" }); - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { firebase: { enabled: true, project_id: "demo" } }, - }, - }, - }, - }); - const requested: string[] = []; - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => - Effect.sync(() => { - requested.push(url); - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }), - }); - const material = yield* owner.resolve(state, "auth:auth"); - expect(requested).toEqual([ - "https://securetoken.google.com/demo/.well-known/openid-configuration", - "https://issuer.example/keys", - ]); - expect(decodeJwks(material.auth?.jwks ?? '{"keys":[]}').keys).toHaveLength(2); - }), - ), - ); - - it.live("publishes the persisted symmetric JWT secret as an oct JWK", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-symmetric-", - }); - const state = yield* compiledState(root, { - capabilities: { - auth: { settings: { jwt_secret: Redacted.make("symmetric-secret") } }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const jwks = decodeJwks( - (yield* owner.resolve(state, "auth:auth")).auth?.jwks ?? '{"keys":[]}', - ); - expect(jwks.keys).toEqual([ - { - kty: "oct", - alg: "HS256", - use: "sig", - key_ops: ["verify"], - k: "c3ltbWV0cmljLXNlY3JldA", - }, - ]); - }), - ), - ); - - it.live("skips JWT file and OIDC resolution when every JWT consumer is disabled", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-no-jwt-" }); - const base = yield* compiledState(root); - if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); - const definition = base.definition; - const state: PersistedStackState = { - ...base, - definition: { - ...definition, - capabilities: { - ...definition.capabilities, - rest: { ...definition.capabilities.rest, enabled: false }, - auth: { ...definition.capabilities.auth, enabled: false }, - realtime: { ...definition.capabilities.realtime, enabled: false }, - storage: { ...definition.capabilities.storage, enabled: false }, - functions: { ...definition.capabilities.functions, enabled: false }, - }, - security: { - ...definition.security, - jwt: { - ...definition.security.jwt, - signing: { kind: "jwks-file", path: "missing.json" }, - }, - }, - }, - }; - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: () => Effect.die("OIDC fetch should not run"), - }); - const material = yield* owner.resolve(state, "auth:auth"); - expect(material.auth).toBeUndefined(); - }), - ), - ); - - it.live("resolves only material needed by the requested workload", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-workload-scope-", - }); - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { - workos: { enabled: true, issuer_url: "https://issuer.example" }, - }, - }, - }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: () => Effect.fail(new StackPreparationError({ message: "OIDC unavailable" })), - }); - const database = yield* owner.resolve(state, "database:database"); - expect(database.auth).toBeUndefined(); - const auth = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); - expect(Exit.isFailure(auth)).toBe(true); - }), - ), - ); +const authInstance = (id: string, settings: unknown): PersistedServiceInstance => + Schema.decodeUnknownSync(PersistedServiceInstanceSchema)({ + id: ServiceInstanceIdSchema.make(id), + service: "auth", + intent: "stopped", + dependencies: { database: ServiceInstanceIdSchema.make("database") }, + resources: {}, + revisions: { config: 0, intent: 0 }, + pendingOperation: null, + initialization: null, + initializationInputs: null, + data: { origin: "absent" }, + config: { + enabled: true, + activation: "eager", + idleTimeoutSeconds: false, + version: "test", + settings, + endpoints: {}, + }, + }); - it.live("creates the configured Functions root only for mounted workloads", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-functions-root-" }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - functions: { enabled: true }, - studio: { enabled: true }, - }, - }, - }); - const resolved = yield* resolveSecrets( - { declarations: compiled.secrets }, - undefined, - "stopped", - ); - const state = stateFor(root, compiled, resolved.persisted, { - kind: "container", - engine: "docker", - }); - const withPrivatePorts: PersistedStackState = { - ...state, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: 30_001 }, - { workloadId: "functions:edge-runtime", binding: "primary", port: 30_002 }, - { workloadId: "functions:edge-runtime", binding: "inspector", port: 30_004 }, - { workloadId: "studio:studio", binding: "primary", port: 30_003 }, - ], - }; - const functionsRoot = compiled.definition?.capabilities.functions.settings.functions_root; - if (functionsRoot === undefined || functionsRoot === null) - return yield* Effect.die("Compiled Functions root is missing"); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - yield* owner.resolve(withPrivatePorts, "database:database"); - expect(yield* fs.exists(functionsRoot)).toBe(false); +const authSettings = (issuer: string, templatePath?: string) => + nullify({ + ...AuthModule.defaultSettings, + email: { + ...AuthModule.defaultSettings.email, + template: + templatePath === undefined + ? {} + : { confirm: { subject: "Confirm", content_path: templatePath } }, + }, + third_party: { + ...AuthModule.defaultSettings.third_party, + workos: { enabled: true, issuer_url: issuer }, + }, + }); - const functionsMaterial = yield* owner.resolve(withPrivatePorts, "functions:edge-runtime"); - const functions = compiled.executionPlan.workloads.find( - ({ id }) => id === "functions:edge-runtime", - ); - if (functions === undefined) return yield* Effect.die("Functions workload is missing"); - const functionsResolution = yield* resolveContainerResolutionFor( - withPrivatePorts, - functions, - functionsMaterial, - ); - expect(yield* fs.exists(functionsRoot)).toBe(true); - expect(functionsResolution?.mounts[0]?.source).toBe(functionsRoot); +const serviceInstance = ( + id: string, + service: "functions" | "studio", + enabled: boolean, + settings: unknown, +): PersistedServiceInstance => + Schema.decodeUnknownSync(PersistedServiceInstanceSchema)({ + id: ServiceInstanceIdSchema.make(id), + service, + intent: "stopped", + dependencies: + service === "studio" + ? { + database: ServiceInstanceIdSchema.make("database-default"), + rest: ServiceInstanceIdSchema.make("rest-default"), + analytics: ServiceInstanceIdSchema.make("analytics-default"), + } + : {}, + resources: {}, + revisions: { config: 0, intent: 0 }, + pendingOperation: null, + initialization: null, + initializationInputs: null, + data: { origin: "absent" }, + config: { + enabled, + activation: "lazy", + idleTimeoutSeconds: false, + version: "test", + settings, + endpoints: {}, + }, + }); - yield* fs.remove(functionsRoot, { recursive: true, force: true }); - expect(yield* fs.exists(functionsRoot)).toBe(false); - const studioMaterial = yield* owner.resolve(withPrivatePorts, "studio:studio"); - const studio = compiled.executionPlan.workloads.find(({ id }) => id === "studio:studio"); - if (studio === undefined) return yield* Effect.die("Studio workload is missing"); - const studioResolution = yield* resolveContainerResolutionFor( - withPrivatePorts, - studio, - studioMaterial, - ); - expect(yield* fs.exists(functionsRoot)).toBe(true); - expect(studioResolution?.mounts[0]?.source).toBe(functionsRoot); - }), - ), - ); +const nullify = (value: unknown): unknown => { + if (value === undefined) return null; + if (Array.isArray(value)) return value.map(nullify); + if (typeof value === "object" && value !== null) + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, nullify(entry)])); + return value; +}; + +const analyticsBackend = "postgres" as const; +const analyticsSettings = { + backend: analyticsBackend, + gcp_project_id: "local", + gcp_project_number: "0", + gcp_jwt_path: "", + api_key: { slot: "secret:analytics.api_key" }, +}; - it.live("resolves JWT material when Auth is disabled but Rest remains enabled", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-rest-jwt-", - }); - const base = yield* compiledState(root); - if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); - const state: PersistedStackState = { - ...base, - definition: { - ...base.definition, - capabilities: { - ...base.definition.capabilities, - auth: { ...base.definition.capabilities.auth, enabled: false }, - }, - }, - }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, "rest:rest"); - expect(material.auth?.jwks).toContain('"kty":"oct"'); - }), - ), +describe("runtime input owner", () => { + it.live("resolves a contained regular file and rejects escapes, symlinks, and directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-files-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-outside-" }); + yield* fs.makeDirectory(path.join(root, "nested"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "nested", "config.json"), "{}"); + yield* fs.writeFileString(path.join(outside, "secret.json"), "secret"); + yield* fs.symlink(path.join(outside, "secret.json"), path.join(root, "linked.json")); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const state = stateFor(root); + const canonicalRoot = yield* fs.realPath(root); + expect(yield* owner.resolveProjectFile(state, "nested/config.json")).toBe( + path.join(canonicalRoot, "nested", "config.json"), + ); + for (const configured of ["/etc/passwd", "../outside.json", "linked.json"] as const) { + const result = yield* owner.resolveProjectFile(state, configured).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + } + const directory = yield* owner.resolveProjectFile(state, "nested").pipe(Effect.exit); + expect(Exit.isFailure(directory)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("fails when REST needs signing keys from a missing JWKS file", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-rest-jwt-missing-", - }); - const base = yield* compiledState(root, { - capabilities: { - auth: { enabled: false }, - rest: { enabled: true }, - }, - security: { jwt: { signing: { kind: "jwks-file", path: "missing.json" } } }, - }); - if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); - expect(base.definition.capabilities.auth.enabled).toBe(false); - expect(base.definition.capabilities.rest.enabled).toBe(true); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(base, "rest:rest").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(errorOf(failed)).toBeInstanceOf(StackPreparationError); - expect(errorOf(failed)?.message).toContain("Unable to resolve Auth signing keys"); - }), - ), + it.live("rejects a missing configured project file with a typed preparation error", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-missing-" }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const result = yield* owner + .resolveProjectFile(stateFor(root), "missing.json") + .pipe(Effect.exit); + const error = errorOf(result); + expect(error).toMatchObject({ _tag: "StackPreparationError" }); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("sanitizes OIDC URL labels in transport failures", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-oidc-secret-", - }); - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { - workos: { - enabled: true, - issuer_url: "https://issuer.example/tenant?token=secret-token#fragment", + it.live("resolves Functions secrets for instance-qualified workloads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-functions-" }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const base = stateFor(root); + const instanceId = ServiceInstanceIdSchema.make("functions-extra"); + const state: PersistedStackState = { + ...base, + registry: { + initialized: true, + defaultInstanceIds: { functions: instanceId }, + instances: [ + { + id: instanceId, + service: "functions", + intent: "stopped", + dependencies: {}, + resources: {}, + revisions: { config: 0, intent: 0 }, + pendingOperation: null, + initialization: null, + initializationInputs: null, + data: { origin: "absent" }, + config: { + enabled: true, + activation: "eager", + idleTimeoutSeconds: false, + version: "test", + settings: { + functions_root: root, + edge_runtime: { + policy: null, + deno_version: null, + verify_jwt_default: null, + import_map_default: null, + secrets: { CUSTOM_TOKEN: { slot: "secret:functions.token" } }, }, + inspector: null, + functions: null, }, + endpoints: {}, }, }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: () => Effect.fail(new StackPreparationError({ message: "transport failure" })), - }); - const failed = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); - const error = errorOf(failed); - expect(error?.message).toContain("OIDC discovery request failed"); - expect(encodeJson(error)).not.toContain("secret-token"); - expect(encodeJson(error)).not.toContain("fragment"); - }), - ), - ); - - it.live("fails closed on malformed or empty third-party OIDC responses", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-oidc-invalid-" }); - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => - Effect.succeed( - url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [] }, - ), - }); - const failed = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); - expect(errorOf(failed)?.message).toContain("contains no keys"); - }), - ), - ); - - it.live("provides readable Vector config retaining runtime environment placeholders", () => - withPlatform( - Effect.gen(function* () { - const { fs, resolveConfigPath } = yield* vectorFixture(); - - const configPath = yield* resolveConfigPath(); - - const configText = yield* fs.readFileString(configPath); - const config = yield* Effect.try(() => Bun.YAML.parse(configText)); - expect(config).toMatchObject({ - api: { address: "${VECTOR_API_ADDRESS}" }, - sinks: { - analytics: { - uri: "${LOGFLARE_URL}/logs?source_name=postgres.logs", - request: { headers: { "x-api-key": "${LOGFLARE_PRIVATE_ACCESS_TOKEN}" } }, - }, - }, - }); - }), - ), + ], + }, + secrets: { + "secret:functions.token": { policy: "managed", value: "token-value" }, + }, + }; + const material = yield* owner.resolve(state, instanceId, `${instanceId}:edge-runtime`); + expect(material.functions?.secrets).toEqual({ CUSTOM_TOKEN: "token-value" }); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("removes the returned Vector config file on cleanup", () => - withPlatform( - Effect.gen(function* () { - const { fs, owner, resolveConfigPath } = yield* vectorFixture(); - const configPath = yield* resolveConfigPath(); - - expect(yield* fs.exists(configPath)).toBe(true); - - yield* owner.cleanupAll; - - expect(yield* fs.exists(configPath)).toBe(false); - }), - ), + it.live("uses the default Functions root for Studio without changing instance roots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-studio-" }); + const defaultFunctionsRoot = pathJoin(root, "default-functions"); + const extraFunctionsRoot = pathJoin(root, "extra-functions"); + const studioRoot = pathJoin(root, "studio"); + const functionsSettings = (functionsRoot: string) => ({ + functions_root: functionsRoot, + edge_runtime: { + policy: null, + deno_version: null, + verify_jwt_default: null, + import_map_default: null, + secrets: {}, + }, + inspector: null, + functions: null, + }); + const defaultFunctions = serviceInstance( + "functions-default", + "functions", + true, + functionsSettings(defaultFunctionsRoot), + ); + const extraFunctions = serviceInstance( + "functions-extra", + "functions", + true, + functionsSettings(extraFunctionsRoot), + ); + const studio = serviceInstance("studio-default", "studio", true, { + api_url: "", + openai_api_key: null, + }); + const state: PersistedStackState = { + ...stateFor(root), + registry: { + initialized: true, + defaultInstanceIds: { functions: defaultFunctions.id, studio: studio.id }, + instances: [defaultFunctions, extraFunctions, studio], + }, + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + + yield* owner.resolve(state, studio.id, `${studio.id}:studio`); + expect(yield* fs.exists(defaultFunctionsRoot)).toBe(true); + expect(yield* fs.exists(studioRoot)).toBe(false); + + yield* owner.resolve(state, extraFunctions.id, `${extraFunctions.id}:edge-runtime`); + expect(yield* fs.exists(extraFunctionsRoot)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("recreates readable Vector config after cleanup", () => - withPlatform( - Effect.gen(function* () { - const { fs, owner, resolveConfigPath } = yield* vectorFixture(); - const firstConfigPath = yield* resolveConfigPath(); - - yield* owner.cleanupAll; - expect(yield* fs.exists(firstConfigPath)).toBe(false); - - const configPath = yield* resolveConfigPath(); - const config = yield* fs.readFileString(configPath); - - const parsed = yield* Effect.try(() => Bun.YAML.parse(config)); - expect(parsed).toMatchObject({ - api: { address: "${VECTOR_API_ADDRESS}" }, - sinks: { - analytics: { - uri: "${LOGFLARE_URL}/logs?source_name=postgres.logs", - }, - }, - }); - }), - ), + it.live("resolves Auth issuer and templates for the requested instance", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-auth-" }); + yield* fs.writeFileString(pathJoin(root, "one.html"), "one"); + yield* fs.writeFileString(pathJoin(root, "two.html"), "two"); + const first = authInstance("auth-one", authSettings("https://issuer.one.test", "one.html")); + const second = authInstance("auth-two", authSettings("https://issuer.two.test", "two.html")); + const base = stateFor(root); + const state: PersistedStackState = { + ...base, + registry: { + initialized: true, + defaultInstanceIds: { auth: first.id }, + instances: [first, second], + }, + }; + const requestedUrls: string[] = []; + const fetchJson = (url: string) => + Effect.sync(() => { + requestedUrls.push(url); + if (url.includes("issuer.one")) return { jwks_uri: "https://jwks.one.test/keys" }; + if (url.includes("issuer.two")) return { jwks_uri: "https://jwks.two.test/keys" }; + if (url.includes("jwks.one")) return { keys: [{ kid: "one" }] }; + return { keys: [{ kid: "two" }] }; + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId, fetchJson }); + const secondMaterial = yield* owner.resolve(state, second.id, `${second.id}:auth`); + const firstMaterial = yield* owner.resolve(state, first.id, `${first.id}:auth`); + const secondJwks = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + secondMaterial.auth?.jwks ?? "{}", + ); + const firstJwks = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + firstMaterial.auth?.jwks ?? "{}", + ); + expect(secondJwks).toMatchObject({ keys: expect.arrayContaining([{ kid: "two" }]) }); + expect(firstJwks).toMatchObject({ keys: expect.arrayContaining([{ kid: "one" }]) }); + expect(secondMaterial.auth?.templates?.[0]?.path).toBe("two.html"); + expect(firstMaterial.auth?.templates?.[0]?.path).toBe("one.html"); + expect(requestedUrls.filter((url) => url.includes("issuer.one"))).toHaveLength(1); + expect(requestedUrls.filter((url) => url.includes("issuer.two"))).toHaveLength(1); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("shares OIDC material across JWT-consuming workloads", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-auth-shared-", - }); - let fetches = 0; - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => { - fetches += 1; - return Effect.succeed( - url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, - ); - }, - }); - const restMaterial = yield* owner.resolve(state, "rest:rest"); - const cachedRestMaterial = yield* owner.resolve(state, "rest:rest"); - const realtimeMaterial = yield* owner.resolve(state, "realtime:realtime"); - expect(fetches).toBe(2); - expect(cachedRestMaterial.auth?.jwks).toBe(restMaterial.auth?.jwks); - expect(restMaterial.auth?.jwks).toBe(realtimeMaterial.auth?.jwks); - expect(restMaterial.analytics).toBeUndefined(); - expect(realtimeMaterial.analytics).toBeUndefined(); - const changedState = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://other.example" } }, - }, - }, - }, - }); - yield* owner.resolve(changedState, "rest:rest"); - expect(fetches).toBe(4); - }), - ), + it.live("keeps shared materialization alive when its creator waiter is interrupted", () => + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-singleflight-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const fetchJson = (url: string) => + Effect.gen(function* () { + calls += 1; + if (url.includes("issuer.singleflight")) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { jwks_uri: "https://jwks.singleflight.test/keys" } as unknown; + } + return { keys: [{ kid: "singleflight" }] } as unknown; + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId, fetchJson }); + const instance = authInstance( + "auth-singleflight", + authSettings("https://issuer.singleflight.test"), + ); + const base = stateFor(root); + const state: PersistedStackState = { + ...base, + registry: { + initialized: true, + defaultInstanceIds: { auth: instance.id }, + instances: [instance], + }, + }; + const creator = yield* Effect.forkChild( + owner.resolve(state, instance.id, `${instance.id}:auth`), + { startImmediately: true }, + ); + yield* Deferred.await(started); + const joiner = yield* Effect.forkChild( + owner.resolve(state, instance.id, `${instance.id}:auth`), + { startImmediately: true }, + ); + yield* Fiber.interrupt(creator); + yield* Deferred.succeed(release, undefined); + const joined = yield* Fiber.join(joiner); + expect(joined.auth?.jwks).toContain("singleflight"); + expect(calls).toBe(2); + const cached = yield* owner.resolve(state, instance.id, `${instance.id}:auth`); + expect(cached.auth?.jwks).toBe(joined.auth?.jwks); + expect(calls).toBe(2); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("resolves the configured Analytics service-account file without copying it", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-analytics-" }); - yield* fs.writeFileString(path.join(root, "gcp.json"), "{}"); - const state = yield* compiledState(root, { - capabilities: { analytics: { settings: { gcp_jwt_path: "gcp.json" } } }, - }); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, "analytics:analytics"); - expect(material.analytics?.gcpJwtPath).toBe( - path.join(yield* fs.realPath(root), "gcp.json"), + it.live("removes a synchronously failed materialization so the next resolve retries", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-retry-" }); + const instance = authInstance("auth-retry", authSettings("https://issuer.retry.test")); + const base = stateFor(root); + const state: PersistedStackState = { + ...base, + registry: { + initialized: true, + defaultInstanceIds: { auth: instance.id }, + instances: [instance], + }, + }; + let shouldFail = true; + const fetchJson = (url: string) => { + if (shouldFail) { + shouldFail = false; + return Effect.fail(new StackPreparationError({ message: "synthetic discovery failure" })); + } + return Effect.succeed( + url.includes("issuer.retry") + ? { jwks_uri: "https://jwks.retry.test/keys" } + : { keys: [{ kid: "retry" }] }, ); - }), - ), + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId, fetchJson }); + const first = yield* owner + .resolve(state, instance.id, `${instance.id}:auth`) + .pipe(Effect.exit); + expect(errorOf(first)).toMatchObject({ _tag: "StackPreparationError" }); + const retried = yield* owner.resolve(state, instance.id, `${instance.id}:auth`); + expect(retried.auth?.jwks).toContain("retry"); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("does not resolve disabled capability inputs", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-disabled-", - }); - const base = yield* compiledState(root); - if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); - const definition = base.definition; - const state: PersistedStackState = { - ...base, - definition: { - ...definition, - capabilities: { - ...definition.capabilities, - analytics: { - ...definition.capabilities.analytics, - enabled: false, - settings: { - ...definition.capabilities.analytics.settings, - gcp_jwt_path: "missing.json", - }, - }, - functions: { - ...definition.capabilities.functions, - enabled: false, - settings: { - ...definition.capabilities.functions.settings, - edge_runtime: { - policy: null, - deno_version: null, - verify_jwt_default: null, - import_map_default: null, - secrets: { SUPABASE_RESERVED: { slot: "missing" } }, - }, - }, + it.live("writes Vector config under the requested analytics instance", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-vector-" }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const base = stateFor(root); + const instanceId = ServiceInstanceIdSchema.make("analytics-extra"); + const state: PersistedStackState = { + ...base, + registry: { + initialized: true, + defaultInstanceIds: { analytics: instanceId }, + instances: [ + { + id: instanceId, + service: "analytics", + intent: "stopped", + dependencies: { database: ServiceInstanceIdSchema.make("database") }, + resources: {}, + revisions: { config: 0, intent: 0 }, + pendingOperation: null, + initialization: null, + initializationInputs: null, + data: { origin: "absent" }, + config: { + enabled: true, + activation: "lazy", + idleTimeoutSeconds: false, + version: "test", + settings: analyticsSettings, + endpoints: {}, }, }, - }, - }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, "analytics:analytics"); - expect(material.analytics).toBeUndefined(); - expect(material.functions).toBeUndefined(); - }), - ), - ); - - it.live("returns live Auth template mappings and rejects URL id collisions", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-templates-" }); - yield* fs.writeFileString(path.join(root, "confirm.html"), "confirm"); - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - email: { - template: { confirm: { content_path: "confirm.html" } }, - }, - }, - }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const canonicalRoot = yield* fs.realPath(root); - const templates = yield* owner.resolveAuthTemplates(state); - expect(templates).toEqual([ + ], + }, + privatePorts: [ { - id: "confirm", - path: "confirm.html", - canonicalPath: path.join(canonicalRoot, "confirm.html"), - extension: ".html", + instanceId, + workloadId: `${instanceId}:vector`, + binding: "primary", + port: 18_000, }, - ]); - const collisionState = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - email: { - template: { welcome_notification: { content_path: "confirm.html" } }, - notification: { welcome: { enabled: true, content_path: "confirm.html" } }, - }, - }, - }, - }, - }); - const failed = yield* owner.resolveAuthTemplates(collisionState).pipe(Effect.exit); - expect(errorOf(failed)?.message).toContain("Duplicate Auth email template id"); - yield* fs.writeFileString(path.join(root, "welcome"), "welcome"); - const extensionCollisionState = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - email: { - template: { - welcome: { content_path: "confirm.html" }, - "welcome.html": { content_path: "welcome" }, - }, - }, - }, - }, - }, - }); - const extensionCollision = yield* owner - .resolveAuthTemplates(extensionCollisionState) - .pipe(Effect.exit); - expect(errorOf(extensionCollision)?.message).toContain("Duplicate Auth email URL"); - }), - ), - ); - - it.live("validates Functions Edge Runtime secrets and returns real names", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-functions-", - }); - const state = yield* compiledState(root, { - capabilities: { - functions: { - enabled: true, - settings: { - edge_runtime: { secrets: { API_TOKEN: Redacted.make("actual-value") } }, - }, - }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, "functions:edge-runtime"); - expect(material.functions?.secrets).toEqual({ API_TOKEN: "actual-value" }); - const invalid = yield* compiledState(root, { - capabilities: { - functions: { - enabled: true, - settings: { - edge_runtime: { secrets: { SUPABASE_TOKEN: Redacted.make("value") } }, - }, - }, - }, - }); - const failed = yield* owner.resolve(invalid, "functions:edge-runtime").pipe(Effect.exit); - expect(errorOf(failed)?.message).toContain("secret name is reserved"); - }), - ), + ], + }; + const material = yield* owner.resolve(state, instanceId, `${instanceId}:vector`); + const expected = path.join( + root, + stackId, + "runtime", + "instances", + instanceId, + "inputs", + "vector", + "vector.yaml", + ); + expect(material.analytics?.vectorConfigPath).toBe(expected); + expect(yield* fs.readFileString(expected)).toContain("supabase-stack-vector"); + }).pipe(Effect.provide(NodeServices.layer)), ); }); + +const pathJoin = (root: string, file: string): string => `${root}/${file}`; diff --git a/packages/stack/src/runtime/schema-init.integration.test.ts b/packages/stack/src/runtime/schema-init.integration.test.ts deleted file mode 100644 index de025a20b3..0000000000 --- a/packages/stack/src/runtime/schema-init.integration.test.ts +++ /dev/null @@ -1,753 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - ConfigProvider, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Option, - Redacted, - Schema, - Sink, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- capture env-file contents before the scoped workspace is removed -import { readFileSync } from "node:fs"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { deriveStackId } from "../identity/Identity.ts"; -import { RequiresActivatedProcessError } from "../public/Errors.ts"; -import { StackIdSchema } from "../public/StackId.ts"; -import { STACK_STATE_FORMAT } from "../state/StackState.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; -import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import type { - ContainerContainerSpec, - ContainerEngine, - ContainerNetworkSpec, - ContainerResource, - ContainerVolumeSpec, -} from "./ContainerEngine.ts"; -import { schemaInitContainerName } from "./ContainerRuntime.ts"; -import { schemaInitWorkloads } from "./SchemaInit.ts"; - -interface FakeContainerState { - resources: Array; - calls: Array; - createdSpecs: Array; - envFiles: Map; - nextId: number; -} - -const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { - const id = (prefix: string): string => `${prefix}-${state.nextId++}`; - const find = (resourceId: string): ContainerResource | undefined => - state.resources.find((resource) => resource.id === resourceId); - return { - kind: "docker", - preflight: Effect.succeed({ host: "host.docker.internal" }), - probe: Effect.void, - inspectImage: () => Effect.succeed({ present: true }), - pullImage: () => Effect.void, - listResources: () => - Effect.sync(() => { - state.calls.push("list-resources"); - return [...state.resources]; - }), - createNetwork: (spec: ContainerNetworkSpec) => - Effect.sync(() => { - state.calls.push("create-network"); - const resource: ContainerResource = { - id: id("network"), - name: spec.name, - kind: "network", - labels: spec.labels, - }; - state.resources.push(resource); - return resource; - }), - removeNetwork: (resourceId: string) => - Effect.sync(() => { - state.calls.push(`remove-network:${resourceId}`); - state.resources = state.resources.filter( - (resource) => resource.id !== resourceId && resource.name !== resourceId, - ); - }), - createVolume: (spec: ContainerVolumeSpec) => - Effect.sync(() => { - const resource: ContainerResource = { - id: id("volume"), - name: spec.name, - kind: "volume", - labels: spec.labels, - }; - state.resources.push(resource); - return resource; - }), - removeVolume: (resourceId: string) => - Effect.sync(() => { - state.resources = state.resources.filter((resource) => resource.id !== resourceId); - }), - createContainer: (spec: ContainerContainerSpec) => - Effect.sync(() => { - state.calls.push("create-container"); - if (spec.envFile !== undefined) - state.envFiles.set(spec.envFile, readFileSync(spec.envFile, "utf8")); - state.createdSpecs.push(spec); - const resource: ContainerResource = { - id: id("container"), - name: spec.name, - kind: spec.role, - labels: spec.labels, - state: "created", - }; - state.resources.push(resource); - return resource; - }), - copyToContainer: () => Effect.void, - startContainer: (resourceId: string) => - Effect.sync(() => { - state.calls.push(`start:${resourceId}`); - const resource = find(resourceId); - if (resource !== undefined) - state.resources = state.resources.map((entry) => - entry.id === resourceId ? { ...entry, state: "running" } : entry, - ); - }), - waitContainer: (resourceId: string) => - Effect.sync(() => { - state.calls.push(`wait:${resourceId}`); - return 0; - }), - stopContainer: () => Effect.void, - removeContainer: (resourceId: string) => - Effect.sync(() => { - state.calls.push(`remove:${resourceId}`); - state.resources = state.resources.filter((resource) => resource.id !== resourceId); - }), - streamLogs: () => Stream.empty, - }; -}; - -const fakePreparer: RuntimeArtifactPreparer = { - prepare: (_runtime, workload: PlannedWorkload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "1", - outcome: "cached", - artifactRoot: "/tmp/schema-init-artifact", - executablePath: "bin/prepare", - image: `example/${workload.capability}:1`, - }), -}; - -const liveStackId = StackIdSchema.make("b".repeat(64)); -const password = Redacted.make("s3cret"); -const jwtSecret = Redacted.make("jwt-secret-value-that-is-long-enough"); -const nativeLaunchEnvSchema = Schema.Struct({ - executable: Schema.optionalKey(Schema.String), - env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), -}); - -const envFromFile = (text: string): Record => - Object.fromEntries( - text - .split("\n") - .filter((line) => line.includes("=")) - .map((line) => { - const index = line.indexOf("="); - return [line.slice(0, index), line.slice(index + 1)]; - }), - ); - -describe("schemaInit", () => { - it.live("runs container one-shots with distinct names and empty publications", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-live-" }); - const state: FakeContainerState = { - resources: [ - { - id: "net-live", - name: "supabase-live-network", - kind: "network", - labels: { - stackId: liveStackId, - ownerSessionId: "owner-session", - role: "network", - }, - }, - ], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["auth"], - { - kind: "live", - stackId: liveStackId, - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { containerEngine: fakeContainerEngine(state), artifactPreparer: fakePreparer }, - ); - expect(state.createdSpecs).toHaveLength(1); - const spec = state.createdSpecs[0]; - expect(spec).toBeDefined(); - if (spec === undefined) return; - expect(spec.name.endsWith("-schema-init")).toBe(true); - expect(spec.publications).toEqual([]); - expect(spec.network).toBe("net-live"); - expect(spec.entrypoint).toBe("/usr/local/bin/auth"); - expect(spec.command).toEqual(["migrate"]); - expect(spec.envFile).toBeDefined(); - if (spec.envFile === undefined) return; - const env = envFromFile(state.envFiles.get(spec.envFile) ?? ""); - expect(env.GOTRUE_DB_DATABASE_URL).toContain("@supabase-database:5432/postgres"); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("joins the ephemeral Postgres network and dials supabase-database:5432", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-eph-" }); - const state: FakeContainerState = { - resources: [], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["auth"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - networkId: "net-eph", - }, - { - containerEngine: fakeContainerEngine(state), - artifactPreparer: fakePreparer, - platform: "linux", - }, - ); - expect(state.calls).not.toContain("create-network"); - const spec = state.createdSpecs[0]; - expect(spec).toBeDefined(); - if (spec === undefined) return; - expect(spec.name).toBe( - schemaInitContainerName({ - stackId: spec.labels.stackId, - workloadId: "auth:auth", - }), - ); - expect(spec.network).toBe("net-eph"); - expect(spec.extraHosts).toEqual(["host.docker.internal:host-gateway"]); - expect(spec.envFile).toBeDefined(); - if (spec.envFile === undefined) return; - const env = envFromFile(state.envFiles.get(spec.envFile) ?? ""); - expect(env.GOTRUE_DB_DATABASE_URL).toContain("@supabase-database:5432/postgres"); - expect(env.GOTRUE_DB_DATABASE_URL).toContain("supabase_auth_admin"); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live( - "rewrites ephemeral docker endpoints to the published URL without a cluster network", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "schema-init-eph-rewrite-", - }); - const state: FakeContainerState = { - resources: [], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["auth"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { - containerEngine: fakeContainerEngine(state), - artifactPreparer: fakePreparer, - platform: "linux", - }, - ); - expect(state.calls).toContain("create-network"); - const spec = state.createdSpecs[0]; - expect(spec).toBeDefined(); - if (spec === undefined) return; - expect(spec.network).not.toBe("net-eph"); - expect(spec.extraHosts).toEqual(["host.docker.internal:host-gateway"]); - expect(spec.envFile).toBeDefined(); - if (spec.envFile === undefined) return; - const env = envFromFile(state.envFiles.get(spec.envFile) ?? ""); - expect(env.GOTRUE_DB_DATABASE_URL).toContain("@host.docker.internal:54322/postgres"); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("removes a schema-init network by name if create is interrupted", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "schema-init-net-interrupt-", - }); - const state: FakeContainerState = { - resources: [], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - const created = yield* Deferred.make(); - const engine = fakeContainerEngine(state); - const hanging: typeof engine = { - ...engine, - createNetwork: (spec) => - Effect.gen(function* () { - yield* engine.createNetwork(spec); - yield* Deferred.succeed(created, undefined); - return yield* Effect.never; - }), - }; - const fiber = yield* Effect.forkChild( - schemaInitWorkloads( - ["auth"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { - containerEngine: hanging, - artifactPreparer: fakePreparer, - platform: "linux", - }, - ), - ); - yield* Deferred.await(created); - yield* Fiber.interrupt(fiber); - expect(state.resources.filter((resource) => resource.kind === "network")).toEqual([]); - expect(state.calls.some((call) => call.startsWith("remove-network:"))).toBe(true); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("resolves pooler env without an activated pooler process", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-pooler-" }); - const state: FakeContainerState = { - resources: [ - { - id: "net-live", - name: "supabase-live-network", - kind: "network", - labels: { - stackId: liveStackId, - ownerSessionId: "owner-session", - role: "network", - }, - }, - ], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["pooler"], - { - kind: "live", - stackId: liveStackId, - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { containerEngine: fakeContainerEngine(state), artifactPreparer: fakePreparer }, - ); - expect(state.createdSpecs).toHaveLength(2); - expect(state.createdSpecs.map((spec) => spec.entrypoint)).toEqual([ - "/app/bin/prepare", - "/app/bin/provision-tenant", - ]); - const envFile = state.createdSpecs[0]?.envFile; - expect(envFile).toBeDefined(); - if (envFile === undefined) return; - const env = envFromFile(state.envFiles.get(envFile) ?? ""); - expect(env.DATABASE_URL).toContain("@supabase-database:5432/_supabase"); - expect(env.POSTGRES_HOST).toBe("supabase-database"); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("tags docker analytics as requiring an activated process", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-analytics-" }); - const state: FakeContainerState = { - resources: [], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - const exit = yield* schemaInitWorkloads( - ["analytics"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { containerEngine: fakeContainerEngine(state), artifactPreparer: fakePreparer }, - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(error).toBeInstanceOf(RequiresActivatedProcessError); - if (!(error instanceof RequiresActivatedProcessError)) return; - expect(error.capability).toBe("analytics"); - expect(state.createdSpecs).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("skips a disabled capability without creating a one-shot", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-disabled-" }); - const state: FakeContainerState = { - resources: [], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["analytics"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - analytics: { enabled: false }, - }, - }, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { containerEngine: fakeContainerEngine(state), artifactPreparer: fakePreparer }, - ); - expect(state.createdSpecs).toEqual([]); - expect(state.calls.filter((call) => call === "create-container")).toEqual([]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("schema-inits the trio when Studio is on and analytics is off", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-trio-" }); - const state: FakeContainerState = { - resources: [], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["auth", "storage", "realtime"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - studio: { enabled: true }, - analytics: { enabled: false }, - }, - }, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { containerEngine: fakeContainerEngine(state), artifactPreparer: fakePreparer }, - ); - const workloads = new Set(state.createdSpecs.map((spec) => spec.labels.workloadId)); - expect(workloads.has("auth:auth")).toBe(true); - expect(workloads.has("storage:storage")).toBe(true); - expect(workloads.has("realtime:realtime")).toBe(true); - expect(workloads.has("studio:studio")).toBe(false); - expect(workloads.has("mail:mail")).toBe(false); - expect(workloads.has("functions:edge-runtime")).toBe(false); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("omits SEED_SELF_HOST from native realtime schema-init", () => { - const recorded: Array<{ readonly executable: string; readonly env: Record }> = - []; - const decoder = new TextDecoder(); - const spawner = ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(999_999), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: (fd) => - fd === 4 - ? Sink.forEach((chunk: Uint8Array) => - Effect.sync(() => { - const decoded = Schema.decodeOption( - Schema.fromJsonString(nativeLaunchEnvSchema), - )(decoder.decode(chunk)); - if (Option.isNone(decoded)) return; - recorded.push({ - executable: decoded.value.executable ?? "", - env: { ...decoded.value.env }, - }); - }), - ) - : Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), - ), - ); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-native-" }); - yield* schemaInitWorkloads( - ["realtime"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "native" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { artifactPreparer: fakePreparer }, - ); - expect(recorded).toHaveLength(1); - expect(recorded[0]?.executable.endsWith("bin/prepare")).toBe(true); - expect(recorded[0]?.env.SEED_SELF_HOST).toBeUndefined(); - expect(recorded[0]?.env.APP_NAME).toBe("realtime"); - expect(recorded[0]?.env.GEN_RPC_TCP_SERVER_PORT).toBe("5369"); - expect(recorded[0]?.env.GEN_RPC_SOCKET_IP).toBe("127.0.0.1"); - }), - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(NodeServices.layer), - ); - }); - - it.live("includes native schema-init stderr on a one-shot failure", () => { - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - const reservedPassword = "s3cret@x"; - const encodedPassword = encodeURIComponent(reservedPassword); - let dbEncKey = ""; - const spawner = ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(999_998), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.suspend(() => - Stream.fromIterable([ - encoder.encode( - `migrate failed password=${reservedPassword} encoded=${encodedPassword} enc=${dbEncKey}\n`, - ), - ]), - ), - all: Stream.empty, - getInputFd: (fd) => - fd === 4 - ? Sink.forEach((chunk: Uint8Array) => - Effect.sync(() => { - const decoded = Schema.decodeOption( - Schema.fromJsonString(nativeLaunchEnvSchema), - )(decoder.decode(chunk)); - if (Option.isNone(decoded)) return; - dbEncKey = decoded.value.env?.DB_ENC_KEY ?? ""; - }), - ) - : Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), - ), - ); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "schema-init-native-fail-", - }); - const exit = yield* schemaInitWorkloads( - ["realtime"], - { - kind: "ephemeral", - projectRoot, - runtime: { kind: "native" }, - config: {}, - databaseUrl: `postgresql://postgres:${encodedPassword}@127.0.0.1:54322/postgres`, - secrets: { databasePassword: Redacted.make(reservedPassword), jwtSecret }, - }, - { artifactPreparer: fakePreparer }, - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const pretty = Cause.pretty(exit.cause); - expect(pretty).toContain("stderr: migrate failed"); - expect(pretty).toContain("[REDACTED]"); - expect(pretty).not.toContain(reservedPassword); - expect(pretty).not.toContain(encodedPassword); - expect(dbEncKey.length).toBeGreaterThan(0); - expect(pretty).not.toContain(dbEncKey); - } - }), - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(NodeServices.layer), - ); - }); - - it.live("reuses persisted Realtime secrets for live docker schema-init", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-home-" }); - const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "schema-init-live-rt-" }); - const knownEncKey = "live-db-enc-key1"; - const knownKeyBase = "live-secret-key-base-value-that-is-long-enough"; - yield* Effect.gen(function* () { - const env = yield* defaultRuntimeEnvironment; - const identity = { - projectRoot, - branchContext: "main", - stackName: "default", - }; - const stackId = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - yield* store.initialize(stackId, { - format: STACK_STATE_FORMAT, - identity, - runtime: { kind: "container", engine: "docker" }, - desiredLifecycle: "running", - ports: [], - privatePorts: [], - secrets: { - "secret:realtime.settings.db_enc_key": { - policy: "managed", - value: knownEncKey, - }, - "secret:realtime.settings.secret_key_base": { - policy: "managed", - value: knownKeyBase, - }, - "secret:functions.environment.DOGFOOD_SMTP": { - policy: "passthrough", - value: "smtp-pass", - }, - }, - }); - const state: FakeContainerState = { - resources: [ - { - id: "net-live", - name: "supabase-live-network", - kind: "network", - labels: { - stackId, - ownerSessionId: "owner-session", - role: "network", - }, - }, - ], - calls: [], - createdSpecs: [], - envFiles: new Map(), - nextId: 1, - }; - yield* schemaInitWorkloads( - ["realtime"], - { - kind: "live", - stackId, - projectRoot, - runtime: { kind: "container", engine: "docker" }, - config: {}, - databaseUrl: "postgresql://postgres:s3cret@127.0.0.1:54322/postgres", - secrets: { databasePassword: password, jwtSecret }, - }, - { containerEngine: fakeContainerEngine(state), artifactPreparer: fakePreparer }, - ); - const spec = state.createdSpecs[0]; - expect(spec).toBeDefined(); - if (spec === undefined) return; - expect(spec.envFile).toBeDefined(); - if (spec.envFile === undefined) return; - const launched = envFromFile(state.envFiles.get(spec.envFile) ?? ""); - expect(launched.SEED_SELF_HOST).toBe("true"); - expect(launched.DB_ENC_KEY).toBe(knownEncKey); - expect(launched.SECRET_KEY_BASE).toBe(knownKeyBase); - }).pipe( - Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ SUPABASE_HOME: home }))), - ); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/packages/stack/src/runtime/schema-init.unit.test.ts b/packages/stack/src/runtime/schema-init.unit.test.ts deleted file mode 100644 index 99f14a74f5..0000000000 --- a/packages/stack/src/runtime/schema-init.unit.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { - parseSchemaInitDatabaseUrl, - rewriteDatabaseEnvironment, - schemaInitArtifactIdentity, - schemaInitHostGatewayExtraHosts, -} from "./SchemaInit.ts"; - -describe("parseSchemaInitDatabaseUrl", () => { - it("keeps host, port, password, and database name", () => { - const parsed = parseSchemaInitDatabaseUrl( - "postgresql://supabase_auth_admin:s3cret%40x@127.0.0.1:54322/_supabase", - ); - expect(parsed).toEqual({ - host: "127.0.0.1", - port: 54322, - password: "s3cret@x", - database: "_supabase", - }); - }); - - it("rejects a non-postgres URL", () => { - expect(parseSchemaInitDatabaseUrl("https://example.test/postgres")).toBeUndefined(); - }); -}); - -describe("rewriteDatabaseEnvironment", () => { - it("rewrites host, port, and password without changing user or database name", () => { - const rewritten = rewriteDatabaseEnvironment( - { - DB_HOST: "supabase-database", - DB_PORT: "5432", - DB_PASSWORD: "old", - GOTRUE_DB_DATABASE_URL: - "postgresql://supabase_auth_admin:old@supabase-database:5432/postgres", - DATABASE_URL: "ecto://supabase_admin:old@supabase-database:5432/_supabase", - API_EXTERNAL_URL: "http://127.0.0.1:54321", - }, - { host: "host.docker.internal", port: 54322, password: "fresh" }, - ); - expect(rewritten.DB_HOST).toBe("host.docker.internal"); - expect(rewritten.DB_PORT).toBe("54322"); - expect(rewritten.DB_PASSWORD).toBe("fresh"); - expect(rewritten.GOTRUE_DB_DATABASE_URL).toBe( - "postgresql://supabase_auth_admin:fresh@host.docker.internal:54322/postgres", - ); - expect(rewritten.DATABASE_URL).toBe( - "ecto://supabase_admin:fresh@host.docker.internal:54322/_supabase", - ); - expect(rewritten.API_EXTERNAL_URL).toBe("http://127.0.0.1:54321"); - }); -}); - -describe("schemaInitHostGatewayExtraHosts", () => { - it("adds host-gateway only on Linux Engine for host.docker.internal", () => { - expect(schemaInitHostGatewayExtraHosts("linux", "host.docker.internal")).toEqual([ - "host.docker.internal:host-gateway", - ]); - expect(schemaInitHostGatewayExtraHosts("darwin", "host.docker.internal")).toEqual([]); - expect(schemaInitHostGatewayExtraHosts("linux", "127.0.0.1")).toEqual([]); - }); -}); - -describe("schemaInitArtifactIdentity", () => { - it("returns a version:image pin and rejects unknown releases", () => { - const identity = schemaInitArtifactIdentity("auth"); - expect(identity).toMatch(/^v.+:/); - expect(schemaInitArtifactIdentity("auth", "not-a-catalog-release")).toBeUndefined(); - }); -}); diff --git a/packages/stack/src/runtime/snapshot-validation.integration.test.ts b/packages/stack/src/runtime/snapshot-validation.integration.test.ts new file mode 100644 index 0000000000..740bcec3e4 --- /dev/null +++ b/packages/stack/src/runtime/snapshot-validation.integration.test.ts @@ -0,0 +1,237 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, FileSystem, Path, Ref, Schema } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { pack, type Headers } from "tar-stream"; +import { compileServiceInstance, createExecutionPlan } from "../model/Compiler.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import { StackPreparationError, UnsupportedSnapshotError } from "../public/Errors.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackPaths } from "../state/Paths.ts"; +import type { RuntimeDriver } from "./RuntimeDriver.ts"; +import { makePostgresInstanceRuntime } from "./PostgresInstanceRuntime.ts"; + +interface ArchiveEntry { + readonly name: string; + readonly contents?: string; + readonly type?: "file" | "directory"; + readonly pax?: Record; +} + +const archiveBytes = (entries: ReadonlyArray) => + Effect.callback((resume) => { + const archive = pack(); + const chunks: Buffer[] = []; + archive.on("data", (chunk: Buffer) => chunks.push(chunk)); + archive.once("error", (error: Error) => resume(Effect.fail(error))); + archive.once("end", () => resume(Effect.succeed(Buffer.concat(chunks)))); + for (const entry of entries) { + const header: Headers & Pick = { + name: entry.name, + type: entry.type ?? "file", + ...(entry.pax === undefined ? {} : { pax: entry.pax }), + mode: entry.type === "directory" ? 0o700 : 0o600, + }; + archive.entry(header, entry.contents ?? ""); + } + archive.finalize(); + return Effect.sync(() => archive.destroy()); + }); + +const fixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "snapshot-validation-" }); + const identity = { projectRoot: root, branchContext: "test", stackName: "snapshot" }; + const stackId = yield* deriveStackId(identity); + const runtime = { kind: "native" } as const; + const compiled = yield* compileServiceInstance( + { service: "database", config: {} }, + { projectRoot: root, path, runtime }, + ); + const paths: StackPaths = { + stackRoot: root, + stateDocument: path.join(root, "state.json"), + data: path.join(root, "data"), + logs: path.join(root, "logs"), + runtime: path.join(root, "runtime"), + controlMetadata: path.join(root, "control.json"), + }; + const state: PersistedStackState = { + format: "supabase-stack-state-v2", + identity, + runtime, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "test-jwt" } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [compiled.instance], defaultInstanceIds: {} }, + ports: [], + privatePorts: [], + secrets: {}, + }; + const plan = yield* createExecutionPlan(runtime, state.registry); + const driver: RuntimeDriver = { + observe: () => Effect.die("Snapshot restore must not observe processes"), + start: () => Effect.die("Snapshot restore must not start processes"), + stop: () => Effect.die("Snapshot restore must not stop processes"), + remove: () => Effect.die("Snapshot restore must not remove processes"), + cleanup: () => Effect.die("Snapshot restore must not clean unrelated resources"), + wipePersistentData: () => Effect.die("Snapshot restore must not wipe existing data"), + }; + const publications = yield* Ref.make(0); + const target = path.join(paths.data, "instances", compiled.id); + const provider = makePostgresInstanceRuntime({ + runtime, + paths, + driver, + artifactPreparer: { prepare: () => Effect.die("Metadata is already resolved") }, + context: Context.empty().pipe( + Context.add(FileSystem.FileSystem, fs), + Context.add(Path.Path, path), + Context.add(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + snapshotData: { + exists: () => Effect.succeed(false), + readVersion: () => Effect.succeed(17), + restoreTargetEmpty: () => + fs.exists(target).pipe( + Effect.map((exists) => !exists), + Effect.mapError( + (cause) => new StackPreparationError({ message: "Target inspection failed", cause }), + ), + ), + export: () => Effect.die("Restore does not export"), + restore: (_input, source, destination) => + fs.copy(source, destination).pipe( + Effect.andThen(Ref.update(publications, (count) => count + 1)), + Effect.mapError( + (cause) => new StackPreparationError({ message: "Publication failed", cause }), + ), + ), + rollbackRestore: () => + fs + .remove(target, { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "Rollback failed", cause }), + ), + ), + }, + snapshotMetadata: () => + Effect.succeed({ + artifactIdentity: "test-postgres", + runtimeIdentity: "test-native", + majorVersion: 17, + }), + reconcileManaged: () => Effect.die("Restore must not reconcile a running database"), + reconcileCatalogRecipe: () => Effect.die("Restore must not run catalog migrations"), + publishInitialization: () => Effect.void, + publishFreshData: () => Effect.die("Restore preserves source lineage"), + publishIncompleteData: () => Effect.void, + publishAbsentData: () => Effect.void, + journal: () => Effect.void, + }); + const manifest = { + format: "supabase-postgres-instance-v1", + majorVersion: 1, + sourceInstanceId: compiled.id, + lineageId: "source-lineage", + profileId: null, + artifactIdentity: "test-postgres", + runtimeIdentity: "test-native", + exportOperationId: "source-export", + recipes: [], + dataFormat: { provider: "postgres", format: "pgdata", majorVersion: 17 }, + }; + const restore = (entries: ReadonlyArray) => + Effect.gen(function* () { + const source = path.join(root, "source.tar"); + const encodedManifest = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( + manifest, + ); + const bytes = yield* archiveBytes([ + { name: "manifest.json", contents: encodedManifest }, + { name: "postgres", type: "directory" }, + ...entries, + ]); + yield* fs.writeFile(source, bytes); + return yield* provider.restoreSnapshot( + { + stackId, + state, + instance: compiled.instance, + plan, + operation: { id: "restore-test", generation: 1 }, + }, + { source }, + ); + }); + return { fs, path, root, target, restore, publications }; +}); + +describe("snapshot validation before storage publication", () => { + it.live("restores ordinary PAX metadata and long filenames", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const name = `postgres/${"catalog".repeat(25)}`; + const restored = yield* f.restore([ + { name: "postgres/PG_VERSION", contents: "17\n", pax: { mtime: "1712345678.125" } }, + { name, contents: "catalog payload" }, + ]); + expect(restored.lineageId).toBe("source-lineage"); + expect(yield* Ref.get(f.publications)).toBe(1); + expect(yield* f.fs.readFileString(f.path.join(f.target, name))).toBe("catalog payload"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + for (const payload of ["16\n", "not-a-version\n", undefined]) { + it.live(`rejects PG_VERSION ${JSON.stringify(payload)} before publication`, () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const error = yield* f + .restore( + payload === undefined ? [] : [{ name: "postgres/PG_VERSION", contents: payload }], + ) + .pipe(Effect.flip); + expect(error).toBeInstanceOf( + payload === undefined ? StackPreparationError : UnsupportedSnapshotError, + ); + expect(yield* Ref.get(f.publications)).toBe(0); + expect(yield* f.fs.exists(f.target)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + } + + for (const pax of [false, true]) { + it.live(`rejects an absolute ${pax ? "PAX" : "USTAR"} path containing whitespace`, () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const outside = f.path.join(f.root, "outside \npostgres", "allowed"); + const error = yield* f + .restore([ + { name: "postgres/PG_VERSION", contents: "17\n" }, + { name: outside, contents: "escape", ...(pax ? { pax: { path: outside } } : {}) }, + ]) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(StackPreparationError); + expect(error.message).toBe("Snapshot archive contains an unsafe path"); + expect(yield* Ref.get(f.publications)).toBe(0); + expect(yield* f.fs.exists(outside)).toBe(false); + expect(yield* f.fs.exists(f.target)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + } +}); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 2e48ba1000..4f563ec710 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,1449 +1,352 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Redacted, Schema } from "effect"; -import { catalogReleaseFor, WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { deriveStackId } from "../identity/Identity.ts"; -import type { PersistedStackState } from "../state/StackState.ts"; -import { makePortCoordinator, type ListenerIntents } from "../state/PortCoordinator.ts"; -import { bindHeldPort, bindHostListener } from "../supervisor/HostListener.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import { compileStack } from "../model/Compiler.ts"; +import { Effect, Path, Schema } from "effect"; +import { compileStack, createExecutionPlan, seedServiceRegistry } from "../model/Compiler.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; +import { PersistedStackStateSchema, type PersistedStackState } from "../state/StackState.ts"; +import { isRecord, settingValue } from "../state/MaterializedSettings.ts"; +import type { StackConfig } from "../public/Config.ts"; +import { resolveSecrets } from "../state/SecretStore.ts"; import { containerResolutionFor, - FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, - FUNCTIONS_CONTAINER_ROOT, privateBindingIntentsFor, resolveContainerResolutionFor, runtimeSpecFor, - validateWorkloadRuntimeInputs, } from "./WorkloadRuntimeSpec.ts"; -const disabledListenerIntents: ListenerIntents = { - api: { enabled: false, address: "127.0.0.1", port: "automatic" }, - database: { enabled: false, address: "127.0.0.1", port: "automatic" }, - pooler: { enabled: false, address: "127.0.0.1", port: "automatic" }, - studio: { enabled: false, address: "127.0.0.1", port: "automatic" }, - mailUi: { enabled: false, address: "127.0.0.1", port: "automatic" }, - smtp: { enabled: false, address: "127.0.0.1", port: "automatic" }, - pop3: { enabled: false, address: "127.0.0.1", port: "automatic" }, - functionsInspector: { enabled: false, address: "127.0.0.1", port: "automatic" }, -}; - -const state: PersistedStackState = { - format: "supabase-stack-state-v1", - identity: { - projectRoot: "/tmp/supabase-runtime-spec", - branchContext: "ordinary-workspace", - stackName: "runtime-spec", - }, - runtime: { kind: "native" }, - desiredLifecycle: "running", - ports: [ - { field: "database", port: 55432, intent: "exact" }, - { field: "api", port: 54321, intent: "exact" }, - ], - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: 30_001 }, - { workloadId: "rest:rest", binding: "primary", port: 30_002 }, - { workloadId: "rest:rest", binding: "admin", port: 30_015 }, - { workloadId: "auth:auth", binding: "primary", port: 30_003 }, - { workloadId: "realtime:realtime", binding: "primary", port: 30_004 }, - { workloadId: "realtime:realtime", binding: "rpc", port: 30_019 }, - { workloadId: "storage:storage", binding: "primary", port: 30_005 }, - { workloadId: "storage:imgproxy", binding: "primary", port: 30_006 }, - { workloadId: "functions:edge-runtime", binding: "primary", port: 30_007 }, - { workloadId: "functions:edge-runtime", binding: "inspector", port: 30_018 }, - { workloadId: "studio:studio", binding: "primary", port: 30_008 }, - { workloadId: "studio:pgmeta", binding: "primary", port: 30_009 }, - { workloadId: "mail:mail", binding: "ui", port: 30_010 }, - { workloadId: "mail:mail", binding: "smtp", port: 30_011 }, - { workloadId: "mail:mail", binding: "pop3", port: 30_012 }, - { workloadId: "analytics:analytics", binding: "primary", port: 30_013 }, - { workloadId: "analytics:vector", binding: "primary", port: 30_014 }, - { workloadId: "pooler:pooler", binding: "primary", port: 30_016 }, - { workloadId: "pooler:pooler", binding: "admin", port: 30_017 }, - ], - secrets: { - "secret:database.internal.password": { policy: "managed", value: "postgres" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "symmetric-secret" }, - "secret:auth.settings.publishable_key": { - policy: "managed", - value: "sb_publishable_test", - }, - "secret:auth.settings.secret_key": { policy: "managed", value: "sb_secret_test" }, - "secret:realtime.settings.db_enc_key": { policy: "managed", value: "realtime-db-key" }, - "secret:realtime.settings.secret_key_base": { - policy: "managed", - value: "realtime-secret-base", - }, - }, -}; - -const planned = (id: string): PlannedWorkload => { - const release = catalogReleaseFor(id); - if (release === undefined) throw new Error(`Missing test catalog release: ${id}`); - const separator = id.indexOf(":"); - const capability = CAPABILITY_NAMES.find((name) => name === id.slice(0, separator)); - if (capability === undefined) throw new Error(`Missing test capability: ${id}`); - return { - id, - capability, - dependencies: [], - readiness: {}, - artifacts: { - native: { kind: "native", release: release.version }, - container: { kind: "container", image: release.containerImage }, - }, - selected: { kind: "native", release: release.version }, - }; -}; - -describe("workload runtime catalog", () => { - it.live("consumes persisted runtime defaults without rebuilding them", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured: PersistedStackState = { ...state, definition: compiled.definition }; - const rest = planned("rest:rest"); - expect(runtimeSpecFor(rest)?.env(configured, rest, 3000)).toMatchObject({ - PGRST_DB_SCHEMAS: "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", - PGRST_DB_MAX_ROWS: "1000", - }); - const auth = planned("auth:auth"); - expect(runtimeSpecFor(auth)?.env(configured, auth, 3000)).toMatchObject({ - GOTRUE_SITE_URL: "http://127.0.0.1:3000", - GOTRUE_JWT_EXP: "3600", - GOTRUE_SMTP_ADMIN_EMAIL: "admin@email.com", - GOTRUE_SMTP_SENDER_NAME: "Admin", - }); - const storage = planned("storage:storage"); - expect(runtimeSpecFor(storage)?.env(configured, storage, 5000)).toMatchObject({ - FILE_SIZE_LIMIT: "52428800", - ENABLE_IMAGE_TRANSFORMATION: "true", - S3_PROTOCOL_ENABLED: "true", - S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c", - STORAGE_S3_REGION: "local", - }); - const realtime = planned("realtime:realtime"); - expect(runtimeSpecFor(realtime)?.env(configured, realtime, 4000)).toMatchObject({ - MAX_HEADER_LENGTH: "4096", - GEN_RPC_TCP_SERVER_PORT: "30019", - GEN_RPC_TCP_CLIENT_PORT: "30019", - GEN_RPC_SOCKET_IP: "127.0.0.1", - }); - const functions = planned("functions:edge-runtime"); - expect(runtimeSpecFor(functions)?.env(configured, functions, 9000)).toMatchObject({ - SUPABASE_INTERNAL_FUNCTIONS_ROOT: `${state.identity.projectRoot}/supabase/functions`, - EDGE_RUNTIME_POLICY: "per_worker", - EDGE_RUNTIME_DENO_VERSION: "2", - }); - expect(runtimeSpecFor(functions)?.args(configured, functions, 9000)).toContain( - "--policy=per_worker", - ); - - const bigquery = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { capabilities: { analytics: { settings: { backend: "bigquery" } } } }, - }).pipe(Effect.provide(NodeServices.layer)); - const analytics = planned("analytics:analytics"); - expect( - runtimeSpecFor(analytics)?.env( - { ...state, definition: bigquery.definition }, - analytics, - 4000, - ), - ).toMatchObject({ - GOOGLE_PROJECT_ID: "local", - GOOGLE_PROJECT_NUMBER: "0", - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("derives one closed private binding intent for every planned workload binding", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - }).pipe(Effect.provide(NodeServices.layer)); - const intents = privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }); - expect(intents).toContainEqual({ workloadId: "database:database", binding: "primary" }); - expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "ui" }); - expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "smtp" }); - expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "pop3" }); - expect(intents).toContainEqual({ workloadId: "rest:rest", binding: "admin" }); - expect(intents).toContainEqual({ workloadId: "realtime:realtime", binding: "rpc" }); - expect(intents.filter(({ workloadId }) => workloadId === "mail:mail")).toHaveLength(3); - expect( - intents.every(({ binding }) => - ["primary", "admin", "ui", "smtp", "pop3", "inspector", "rpc"].includes(binding), - ), - ).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("assigns distinct native realtime rpc ports to two stacks", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - }).pipe(Effect.provide(NodeServices.layer)); - const intents = privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }); - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-realtime-rpc-" }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const coordinator = makePortCoordinator({ - stateRoot: root, - store, - bindHost: bindHostListener, - bindPrivate: (address, port) => bindHeldPort(address, port, "private-binding"), - }); - const ports: number[] = []; - for (const stackName of ["first", "second"] as const) { - const identity = { ...state.identity, projectRoot: root, stackName }; - const stackId = yield* deriveStackId(identity); - yield* store.initialize(stackId, { - ...state, - identity, - desiredLifecycle: "running", - ports: [], - privatePorts: [], - }); - const reservation = yield* coordinator.acquire(stackId, disabledListenerIntents, intents); - const rpc = reservation.privateAssignments.find( - ({ workloadId, binding }) => workloadId === "realtime:realtime" && binding === "rpc", - ); - if (rpc === undefined) throw new Error("Missing realtime rpc assignment"); - ports.push(rpc.port); - } - expect(ports).toHaveLength(2); - expect(ports[0]).not.toBe(ports[1]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("omits realtime rpc from Docker publications", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "container", engine: "docker" }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured: PersistedStackState = { - ...state, - definition: compiled.definition, - runtime: { kind: "container", engine: "docker" }, - }; - expect( - privateBindingIntentsFor(compiled.executionPlan, configured).some( - ({ workloadId, binding }) => workloadId === "realtime:realtime" && binding === "rpc", - ), - ).toBe(false); - expect( - containerResolutionFor(configured, planned("realtime:realtime"))?.publications, - ).not.toContainEqual(expect.objectContaining({ containerPort: 5369 })); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("starts the Edge Runtime inspector on its private binding", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { - capabilities: { functions: { settings: { inspector: { mode: "run" } } } }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const functions = planned("functions:edge-runtime"); - const configured: PersistedStackState = { - ...state, - definition: compiled.definition, - }; - const spec = runtimeSpecFor(functions); - expect(spec?.args(configured, functions, 30_007)).toContain("--inspect=127.0.0.1:30018"); - expect(spec?.privateEndpoint(configured, "inspector", "native")).toEqual({ - host: "127.0.0.1", - port: 30_018, - }); - expect(spec?.containerArgs(configured, functions, 9000)).toContain("--inspect=0.0.0.0:9229"); - expect(containerResolutionFor(configured, functions)?.publications).toContainEqual({ - address: "127.0.0.1", - hostPort: 30_018, - containerPort: 9229, - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("does not reserve disabled pooler or unconfigured inspector bindings", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { capabilities: { pooler: { enabled: false } } }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured = { ...state, definition: compiled.definition }; - const intents = privateBindingIntentsFor(compiled.executionPlan, configured); - expect(intents.some(({ workloadId }) => workloadId === "pooler:pooler")).toBe(false); - expect( - intents.some( - ({ workloadId, binding }) => - workloadId === "functions:edge-runtime" && binding === "inspector", - ), - ).toBe(false); - expect( - containerResolutionFor(configured, planned("functions:edge-runtime"))?.publications, - ).not.toContainEqual(expect.objectContaining({ containerPort: 9229 })); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("defaults an enabled inspector listener to run mode", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { listeners: { functionsInspector: { enabled: true, port: 9223 } } }, - }).pipe(Effect.provide(NodeServices.layer)); - const functions = planned("functions:edge-runtime"); - const configured: PersistedStackState = { - ...state, - definition: compiled.definition, - ports: [...state.ports, { field: "functionsInspector", port: 9223, intent: "exact" }], - }; - expect(runtimeSpecFor(functions)?.args(configured, functions, 30_007)).toContain( - "--inspect=127.0.0.1:30018", - ); - const retainedDisabledPort: PersistedStackState = { - ...configured, - definition: { - ...compiled.definition, - listeners: { - ...compiled.definition.listeners, - functionsInspector: { - ...compiled.definition.listeners.functionsInspector, - enabled: false, - }, - }, - }, - ports: [...state.ports, { field: "functionsInspector", port: 9223, intent: "exact" }], - }; - expect( - runtimeSpecFor(functions)?.args(retainedDisabledPort, functions, 30_007), - ).not.toContain("--inspect=127.0.0.1:30018"); - - const mainOnly = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { capabilities: { functions: { settings: { inspector: { main: true } } } } }, - }).pipe(Effect.provide(NodeServices.layer)); - expect( - runtimeSpecFor(functions)?.args( - { ...configured, definition: mainOnly.definition }, - functions, - 30_007, - ), - ).toEqual([ - "start", - expect.any(String), - "--port=30007", - "--policy=per_worker", - "--inspect=127.0.0.1:30018", - "--inspect-main", - ]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("compiles pgmeta's primary port before the Vector companion", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { - capabilities: { - analytics: { enabled: true, settings: {} }, - studio: { enabled: true }, - }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const relevant = privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }).filter( - ({ workloadId }) => workloadId === "studio:pgmeta" || workloadId === "analytics:vector", - ); - expect(relevant).toEqual([ - { workloadId: "studio:pgmeta", binding: "primary" }, - { workloadId: "analytics:vector", binding: "primary" }, - ]); - - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pgmeta-ports-" }); - const identity = { - ...state.identity, - projectRoot: root, - }; - const stackId = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(stackId, { - ...state, - identity, - desiredLifecycle: "running", - ports: [], - privatePorts: [], - }); - const reservation = yield* makePortCoordinator({ - stateRoot: root, - store, - bindHost: bindHostListener, - bindPrivate: (address, port) => bindHeldPort(address, port, "private-binding"), - }).acquire( - stackId, - disabledListenerIntents, - privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }), - ); - const pgmetaPrimary = reservation.privateAssignments.find( - ({ workloadId, binding }) => workloadId === "studio:pgmeta" && binding === "primary", - ); - const vectorPrimary = reservation.privateAssignments.find( - ({ workloadId, binding }) => workloadId === "analytics:vector" && binding === "primary", - ); - if (pgmetaPrimary === undefined || vectorPrimary === undefined) - throw new Error("Compiled plan did not reserve pgmeta and Vector bindings"); - expect(vectorPrimary.port).not.toBe(pgmetaPrimary.port); - const pgmetaResolution = containerResolutionFor( - { ...state, privatePorts: reservation.privateAssignments }, - planned("studio:pgmeta"), - ); - expect(pgmetaResolution?.publications).toEqual([ - { address: "127.0.0.1", hostPort: pgmetaPrimary.port, containerPort: 8080 }, - ]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it("provides private command, environment and readiness metadata for every workload", () => { - for (const [index, id] of Object.keys(WORKLOAD_CATALOG).entries()) { - const workload = planned(id); - const spec = runtimeSpecFor(workload); - expect(spec).toBeDefined(); - if (spec === undefined) continue; - const port = 30_000 + index; - expect(spec.containerPort).toBeGreaterThan(0); - expect(spec.readiness.protocol).toMatch(/http|tcp/u); - expect(spec.args(state, workload, port)).toBeInstanceOf(Array); - expect(typeof spec.cwd(state, workload)).toBe("string"); - expect(spec.privateEndpoint(state, spec.readiness.binding)).toEqual({ - host: "127.0.0.1", - port: expect.any(Number), - }); - expect(spec.privateEndpoint(state, spec.readiness.binding, "container")).toEqual({ - host: WORKLOAD_CATALOG[id]?.containerAlias, - port: spec.bindings[spec.readiness.binding]?.containerPort, - }); - expect(containerResolutionFor(state, workload)?.networkAliases).toEqual([ - WORKLOAD_CATALOG[id]?.containerAlias, - ]); - } - }); - - it.live("uses durable binding assignments for native endpoints and container publications", () => - Effect.gen(function* () { - const mail = planned("mail:mail"); - const mailResolution = containerResolutionFor(state, mail); - expect(mailResolution?.publications).toEqual([ - { address: "127.0.0.1", hostPort: 30010, containerPort: 8025 }, - { address: "127.0.0.1", hostPort: 30011, containerPort: 1025 }, - { address: "127.0.0.1", hostPort: 30012, containerPort: 1110 }, - ]); - expect(runtimeSpecFor(mail)?.env(state, mail, 30010)).toMatchObject({ - MP_UI_BIND_ADDR: "127.0.0.1:30010", - MP_SMTP_BIND_ADDR: "127.0.0.1:30011", - MP_POP3_BIND_ADDR: "127.0.0.1:30012", - }); - const realtime = planned("realtime:realtime"); - expect(runtimeSpecFor(realtime)?.env(state, realtime, 32000).PORT).toBe("32000"); - const secondState: PersistedStackState = { - ...state, - privatePorts: state.privatePorts.map((assignment) => ({ - ...assignment, - port: assignment.port + 1000, - })), - }; - expect( - runtimeSpecFor(planned("rest:rest"))?.env(secondState, planned("rest:rest"), 3000), - ).toMatchObject({ - PGRST_DB_URI: expect.stringContaining("@127.0.0.1:31001"), - }); - expect( - runtimeSpecFor(planned("storage:storage"))?.env( - secondState, - planned("storage:storage"), - 5000, - ).IMGPROXY_URL, - ).toBe("http://127.0.0.1:31006"); - const missing = { - ...state, - privatePorts: state.privatePorts.filter( - ({ workloadId, binding }) => !(workloadId === mail.id && binding === "smtp"), - ), - }; - const failed = yield* resolveContainerResolutionFor(missing, mail).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("uses owner-resolved StackId data paths for native persistence", () => - Effect.sync(() => { - const database = planned("database:database"); - const storage = planned("storage:storage"); - const databaseSpec = runtimeSpecFor(database); - const storageSpec = runtimeSpecFor(storage); - expect( - databaseSpec?.env(state, database, 5432, "native", { - database: { dataPath: "/state/stack/data/database" }, - }).PGDATA, - ).toBe("/state/stack/data/database"); - expect( - storageSpec?.env(state, storage, 5000, "native", { - storage: { dataPath: "/state/stack/data/storage" }, - }).FILE_STORAGE_BACKEND_PATH, - ).toBe("/state/stack/data/storage"); - expect( - storageSpec?.env(state, storage, 5000, "container", { - storage: { dataPath: "/ignored/native/path" }, - }).FILE_STORAGE_BACKEND_PATH, - ).toBe("/mnt"); - expect( - runtimeSpecFor(planned("storage:imgproxy"))?.env( - state, - planned("storage:imgproxy"), - 5001, - "native", - { - storage: { dataPath: "/state/stack/data/storage" }, - }, - ).IMGPROXY_LOCAL_FILESYSTEM_ROOT, - ).toBe("/"); - expect( - runtimeSpecFor(planned("storage:imgproxy"))?.env( - state, - planned("storage:imgproxy"), - 5001, - "container", - ).IMGPROXY_LOCAL_FILESYSTEM_ROOT, - ).toBe("/"); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it("resolves service-owned startup processes and container init contracts", () => { - const root = "/tmp/slim artifact"; - const auth = planned("auth:auth"); - expect(runtimeSpecFor(auth)?.nativeStartupProcesses(root, state, auth, 9999)).toEqual([ - { executable: `${root}/bin/auth`, args: ["migrate"], cwd: root }, - ]); - const storage = planned("storage:storage"); - expect(runtimeSpecFor(storage)?.nativeStartupProcesses(root, state, storage, 5000)).toEqual([ - { executable: `${root}/bin/prepare`, args: [], cwd: root }, - ]); - const realtime = planned("realtime:realtime"); - expect(runtimeSpecFor(realtime)?.nativeStartupProcesses(root, state, realtime, 4000)).toEqual([ - { executable: `${root}/bin/prepare`, args: [], cwd: root }, - ]); - const analytics = planned("analytics:analytics"); - expect(runtimeSpecFor(analytics)?.nativeStartupProcesses(root, state, analytics, 4000)).toEqual( - [{ executable: `${root}/bin/prepare`, args: [], cwd: root }], +const makeFixture = ( + runtime: PersistedStackState["runtime"] = { kind: "container", engine: "docker" }, + config?: StackConfig, +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const compiled = yield* compileStack({ projectRoot: "/tmp/workload-runtime", runtime, config }); + const seeded = yield* seedServiceRegistry( + compiled.definition, + { projectRoot: "/tmp/workload-runtime", path, runtime }, + compiled.sourceConfig, + compiled.secrets, ); - const pooler = planned("pooler:pooler"); - expect(runtimeSpecFor(pooler)?.nativeStartupProcesses(root, state, pooler, 6543)).toEqual([ - { executable: `${root}/bin/prepare`, args: [], cwd: root }, - { executable: `${root}/bin/provision-tenant`, args: [], cwd: root }, - ]); - expect(runtimeSpecFor(pooler)?.env(state, pooler, 6543, "container")).toMatchObject({ - POSTGRES_HOST: "supabase-database", - POSTGRES_PORT: "5432", - POSTGRES_PASSWORD: "postgres", - }); - - expect(containerResolutionFor(state, auth)?.command).toEqual([]); - expect(containerResolutionFor(state, auth)?.startup).toEqual([ - { entrypoint: "/usr/local/bin/auth", command: ["migrate"] }, - ]); - expect(containerResolutionFor(state, storage)?.command).toEqual([]); - expect(containerResolutionFor(state, storage)?.startup).toEqual([ - { entrypoint: "/slim-runtime/bin/prepare", command: [] }, - ]); - expect(containerResolutionFor(state, realtime)?.entrypoint).toBe("/usr/bin/tini"); - expect(containerResolutionFor(state, realtime)?.command).toEqual([ - "-s", - "-g", - "--", - "/app/bin/server", - ]); - expect(containerResolutionFor(state, realtime)?.startup).toEqual([ - { entrypoint: "/app/bin/prepare", command: [] }, - ]); - expect(containerResolutionFor(state, analytics)?.command).toEqual([]); - expect(containerResolutionFor(state, pooler)?.entrypoint).toBe("/usr/bin/tini"); - expect(containerResolutionFor(state, pooler)?.command).toEqual([ - "-s", - "-g", - "--", - "/app/bin/server", - ]); - expect(containerResolutionFor(state, pooler)?.startup).toEqual([ - { entrypoint: "/app/bin/prepare", command: [] }, - { entrypoint: "/app/bin/provision-tenant", command: [] }, - ]); - }); - - const compileNestedConfiguredState = () => - compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { - capabilities: { - rest: { - settings: { - schemas: ["private"], - extra_search_path: ["extensions"], - external_url: "https://api.example", - }, - }, - storage: { - settings: { - image_transformation: { enabled: true }, - s3_protocol: { enabled: false }, - }, - }, - functions: { - settings: { - edge_runtime: { - policy: "oneshot", - deno_version: 1, - verify_jwt_default: false, - import_map_default: "shared-deno.json", - }, - inspector: { mode: "brk", main: true }, - functions: { hello: { verify_jwt: false } }, - }, - }, - studio: { settings: { api_url: "https://studio.example" } }, - pooler: { enabled: true, settings: { pool_mode: "session", max_client_conn: 250 } }, - auth: { - settings: { - site_url: "https://example.test", - additional_redirect_urls: ["https://example.test/callback"], - jwt_issuer: "https://issuer.example", - enable_signup: false, - minimum_password_length: 12, - password_requirements: "letters_digits", - email: { - double_confirm_changes: true, - secure_password_change: false, - template: { - confirmation: { - content_path: "templates/confirmation.html", - subject: "Confirm", - }, - }, - notification: { - password_recovery: { - enabled: true, - content_path: "templates/recovery.html", - subject: "Reset", - }, - }, - }, - sms: { - enable_signup: true, - twilio: { enabled: true, account_sid: "AC123", message_service_sid: "MG123" }, - twilio_verify: { enabled: true, account_sid: "VA123" }, - test_otp: { "+33123456789": "123456" }, - }, - mfa: { phone: { otp_length: 8 } }, - }, - }, - analytics: { - settings: { - backend: "bigquery", - gcp_project_id: "project-42", - gcp_project_number: "42", - gcp_jwt_path: "secrets/gcp.json", - }, + const resolved = yield* resolveSecrets( + { declarations: seeded.secretSlots }, + undefined, + "stopped", + ); + const database = seeded.registry.instances.find((entry) => entry.service === "database"); + if (database === undefined) return yield* Effect.die("database fixture missing"); + const databaseWithPassword = { + ...database, + config: { ...database.config, passwordSecretRef: "secret:database.password" }, + }; + const registry = { + ...seeded.registry, + instances: seeded.registry.instances.map((entry) => + entry.id === database.id ? databaseWithPassword : entry, + ), + }; + const base = { + format: "supabase-stack-state-v2" as const, + identity: { + projectRoot: "/tmp/workload-runtime", + branchContext: "test", + stackName: "workload-runtime", + }, + runtime, + preparation: "on-demand" as const, + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { + kind: "symmetric" as const, + secret: { slot: "secret:auth.settings.jwt_secret" }, }, - realtime: { settings: { ip_version: "IPv6" } }, }, }, - }).pipe( - Effect.map((compiled): PersistedStackState => ({ - ...state, - definition: compiled.definition, - secrets: { - ...state.secrets, - "secret:analytics.settings.api_key": { policy: "passthrough", value: "api-key" }, - }, - })), - Effect.provide(NodeServices.layer), - ); - - it.live("maps Rest and dependent database endpoints from nested settings", () => - Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const rest = planned("rest:rest"); - expect(runtimeSpecFor(rest)?.env(configured, rest, 3000, "native")).toMatchObject({ - PGRST_DB_SCHEMAS: "private", - PGRST_DB_EXTRA_SEARCH_PATH: "extensions", - PGRST_DB_URI: expect.stringContaining("@127.0.0.1:30001"), - PGRST_ADMIN_SERVER_PORT: "30015", - PGRST_OPENAPI_SERVER_PROXY_URI: "https://api.example", - }); - expect(runtimeSpecFor(rest)?.env(configured, rest, 3000, "container")).toMatchObject({ - PGRST_DB_URI: expect.stringContaining("@supabase-database:5432"), - PGRST_ADMIN_SERVER_PORT: "3001", - }); - for (const { id, key } of [ - { id: "realtime:realtime", key: "DB_HOST" }, - { id: "studio:pgmeta", key: "PG_META_DB_HOST" }, - { id: "analytics:analytics", key: "DB_HOSTNAME" }, - ]) { - expect( - runtimeSpecFor(planned(id))?.env(configured, planned(id), 4000, "container")[key], - ).toBe("supabase-database"); - } - }), - ); - - it.live("maps Storage capability settings and persistence paths", () => - Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const storage = planned("storage:storage"); - expect(runtimeSpecFor(storage)?.env(configured, storage, 5000)).toMatchObject({ - ENABLE_IMAGE_TRANSFORMATION: "true", - S3_PROTOCOL_ENABLED: "false", - FILE_SIZE_LIMIT: "52428800", - VECTOR_ENABLED: "true", - VECTOR_BUCKET_PROVIDER: "pgvector", - VECTOR_STORE_MIGRATIONS_ENABLED: "true", - VECTOR_DATABASE_URL: expect.stringContaining("postgres:postgres@127.0.0.1"), - }); - expect( - runtimeSpecFor(storage)?.env(configured, storage, 5000, "container").IMGPROXY_URL, - ).toBe("http://supabase-imgproxy:5001"); - }), - ); + listeners: {}, + registry, + ports: [], + privatePorts: [], + secrets: { + ...resolved.persisted, + "secret:database.password": { policy: "managed", value: "database-secret" }, + }, + } satisfies PersistedStackState; + const plan = yield* createExecutionPlan(runtime, registry); + const intents = privateBindingIntentsFor(plan, base); + const state: PersistedStackState = { + ...base, + privatePorts: intents.map((intent, index) => ({ ...intent, port: 30_000 + index })), + }; + return { state, plan }; + }).pipe(Effect.provide(NodeServices.layer)); + +const workloadFor = (plan: ExecutionPlan, recipeId: string): PlannedWorkload => { + const workload = plan.workloads.find((entry) => entry.recipeId === recipeId); + if (workload === undefined) throw new Error(`Missing workload ${recipeId}`); + return workload; +}; - it.live.each([ - { input: "50MiB", expected: "52428800" }, - { input: "1.5KB", expected: "1500" }, - { input: "2 GiB", expected: "2147483648" }, - ])("normalizes Storage file size $input for the runtime", ({ input, expected }) => +describe("workload runtime", () => { + it.live("assigns private bindings to their physical instance and workload IDs", () => Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { capabilities: { storage: { settings: { file_size_limit: input } } } }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured: PersistedStackState = { ...state, definition: compiled.definition }; - const storage = planned("storage:storage"); - - expect(runtimeSpecFor(storage)?.env(configured, storage, 5000).FILE_SIZE_LIMIT).toBe( - expected, + const { state, plan } = yield* makeFixture(); + const database = workloadFor(plan, "database:database"); + const assignment = state.privatePorts.find( + (entry) => entry.workloadId === database.id && entry.binding === "sql:internal", ); - }), - ); - - it.live("maps Auth capability settings and template requirements", () => - Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const auth = planned("auth:auth"); - const authSpec = runtimeSpecFor(auth); - const authEnvironment = authSpec?.env(configured, auth, 9999); - const authTemplateEnvironment = authSpec?.env(configured, auth, 9999, "native", { - auth: { templateBaseUrl: "http://supabase-gateway:8088" }, - }); - const authEnvironmentWithKeys = authSpec?.env(configured, auth, 9999, "container", { - auth: { jwtKeys: '[{"kty":"EC"}]' }, - }); - expect(authEnvironment).toMatchObject({ - GOTRUE_SITE_URL: "https://example.test", - GOTRUE_URI_ALLOW_LIST: "https://example.test/callback", - GOTRUE_DISABLE_SIGNUP: "true", - GOTRUE_PASSWORD_MIN_LENGTH: "12", - GOTRUE_PASSWORD_REQUIRED_CHARACTERS: - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - GOTRUE_SMS_PROVIDER: "twilio", - GOTRUE_SMS_TEST_OTP: "+33123456789:123456", - GOTRUE_SMS_OTP_LENGTH: "6", - GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true", - GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: "false", - GOTRUE_SMTP_HOST: "127.0.0.1", - GOTRUE_SMTP_PORT: "30011", - GOTRUE_JWT_ISSUER: "https://issuer.example", - GOTRUE_SMS_TWILIO_ACCOUNT_SID: "AC123", - GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID: "VA123", - }); - expect(authEnvironment?.GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI).toBeUndefined(); - expect(authEnvironmentWithKeys?.GOTRUE_JWT_KEYS).toBe('[{"kty":"EC"}]'); - expect(authTemplateEnvironment).toMatchObject({ - GOTRUE_MAILER_TEMPLATES_CONFIRMATION: - "http://supabase-gateway:8088/email/confirmation.html", - GOTRUE_MAILER_SUBJECTS_CONFIRMATION: "Confirm", - GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_RECOVERY_ENABLED: "true", - GOTRUE_MAILER_TEMPLATES_PASSWORD_RECOVERY_NOTIFICATION: - "http://supabase-gateway:8088/email/password_recovery_notification.html", - GOTRUE_MAILER_SUBJECTS_PASSWORD_RECOVERY_NOTIFICATION: "Reset", + expect(assignment).toMatchObject({ + instanceId: database.instanceId, + workloadId: database.id, }); - yield* validateWorkloadRuntimeInputs(configured, auth, { - auth: { templateBaseUrl: "http://supabase-gateway:8088" }, - }); - const missingTemplateBase = yield* validateWorkloadRuntimeInputs(configured, auth).pipe( - Effect.exit, - ); - expect(Exit.isFailure(missingTemplateBase)).toBe(true); - }), - ); - - it.live("maps Realtime readiness and JWT material from nested settings", () => - Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const realtime = planned("realtime:realtime"); expect( - runtimeSpecFor(realtime)?.env(configured, realtime, 4000, "container", { - auth: { jwks: '{"keys":[]}' }, - }), - ).toMatchObject({ - ERL_AFLAGS: "-proto_dist inet6_tcp", - API_JWT_JWKS: '{"keys":[]}', - }); - expect(runtimeSpecFor(realtime)?.readiness).toMatchObject({ path: "/healthcheck" }); - expect(runtimeSpecFor(realtime)?.readiness.headers).toBeUndefined(); + new Set( + state.privatePorts.map( + (entry) => `${entry.instanceId}:${entry.workloadId}:${entry.binding}`, + ), + ).size, + ).toBe(state.privatePorts.length); }), ); - it.live("maps Analytics credentials and mount inputs", () => + it.live("resolves a container alias and publications for one physical workload", () => Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const analytics = planned("analytics:analytics"); - const analyticsSpec = runtimeSpecFor(analytics); - expect( - analyticsSpec?.env(configured, analytics, 4000, "native", { - analytics: { gcpJwtPath: "/tmp/gcp.json" }, - }), - ).toMatchObject({ - GOOGLE_PROJECT_ID: "project-42", - GOOGLE_PROJECT_NUMBER: "42", - GOOGLE_APPLICATION_CREDENTIALS: "/tmp/gcp.json", - LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", - }); - expect(analyticsSpec?.env(configured, analytics, 4000, "container")).toMatchObject({ - LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", - }); - expect(analyticsSpec?.env(configured, analytics, 4000, "native")).not.toHaveProperty( - "LOGFLARE_PUBLIC_ACCESS_TOKEN", - ); - expect( - analyticsSpec?.containerMounts?.(configured, analytics, { - analytics: { gcpJwtPath: "/tmp/gcp.json" }, - }), - ).toEqual([ - { - source: "/tmp/gcp.json", - target: "/opt/app/rel/logflare/bin/gcloud.json", - readOnly: true, - }, + const { state, plan } = yield* makeFixture(); + const workload = workloadFor(plan, "rest:rest"); + const resolution = yield* resolveContainerResolutionFor(state, workload); + const catalog = catalogEntryFor(workload.recipeId); + expect(catalog).toBeDefined(); + expect(resolution?.networkAliases).toEqual([ + `${catalog?.containerAlias}-${workload.instanceId}`, ]); - expect(analyticsSpec?.args(configured, analytics, 4000)).toEqual(["start"]); - expect(containerResolutionFor(configured, analytics)?.command).toEqual([]); + expect(resolution?.publications.every((entry) => entry.address === "127.0.0.1")).toBe(true); + expect(resolution?.publications.length).toBeGreaterThan(0); }), ); - it.live("maps Pooler native and container publications", () => + it.live("keeps runtime environment resolution tied to the selected recipe", () => Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const pooler = planned("pooler:pooler"); - const poolerSpec = runtimeSpecFor(pooler); - expect(poolerSpec?.env(configured, pooler, 30016)).toMatchObject({ - POOL_MODE: "session", - MAX_CLIENT_CONN: "250", - TENANT_ID: "pooler-dev", - PORT: "30017", - PROXY_PORT_SESSION: "30016", - PROXY_PORT_TRANSACTION: "6543", - }); - expect(poolerSpec?.env(configured, pooler, 30016, "container")).toMatchObject({ - PROXY_PORT_SESSION: "5432", - PROXY_PORT_TRANSACTION: "6543", - }); - expect(containerResolutionFor(configured, pooler)?.publications).toEqual([ - { address: "127.0.0.1", hostPort: 30016, containerPort: 5432 }, - { address: "127.0.0.1", hostPort: 30017, containerPort: 4000 }, - ]); + const { state, plan } = yield* makeFixture({ kind: "native" }); + const workload = workloadFor(plan, "rest:rest"); + const spec = runtimeSpecFor(workload); + if (spec === undefined) return yield* Effect.die("REST runtime spec missing"); + const port = state.privatePorts.find( + (entry) => entry.instanceId === workload.instanceId && entry.workloadId === workload.id, + )?.port; + if (port === undefined) return yield* Effect.die("REST private port missing"); + expect(spec.env(state, workload, port).PGRST_DB_SCHEMAS).toContain("public"); + expect(containerResolutionFor(state, workload)?.networkAliases[0]).toContain( + workload.instanceId, + ); }), ); - it.live("builds the Functions Edge Runtime launch contract", () => + it.live("provides Functions with the managed database endpoint for each runtime", () => Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const functions = planned("functions:edge-runtime"); - const environment = runtimeSpecFor(functions)?.env(configured, functions, 9000, "native"); - const functionsConfig = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( - environment?.SUPABASE_INTERNAL_FUNCTIONS_CONFIG ?? "{}", - ); - expect(functionsConfig).toMatchObject({ - $default: { verify_jwt: false, import_map_root: "shared-deno.json" }, - }); - expect(environment).toMatchObject({ SUPABASE_URL: "http://127.0.0.1:54321" }); - expect( - runtimeSpecFor(functions)?.env(configured, functions, 9000, "container", { - hostRoute: { host: "host.docker.internal" }, - }), - ).toMatchObject({ SUPABASE_URL: "http://host.docker.internal:54321" }); - const resolution = containerResolutionFor(configured, functions, { - hostRoute: { host: "host.docker.internal" }, - }); - expect(resolution?.command).toEqual( - expect.arrayContaining([ - "--main-service=" + FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, - "--inspect-brk=0.0.0.0:9229", - "--inspect-main", - ]), - ); - expect(resolution).toMatchObject({ - env: { - EDGE_RUNTIME_POLICY: "oneshot", - EDGE_RUNTIME_DENO_VERSION: "1", - INSPECTOR_MODE: "brk", - INSPECTOR_MAIN: "true", - }, - mounts: [ - { - source: state.identity.projectRoot + "/supabase/functions", - target: FUNCTIONS_CONTAINER_ROOT, - readOnly: true, - }, - ], - }); - expect( - Object.keys(resolution?.env ?? {}).some((key) => key.startsWith("FUNCTIONS_FUNCTIONS_")), - ).toBe(false); - expect(resolution?.env.EDGE_RUNTIME_PORT).toBe("9000"); + for (const runtime of [ + { kind: "native" }, + { kind: "container", engine: "docker" }, + ] as const) { + const { state, plan } = yield* makeFixture(runtime); + const workload = workloadFor(plan, "functions:edge-runtime"); + const spec = runtimeSpecFor(workload); + if (spec === undefined) return yield* Effect.die("Functions runtime spec missing"); + const port = state.privatePorts.find( + (entry) => entry.instanceId === workload.instanceId && entry.workloadId === workload.id, + )?.port; + if (port === undefined) return yield* Effect.die("Functions private port missing"); + const database = state.registry.instances.find((entry) => entry.service === "database"); + if (database === undefined) return yield* Effect.die("database fixture missing"); + const publicDatabasePort = 31_000; + const stateWithDatabaseListener = { + ...state, + ports: [ + { + owner: "instance" as const, + instanceId: database.id, + binding: "sql", + address: "127.0.0.1", + port: publicDatabasePort, + intent: "automatic" as const, + }, + ], + }; + const environment = spec.env( + stateWithDatabaseListener, + workload, + port, + runtime.kind, + runtime.kind === "container" ? { hostRoute: { host: "host-gateway" } } : {}, + ); + const databaseHost = runtime.kind === "native" ? "127.0.0.1" : "host-gateway"; + const expectedPort = publicDatabasePort; + expect(environment.SUPABASE_DB_URL).toBe( + `postgresql://supabase_admin:database-secret@${databaseHost}:${expectedPort}/postgres`, + ); + expect( + spec.env( + state, + workload, + port, + runtime.kind, + runtime.kind === "container" ? { hostRoute: { host: "host-gateway" } } : {}, + ).SUPABASE_DB_URL, + ).toBeUndefined(); + } }), ); - it.live("resolves Functions bootstrap and native process paths", () => + it.live("gives Studio the designated default Functions management root", () => Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const functions = planned("functions:edge-runtime"); - const bootstrapResolution = containerResolutionFor(configured, functions, { - functions: { bootstrapPath: "/tmp/functions/4/main.ts" }, - }); - expect(bootstrapResolution?.bootstrap).toEqual({ - source: "/tmp/functions/4/main.ts", - destination: "/root", - }); - expect( - runtimeSpecFor(functions)?.nativeProcess( - "/tmp/edge-artifact", - configured, - functions, - 9000, + const { state, plan } = yield* makeFixture({ kind: "native" }); + const workload = workloadFor(plan, "studio:studio"); + const analytics = workloadFor(plan, "analytics:analytics"); + const spec = runtimeSpecFor(workload); + if (spec === undefined) return yield* Effect.die("Studio runtime spec missing"); + const port = state.privatePorts.find( + (entry) => entry.instanceId === workload.instanceId && entry.workloadId === workload.id, + )?.port; + if (port === undefined) return yield* Effect.die("Studio private port missing"); + const functions = state.registry.instances.find((entry) => entry.service === "functions"); + if (functions === undefined || !isRecord(functions.config.settings)) + return yield* Effect.die("Functions fixture missing"); + const expectedRoot = settingValue(state, functions.config.settings.functions_root); + expect(expectedRoot.length).toBeGreaterThan(0); + const stateWithAnalyticsPort = { + ...state, + privatePorts: [ + ...state.privatePorts, { - functions: { bootstrapPath: "/tmp/functions/4/main.ts" }, + instanceId: analytics.instanceId, + workloadId: analytics.id, + binding: "primary", + port: 32_000, }, - ), - ).toMatchObject({ - args: expect.arrayContaining(["--main-service=."]), - cwd: "/tmp/functions/4", - }); - expect( - runtimeSpecFor(functions)?.nativeProcess("/tmp/edge-artifact", configured, functions, 9000), - ).toMatchObject({ - args: expect.arrayContaining(["--main-service=."]), - cwd: state.identity.projectRoot + "/supabase/functions", - }); - }), - ); - - it.live("builds Studio's native and container launch paths", () => - Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const studio = planned("studio:studio"); + ], + }; expect( - runtimeSpecFor(studio)?.env(configured, studio, 3000, "container", { - hostRoute: { host: "host.docker.internal" }, - }), - ).toMatchObject({ - SUPABASE_URL: "http://host.docker.internal:54321", - STUDIO_PG_META_URL: "http://supabase-pgmeta:8080", - LOGFLARE_URL: "http://supabase-analytics:4000", - EDGE_FUNCTIONS_MANAGEMENT_FOLDER: FUNCTIONS_CONTAINER_ROOT, - }); - expect(runtimeSpecFor(studio)?.env(configured, studio, 3000, "native")).toMatchObject({ - SUPABASE_URL: "https://studio.example", - SUPABASE_PUBLIC_URL: "http://127.0.0.1:54321", - }); - expect(runtimeSpecFor(studio)?.containerMounts?.(configured, studio)).toEqual([ - { - source: state.identity.projectRoot + "/supabase/functions", - target: FUNCTIONS_CONTAINER_ROOT, - readOnly: true, + spec.env(stateWithAnalyticsPort, workload, port).EDGE_FUNCTIONS_MANAGEMENT_FOLDER, + ).toBe(expectedRoot); + const disabledFunctionsState = yield* Schema.decodeUnknownEffect(PersistedStackStateSchema)({ + ...stateWithAnalyticsPort, + registry: { + ...stateWithAnalyticsPort.registry, + instances: stateWithAnalyticsPort.registry.instances.map((entry) => + entry.id === functions.id + ? { ...entry, config: { ...entry.config, enabled: false } } + : entry, + ), }, - ]); - expect( - runtimeSpecFor(studio)?.nativeProcess("/tmp/native-artifact", configured, studio, 3000), - ).toEqual({ - executable: "/tmp/native-artifact/bin/studio", - args: [], - cwd: state.identity.projectRoot, - }); - }), - ); - - it.live("builds Vector and database native/container launch paths", () => - Effect.gen(function* () { - const configured = yield* compileNestedConfiguredState(); - const vector = planned("analytics:vector"); - expect( - runtimeSpecFor(vector)?.nativeProcess("/tmp/native-artifact", configured, vector, 9001), - ).toEqual({ - executable: "/tmp/native-artifact/bin/vector", - args: ["--config", "/tmp/native-artifact/share/doc/vector/config/vector.yaml"], - cwd: state.identity.projectRoot, - }); - expect( - runtimeSpecFor(vector)?.nativeProcess("/tmp/native-artifact", configured, vector, 9001, { - analytics: { vectorConfigPath: "/tmp/vector.yaml" }, - }), - ).toEqual({ - executable: "/tmp/native-artifact/bin/vector", - args: ["--config", "/tmp/vector.yaml"], - cwd: state.identity.projectRoot, - }); - expect(runtimeSpecFor(vector)?.env(configured, vector, 30014, "native")).toMatchObject({ - VECTOR_API_ADDRESS: "127.0.0.1:30014", - LOGFLARE_URL: "http://127.0.0.1:30013", - LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", - }); - expect(runtimeSpecFor(vector)?.env(configured, vector, 9001, "container")).toMatchObject({ - VECTOR_API_ADDRESS: "0.0.0.0:9001", - LOGFLARE_URL: "http://supabase-analytics:4000", - LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", - }); - expect(containerResolutionFor(configured, vector)).toMatchObject({ - command: [], - mounts: [], }); expect( - containerResolutionFor(configured, vector, { - analytics: { vectorConfigPath: "/tmp/vector.yaml" }, - }), - ).toMatchObject({ - command: ["--config", "/etc/vector/vector.yaml"], - mounts: [{ source: "/tmp/vector.yaml", target: "/etc/vector/vector.yaml", readOnly: true }], - }); - const database = planned("database:database"); - expect( - runtimeSpecFor(database)?.nativeProcess("/tmp/native-artifact", configured, database, 5432), - ).toMatchObject({ - gracefulStopSignal: "SIGINT", - gracefulStopTimeout: "15 seconds", - }); + spec.env(disabledFunctionsState, workload, port).EDGE_FUNCTIONS_MANAGEMENT_FOLDER, + ).toBeUndefined(); }), ); - it.live("selects PostgREST symmetric and resolved-JWKS credentials", () => + it.live("preserves Functions global defaults when rendering per-function bootstrap config", () => Effect.gen(function* () { - const rest = runtimeSpecFor(planned("rest:rest")); - expect(rest?.env(state, planned("rest:rest"), 3000).PGRST_JWT_SECRET).toBe( - "symmetric-secret", - ); - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } } }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured: PersistedStackState = { ...state, definition: compiled.definition }; - expect( - rest?.env(configured, planned("rest:rest"), 3000, "native", { - auth: { jwks: '{"keys":[]}' }, - }).PGRST_JWT_SECRET, - ).toBe('{"keys":[]}'); - const failed = yield* validateWorkloadRuntimeInputs(configured, planned("rest:rest")).pipe( - Effect.exit, - ); - expect(Exit.isFailure(failed)).toBe(true); - const unresolved = yield* resolveContainerResolutionFor( - configured, - planned("rest:rest"), - ).pipe(Effect.exit); - expect(Exit.isFailure(unresolved)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("emits only enabled Auth provider fields and gates phone MFA options", () => - Effect.gen(function* () { - const disabledCompiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - }).pipe(Effect.provide(NodeServices.layer)); - const disabledState: PersistedStackState = { - ...state, - definition: disabledCompiled.definition, - }; - const disabled = runtimeSpecFor(planned("auth:auth"))?.env( - disabledState, - planned("auth:auth"), - 9999, - ); - expect(disabled?.GOTRUE_EXTERNAL_GOOGLE_ENABLED).toBe("false"); - expect(disabled?.GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI).toBeUndefined(); - expect(disabled?.GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID).toBeUndefined(); - expect(disabled?.GOTRUE_MFA_PHONE_ENROLL_ENABLED).toBe("false"); - expect(disabled?.GOTRUE_MFA_PHONE_VERIFY_ENABLED).toBe("false"); - expect(disabled?.GOTRUE_MFA_PHONE_OTP_LENGTH).toBeUndefined(); - - const enabledCompiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { + const { state, plan } = yield* makeFixture( + { kind: "native" }, + { capabilities: { - auth: { + functions: { settings: { - external: { - google: { - enabled: true, - client_id: "google-client", - url: "https://accounts.google.test", - }, + edge_runtime: { + verify_jwt_default: false, + import_map_default: "shared-deno.json", }, - mfa: { phone: { enroll_enabled: true, otp_length: 8 } }, - }, - }, - }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const enabledState: PersistedStackState = { - ...state, - definition: enabledCompiled.definition, - }; - const enabled = runtimeSpecFor(planned("auth:auth"))?.env( - enabledState, - planned("auth:auth"), - 9999, - ); - expect(enabled).toMatchObject({ - GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true", - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "google-client", - GOTRUE_EXTERNAL_GOOGLE_URL: "https://accounts.google.test", - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: expect.stringContaining("/callback"), - GOTRUE_MFA_PHONE_ENROLL_ENABLED: "true", - GOTRUE_MFA_PHONE_OTP_LENGTH: "8", - GOTRUE_MFA_PHONE_TEMPLATE: "Your code is {{ .Code }}", - GOTRUE_MFA_PHONE_MAX_FREQUENCY: "5s", - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("maps persisted Storage S3 credentials and omits vector settings when disabled", () => - Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { - capabilities: { - storage: { - settings: { - s3_protocol: { - enabled: true, - region: "eu-west-1", - access_key_id: "access-42", - secret_access_key: Redacted.make("secret-42"), + functions: { + hello: { enabled: true }, + explicit: { enabled: true, verify_jwt: true, import_map: "custom-deno.json" }, }, - vector: { enabled: false }, }, }, }, }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured: PersistedStackState = { - ...state, - definition: compiled.definition, - secrets: { - ...state.secrets, - "secret:storage.settings.s3_protocol.secret_access_key": { - policy: "managed", - value: "secret-42", - }, - }, - }; - const env = runtimeSpecFor(planned("storage:storage"))?.env( - configured, - planned("storage:storage"), - 5000, ); - expect(env).toMatchObject({ - S3_PROTOCOL_ENABLED: "true", - S3_PROTOCOL_ACCESS_KEY_ID: "access-42", - S3_PROTOCOL_ACCESS_KEY_SECRET: "secret-42", - STORAGE_S3_REGION: "eu-west-1", - }); - expect(env).not.toHaveProperty("VECTOR_ENABLED"); - expect(env).not.toHaveProperty("VECTOR_DATABASE_URL"); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it("uses Auth's local JWT secret for every internal JWT consumer", () => { - const consumers = [ - ["auth:auth", "GOTRUE_JWT_SECRET"], - ["realtime:realtime", "API_JWT_SECRET"], - ["storage:storage", "AUTH_JWT_SECRET"], - ["storage:storage", "PGRST_JWT_SECRET"], - ["pooler:pooler", "API_JWT_SECRET"], - ["pooler:pooler", "METRICS_JWT_SECRET"], - ["functions:edge-runtime", "SUPABASE_INTERNAL_JWT_SECRET"], - ] as const; - for (const [id, key] of consumers) { - const workload = planned(id); + const workload = workloadFor(plan, "functions:edge-runtime"); const spec = runtimeSpecFor(workload); - expect(spec?.env(state, workload, 3000)[key]).toBe("symmetric-secret"); - } - }); - - it("uses managed per-stack Realtime encryption keys", () => { - const workload = planned("realtime:realtime"); - expect(runtimeSpecFor(workload)?.env(state, workload, 3000)).toMatchObject({ - DB_ENC_KEY: "realtime-db-key", - SECRET_KEY_BASE: "realtime-secret-base", - }); - }); - - it.live("keeps Auth's local JWT secret alongside resolved JWKS material", () => - Effect.gen(function* () { - const compileWith = (config: Parameters[0]["config"]) => - compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config, - }).pipe(Effect.provide(NodeServices.layer)); - const jwks = '{"keys":[{"kty":"EC"}]}'; - const jwksCompiled = yield* compileWith({ - security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } }, - }); - const jwksState: PersistedStackState = { - ...state, - definition: jwksCompiled.definition, - }; - const internal = [ - ["auth:auth", "GOTRUE_JWT_SECRET"], - ["realtime:realtime", "API_JWT_SECRET"], - ["realtime:realtime", "METRICS_JWT_SECRET"], - ["storage:storage", "AUTH_JWT_SECRET"], - ["storage:storage", "PGRST_JWT_SECRET"], - ["pooler:pooler", "API_JWT_SECRET"], - ["pooler:pooler", "METRICS_JWT_SECRET"], - ["functions:edge-runtime", "SUPABASE_INTERNAL_JWT_SECRET"], - ] as const; - for (const [id, key] of internal) { - const workload = planned(id); - const spec = runtimeSpecFor(workload); - expect( - spec?.env(jwksState, workload, 3000, "native", { - auth: { jwtKeys: '[{"kty":"EC"}]', jwks }, - })[key], - ).toBe("symmetric-secret"); - } - expect( - runtimeSpecFor(planned("rest:rest"))?.env(jwksState, planned("rest:rest"), 3000, "native", { - auth: { jwtKeys: '[{"kty":"EC"}]', jwks }, - }).PGRST_JWT_SECRET, - ).toBe(jwks); - - const thirdPartyCompiled = yield* compileWith({ - capabilities: { - auth: { - settings: { - third_party: { firebase: { enabled: true, project_id: "project-42" } }, - }, - }, - }, - }); - const thirdPartyState: PersistedStackState = { - ...state, - definition: thirdPartyCompiled.definition, - }; - expect( - runtimeSpecFor(planned("realtime:realtime"))?.env( - thirdPartyState, - planned("realtime:realtime"), - 3000, - "native", - { auth: { jwks } }, - ).API_JWT_SECRET, - ).toBe("symmetric-secret"); - expect( - runtimeSpecFor(planned("realtime:realtime"))?.env( - thirdPartyState, - planned("realtime:realtime"), - 3000, - "native", - { auth: { jwks } }, - ).API_JWT_JWKS, - ).toBe(jwks); - expect( - runtimeSpecFor(planned("storage:storage"))?.env( - thirdPartyState, - planned("storage:storage"), - 3000, - "native", - { auth: { jwks } }, - ).AUTH_JWT_SECRET, - ).toBe("symmetric-secret"); - expect( - runtimeSpecFor(planned("pooler:pooler"))?.env( - thirdPartyState, - planned("pooler:pooler"), - 3000, - "native", - { auth: { jwks } }, - ).METRICS_JWT_SECRET, - ).toBe("symmetric-secret"); - }).pipe(Effect.provide(NodeServices.layer)), + if (spec === undefined) return yield* Effect.die("Functions runtime spec missing"); + const port = state.privatePorts.find( + (entry) => entry.instanceId === workload.instanceId && entry.workloadId === workload.id, + )?.port; + if (port === undefined) return yield* Effect.die("Functions private port missing"); + const config = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + verifyJWT: Schema.optionalKey(Schema.Boolean), + importMapRoot: Schema.optionalKey(Schema.String), + importMapPath: Schema.optionalKey(Schema.String), + }), + ), + ), + )(spec.env(state, workload, port).SUPABASE_INTERNAL_FUNCTIONS_CONFIG); + expect(config).toMatchObject({ + $default: { verifyJWT: false, importMapRoot: "shared-deno.json" }, + hello: { enabled: true }, + explicit: { enabled: true, verifyJWT: true, importMapPath: "custom-deno.json" }, + }); + expect(config.hello?.verifyJWT).toBeUndefined(); + expect(config.hello?.importMapPath).toBeUndefined(); + }), ); - it.live("requires resolved JWKS material for an enabled third-party provider", () => + it.live("preserves host function paths for the mirrored container root", () => Effect.gen(function* () { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { + const { state, plan } = yield* makeFixture( + { kind: "container", engine: "docker" }, + { capabilities: { - auth: { + functions: { settings: { - third_party: { firebase: { enabled: true, project_id: "project-42" } }, + functions: { + hello: { + enabled: true, + entrypoint: "/tmp/workload-runtime/supabase/functions/hello/index.ts", + import_map: "/tmp/workload-runtime/supabase/functions/shared/deno.json", + static_files: ["/tmp/workload-runtime/supabase/functions/hello/public/*.txt"], + }, + }, }, }, }, }, - }).pipe(Effect.provide(NodeServices.layer)); - const configured: PersistedStackState = { ...state, definition: compiled.definition }; - const failed = yield* validateWorkloadRuntimeInputs(configured, planned("rest:rest")).pipe( - Effect.exit, - ); - expect(Exit.isFailure(failed)).toBe(true); - const valid = yield* validateWorkloadRuntimeInputs(configured, planned("rest:rest"), { - auth: { jwks: '{"keys":[]}' }, - }).pipe(Effect.exit); - expect(Exit.isSuccess(valid)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("passes Edge Runtime the JWT material used by functions serve", () => - Effect.gen(function* () { - const functions = planned("functions:edge-runtime"); - const functionSpec = runtimeSpecFor(functions); - expect(functionSpec).toBeDefined(); - if (functionSpec === undefined) return; - const symmetricDefault = containerResolutionFor(state, functions); - expect(symmetricDefault?.env).toMatchObject({ - SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", - SUPABASE_INTERNAL_PUBLISHABLE_KEY: "sb_publishable_test", - SUPABASE_INTERNAL_SECRET_KEY: "sb_secret_test", - SUPABASE_INTERNAL_HOST_PORT: "54321", - SUPABASE_JWKS: '{"keys":[]}', - }); - expect( - functionSpec?.containerArgs(state, functions, functionSpec.containerPort), - ).not.toContain("sb_publishable_test"); - expect( - functionSpec?.containerArgs(state, functions, functionSpec.containerPort), - ).not.toContain("sb_secret_test"); - expect(functionSpec?.args(state, functions, functionSpec.containerPort)).not.toContain( - "sb_publishable_test", - ); - expect(functionSpec?.args(state, functions, functionSpec.containerPort)).not.toContain( - "sb_secret_test", - ); - const withoutApiAssignment: PersistedStackState = { - ...state, - ports: state.ports.filter((assignment) => assignment.field !== "api"), - }; - expect(containerResolutionFor(withoutApiAssignment, functions)?.env).not.toHaveProperty( - "SUPABASE_INTERNAL_HOST_PORT", ); - const symmetric = containerResolutionFor(state, functions, { - auth: { jwks: '{"keys":[{"kty":"EC"}]}' }, - functions: { - secrets: { - APP_SECRET: "value", - EMPTY_SECRET: "", - EDGE_RUNTIME_PORT: "secret-collision", - SUPABASE_INTERNAL_JWT_SECRET: "forbidden-jwt", - SUPABASE_INTERNAL_PUBLISHABLE_KEY: "forbidden-publishable", - SUPABASE_INTERNAL_SECRET_KEY: "forbidden-secret", - SUPABASE_INTERNAL_HOST_PORT: "forbidden-port", - SUPABASE_JWKS: "forbidden-jwks", - }, - }, - }); - expect(symmetric?.env).toMatchObject({ - SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", - SUPABASE_INTERNAL_PUBLISHABLE_KEY: "sb_publishable_test", - SUPABASE_INTERNAL_SECRET_KEY: "sb_secret_test", - SUPABASE_INTERNAL_HOST_PORT: "54321", - SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', - APP_SECRET: "value", - EMPTY_SECRET: "", - }); - expect(symmetric?.env.EDGE_RUNTIME_PORT).toBe("9000"); - - const jwksCompiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } } }, - }).pipe(Effect.provide(NodeServices.layer)); - const jwksState: PersistedStackState = { ...state, definition: jwksCompiled.definition }; - const jwksResolution = yield* resolveContainerResolutionFor(jwksState, functions, { - auth: { jwtKeys: '[{"kty":"EC"}]', jwks: '{"keys":[{"kty":"EC"}]}' }, - }); - expect(jwksResolution?.env).toMatchObject({ - SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', - SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", + const workload = workloadFor(plan, "functions:edge-runtime"); + const spec = runtimeSpecFor(workload); + if (spec === undefined) return yield* Effect.die("Functions runtime spec missing"); + const port = state.privatePorts.find( + (entry) => entry.instanceId === workload.instanceId && entry.workloadId === workload.id, + )?.port; + if (port === undefined) return yield* Effect.die("Functions private port missing"); + const encoded = spec.env( + state, + workload, + port, + "container", + ).SUPABASE_INTERNAL_FUNCTIONS_CONFIG; + const config = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + entrypointPath: Schema.optionalKey(Schema.String), + importMapPath: Schema.optionalKey(Schema.String), + staticFiles: Schema.optionalKey(Schema.Array(Schema.String)), + }), + ), + ), + )(encoded); + expect(config.hello).toEqual({ + entrypointPath: "/tmp/workload-runtime/supabase/functions/hello/index.ts", + importMapPath: "/tmp/workload-runtime/supabase/functions/shared/deno.json", + staticFiles: ["/tmp/workload-runtime/supabase/functions/hello/public/*.txt"], }); - - const thirdPartyCompiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: { kind: "native" }, - config: { - capabilities: { - auth: { - settings: { - third_party: { firebase: { enabled: true, project_id: "project-42" } }, - }, - }, - }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const thirdPartyState: PersistedStackState = { - ...state, - definition: thirdPartyCompiled.definition, - }; - const thirdPartyResolution = yield* resolveContainerResolutionFor( - thirdPartyState, - functions, - { auth: { jwks: '{"keys":[{"kty":"EC"}]}' } }, - ); - expect(thirdPartyResolution?.env.SUPABASE_JWKS).toBe('{"keys":[{"kty":"EC"}]}'); - expect(thirdPartyResolution?.env.SUPABASE_INTERNAL_JWT_SECRET).toBe("symmetric-secret"); - }).pipe(Effect.provide(NodeServices.layer)), + }), ); }); diff --git a/packages/stack/src/state/MaterializedSettings.ts b/packages/stack/src/state/MaterializedSettings.ts index 7f993d221f..ab63dcd6a2 100644 --- a/packages/stack/src/state/MaterializedSettings.ts +++ b/packages/stack/src/state/MaterializedSettings.ts @@ -8,8 +8,22 @@ export const isRecord = (value: unknown): value is Readonly - state.definition?.capabilities[capability].settings; +const settingsFor = (state: PersistedStackState, capability: CapabilityName): unknown => + state.registry.instances.find( + (instance) => + instance.id === state.registry.defaultInstanceIds[capability] && + instance.service === capability, + )?.config.settings; + +/** Returns one instance's materialized settings without substituting another instance. */ +export const settingsForInstance = ( + state: PersistedStackState, + instanceId: string, + capability: CapabilityName, +): unknown => + state.registry.instances.find( + (instance) => instance.id === instanceId && instance.service === capability, + )?.config.settings; /** Resolves a persisted secret slot to the value supplied to a workload. */ export const secret = (state: PersistedStackState, slot: string): string => @@ -100,7 +114,10 @@ export const validateMaterializedSecrets = ( }; const names = capability === undefined ? CAPABILITY_NAMES : [capability]; for (const name of names) { - const settings = state.definition?.capabilities[name]?.settings; + const settings = state.registry.instances.find( + (instance) => + instance.id === state.registry.defaultInstanceIds[name] && instance.service === name, + )?.config.settings; const failure = visit(settings, `${name}.settings`); if (failure !== undefined) return Effect.fail(failure); } diff --git a/packages/stack/src/state/MaterializedSettingsValidation.ts b/packages/stack/src/state/MaterializedSettingsValidation.ts new file mode 100644 index 0000000000..207f545d28 --- /dev/null +++ b/packages/stack/src/state/MaterializedSettingsValidation.ts @@ -0,0 +1,212 @@ +import { Effect, Redacted, Schema, SchemaAST, SchemaIssue } from "effect"; +import { + AuthModule, + DatabaseModule, + FunctionsModule, + MailModule, + PoolerModule, + RealtimeModule, + RestModule, + StorageModule, + StudioModule, + AnalyticsModule, + AuthSettingsSchema, + DatabaseSettingsSchema, + FunctionsSettingsSchema, + MailSettingsSchema, + PoolerSettingsSchema, + RealtimeSettingsSchema, + RestSettingsSchema, + StorageSettingsSchema, + StudioSettingsSchema, + AnalyticsSettingsSchema, +} from "../model/capabilities/index.ts"; +import type { + AnalyticsSettings, + AuthSettings, + DatabaseSettings, + FunctionsSettings, + MailSettings, + PoolerSettings, + RealtimeSettings, + RestSettings, + StorageSettings, + StudioSettings, +} from "../model/capabilities/index.ts"; +import type { MaterializedSettings } from "../model/CapabilityModule.ts"; +import type { CapabilityName } from "../public/Capability.ts"; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !Redacted.isRedacted(value); + +const hasExactKeys = ( + value: unknown, + keys: ReadonlyArray, +): value is Record => { + if (!isRecord(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && expected.every((key, index) => actual[index] === key); +}; + +const isSecretSlot = (value: unknown): value is { readonly slot: string } => + hasExactKeys(value, ["slot"]) && typeof value.slot === "string" && value.slot.length > 0; + +const restoreForValidation = (value: unknown): unknown => { + if (isSecretSlot(value)) return Redacted.make(""); + if (value === null) return undefined; + if (Array.isArray(value)) return value.map(restoreForValidation); + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value) + .map(([key, entry]) => [key, restoreForValidation(entry)] as const) + .filter(([, entry]) => entry !== undefined), + ); + } + return value; +}; + +const hasCompleteDefaults = (value: unknown, defaults: unknown): boolean => { + if (Array.isArray(defaults)) return Array.isArray(value); + if (!isRecord(defaults)) return value !== undefined; + if (!isRecord(value)) return false; + for (const key of Object.keys(defaults)) { + if (!Object.hasOwn(value, key)) return false; + const defaultValue = defaults[key]; + const actualValue = value[key]; + if (isRecord(defaultValue) && Object.keys(defaultValue).length > 0) { + if (!hasCompleteDefaults(actualValue, defaultValue)) return false; + } + } + return true; +}; + +const invalid = (message: string): Effect.Effect => + Effect.fail(new SchemaIssue.InvalidValue({ message })); + +const validateDynamicRecords = ( + name: CapabilityName, + settings: unknown, +): Effect.Effect => { + if (!isRecord(settings)) return Effect.void; + if (name === "functions" && isRecord(settings.functions)) { + for (const [slug, value] of Object.entries(settings.functions)) { + if (!/^[a-zA-Z0-9_-]+$/.test(slug)) + return invalid(`Materialized function ${slug} has an invalid name`); + if (!isRecord(value)) return invalid(`Materialized function ${slug} is not an object`); + const allowedKeys = new Set([ + "enabled", + "verify_jwt", + "import_map", + "entrypoint", + "static_files", + "env", + ]); + if ( + !Object.hasOwn(value, "enabled") || + !Object.hasOwn(value, "env") || + Object.keys(value).some((key) => !allowedKeys.has(key)) + ) + return invalid(`Materialized function ${slug} is missing a defaulted field`); + if (!isRecord(value.env)) return invalid(`Materialized function ${slug} has invalid env`); + for (const secret of Object.values(value.env)) + if (!isSecretSlot(secret)) return invalid("Function secret must be a slot"); + } + } + if (name === "storage" && isRecord(settings.buckets)) { + for (const [bucket, value] of Object.entries(settings.buckets)) { + if (!hasExactKeys(value, ["public", "file_size_limit", "allowed_mime_types", "objects_path"])) + return invalid(`Materialized bucket ${bucket} is missing a defaulted field`); + } + } + if (name === "auth" && isRecord(settings.email)) { + const template = settings.email.template; + if (isRecord(template)) { + for (const [name, value] of Object.entries(template)) + if (!hasExactKeys(value, ["subject", "content_path"])) + return invalid(`Materialized auth email template ${name} is missing a defaulted field`); + } + const notification = settings.email.notification; + if (isRecord(notification)) { + for (const [name, value] of Object.entries(notification)) + if (!hasExactKeys(value, ["enabled", "subject", "content_path"])) + return invalid( + `Materialized auth email notification ${name} is missing a defaulted field`, + ); + } + } + return Effect.void; +}; + +const validateModuleSettings = ( + module: { readonly name: CapabilityName; readonly defaultSettings: unknown }, + schema: Schema.Codec, + settings: unknown, + options: SchemaAST.ParseOptions, +): Effect.Effect => { + if (!hasCompleteDefaults(settings, module.defaultSettings)) + return invalid(`Materialized ${module.name} settings are incomplete`); + return Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" })( + restoreForValidation(settings), + options, + ).pipe( + Effect.asVoid, + Effect.mapError((error) => error.issue), + Effect.flatMap(() => validateDynamicRecords(module.name, settings)), + ); +}; + +const validateModuleSettingsByName = ( + name: CapabilityName, + settings: unknown, + options: SchemaAST.ParseOptions, +) => { + switch (name) { + case "database": + return validateModuleSettings(DatabaseModule, DatabaseSettingsSchema, settings, options); + case "rest": + return validateModuleSettings(RestModule, RestSettingsSchema, settings, options); + case "auth": + return validateModuleSettings(AuthModule, AuthSettingsSchema, settings, options); + case "realtime": + return validateModuleSettings(RealtimeModule, RealtimeSettingsSchema, settings, options); + case "storage": + return validateModuleSettings(StorageModule, StorageSettingsSchema, settings, options); + case "functions": + return validateModuleSettings(FunctionsModule, FunctionsSettingsSchema, settings, options); + case "studio": + return validateModuleSettings(StudioModule, StudioSettingsSchema, settings, options); + case "mail": + return validateModuleSettings(MailModule, MailSettingsSchema, settings, options); + case "analytics": + return validateModuleSettings(AnalyticsModule, AnalyticsSettingsSchema, settings, options); + case "pooler": + return validateModuleSettings(PoolerModule, PoolerSettingsSchema, settings, options); + } +}; + +type MaterializedSettingsByName = { + database: MaterializedSettings; + rest: MaterializedSettings; + auth: MaterializedSettings; + realtime: MaterializedSettings; + storage: MaterializedSettings; + functions: MaterializedSettings; + studio: MaterializedSettings; + mail: MaterializedSettings; + analytics: MaterializedSettings; + pooler: MaterializedSettings; +}; + +/** Validates a materialized leaf and preserves its service-specific type for registry codecs. */ +export const validateMaterializedSettingsByName = ( + name: K, + settings: unknown, + options: SchemaAST.ParseOptions, +): Effect.Effect => + validateModuleSettingsByName(name, settings, options).pipe( + Effect.as(settings as MaterializedSettingsByName[K]), + ); diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index e4b7baf4e3..c2c0ad61c4 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -8,6 +8,7 @@ import { Path, Predicate, Schema, + Schedule, Scope, } from "effect"; import { NodeSocketServer } from "@effect/platform-node"; @@ -259,6 +260,38 @@ export const ownerLockExists = ( ); }); +/** Waits for one owner session to release, accepting a successor publication. */ +export const waitForOwnerRelease = ( + stateRoot: string, + stackId: StackId | string, + environment: Pick, + ownerSessionId?: string, +): Effect.Effect< + void, + StackOwnershipConflictError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const metadata = yield* readOwnerMetadata(stateRoot, stackId, environment); + // A successor may publish its session before the retiring owner disappears. + if ( + metadata !== undefined && + (ownerSessionId === undefined || metadata.ownerSessionId === ownerSessionId) + ) + return yield* new StackOwnershipConflictError({ + message: "Supervisor ownership lease is still held", + }); + if (metadata === undefined && (yield* ownerLockExists(stateRoot, stackId))) + return yield* new StackOwnershipConflictError({ + message: "Supervisor ownership lease is still held", + }); + }).pipe( + Effect.retry({ + schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 })), + while: (error) => Predicate.isTagged(error, "StackOwnershipConflictError"), + }), + ); + /** * Publishes metadata through a sibling temporary file and same-directory * rename. Readers therefore observe either no document or one complete JSON diff --git a/packages/stack/src/state/Paths.ts b/packages/stack/src/state/Paths.ts index c50184019a..6f5aeaf247 100644 --- a/packages/stack/src/state/Paths.ts +++ b/packages/stack/src/state/Paths.ts @@ -1,6 +1,7 @@ import { Effect, Path, Schema } from "effect"; import { InvalidProjectRootError, InvalidStackIdentityError } from "../public/Errors.ts"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; export interface StackPaths { /** The exact `/` directory. */ @@ -14,11 +15,54 @@ export interface StackPaths { readonly controlMetadata: string; } +/** Exact stack-owned paths for one registered service instance. */ +export interface ServiceInstancePaths { + readonly instanceRoot: string; + readonly data: string; + /** Persistent data directory owned by the PostgreSQL implementation. */ + readonly postgresData: string; + readonly runtime: string; + /** Durable provider and snapshot compatibility metadata. */ + readonly manifest: string; + readonly locks: string; + readonly operations: string; + readonly snapshotStaging: string; +} + export interface ResolveStackPathsOptions { readonly stateRoot: string; readonly stackId: StackId; } +/** Resolves instance-owned storage and runtime paths beneath the validated stack root. */ +export const resolveServiceInstancePaths = ( + stack: StackPaths, + instanceId: ServiceInstanceId, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + const validated = yield* Schema.decodeEffect(ServiceInstanceIdSchema)(instanceId).pipe( + Effect.mapError( + () => + new InvalidStackIdentityError({ + message: `Invalid service instance identifier: ${instanceId}`, + }), + ), + ); + const instanceRoot = path.join(stack.runtime, "instances", validated); + const data = path.join(stack.data, "instances", validated); + return { + instanceRoot, + data, + postgresData: path.join(data, "postgres"), + runtime: instanceRoot, + manifest: path.join(data, "manifest.json"), + locks: path.join(instanceRoot, "locks"), + operations: path.join(instanceRoot, "operations"), + snapshotStaging: path.join(instanceRoot, "snapshots"), + }; + }); + /** * Resolves all durable and runtime paths under one validated StackId. * diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index a2edfb1e5b..eed2fb323a 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -1,5 +1,5 @@ import { Crypto, Effect, Exit, FileSystem, Path, Scope, Schema } from "effect"; -import { NetworkPortSchema, PORT_FIELDS, type PortField } from "../public/Status.ts"; +import { NetworkPortSchema, type PortField } from "../public/Status.ts"; import { InvalidProjectRootError, PortAllocationError, @@ -20,21 +20,33 @@ import { } from "./StackStateStore.ts"; import type { HeldPort, HostListener } from "../supervisor/HostListener.ts"; -interface ListenerIntent { - readonly enabled: boolean; - readonly address: string; - readonly port: "automatic" | number; -} - -export type ListenerIntents = Readonly>; - interface PrivatePortIntent { + readonly instanceId: string; readonly workloadId: string; readonly binding: string; } +/** One durable host binding requested by a stack-owned or instance-owned listener. */ +export type PublicPortIntent = + | { + readonly owner: "stack"; + readonly binding: "api" | "api:internal"; + readonly listenerField?: PortField; + readonly address: string; + readonly port: "automatic" | number; + } + | { + readonly owner: "instance"; + readonly instanceId: string; + readonly binding: string; + readonly listenerField?: PortField; + readonly address: string; + readonly port: "automatic" | number; + }; + export interface PortReservation { - readonly assignments: Readonly>>; + /** Keys are persisted binding keys; listener fields are retained as gateway lookup aliases. */ + readonly assignments: Readonly>; readonly privateAssignments: ReadonlyArray; readonly hostListeners: ReadonlyArray; } @@ -57,7 +69,7 @@ export interface PortCoordinatorOptions { export interface PortCoordinator { readonly acquire: ( stackId: string, - listenerIntents: ListenerIntents, + publicBindings: ReadonlyArray, privateBindings: ReadonlyArray, ) => Effect.Effect< PortReservation, @@ -70,7 +82,6 @@ export interface PortCoordinator { >; } -const fields: ReadonlyArray = PORT_FIELDS; const PORT_MIN = 20_000; const PORT_MAX = 32_767; const PORT_POOL_SIZE = PORT_MAX - PORT_MIN + 1; @@ -79,7 +90,60 @@ const MAX_FRESH_BIND_FAILURES = 64; const idPattern = /^[0-9a-f]{64}$/; const assignmentMap = (assignments: ReadonlyArray) => - new Map(assignments.map((assignment) => [assignment.field, assignment])); + new Map( + assignments.map((assignment) => [ + assignment.owner === "stack" + ? `stack:${assignment.binding}` + : `instance:${assignment.instanceId}:${assignment.binding}`, + assignment, + ]), + ); +const bindingKey = (intent: PublicPortIntent): string => + intent.owner === "stack" + ? `stack:${intent.binding}` + : `instance:${intent.instanceId}:${intent.binding}`; +const assignmentKey = (assignment: HostPortAssignment): string => + assignment.owner === "stack" + ? `stack:${assignment.binding}` + : `instance:${assignment.instanceId}:${assignment.binding}`; + +const assignmentFor = ( + intent: PublicPortIntent, + port: number, + allocationIntent: "automatic" | "exact", +): HostPortAssignment => + intent.owner === "stack" + ? { + owner: "stack", + binding: intent.binding, + address: intent.address, + port, + intent: allocationIntent, + } + : { + owner: "instance", + instanceId: intent.instanceId, + binding: intent.binding, + address: intent.address, + port, + intent: allocationIntent, + }; + +const listenerFieldFor = (intent: PublicPortIntent): PortField => + intent.listenerField ?? + (intent.owner === "stack" + ? "api" + : intent.binding === "sql" + ? "database" + : intent.binding === "pooler" + ? "pooler" + : intent.binding === "studio" + ? "studio" + : intent.binding === "smtp" + ? "smtp" + : intent.binding === "pop3" + ? "pop3" + : "functionsInspector"); const validPort = (port: number): boolean => Schema.is(NetworkPortSchema)(port); const unavailable = ( port: number, @@ -132,7 +196,6 @@ type ForeignPublicOwner = { readonly stackId: string; readonly field: string; readonly intent: "automatic" | "exact"; - readonly lifecycle: PersistedStackState["desiredLifecycle"]; }; type ForeignPrivateOwner = { readonly stackId: string; @@ -152,7 +215,7 @@ const retryable = (error: PortUnavailableError): boolean => { }; export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordinator => ({ - acquire: (stackId, listenerIntents, privateBindings) => + acquire: (stackId, publicBindings, privateBindings) => withRegistryLock( options.stateRoot, Effect.gen(function* () { @@ -161,11 +224,6 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi return yield* new StackStateInvalidError({ message: "Cannot acquire ports for an unconfigured stack", }); - if (current.desiredLifecycle !== "running") - return yield* new StackStateInvalidError({ - message: "Port acquisition requires desiredLifecycle=running", - }); - const publicOwners = new Map>(); const privateOwners = new Map(); for (const entry of yield* readAuthoritativeStates(options)) { @@ -173,9 +231,11 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi for (const assignment of entry.state.ports) { const owner: ForeignPublicOwner = { stackId: entry.stackId, - field: assignment.field, + field: + assignment.owner === "stack" + ? assignment.binding + : `${assignment.instanceId}:${assignment.binding}`, intent: assignment.intent, - lifecycle: entry.state.desiredLifecycle, }; publicOwners.set(assignment.port, [ ...(publicOwners.get(assignment.port) ?? []), @@ -185,7 +245,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi for (const assignment of entry.state.privatePorts) privateOwners.set(assignment.port, { stackId: entry.stackId, - field: `${assignment.workloadId}:${assignment.binding}`, + field: `${assignment.instanceId}:${assignment.workloadId}:${assignment.binding}`, }); } @@ -193,7 +253,15 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi const existingPrivate = new Map( current.privatePorts.map((entry) => [privateBindingKey(entry), entry]), ); - const retainedPublic = new Map(); + const requestedPublic = new Map(); + for (const intent of publicBindings) { + const key = bindingKey(intent); + if (requestedPublic.has(key)) + return yield* allocation(intent.binding, "Duplicate public listener binding"); + requestedPublic.set(key, intent); + } + const requestedPrivate = new Map(); + const retainedPublic = new Map(); const retainedPrivate = new Map(); const hardClaims = new Map(); const occupied = new Set(); @@ -205,6 +273,12 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi occupied.add(port); return undefined; }; + for (const assignment of current.ports) { + if (!requestedPublic.has(assignmentKey(assignment))) { + const duplicate = claim(assignment.port, assignmentKey(assignment)); + if (duplicate !== undefined) return yield* duplicate; + } + } const foreignConflict = (port: number, field: string): PortUnavailableError | undefined => { const privateOwner = privateOwners.get(port); if (privateOwner !== undefined) @@ -214,9 +288,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi `Port ${port} for ${field} is reserved by ${ownerText(privateOwner)}`, ); const owners = publicOwners.get(port); - const conflict = owners?.find( - (owner) => owner.intent === "automatic" || owner.lifecycle === "running", - ); + const conflict = owners?.find(() => true); if (conflict !== undefined) return unavailable( port, @@ -227,35 +299,38 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi }; // Preseed every own retained assignment before allocating any fresh field. - for (const field of fields) { - const intent = listenerIntents[field]; - const prior = existingPublic.get(field); - if (!intent.enabled || intent.port !== "automatic" || prior?.intent !== "automatic") - continue; - if (!validPort(prior.port)) return yield* unavailable(prior.port, field); - const foreign = foreignConflict(prior.port, field); + for (const intent of publicBindings) { + if (intent.port !== "automatic") continue; + const key = bindingKey(intent); + const prior = existingPublic.get(key); + if (prior?.intent !== "automatic") continue; + if (!validPort(prior.port)) return yield* unavailable(prior.port, intent.binding); + const foreign = foreignConflict(prior.port, intent.binding); if (foreign !== undefined) return yield* foreign; - const duplicate = claim(prior.port, field); + const duplicate = claim(prior.port, intent.binding); if (duplicate !== undefined) return yield* duplicate; - retainedPublic.set(field, prior); + retainedPublic.set(key, assignmentFor(intent, prior.port, "automatic")); } - const requestedPrivate = new Map(); for (const intent of privateBindings) { const key = privateBindingKey(intent); - if (intent.workloadId.length === 0 || intent.binding.length === 0) + if ( + intent.instanceId.length === 0 || + intent.workloadId.length === 0 || + intent.binding.length === 0 + ) return yield* allocation( - `${intent.workloadId}:${intent.binding}`, + `${intent.instanceId}:${intent.workloadId}:${intent.binding}`, "Private workload binding is invalid", ); if (requestedPrivate.has(key)) return yield* allocation( - `${intent.workloadId}:${intent.binding}`, + `${intent.instanceId}:${intent.workloadId}:${intent.binding}`, "Duplicate private workload binding", ); requestedPrivate.set(key, intent); const prior = existingPrivate.get(key); if (prior === undefined) continue; - const label = `${intent.workloadId}:${intent.binding}`; + const label = `${intent.instanceId}:${intent.workloadId}:${intent.binding}`; if (!validPort(prior.port)) return yield* unavailable(prior.port, label); const foreign = foreignConflict(prior.port, label); if (foreign !== undefined) return yield* foreign; @@ -263,16 +338,23 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi if (duplicate !== undefined) return yield* duplicate; retainedPrivate.set(key, prior); } - const exactAssignments = new Map(); - for (const field of fields) { - const intent = listenerIntents[field]; - if (!intent.enabled || intent.port === "automatic") continue; - if (!validPort(intent.port)) return yield* unavailable(intent.port, field); - const foreign = foreignConflict(intent.port, field); + for (const assignment of current.privatePorts) { + const key = privateBindingKey(assignment); + if (!requestedPrivate.has(key)) { + const duplicate = claim(assignment.port, key); + if (duplicate !== undefined) return yield* duplicate; + } + } + const exactAssignments = new Map(); + for (const intent of publicBindings) { + if (intent.port === "automatic") continue; + const key = bindingKey(intent); + if (!validPort(intent.port)) return yield* unavailable(intent.port, intent.binding); + const foreign = foreignConflict(intent.port, intent.binding); if (foreign !== undefined) return yield* foreign; - const duplicate = claim(intent.port, field); + const duplicate = claim(intent.port, intent.binding); if (duplicate !== undefined) return yield* duplicate; - exactAssignments.set(field, { field, port: intent.port, intent: "exact" }); + exactAssignments.set(key, assignmentFor(intent, intent.port, "exact")); } for (const port of publicOwners.keys()) occupied.add(port); for (const port of privateOwners.keys()) occupied.add(port); @@ -330,43 +412,38 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi Effect.gen(function* () { const privateScope = yield* Scope.fork(attemptScope, "sequential"); const assignments: HostPortAssignment[] = []; - const byField: Partial> = {}; + const byBinding: Record = {}; const listeners: HostListener[] = []; - for (const field of fields) { - const intent = listenerIntents[field]; - if (!intent.enabled) continue; - const retained = retainedPublic.get(field); - const exact = exactAssignments.get(field); + for (const intent of publicBindings) { + const key = bindingKey(intent); + const retained = retainedPublic.get(key); + const exact = exactAssignments.get(key); const assignment = retained ?? exact; if (assignment !== undefined) { const listener = yield* options - .bindHost(intent.address, assignment.port, field) + .bindHost(intent.address, assignment.port, listenerFieldFor(intent)) .pipe(Effect.provideService(Scope.Scope, attemptScope)); assignments.push(assignment); - byField[field] = assignment; - listeners.push(listener); + byBinding[key] = assignment; + listeners.push({ ...listener, routeKey: key }); occupied.add(assignment.port); continue; } - const fresh = yield* allocateFresh(field, (port) => + const fresh = yield* allocateFresh(intent.binding, (port) => options - .bindHost(intent.address, port, field) + .bindHost(intent.address, port, listenerFieldFor(intent)) .pipe(Effect.provideService(Scope.Scope, attemptScope)), ); - const assignmentFresh: HostPortAssignment = { - field, - port: fresh.port, - intent: "automatic", - }; + const assignmentFresh = assignmentFor(intent, fresh.port, "automatic"); assignments.push(assignmentFresh); - byField[field] = assignmentFresh; - listeners.push(fresh.value); + byBinding[key] = assignmentFresh; + listeners.push({ ...fresh.value, routeKey: key }); } const privateAssignments: PrivatePortAssignment[] = []; for (const intent of requestedPrivate.values()) { const key = privateBindingKey(intent); - const label = `${intent.workloadId}:${intent.binding}`; + const label = `${intent.instanceId}:${intent.workloadId}:${intent.binding}`; const retained = retainedPrivate.get(key); if (retained !== undefined) { const held = yield* options @@ -382,21 +459,49 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi .pipe(Effect.provideService(Scope.Scope, privateScope)), ); privateAssignments.push({ + instanceId: intent.instanceId, workloadId: intent.workloadId, binding: intent.binding, port: fresh.port, }); } + const requestedPublicKeys = new Set(requestedPublic.keys()); + const requestedPrivateKeys = new Set(requestedPrivate.keys()); const next: PersistedStackState = { ...current, - ports: assignments, - privatePorts: privateAssignments, + ports: [ + ...current.ports.filter( + (assignment) => !requestedPublicKeys.has(assignmentKey(assignment)), + ), + ...assignments, + ], + privatePorts: [ + ...current.privatePorts.filter( + (assignment) => !requestedPrivateKeys.has(privateBindingKey(assignment)), + ), + ...privateAssignments, + ], }; return { privateScope, next, reservation: { - assignments: byField, + assignments: { + ...byBinding, + ...Object.fromEntries( + assignments.flatMap((assignment) => { + const field = + assignment.owner === "stack" + ? assignment.binding + : assignment.binding === "sql" + ? "database" + : assignment.binding === "inspector" + ? "functionsInspector" + : undefined; + return field === undefined ? [] : [[field, assignment]]; + }), + ), + }, privateAssignments, hostListeners: listeners, }, diff --git a/packages/stack/src/state/PortPlanner.ts b/packages/stack/src/state/PortPlanner.ts new file mode 100644 index 0000000000..a444e1e466 --- /dev/null +++ b/packages/stack/src/state/PortPlanner.ts @@ -0,0 +1,176 @@ +import { Effect, Schema } from "effect"; +import { NetworkPortSchema } from "../public/Status.ts"; +import { StackStateInvalidError } from "../public/Errors.ts"; +import type { + HostPortAssignment, + PersistedStackState, + PrivatePortAssignment, +} from "./StackState.ts"; + +const PORT_MIN = 20_000; +const PORT_MAX = 32_767; +const PORT_POOL_SIZE = PORT_MAX - PORT_MIN + 1; + +type PortAssignment = HostPortAssignment | PrivatePortAssignment; +type SiblingState = Readonly<{ stackId: string; state: PersistedStackState }>; + +const isValidPort = Schema.is(NetworkPortSchema); + +const assignmentKey = (assignment: PortAssignment): string => + "owner" in assignment + ? assignment.owner === "stack" + ? `stack:${assignment.binding}` + : `instance:${assignment.instanceId}:${assignment.binding}` + : `private:${assignment.instanceId}:${assignment.workloadId}:${assignment.binding}`; + +const assignmentField = (assignment: PortAssignment): string => + "owner" in assignment + ? assignment.owner === "stack" + ? assignment.binding + : `${assignment.instanceId}:${assignment.binding}` + : `${assignment.instanceId}:${assignment.workloadId}:${assignment.binding}`; + +const assignmentIsExact = (assignment: PortAssignment): boolean => + "intent" in assignment && assignment.intent === "exact"; + +const portError = (message: string, field?: string) => + new StackStateInvalidError({ + message, + code: "stable-port-plan", + ...(field === undefined ? {} : { path: field }), + }); + +const hashStart = (identity: string): number => { + let hash = 2_166_136_261; + for (const character of identity) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16_777_619); + } + return Math.abs(hash) % PORT_POOL_SIZE; +}; + +const replacement = (assignment: PortAssignment, port: number): PortAssignment => ({ + ...assignment, + port, +}); + +/** + * Plans durable automatic bindings against sibling state while the caller holds the registry + * transaction. Existing automatic assignments are retained whenever their claim is still free. + */ +export const planStablePorts = ( + stackId: string, + state: PersistedStackState, + siblings: ReadonlyArray, + previous?: PersistedStackState, +): Effect.Effect => + Effect.gen(function* () { + const occupied = new Set(); + for (const sibling of siblings) + for (const assignment of [...sibling.state.ports, ...sibling.state.privatePorts]) + occupied.add(assignment.port); + + const assignments: ReadonlyArray = [...state.ports, ...state.privatePorts]; + const previousByKey = new Map( + previous === undefined + ? [] + : [...previous.ports, ...previous.privatePorts].map((assignment) => [ + assignmentKey(assignment), + assignment, + ]), + ); + const preferredPort = (assignment: PortAssignment): number => { + const prior = previousByKey.get(assignmentKey(assignment)); + if (prior === undefined) return assignment.port; + if ("owner" in assignment && assignment.intent === "exact") return assignment.port; + return prior.port; + }; + for (const assignment of assignments) { + const prior = previousByKey.get(assignmentKey(assignment)); + if (prior === undefined || ("owner" in assignment && assignment.intent === "exact")) continue; + if (occupied.has(prior.port)) + return yield* portError( + `Previously published port ${prior.port} for ${assignmentField(assignment)} conflicts with another stack binding`, + assignmentField(assignment), + ); + } + const reservedCandidatePorts = new Set(); + for (const assignment of assignments) { + const port = preferredPort(assignment); + if (!occupied.has(port)) reservedCandidatePorts.add(port); + } + const planned = new Map(); + const results = new Map(); + const fresh = (assignment: PortAssignment, index: number): number => { + const start = hashStart(`${stackId}:${assignmentKey(assignment)}:${index}`); + for (let offset = 0; offset < PORT_POOL_SIZE; offset += 1) { + const port = PORT_MIN + ((start + offset) % PORT_POOL_SIZE); + if (!occupied.has(port) && !planned.has(port) && !reservedCandidatePorts.has(port)) + return port; + } + return -1; + }; + + const order = assignments + .map((assignment, index) => ({ assignment, index })) + .sort( + (left, right) => + Number(assignmentIsExact(right.assignment)) - Number(assignmentIsExact(left.assignment)), + ); + for (const { assignment, index } of order) { + const field = assignmentField(assignment); + const requestedPort = preferredPort(assignment); + if (!isValidPort(requestedPort)) + return yield* portError( + `Port ${requestedPort} for ${field} is outside the managed range`, + field, + ); + + const ownCollision = planned.has(requestedPort); + const siblingCollision = occupied.has(requestedPort); + const collision = ownCollision || siblingCollision; + if (!collision) { + const retained = + requestedPort === assignment.port ? assignment : replacement(assignment, requestedPort); + planned.set(requestedPort, retained); + results.set(index, retained); + continue; + } + if (ownCollision) + return yield* portError( + `Port overlap: ${requestedPort} is claimed by multiple bindings in this plan`, + field, + ); + if (assignmentIsExact(assignment)) + return yield* portError( + `Exact port ${requestedPort} for ${field} conflicts with another stack binding`, + field, + ); + const port = fresh(assignment, index); + if (port < 0) return yield* portError(`No automatic port is available for ${field}`, field); + const next = replacement(assignment, port); + planned.set(port, next); + results.set(index, next); + } + + const ports: HostPortAssignment[] = []; + for (let index = 0; index < state.ports.length; index += 1) { + const assignment = results.get(index); + if (assignment === undefined || !("owner" in assignment)) + return yield* portError("Stable port planner lost a public binding", String(index)); + ports.push(assignment); + } + const privatePorts: PrivatePortAssignment[] = []; + for (let index = 0; index < state.privatePorts.length; index += 1) { + const assignment = results.get(state.ports.length + index); + if (assignment === undefined || "owner" in assignment) + return yield* portError("Stable port planner lost a private binding", String(index)); + privatePorts.push(assignment); + } + + return { + ...state, + ports, + privatePorts, + }; + }); diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index b344817fb4..a5a33c1ae4 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -14,7 +14,6 @@ export const AUTH_SECRET_KEY_SLOT = "secret:auth.settings.secret_key"; export const AUTH_ANON_KEY_SLOT = "secret:auth.settings.anon_key"; export const AUTH_SERVICE_ROLE_KEY_SLOT = "secret:auth.settings.service_role_key"; export const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; -export const DATABASE_INTERNAL_PASSWORD_SLOT = "secret:database.internal.password"; type SecretPolicy = "managed" | "passthrough"; @@ -34,7 +33,7 @@ export type SecretGenerator = readonly signing: SecretJwtSigning; }; -export interface SecretDeclaration { +interface SecretDeclaration { readonly slot: string; readonly policy: SecretPolicy; readonly value?: Redacted.Redacted; diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index cc4d146aa7..3b906b00d6 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -1,349 +1,78 @@ -import { Effect, Redacted, Schema, SchemaAST, SchemaGetter, SchemaIssue } from "effect"; -import type { StackIdentity } from "../identity/Identity.ts"; +import { Schema } from "effect"; import { - AuthModule, - DatabaseModule, - FunctionsModule, - MailModule, - PoolerModule, - RealtimeModule, - RestModule, - StorageModule, - StudioModule, - AnalyticsModule, - AuthSettingsSchema, - DatabaseSettingsSchema, - FunctionsSettingsSchema, - MailSettingsSchema, - PoolerSettingsSchema, - RealtimeSettingsSchema, - RestSettingsSchema, - StorageSettingsSchema, - StudioSettingsSchema, - AnalyticsSettingsSchema, -} from "../model/capabilities/index.ts"; -import type { StackDefinition } from "../model/Compiler.ts"; -import type { CapabilityModule } from "../model/CapabilityModule.ts"; -import type { CapabilityName } from "../public/Capability.ts"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; + HostPortAssignmentSchema, + PrivatePortAssignmentSchema, + PersistedSecretValuesSchema, + PersistedStackIdentitySchema, +} from "./StackStatePrimitives.ts"; +import { PersistedServiceRegistrySchema } from "../model/ServiceRegistry.ts"; import { StackRuntimeSchema } from "../public/Runtime.ts"; -import { DesiredStackLifecycleSchema, NetworkPortSchema, PORT_FIELDS } from "../public/Status.ts"; +import { ListenerConfigSchema } from "../public/Config.ts"; -export const STACK_STATE_FORMAT = "supabase-stack-state-v1" as const; +export const STACK_STATE_FORMAT = "supabase-stack-state-v2" as const; -const PersistedStackIdentitySchema = Schema.Struct({ - projectRoot: Schema.String, - branchContext: Schema.String, - stackName: Schema.String, -}); -export type PersistedStackIdentity = Schema.Schema.Type; - -const HostPortAssignmentSchema = Schema.Struct({ - field: Schema.Literals(PORT_FIELDS), - port: NetworkPortSchema, - intent: Schema.Literals(["automatic", "exact"] as const), -}); -export type HostPortAssignment = Schema.Schema.Type; - -/** A durable loopback endpoint used by the host gateway to reach one workload. */ -const PrivatePortAssignmentSchema = Schema.Struct({ - workloadId: Schema.String.check(Schema.isNonEmpty()), - binding: Schema.String.check(Schema.isNonEmpty()), - port: NetworkPortSchema, -}); -export type PrivatePortAssignment = Schema.Schema.Type; - -/** Stable identity for one durable private workload binding. */ -export const privateBindingKey = ( - assignment: Pick, -): string => `${assignment.workloadId}\u0000${assignment.binding}`; - -const PersistedSecretEntrySchema = Schema.Struct({ - policy: Schema.Literals(["managed", "passthrough"] as const), - value: Schema.String, -}); -/** Secret slots are dynamic because function environment names are user-defined. */ -const PersistedSecretValuesSchema = Schema.Record( - Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_.:/-]+$/)), - PersistedSecretEntrySchema, -); -export type PersistedSecretValues = Schema.Schema.Type; - -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - !Redacted.isRedacted(value); - -const hasExactKeys = ( - value: unknown, - keys: ReadonlyArray, -): value is Record => { - if (!isRecord(value)) return false; - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && expected.every((key, index) => actual[index] === key); -}; - -const isSecretSlot = (value: unknown): value is { readonly slot: string } => - hasExactKeys(value, ["slot"]) && typeof value.slot === "string" && value.slot.length > 0; - -/** Convert persisted materialized leaves back to the input representation solely for validation. */ -const restoreForValidation = (value: unknown): unknown => { - if (isSecretSlot(value)) return Redacted.make(""); - if (value === null) return undefined; - if (Array.isArray(value)) return value.map(restoreForValidation); - if (isRecord(value)) { - return Object.fromEntries( - Object.entries(value) - .map(([key, entry]) => [key, restoreForValidation(entry)] as const) - .filter(([, entry]) => entry !== undefined), - ); - } - return value; -}; - -const hasCompleteDefaults = (value: unknown, defaults: unknown): boolean => { - if (Array.isArray(defaults)) return Array.isArray(value); - if (!isRecord(defaults)) return value !== undefined; - if (!isRecord(value)) return false; - for (const key of Object.keys(defaults)) { - if (!Object.hasOwn(value, key)) return false; - const defaultValue = defaults[key]; - const actualValue = value[key]; - if (isRecord(defaultValue) && Object.keys(defaultValue).length > 0) { - if (!hasCompleteDefaults(actualValue, defaultValue)) return false; - } - } - return true; -}; - -const capabilityKeys = [ - "enabled", - "activation", - "idleTimeoutSeconds", - "version", - "settings", -] as const; - -const invalid = (message: string): Effect.Effect => - Effect.fail(new SchemaIssue.InvalidValue({ message })); - -const validateDynamicRecords = ( - name: CapabilityName, - settings: unknown, -): Effect.Effect => { - if (!isRecord(settings)) return Effect.void; - if (name === "functions" && isRecord(settings.functions)) { - for (const [slug, value] of Object.entries(settings.functions)) { - if (!/^[a-zA-Z0-9_-]+$/.test(slug)) - return invalid(`Materialized function ${slug} has an invalid name`); - if ( - !hasExactKeys(value, [ - "enabled", - "verify_jwt", - "import_map", - "entrypoint", - "static_files", - "env", - ]) - ) - return invalid(`Materialized function ${slug} is missing a defaulted field`); - if (!isRecord(value.env)) return invalid(`Materialized function ${slug} has invalid env`); - for (const secret of Object.values(value.env)) - if (!isSecretSlot(secret)) return invalid("Function secret must be a slot"); - } - } - if (name === "storage" && isRecord(settings.buckets)) { - for (const [bucket, value] of Object.entries(settings.buckets)) { - if (!hasExactKeys(value, ["public", "file_size_limit", "allowed_mime_types", "objects_path"])) - return invalid(`Materialized bucket ${bucket} is missing a defaulted field`); - } - } - if (name === "auth" && isRecord(settings.email)) { - const template = settings.email.template; - if (isRecord(template)) { - for (const [name, value] of Object.entries(template)) - if (!hasExactKeys(value, ["subject", "content_path"])) - return invalid(`Materialized auth email template ${name} is missing a defaulted field`); - } - const notification = settings.email.notification; - if (isRecord(notification)) { - for (const [name, value] of Object.entries(notification)) - if (!hasExactKeys(value, ["enabled", "subject", "content_path"])) - return invalid( - `Materialized auth email notification ${name} is missing a defaulted field`, - ); - } - } - return Effect.void; -}; - -const validateModuleSettings = ( - module: CapabilityModule, - schema: Schema.Codec, - settings: unknown, - options: SchemaAST.ParseOptions, -): Effect.Effect => { - if (!hasCompleteDefaults(settings, module.defaultSettings)) - return invalid(`Materialized ${module.name} settings are incomplete`); - return Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" })( - restoreForValidation(settings), - options, - ).pipe( - Effect.asVoid, - Effect.mapError((error) => error.issue), - Effect.flatMap(() => validateDynamicRecords(module.name, settings)), - ); -}; - -const validateModuleSettingsByName = ( - name: CapabilityName, - settings: unknown, - options: SchemaAST.ParseOptions, -) => { - switch (name) { - case "database": - return validateModuleSettings(DatabaseModule, DatabaseSettingsSchema, settings, options); - case "rest": - return validateModuleSettings(RestModule, RestSettingsSchema, settings, options); - case "auth": - return validateModuleSettings(AuthModule, AuthSettingsSchema, settings, options); - case "realtime": - return validateModuleSettings(RealtimeModule, RealtimeSettingsSchema, settings, options); - case "storage": - return validateModuleSettings(StorageModule, StorageSettingsSchema, settings, options); - case "functions": - return validateModuleSettings(FunctionsModule, FunctionsSettingsSchema, settings, options); - case "studio": - return validateModuleSettings(StudioModule, StudioSettingsSchema, settings, options); - case "mail": - return validateModuleSettings(MailModule, MailSettingsSchema, settings, options); - case "analytics": - return validateModuleSettings(AnalyticsModule, AnalyticsSettingsSchema, settings, options); - case "pooler": - return validateModuleSettings(PoolerModule, PoolerSettingsSchema, settings, options); - } -}; - -const isDefinitionShape = (input: unknown): input is StackDefinition => { - if (!hasExactKeys(input, ["preparation", "capabilities", "listeners", "security"])) return false; - if (input.preparation !== "background" && input.preparation !== "on-demand") return false; - const capabilities = input.capabilities; - if (!hasExactKeys(capabilities, CAPABILITY_NAMES)) return false; - for (const name of CAPABILITY_NAMES) { - const capability = capabilities[name]; - if (!hasExactKeys(capability, capabilityKeys)) return false; - if (typeof capability.enabled !== "boolean") return false; - if (capability.activation !== "eager" && capability.activation !== "lazy") return false; - if ( - capability.idleTimeoutSeconds !== false && - (typeof capability.idleTimeoutSeconds !== "number" || - !Number.isFinite(capability.idleTimeoutSeconds) || - capability.idleTimeoutSeconds <= 0) - ) - return false; - if (typeof capability.version !== "string" || capability.version.length === 0) return false; - } - const listeners = input.listeners; - if (!hasExactKeys(listeners, PORT_FIELDS)) return false; - for (const field of PORT_FIELDS) { - const listener = listeners[field]; - if (!hasExactKeys(listener, ["enabled", "address", "port"])) return false; - if (typeof listener.enabled !== "boolean" || typeof listener.address !== "string") return false; - if (!(listener.port === "automatic" || Schema.is(NetworkPortSchema)(listener.port))) - return false; - } - const security = input.security; - if (!hasExactKeys(security, ["jwt"]) || !hasExactKeys(security.jwt, ["issuer", "signing"])) - return false; - const jwt = security.jwt; - if (!(jwt.issuer === null || typeof jwt.issuer === "string")) return false; - const signing = jwt.signing; - if (signing === null) return true; - if (!isRecord(signing)) return false; - if (signing.kind === "symmetric") - return hasExactKeys(signing, ["kind", "secret"]) && isSecretSlot(signing.secret); - return ( - hasExactKeys(signing, ["kind", "path"]) && - signing.kind === "jwks-file" && - typeof signing.path === "string" - ); -}; - -/** One closed, exhaustive schema for the compiler's fully materialized definition. */ -const StackDefinitionSchema = Schema.declareConstructor()( - [], - () => (input, ast, options) => - Effect.gen(function* () { - if (!isDefinitionShape(input)) - return yield* invalid("Materialized definition shape is invalid"); - const capabilities = input.capabilities; - for (const name of CAPABILITY_NAMES) { - const capability = capabilities[name]; - yield* validateModuleSettingsByName(name, capability.settings, options); - } - return input; - }), - { title: "Materialized StackDefinition" }, -); - -const PortAssignmentsSchema = Schema.Array(HostPortAssignmentSchema).pipe( - Schema.decode({ - decode: SchemaGetter.checkEffect((assignments) => - Effect.succeed( - new Set(assignments.map(({ field }) => field)).size === assignments.length && - new Set(assignments.map(({ port }) => port)).size === assignments.length - ? undefined - : "Duplicate persisted port field or port", - ), - ), - encode: SchemaGetter.passthrough(), +const PersistedJwtSigningSchema = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("symmetric"), + secret: Schema.Struct({ slot: Schema.String.check(Schema.isNonEmpty()) }), }), -); - -const PrivatePortAssignmentsSchema = Schema.Array(PrivatePortAssignmentSchema).pipe( - Schema.decode({ - decode: SchemaGetter.checkEffect((assignments) => - Effect.succeed( - new Set(assignments.map(privateBindingKey)).size === assignments.length && - new Set(assignments.map(({ port }) => port)).size === assignments.length - ? undefined - : "Duplicate persisted private binding or port", - ), - ), - encode: SchemaGetter.passthrough(), + Schema.Struct({ + kind: Schema.Literal("jwks-file"), + path: Schema.String.check(Schema.isNonEmpty()), + }), +]); +const PersistedSharedSecuritySchema = Schema.Struct({ + jwt: Schema.Struct({ + issuer: Schema.NullOr(Schema.String), + expirySeconds: Schema.Int.check(Schema.isGreaterThan(0)), + signing: PersistedJwtSigningSchema, }), -); +}); +const PersistedSharedListenersSchema = Schema.Struct({ + api: Schema.optionalKey(ListenerConfigSchema), +}); -const stateShape = Schema.Struct({ +/** Canonical durable state keeps shared stack material separate from service instances. */ +export const PersistedStackStateSchema = Schema.Struct({ format: Schema.Literal(STACK_STATE_FORMAT), identity: PersistedStackIdentitySchema, runtime: StackRuntimeSchema, - desiredLifecycle: DesiredStackLifecycleSchema, - definition: Schema.optional(StackDefinitionSchema), - ports: PortAssignmentsSchema, - privatePorts: PrivatePortAssignmentsSchema, + preparation: Schema.Literals(["background", "on-demand"] as const), + security: PersistedSharedSecuritySchema, + listeners: PersistedSharedListenersSchema, + registry: PersistedServiceRegistrySchema, + ports: Schema.Array(HostPortAssignmentSchema), + privatePorts: Schema.Array(PrivatePortAssignmentSchema), secrets: PersistedSecretValuesSchema, }); - -/** Complete durable state. An optional definition is present once the identity is configured. */ -export const PersistedStackStateSchema = stateShape.pipe( - Schema.decode({ - decode: SchemaGetter.checkEffect((state) => - Effect.succeed( - state.ports.some(({ port }) => state.privatePorts.some((entry) => entry.port === port)) - ? "Public and private persisted ports must not overlap" - : undefined, - ), - ), - encode: SchemaGetter.passthrough(), - }), -); export type PersistedStackState = Schema.Schema.Type; -export const toPersistedIdentity = (identity: StackIdentity): PersistedStackIdentity => ({ - projectRoot: identity.projectRoot, - branchContext: identity.branchContext, - stackName: identity.stackName, -}); +/** Validates host bindings and the native runtime's shared loopback namespace. */ +export const validatePortAssignments = ( + state: Pick, +): string | undefined => { + const hostBindings = new Set(); + for (const assignment of state.ports) { + const key = `${assignment.address}\u0000${assignment.port}`; + if (hostBindings.has(key)) + return `Port overlap: duplicate host binding ${assignment.address}:${assignment.port}`; + hostBindings.add(key); + } + if (state.runtime.kind !== "native") return undefined; + const privatePorts = new Set(); + for (const assignment of state.privatePorts) { + if (privatePorts.has(assignment.port)) + return `Port overlap: duplicate native private binding ${assignment.port}`; + privatePorts.add(assignment.port); + if ([...state.ports].some((host) => host.port === assignment.port)) + return `Port overlap: native host/private binding ${assignment.port}`; + } + return undefined; +}; + +export { privateBindingKey, toPersistedIdentity } from "./StackStatePrimitives.ts"; +export type { + HostPortAssignment, + PrivatePortAssignment, + PersistedSecretValues, +} from "./StackStatePrimitives.ts"; diff --git a/packages/stack/src/state/StackStatePrimitives.ts b/packages/stack/src/state/StackStatePrimitives.ts new file mode 100644 index 0000000000..11eb2cce5d --- /dev/null +++ b/packages/stack/src/state/StackStatePrimitives.ts @@ -0,0 +1,60 @@ +import { Schema } from "effect"; +import type { StackIdentity } from "../identity/Identity.ts"; +import { NetworkPortSchema } from "../public/Status.ts"; + +export const PersistedStackIdentitySchema = Schema.Struct({ + projectRoot: Schema.String, + branchContext: Schema.String, + stackName: Schema.String, +}); +export type PersistedStackIdentity = Schema.Schema.Type; + +export const HostPortAssignmentSchema = Schema.Union([ + Schema.Struct({ + owner: Schema.Literal("stack"), + binding: Schema.Literals(["api", "api:internal"] as const), + address: Schema.String, + port: NetworkPortSchema, + intent: Schema.Literals(["automatic", "exact"] as const), + }), + Schema.Struct({ + owner: Schema.Literal("instance"), + instanceId: Schema.String.check(Schema.isNonEmpty()), + binding: Schema.String.check(Schema.isNonEmpty()), + address: Schema.String, + port: NetworkPortSchema, + intent: Schema.Literals(["automatic", "exact"] as const), + }), +]); +export type HostPortAssignment = Schema.Schema.Type; + +/** A durable loopback endpoint used by the host gateway to reach one workload. */ +export const PrivatePortAssignmentSchema = Schema.Struct({ + instanceId: Schema.String.check(Schema.isNonEmpty()), + workloadId: Schema.String.check(Schema.isNonEmpty()), + binding: Schema.String.check(Schema.isNonEmpty()), + port: NetworkPortSchema, +}); +export type PrivatePortAssignment = Schema.Schema.Type; + +/** Stable identity for one durable private workload binding. */ +export const privateBindingKey = ( + assignment: Pick, +): string => `${assignment.instanceId}\u0000${assignment.workloadId}\u0000${assignment.binding}`; + +const PersistedSecretEntrySchema = Schema.Struct({ + policy: Schema.Literals(["managed", "passthrough"] as const), + value: Schema.String, +}); +/** Secret slots are dynamic because function environment names are user-defined. */ +export const PersistedSecretValuesSchema = Schema.Record( + Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_.:/-]+$/)), + PersistedSecretEntrySchema, +); +export type PersistedSecretValues = Schema.Schema.Type; + +export const toPersistedIdentity = (identity: StackIdentity): PersistedStackIdentity => ({ + projectRoot: identity.projectRoot, + branchContext: identity.branchContext, + stackName: identity.stackName, +}); diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts index a2b5775925..7b4ccb5952 100644 --- a/packages/stack/src/state/StackStateStore.ts +++ b/packages/stack/src/state/StackStateStore.ts @@ -23,6 +23,7 @@ import { StackIdSchema } from "../public/StackId.ts"; import { PersistedStackStateSchema, STACK_STATE_FORMAT, + validatePortAssignments, type PersistedStackState, } from "./StackState.ts"; import { @@ -34,6 +35,7 @@ import { type OwnerLock, OWNER_LOCK_FORMAT, } from "./Ownership.ts"; +import { planStablePorts } from "./PortPlanner.ts"; class RegistryBusyError extends Data.TaggedError("RegistryBusyError")<{}> {} @@ -64,6 +66,15 @@ export interface StackStateStore { InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; + /** Applies a pure current-state read-modify-write transaction under the registry lock. */ + readonly update: ( + stackId: string, + transform: (current: PersistedStackState) => Effect.Effect, + ) => Effect.Effect< + PersistedStackState, + E | InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; /** Internal transaction primitive for callers that already hold the registry lock. */ readonly replaceUnlocked: ( stackId: string, @@ -94,39 +105,6 @@ export interface StackStateStore { const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -const withoutKeys = ( - value: Readonly>, - keys: ReadonlyArray, -): Record => { - const result = { ...value }; - for (const key of keys) delete result[key]; - return result; -}; - -/** Restores defaults and drops settings removed from the local model when reading older durable state. */ -const normalizeDurableState = (raw: Readonly>): unknown => { - const identity = isRecord(raw.identity) ? withoutKeys(raw.identity, ["stackId"]) : raw.identity; - const definition = raw.definition; - if (!isRecord(definition) || !isRecord(definition.capabilities)) return { ...raw, identity }; - const capabilities: Record = { ...definition.capabilities }; - for (const [capability, value] of Object.entries(capabilities)) { - if (isRecord(value) && !Object.hasOwn(value, "idleTimeoutSeconds")) - capabilities[capability] = { ...value, idleTimeoutSeconds: false }; - } - const obsolete: ReadonlyArray]> = [ - ["database", ["network_restrictions", "ssl_enforcement", "vault"]], - ["rest", ["auto_expose_new_tables", "tls"]], - ["storage", ["analytics"]], - ["analytics", ["vector_port"]], - ]; - for (const [capability, keys] of obsolete) { - const module = capabilities[capability]; - if (!isRecord(module) || !isRecord(module.settings)) continue; - capabilities[capability] = { ...module, settings: withoutKeys(module.settings, keys) }; - } - return { ...raw, identity, definition: { ...definition, capabilities } }; -}; - const stateError = (message: string, cause?: unknown) => new StackStateInvalidError({ message, ...(cause === undefined ? {} : { cause }) }); @@ -155,23 +133,19 @@ const decodeState = ( StackStateInvalidError | StackStateFormatUnsupportedError > => { if (!isRecord(raw)) return Effect.fail(stateError("Persisted stack state must be an object")); - if (typeof raw.format !== "string") - return Effect.fail(stateError("Persisted stack state format is missing or invalid")); - if (raw.format !== STACK_STATE_FORMAT) { + if (raw.format !== STACK_STATE_FORMAT) return Effect.fail( new StackStateFormatUnsupportedError({ - format: raw.format, + format: typeof raw.format === "string" ? raw.format : undefined, message: `Unsupported stack state format; expected ${STACK_STATE_FORMAT}`, }), ); - } if (!isRecord(raw.secrets)) return Effect.fail(stateError("Persisted secret values must be a record")); - // Schema.Record doesn't enforce key-format checks while decoding JSON, so validate slot names here. for (const slot of Object.keys(raw.secrets)) if (!/^[A-Za-z0-9_.:/-]+$/.test(slot)) return Effect.fail(stateError(`Persisted secret slot key is invalid: ${slot}`)); - return Schema.decodeUnknownEffect(PersistedStackStateSchema)(normalizeDurableState(raw), { + return Schema.decodeUnknownEffect(PersistedStackStateSchema)(raw, { onExcessProperty: "error", }).pipe( Effect.mapError((error) => stateError(`Invalid persisted stack state: ${String(error)}`)), @@ -206,7 +180,10 @@ const validateStateSchema = ( onExcessProperty: "error", }).pipe( Effect.mapError((error) => stateError(`Invalid persisted stack state: ${String(error)}`)), - Effect.asVoid, + Effect.flatMap(() => { + const portError = validatePortAssignments(state); + return portError === undefined ? Effect.void : Effect.fail(stateError(portError)); + }), ); const atomicWrite = ( @@ -218,13 +195,8 @@ const atomicWrite = ( state: PersistedStackState, ): Effect.Effect => Effect.gen(function* () { - const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(state).pipe( - Effect.mapError((error) => - stateError(`Unable to encode persisted stack state: ${String(error)}`), - ), - ); const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(PersistedStackStateSchema))( - encoded, + state, ).pipe( Effect.mapError((error) => stateError(`Unable to encode persisted stack state JSON: ${String(error)}`), @@ -290,6 +262,8 @@ const validateState = ( Effect.gen(function* () { yield* validateIdentityForStackId(state.identity, stackId); yield* validateStateSchema(state); + const portError = validatePortAssignments(state); + if (portError !== undefined) return yield* stateError(portError); }); const persistValidatedState = ( @@ -496,9 +470,59 @@ export const makeStackStateStore = (options: { ); const decoded = yield* decodeState(raw); yield* validateIdentityForStackId(decoded.identity, stackId); + yield* validateStateSchema(decoded); return decoded; }); + const readSiblingStates = ( + stackId: string, + ): Effect.Effect< + ReadonlyArray<{ readonly stackId: string; readonly state: PersistedStackState }>, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + Effect.gen(function* () { + const root = path.resolve(options.stateRoot); + const exists = yield* fs + .exists(root) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stack state root: ${error.message}`), + ), + ); + if (!exists) return []; + const entries = yield* fs + .readDirectory(root) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stack state root: ${error.message}`), + ), + ); + const siblings: Array<{ readonly stackId: string; readonly state: PersistedStackState }> = + []; + for (const siblingId of entries) { + if (siblingId === stackId || !/^[0-9a-f]{64}$/.test(siblingId)) continue; + const sibling = yield* read(siblingId).pipe( + Effect.catchIf(isMissingStateRemnantError, () => Effect.void), + ); + if (sibling !== undefined) siblings.push({ stackId: siblingId, state: sibling }); + } + return siblings; + }); + + const plan = ( + stackId: string, + state: PersistedStackState, + previous?: PersistedStackState, + ): Effect.Effect< + PersistedStackState, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + readSiblingStates(stackId).pipe( + Effect.flatMap((siblings) => planStablePorts(stackId, state, siblings, previous)), + ); + const initialize = ( stackId: string, candidate: PersistedStackState, @@ -517,9 +541,10 @@ export const makeStackStateStore = (options: { ); if (existing !== undefined) return existing; const paths = yield* pathsFor(stackId); - yield* validateState(stackId, candidate); - yield* persistValidatedState(fs, path, crypto, paths, candidate); - return candidate; + const planned = yield* plan(stackId, candidate); + yield* validateState(stackId, planned); + yield* persistValidatedState(fs, path, crypto, paths, planned); + return planned; }), ); @@ -536,8 +561,9 @@ export const makeStackStateStore = (options: { yield* validateIdentityForStackId(next.identity, stackId); const current = yield* read(stackId); if (current === undefined) return yield* stateError("Cannot replace missing stack state"); - yield* validateStateSchema(next); - yield* persistValidatedState(fs, path, crypto, paths, next); + const planned = yield* plan(stackId, next, current); + yield* validateStateSchema(planned); + yield* persistValidatedState(fs, path, crypto, paths, planned); }); const replace = ( @@ -549,6 +575,35 @@ export const makeStackStateStore = (options: { FileSystem.FileSystem | Path.Path | Crypto.Crypto > => withRegistryLock(options.stateRoot, replaceUnlocked(stackId, next)); + const updateUnlocked = ( + stackId: string, + transform: (current: PersistedStackState) => Effect.Effect, + ): Effect.Effect< + PersistedStackState, + E | InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + Effect.gen(function* () { + const current = yield* read(stackId); + if (current === undefined) return yield* stateError("Cannot update missing stack state"); + const next = yield* transform(current); + const planned = yield* plan(stackId, next, current); + const paths = yield* pathsFor(stackId); + yield* validateIdentityForStackId(planned.identity, stackId); + yield* validateStateSchema(planned); + yield* persistValidatedState(fs, path, crypto, paths, planned); + return planned; + }); + + const update = ( + stackId: string, + transform: (current: PersistedStackState) => Effect.Effect, + ): Effect.Effect< + PersistedStackState, + E | InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => withRegistryLock(options.stateRoot, updateUnlocked(stackId, transform)); + // fs.remove({ recursive: false }) maps to Node's fs.rm, which refuses to remove directories. // Native rmdir is used instead so a concurrent child creation fails with ENOTEMPTY rather // than recursively deleting a newly created lease or control file. @@ -662,6 +717,7 @@ export const makeStackStateStore = (options: { read, initialize, replace, + update, replaceUnlocked, cleanup, recoverRuntimeRemnant, diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts index 958965a6bb..cb299ad347 100644 --- a/packages/stack/src/state/ownership.integration.test.ts +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -112,13 +112,22 @@ const identity: StackIdentity = { }; const stateFor = (): PersistedStackState => ({ - format: "supabase-stack-state-v1", + format: "supabase-stack-state-v2", identity, runtime: { kind: "native" }, - desiredLifecycle: "unconfigured", + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "ownership-test-jwt" } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, ports: [], privatePorts: [], - secrets: {}, + secrets: { "ownership-test-jwt": { policy: "managed", value: "ownership-test-secret" } }, }); const errorOf = (exit: Exit.Exit): E | undefined => diff --git a/packages/stack/src/state/port-planning.integration.test.ts b/packages/stack/src/state/port-planning.integration.test.ts new file mode 100644 index 0000000000..2f5e4f7cbb --- /dev/null +++ b/packages/stack/src/state/port-planning.integration.test.ts @@ -0,0 +1,152 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; +import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import { StackStateInvalidError } from "../public/Errors.ts"; +import { AUTH_JWT_SECRET_SLOT } from "./SecretStore.ts"; +import type { PersistedStackState } from "./StackState.ts"; +import { makeStackStateStore } from "./StackStateStore.ts"; + +const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const candidate = ( + identity: StackIdentity, + port: number, + intent: "automatic" | "exact" = "automatic", +): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3_600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: { api: { enabled: true, address: "127.0.0.1" } }, + registry: { + initialized: true, + instances: [], + defaultInstanceIds: {}, + }, + ports: [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port, + intent, + }, + ], + privatePorts: [ + { + instanceId: ServiceInstanceIdSchema.make("database"), + workloadId: "database:database", + binding: "primary", + port: port + 1, + }, + ], + secrets: {}, +}); + +const identity = (root: string, stackName: string): StackIdentity => ({ + projectRoot: `${root}/${stackName}`, + branchContext: "ordinary-workspace", + stackName, +}); + +describe("stable endpoint planning", () => { + it.live("allocates distinct automatic plans for same-name stacks and retains each plan", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-port-plans-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const firstIdentity = identity(root, "first"); + const secondIdentity = identity(root, "second"); + const firstId = yield* deriveStackId(firstIdentity); + const secondId = yield* deriveStackId(secondIdentity); + const first = yield* store.initialize(firstId, candidate(firstIdentity, 21_437)); + const second = yield* store.initialize(secondId, candidate(secondIdentity, 21_437)); + + expect(first.ports[0]?.port).toBe(21_437); + expect(second.ports[0]?.port).not.toBe(first.ports[0]?.port); + expect(second.privatePorts[0]?.port).not.toBe(first.privatePorts[0]?.port); + + const reopened = yield* store.initialize( + firstId, + candidate(firstIdentity, 22_000, "exact"), + ); + expect(reopened.ports).toEqual(first.ports); + expect(reopened.privatePorts).toEqual(first.privatePorts); + + const updated = yield* store.update(firstId, (current) => Effect.succeed(current)); + expect(updated.ports).toEqual(first.ports); + expect(updated.privatePorts).toEqual(first.privatePorts); + }), + ), + ); + + it.live( + "serializes concurrent plans and rejects an exact sibling conflict without publishing", + () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-port-plan-race-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const firstIdentity = identity(root, "first"); + const secondIdentity = identity(root, "second"); + const firstId = yield* deriveStackId(firstIdentity); + const secondId = yield* deriveStackId(secondIdentity); + const results = yield* Effect.forEach( + [ + [firstId, candidate(firstIdentity, 22_100)] as const, + [secondId, candidate(secondIdentity, 22_100)] as const, + ], + ([id, state]) => store.initialize(id, state), + { concurrency: 2 }, + ); + expect(new Set(results.map((state) => state.ports[0]?.port)).size).toBe(2); + + const conflictIdentity = identity(root, "conflict"); + const conflictId = yield* deriveStackId(conflictIdentity); + const conflict = yield* store + .initialize( + conflictId, + candidate(conflictIdentity, results[0]?.ports[0]?.port ?? 22_100, "exact"), + ) + .pipe(Effect.exit); + expect(Exit.isFailure(conflict)).toBe(true); + if (Exit.isFailure(conflict)) + expect(Option.getOrUndefined(Cause.findErrorOption(conflict.cause))).toBeInstanceOf( + StackStateInvalidError, + ); + expect(yield* store.read(conflictId)).toBeUndefined(); + + const firstBefore = yield* store.read(firstId); + if (firstBefore === undefined) + return yield* new StackStateInvalidError({ message: "Fixture is incomplete" }); + const foreignIdentity = identity(root, "foreign-conflict"); + const foreignId = yield* deriveStackId(foreignIdentity); + yield* fs.makeDirectory(path.join(root, foreignId), { recursive: true }); + yield* fs.writeFileString( + path.join(root, foreignId, "state.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( + candidate(foreignIdentity, firstBefore.ports[0]?.port ?? 22_100), + ), + ); + const update = yield* store + .update(firstId, (current) => Effect.succeed(current)) + .pipe(Effect.exit); + expect(Exit.isFailure(update)).toBe(true); + expect(yield* store.read(firstId)).toEqual(firstBefore); + }), + ), + ); +}); diff --git a/packages/stack/src/state/ports-instance.integration.test.ts b/packages/stack/src/state/ports-instance.integration.test.ts new file mode 100644 index 0000000000..6b1d283509 --- /dev/null +++ b/packages/stack/src/state/ports-instance.integration.test.ts @@ -0,0 +1,150 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Scope } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- HostListener requires the native server object; Effect HttpClient cannot supply a listener. +import { createServer } from "node:http"; +import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import { emptyServiceRegistry } from "../model/ServiceRegistry.ts"; +import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; +import { + makePortCoordinator, + type PortCoordinatorOptions, + type PublicPortIntent, +} from "./PortCoordinator.ts"; +import type { HostListener } from "../supervisor/HostListener.ts"; +import { AUTH_JWT_SECRET_SLOT } from "./SecretStore.ts"; + +const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const listener = ( + address: string, + port: number, + field: HostListener["field"], +): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => ({ + address, + port, + field, + binding: { kind: "http" as const, server: createServer() }, + connections: { sockets: new Set() }, + close: Effect.void, + })), + (value) => value.close, + ); + +const stackState = (identity: StackIdentity): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: {}, + registry: emptyServiceRegistry(), + ports: [], + privatePorts: [], + secrets: {}, +}); + +const binding = (id: string): PublicPortIntent => ({ + owner: "instance", + instanceId: ServiceInstanceIdSchema.make(id), + binding: "sql", + address: "127.0.0.1", + port: "automatic", +}); + +describe("instance port acquisition", () => { + it.live("retains one instance binding while allocating a second SQL binding", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-instance-ports-" }); + const identity = { + projectRoot: root, + branchContext: "ordinary-workspace", + stackName: "ports", + } satisfies StackIdentity; + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(stackId, stackState(identity)); + const options: PortCoordinatorOptions = { + stateRoot: root, + store, + bindHost: listener, + bindPrivate: (_address, port) => Effect.succeed({ port, close: Effect.void }), + }; + const coordinator = makePortCoordinator(options); + const first = yield* coordinator.acquire(stackId, [binding("db-a")], []); + const second = yield* coordinator.acquire(stackId, [binding("db-a"), binding("db-b")], []); + expect(second.privateAssignments).toEqual([]); + expect(second.assignments["instance:db-a:sql"]?.port).toBe( + first.assignments["instance:db-a:sql"]?.port, + ); + expect(second.assignments["instance:db-b:sql"]?.port).toBeDefined(); + expect(second.assignments["instance:db-b:sql"]?.port).not.toBe( + second.assignments["instance:db-a:sql"]?.port, + ); + expect((yield* store.read(stackId))?.ports).toHaveLength(2); + }), + ), + ); + + it.live("retains unrelated public and private bindings during an API reservation", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-incremental-ports-" }); + const identity = { + projectRoot: root, + branchContext: "ordinary-workspace", + stackName: "ports", + } satisfies StackIdentity; + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(stackId, stackState(identity)); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + bindHost: listener, + bindPrivate: (_address, port) => Effect.succeed({ port, close: Effect.void }), + }); + const instance = binding("db-a"); + const privateBinding = [ + { instanceId: "db-a", workloadId: "database:database", binding: "primary" }, + ]; + const first = yield* coordinator.acquire(stackId, [instance], privateBinding); + const api = { + owner: "stack" as const, + binding: "api" as const, + address: "127.0.0.1", + port: "automatic" as const, + }; + yield* coordinator.acquire(stackId, [api], []); + const persisted = yield* store.read(stackId); + const apiPort = persisted?.ports.find( + (assignment) => assignment.owner === "stack" && assignment.binding === "api", + )?.port; + expect(persisted?.ports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ owner: "instance", instanceId: "db-a", binding: "sql" }), + expect.objectContaining({ owner: "stack", binding: "api" }), + ]), + ); + expect(apiPort).not.toBe(first.assignments["instance:db-a:sql"]?.port); + expect(apiPort).not.toBe(first.privateAssignments[0]?.port); + expect(persisted?.privatePorts).toEqual(first.privateAssignments); + }), + ), + ); +}); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 611fcbfbc1..9d45526a62 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -24,28 +24,25 @@ import { } from "../public/Errors.ts"; import { makePortCoordinator, - type ListenerIntents, + type PublicPortIntent, type PortCoordinatorOptions, } from "./PortCoordinator.ts"; import type { HostListener } from "../supervisor/HostListener.ts"; -import { - makeStackStateStore, - PersistedStackStateSchema, - type PersistedStackState, -} from "./StackStateStore.ts"; -import { compileStack } from "../model/Compiler.ts"; +import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; import { bindHeldPort, bindHostListener, checkHostPort } from "../supervisor/HostListener.ts"; import { withRegistryLock } from "./StackStateStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "./SecretStore.ts"; -const intents = (api: "automatic" | number = "automatic"): ListenerIntents => ({ - api: { enabled: true, address: "127.0.0.1", port: api }, - database: { enabled: false, address: "127.0.0.1", port: "automatic" }, - pooler: { enabled: false, address: "127.0.0.1", port: "automatic" }, - studio: { enabled: false, address: "127.0.0.1", port: "automatic" }, - mailUi: { enabled: false, address: "127.0.0.1", port: "automatic" }, - smtp: { enabled: false, address: "127.0.0.1", port: "automatic" }, - pop3: { enabled: false, address: "127.0.0.1", port: "automatic" }, - functionsInspector: { enabled: false, address: "127.0.0.1", port: "automatic" }, +const intents = (api: "automatic" | number = "automatic"): ReadonlyArray => [ + { owner: "stack", binding: "api", address: "127.0.0.1", port: api }, +]; +const databaseIntent = (port: "automatic" | number = "automatic"): PublicPortIntent => ({ + owner: "instance", + instanceId: "database-instance", + binding: "sql", + listenerField: "database", + address: "127.0.0.1", + port, }); const identity = (root: string, stackName: string): StackIdentity => ({ @@ -60,10 +57,19 @@ const state = ( ports: PersistedStackState["ports"] = [], privatePorts: PersistedStackState["privatePorts"] = [], ): PersistedStackState => ({ - format: "supabase-stack-state-v1", + format: "supabase-stack-state-v2", identity: value, runtime: { kind: "native" }, - desiredLifecycle: "running", + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, ports, privatePorts, secrets: {}, @@ -100,61 +106,7 @@ const coordinatorOptions = ( const run = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("port acquisition", () => { - it.live("reads legacy sibling state while allocating ports", () => - run( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-ports-legacy-state-" }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const ownIdentity = identity(root, "own"); - const ownId = yield* deriveStackId(ownIdentity); - const siblingIdentity = identity(root, "legacy"); - const siblingId = yield* deriveStackId(siblingIdentity); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: {}, - }); - const sibling = { - ...state(siblingId, siblingIdentity), - definition: compiled.definition, - }; - const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(sibling); - const legacy = structuredClone(encoded) as unknown as { - definition: { capabilities: Record }; - }; - for (const capability of Object.values(legacy.definition.capabilities)) - delete capability.idleTimeoutSeconds; - yield* store.initialize(ownId, { - ...state(ownId, ownIdentity), - desiredLifecycle: "running", - }); - yield* fs.makeDirectory(path.join(root, siblingId), { recursive: true }); - yield* fs.writeFileString( - path.join(root, siblingId, "state.json"), - yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(legacy), - ); - - const result = yield* makePortCoordinator(coordinatorOptions(store, root)).acquire( - ownId, - intents(), - [], - ); - const api = result.assignments.api; - expect(api?.port).toBeGreaterThan(0); - expect(yield* store.read(ownId)).toEqual( - expect.objectContaining({ - ports: expect.arrayContaining([ - expect.objectContaining({ field: "api", port: api?.port }), - ]), - }), - ); - }), - ), - ); - - it.live("requires running state and fails closed on an unreadable sibling", () => + it.live("fails closed on an unreadable sibling", () => run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -163,16 +115,12 @@ describe("port acquisition", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const value = identity(root, "guard"); const id = yield* deriveStackId(value); - yield* store.initialize(id, { ...state(id, value), desiredLifecycle: "stopped" }); + yield* store.initialize(id, state(id, value)); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); - const stopped = yield* coordinator.acquire(id, intents(), []).pipe(Effect.exit); - expect(Exit.isFailure(stopped)).toBe(true); - expect((yield* store.read(id))?.ports).toEqual([]); const sibling = yield* deriveStackId(identity(root, "broken")); const siblingRoot = path.join(root, sibling); yield* fs.makeDirectory(siblingRoot, { recursive: true }); yield* fs.writeFileString(path.join(siblingRoot, "state.json"), "not-json"); - yield* store.replaceUnlocked(id, { ...state(id, value), desiredLifecycle: "running" }); const result = yield* coordinator.acquire(id, intents(), []).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); if (Exit.isFailure(result)) @@ -220,7 +168,7 @@ describe("port acquisition", () => { yield* store.initialize(ownId, state(ownId, ownIdentity)); const siblingState = { ...state(siblingId, identity(root, "unsupported-sibling")), - format: "supabase-stack-state-v2", + format: "unsupported-stack-format", }; yield* fs.makeDirectory(path.join(root, siblingId), { recursive: true }); const encodedSiblingState = yield* Schema.encodeEffect( @@ -235,13 +183,13 @@ describe("port acquisition", () => { const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); expect(error).toBeInstanceOf(StackStateFormatUnsupportedError); if (!(error instanceof StackStateFormatUnsupportedError)) return; - expect(error.format).toBe("supabase-stack-state-v2"); + expect(error.format).toBe("unsupported-stack-format"); expect(error.message).toContain(siblingId); }), ), ); - it.live("excludes durable sibling claims while allowing stopped exact sharing", () => + it.live("excludes automatic and exact durable sibling claims", () => run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -253,21 +201,30 @@ describe("port acquisition", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, state(a, aIdentity)); yield* store.initialize(b, { - ...state(b, bIdentity, [{ field: "api", port: 20_000, intent: "automatic" }]), - desiredLifecycle: "stopped", + ...state(b, bIdentity, [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 20_000, + intent: "automatic", + }, + ]), }); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); const automatic = yield* coordinator.acquire(a, intents(), []); expect(automatic.assignments.api?.port).not.toBe(20_000); yield* store.replaceUnlocked(b, { - ...state(b, bIdentity, [{ field: "api", port: 20_000, intent: "exact" }]), - desiredLifecycle: "stopped", + ...state(b, bIdentity, [ + { owner: "stack", binding: "api", address: "127.0.0.1", port: 20_000, intent: "exact" }, + ]), }); - const exact = yield* coordinator.acquire(a, intents(20_000), []); - expect(exact.assignments.api).toEqual({ field: "api", port: 20_000, intent: "exact" }); + const exact = yield* coordinator.acquire(a, intents(20_000), []).pipe(Effect.exit); + expect(Exit.isFailure(exact)).toBe(true); yield* store.replaceUnlocked(b, { - ...state(b, bIdentity, [{ field: "api", port: 20_000, intent: "exact" }]), - desiredLifecycle: "running", + ...state(b, bIdentity, [ + { owner: "stack", binding: "api", address: "127.0.0.1", port: 20_000, intent: "exact" }, + ]), }); const conflict = yield* coordinator.acquire(a, intents(20_000), []).pipe(Effect.exit); expect(Exit.isFailure(conflict)).toBe(true); @@ -295,14 +252,7 @@ describe("port acquisition", () => { ); const coordinator = makePortCoordinator(coordinatorOptions(store, root, bindHost)); const result = yield* coordinator - .acquire( - id, - { - ...intents(), - database: { enabled: true, address: "127.0.0.1", port: "automatic" }, - }, - [], - ) + .acquire(id, [...intents(), databaseIntent("automatic")], []) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); expect(listeners).toHaveLength(1); @@ -326,7 +276,15 @@ describe("port acquisition", () => { yield* store.initialize(a, state(a, aIdentity)); yield* store.initialize( b, - state(b, bIdentity, [{ field: "api", port: 20_000, intent: "automatic" }]), + state(b, bIdentity, [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 20_000, + intent: "automatic", + }, + ]), ); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); const first = yield* coordinator.acquire(a, intents(), []); @@ -347,15 +305,21 @@ describe("port acquisition", () => { const id = yield* deriveStackId(value); yield* store.initialize( id, - state(id, value, [{ field: "database", port: 20_321, intent: "automatic" }]), + state(id, value, [ + { + owner: "instance", + instanceId: "database-instance", + binding: "sql", + address: "127.0.0.1", + port: 20_321, + intent: "automatic", + }, + ]), ); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); const result = yield* coordinator.acquire( id, - { - ...intents(), - database: { enabled: true, address: "127.0.0.1", port: "automatic" }, - }, + [...intents(), databaseIntent("automatic")], [], ); expect(result.assignments.database?.port).toBe(20_321); @@ -364,31 +328,7 @@ describe("port acquisition", () => { ), ); - it.live("lets a stopped exact sibling share only an explicitly requested exact port", () => - run( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-ports-exact-" }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const aIdentity = identity(root, "a"); - const bIdentity = identity(root, "b"); - const a = yield* deriveStackId(aIdentity); - const b = yield* deriveStackId(bIdentity); - yield* store.initialize(a, state(a, aIdentity)); - yield* store.initialize(b, { - ...state(b, bIdentity, [{ field: "api", port: 24_000, intent: "exact" }]), - desiredLifecycle: "stopped", - }); - const coordinator = makePortCoordinator(coordinatorOptions(store, root)); - const result = yield* coordinator.acquire(a, intents(24_000), []); - expect(result.assignments.api).toEqual({ field: "api", port: 24_000, intent: "exact" }); - const automatic = yield* coordinator.acquire(a, intents(), []); - expect(automatic.assignments.api?.port).not.toBe(24_000); - }), - ), - ); - - it.live("rejects acquisition for a stopped state without changing assignments", () => + it.live("retains a planned binding when the registry has no started instances", () => run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -396,11 +336,19 @@ describe("port acquisition", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const value = identity(root, "stopped"); const id = yield* deriveStackId(value); - const before = state(id, value, [{ field: "api", port: 24_001, intent: "automatic" }]); - yield* store.initialize(id, { ...before, desiredLifecycle: "stopped" }); + const before = state(id, value, [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 24_001, + intent: "automatic", + }, + ]); + yield* store.initialize(id, before); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); const result = yield* coordinator.acquire(id, intents(), []).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); + expect(Exit.isSuccess(result)).toBe(true); expect((yield* store.read(id))?.ports).toEqual(before.ports); }), ), @@ -435,11 +383,10 @@ describe("port acquisition", () => { const id = yield* deriveStackId(value); yield* store.initialize(id, state(id, value)); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); - const bindings = [{ workloadId: "database:database", binding: "primary" }]; - const disabledApi = { - ...intents(), - api: { enabled: false, address: "127.0.0.1", port: "automatic" as const }, - }; + const bindings = [ + { instanceId: "database-instance", workloadId: "database:database", binding: "primary" }, + ]; + const disabledApi: ReadonlyArray = []; const first = yield* coordinator.acquire(id, disabledApi, bindings); const second = yield* coordinator.acquire(id, disabledApi, bindings); expect(second.privateAssignments).toEqual(first.privateAssignments); @@ -459,13 +406,10 @@ describe("port acquisition", () => { const id = yield* deriveStackId(value); yield* store.initialize(id, state(id, value)); const bindings = [ - { workloadId: "database:database", binding: "primary" }, - { workloadId: "rest:rest", binding: "primary" }, + { instanceId: "database-instance", workloadId: "database:database", binding: "primary" }, + { instanceId: "database-instance", workloadId: "rest:rest", binding: "primary" }, ]; - const disabled = { - ...intents(), - api: { enabled: false, address: "127.0.0.1", port: "automatic" as const }, - }; + const disabled: ReadonlyArray = []; const coordinator = makePortCoordinator({ ...coordinatorOptions(store, root, bindHostListener), bindPrivate: (address, port, _binding) => @@ -487,6 +431,7 @@ describe("port acquisition", () => { run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-ports-private-conflict-" }); const store = yield* makeStackStateStore({ stateRoot: root }); const aIdentity = identity(root, "a"); @@ -494,6 +439,7 @@ describe("port acquisition", () => { const a = yield* deriveStackId(aIdentity); const b = yield* deriveStackId(bIdentity); const assignment = { + instanceId: "database-instance", workloadId: "database:database", binding: "primary", port: 20_101, @@ -503,15 +449,27 @@ describe("port acquisition", () => { ...state(b, bIdentity), privatePorts: [{ ...assignment, workloadId: "rest:rest" }], }); + // Preserve the malformed sibling fixture to exercise coordinator fail-closed behavior; + // normal state initialization now prevents publishing this cross-stack collision. + yield* fs.writeFileString( + path.join(root, b, "state.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + ...state(b, bIdentity), + privatePorts: [{ ...assignment, workloadId: "rest:rest" }], + }), + ); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); const result = yield* coordinator .acquire( a, - { - ...intents(), - api: { enabled: false, address: "127.0.0.1", port: "automatic" }, - }, - [{ workloadId: assignment.workloadId, binding: assignment.binding }], + [], + [ + { + instanceId: "database-instance", + workloadId: assignment.workloadId, + binding: assignment.binding, + }, + ], ) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); @@ -520,7 +478,7 @@ describe("port acquisition", () => { ), ); - it.live("drops a removed private binding after successful acquisition", () => + it.live("retains an existing private binding when no replacement is requested", () => run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -529,21 +487,15 @@ describe("port acquisition", () => { const value = identity(root, "remove"); const id = yield* deriveStackId(value); const previous = { + instanceId: "database-instance", workloadId: "database:database", binding: "primary", port: 20_102, } as const; yield* store.initialize(id, { ...state(id, value), privatePorts: [previous] }); const coordinator = makePortCoordinator(coordinatorOptions(store, root)); - yield* coordinator.acquire( - id, - { - ...intents(), - api: { enabled: false, address: "127.0.0.1", port: "automatic" }, - }, - [], - ); - expect((yield* store.read(id))?.privatePorts).toEqual([]); + yield* coordinator.acquire(id, [], []); + expect((yield* store.read(id))?.privatePorts).toEqual([previous]); }), ), ); @@ -560,14 +512,7 @@ describe("port acquisition", () => { const crypto = yield* Crypto.Crypto; const deterministic = { ...crypto, randomIntBetween: () => Effect.succeed(0) }; const result = yield* makePortCoordinator(coordinatorOptions(store, root)) - .acquire( - id, - { - ...intents(), - database: { enabled: true, address: "127.0.0.1", port: 20_000 }, - }, - [], - ) + .acquire(id, [...intents(), databaseIntent(20_000)], []) .pipe(Effect.provideService(Crypto.Crypto, deterministic)); expect(result.assignments.database?.port).toBe(20_000); expect(result.assignments.api?.port).not.toBe(20_000); @@ -697,14 +642,7 @@ describe("port acquisition", () => { }; const crypto = yield* Crypto.Crypto; const result = yield* makePortCoordinator(coordinatorOptions(store, root, bindHost)) - .acquire( - id, - { - ...intents(), - database: { enabled: true, address: "127.0.0.1", port: "automatic" }, - }, - [], - ) + .acquire(id, [...intents(), databaseIntent("automatic")], []) .pipe( Effect.provideService(Crypto.Crypto, { ...crypto, @@ -763,11 +701,14 @@ describe("port acquisition", () => { const result = yield* coordinator .acquire( id, - { - ...intents(), - database: { enabled: true, address: "127.0.0.1", port: "automatic" }, - }, - [{ workloadId: "database:database", binding: "primary" }], + [...intents(), databaseIntent("automatic")], + [ + { + instanceId: "database-instance", + workloadId: "database:database", + binding: "primary", + }, + ], ) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); @@ -788,7 +729,16 @@ describe("port acquisition", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const value = identity(root, "interrupt"); const id = yield* deriveStackId(value); - const before = state(id, value, [{ field: "studio", port: 20_103, intent: "automatic" }]); + const before = state(id, value, [ + { + owner: "instance", + instanceId: "studio-instance", + binding: "studio", + address: "127.0.0.1", + port: 20_103, + intent: "automatic", + }, + ]); yield* store.initialize(id, before); const parentScope = yield* Scope.Scope; const acquisitionScope = yield* Scope.fork(parentScope, "sequential"); @@ -813,14 +763,7 @@ describe("port acquisition", () => { }); const fiber = yield* Effect.forkChild( coordinator - .acquire( - id, - { - ...intents(), - database: { enabled: true, address: "127.0.0.1", port: "automatic" }, - }, - [], - ) + .acquire(id, [...intents(), databaseIntent("automatic")], []) .pipe(Effect.provideService(Scope.Scope, acquisitionScope)), { startImmediately: true }, ); diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index 18f32b32f5..0ebf834c8e 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -38,7 +38,6 @@ const passthrough = (slot: string, value: string): SecretCandidate => ({ const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; const compilerManagedSlots = [ - "secret:database.internal.password", "secret:auth.settings.publishable_key", "secret:auth.settings.secret_key", "secret:auth.settings.jwt_secret", diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index 71d098c1c1..c571f277f9 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -12,7 +12,7 @@ import { Redacted, Schema, } from "effect"; -import { compileStack, type StackDefinition } from "../model/Compiler.ts"; +import { compileStack, seedServiceRegistry } from "../model/Compiler.ts"; import { deriveStackId } from "../identity/Identity.ts"; import { StackStateFormatUnsupportedError, StackStateInvalidError } from "../public/Errors.ts"; import { @@ -22,6 +22,8 @@ import { type PersistedStackState, } from "./StackStateStore.ts"; import { removeLeaseIfHeld } from "./Ownership.ts"; +import { AUTH_JWT_SECRET_SLOT, resolveSecrets } from "./SecretStore.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; const layer = NodeServices.layer; const withPlatform = (effect: Effect.Effect) => @@ -33,16 +35,25 @@ const identity = { stackName: "default", } as const; -const state = (definition?: StackDefinition): PersistedStackState => ({ - format: "supabase-stack-state-v1", +const state = (): PersistedStackState => ({ + format: "supabase-stack-state-v2", identity, runtime: { kind: "native" }, - desiredLifecycle: "stopped", - definition, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, ports: [], privatePorts: [], secrets: {}, }); +const instanceId = ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"); const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; @@ -76,7 +87,22 @@ const completeStateFixture = Effect.gen(function* () { }, }, }); - const complete = state(compiled.definition); + const seeded = yield* seedServiceRegistry( + compiled.definition, + { projectRoot: identity.projectRoot, path, runtime: { kind: "native" } }, + compiled.sourceConfig, + compiled.secrets, + ); + const resolved = yield* resolveSecrets( + { declarations: seeded.secretSlots }, + undefined, + "unconfigured", + ); + const complete: PersistedStackState = { + ...state(), + registry: seeded.registry, + secrets: resolved.persisted, + }; yield* store.initialize(stackId, complete); const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(complete); return { fs, path, store, root, stackId, complete, encoded }; @@ -151,195 +177,80 @@ describe("atomic stack state", () => { ), ); - it.live("round-trips a compiled complete definition", () => + it.live("round-trips an initialized registry and its resolved secrets", () => withPlatform( Effect.gen(function* () { const { store, stackId, complete } = yield* completeStateFixture; - expect(complete.definition?.preparation).toBe("on-demand"); + expect(complete.preparation).toBe("on-demand"); expect(yield* store.read(stackId)).toEqual(complete); }), ), ); - it.live("normalizes legacy definitions without idle timeout fields", () => + it.live("rejects malformed nested state documents without rewriting them", () => withPlatform( Effect.gen(function* () { const { fs, path, store, root, stackId, encoded } = yield* completeStateFixture; - const legacy = structuredClone(encoded) as unknown as { - definition: { capabilities: Record }; - }; - for (const capability of Object.values(legacy.definition.capabilities)) - delete capability.idleTimeoutSeconds; - yield* fs.writeFileString(path.join(root, stackId, "state.json"), jsonTextSync(legacy)); - - const result = yield* store.read(stackId); - if (result?.definition === undefined) return yield* Effect.die("definition missing"); - for (const capability of Object.values(result.definition.capabilities)) - expect(capability.idleTimeoutSeconds).toBe(false); - }), - ), - ); - - it.live("rejects malformed nested state documents", () => - withPlatform( - Effect.gen(function* () { - const { fs, path, store, root, stackId, complete, encoded } = yield* completeStateFixture; const statePath = path.join(root, stackId, "state.json"); - const persisted = yield* Schema.decodeEffect( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), - )(yield* fs.readFileString(statePath)); - expect(persisted).not.toHaveProperty("identity.stackId"); - const nestedUnknown = { - ...encoded, - definition: { - ...encoded.definition, - capabilities: { - ...encoded.definition?.capabilities, - auth: { - ...encoded.definition?.capabilities.auth, - settings: { - ...encoded.definition?.capabilities.auth.settings, - email: { - ...encoded.definition?.capabilities.auth.settings.email, - template: { - ...encoded.definition?.capabilities.auth.settings.email?.template, - confirm: { - ...encoded.definition?.capabilities.auth.settings.email?.template?.confirm, - unknown: true, + const invalidDocuments = [ + { + ...encoded, + registry: { + ...encoded.registry, + instances: encoded.registry.instances.map((instance) => + instance.service === "auth" + ? { + ...instance, + config: { + ...instance.config, + settings: { ...instance.config.settings, unknown: true }, }, - }, - }, - }, - }, - }, - }, - }; - yield* fs.writeFileString(statePath, yield* jsonText(nestedUnknown)); - const unknownExit = yield* store.read(stackId).pipe(Effect.exit); - expect(errorOf(unknownExit)).toBeInstanceOf(StackStateInvalidError); - - const oldSnapshot = { - ...encoded, - identity: { ...encoded.identity, stackId }, - secrets: { preserved: { policy: "managed", value: "secret-value" } }, - definition: { - ...encoded.definition, - capabilities: { - ...encoded.definition?.capabilities, - database: { - ...encoded.definition?.capabilities.database, - settings: { - ...encoded.definition?.capabilities.database.settings, - network_restrictions: { enabled: true, allowed_cidrs: ["10.0.0.0/8"] }, - ssl_enforcement: { enabled: true }, - vault: {}, - }, - }, - rest: { - ...encoded.definition?.capabilities.rest, - settings: { - ...encoded.definition?.capabilities.rest.settings, - auto_expose_new_tables: true, - tls: { enabled: false }, - }, - }, - storage: { - ...encoded.definition?.capabilities.storage, - settings: { - ...encoded.definition?.capabilities.storage.settings, - analytics: { enabled: false }, - }, - }, - analytics: { - ...encoded.definition?.capabilities.analytics, - settings: { - ...encoded.definition?.capabilities.analytics.settings, - vector_port: 9001, - }, - }, + } + : instance, + ), }, }, - }; - yield* fs.writeFileString(statePath, yield* jsonText(oldSnapshot)); - const recovered = yield* store.read(stackId); - expect(recovered).toEqual({ ...complete, secrets: oldSnapshot.secrets }); - if (recovered === undefined) throw new Error("Expected recovered state"); - yield* store.replace(stackId, recovered); - const rewritten = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( - yield* fs.readFileString(statePath), - ); - expect(rewritten).toEqual({ ...encoded, secrets: oldSnapshot.secrets }); - - const missingDefault = { - ...encoded, - definition: { - ...encoded.definition, - capabilities: { - ...encoded.definition?.capabilities, - functions: { - ...encoded.definition?.capabilities.functions, - settings: { - ...encoded.definition?.capabilities.functions.settings, - functions: { - ...encoded.definition?.capabilities.functions.settings.functions, - hello: { - ...encoded.definition?.capabilities.functions.settings.functions?.hello, - verify_jwt: undefined, - }, - }, - }, - }, - }, - }, - }; - yield* fs.writeFileString(statePath, yield* jsonText(missingDefault)); - const missingDefaultExit = yield* store.read(stackId).pipe(Effect.exit); - expect(errorOf(missingDefaultExit)).toBeInstanceOf(StackStateInvalidError); - - const invalidPreparation = { - ...encoded, - definition: { ...encoded.definition, preparation: "invalid" }, - }; - yield* fs.writeFileString(statePath, yield* jsonText(invalidPreparation)); - const invalidPreparationExit = yield* store.read(stackId).pipe(Effect.exit); - expect(errorOf(invalidPreparationExit)).toBeInstanceOf(StackStateInvalidError); - - const invalidRecordKey = { - ...encoded, - definition: { - ...encoded.definition, - capabilities: { - ...encoded.definition?.capabilities, - functions: { - ...encoded.definition?.capabilities.functions, - settings: { - ...encoded.definition?.capabilities.functions.settings, - functions: { - "bad.slug": { - enabled: true, - verify_jwt: true, - import_map: null, - entrypoint: null, - static_files: null, - env: {}, - }, - }, - }, - }, + { + ...encoded, + registry: { + ...encoded.registry, + instances: encoded.registry.instances.map((instance) => + instance.service === "functions" + ? { + ...instance, + config: { + ...instance.config, + settings: { + ...instance.config.settings, + functions: { + "bad.slug": { + enabled: true, + verify_jwt: true, + import_map: null, + entrypoint: null, + static_files: null, + env: {}, + }, + }, + }, + }, + } + : instance, + ), }, }, - }; - yield* fs.writeFileString(statePath, yield* jsonText(invalidRecordKey)); - const recordExit = yield* store.read(stackId).pipe(Effect.exit); - expect(errorOf(recordExit)).toBeInstanceOf(StackStateInvalidError); - - const invalidSecret = { - ...encoded, - secrets: { "": { policy: "managed", value: "x" } }, - }; - yield* fs.writeFileString(statePath, yield* jsonText(invalidSecret)); - const secretExit = yield* store.read(stackId).pipe(Effect.exit); - expect(errorOf(secretExit)).toBeInstanceOf(StackStateInvalidError); + { ...encoded, preparation: "invalid" }, + { ...encoded, identity: { ...encoded.identity, stackId } }, + { ...encoded, secrets: { "": { policy: "managed", value: "x" } } }, + ]; + for (const invalid of invalidDocuments) { + const text = yield* jsonText(invalid); + yield* fs.writeFileString(statePath, text); + expect(errorOf(yield* store.read(stackId).pipe(Effect.exit))).toBeInstanceOf( + StackStateInvalidError, + ); + expect(yield* fs.readFileString(statePath)).toBe(text); + } }), ), ); @@ -359,8 +270,18 @@ describe("atomic stack state", () => { yield* store.initialize(stackId, before); const candidate = { ...before, - ports: [{ field: "api" as const, port: 23_100, intent: "exact" as const }], - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 23_100 }], + ports: [ + { + owner: "stack" as const, + binding: "api" as const, + address: "127.0.0.1", + port: 23_100, + intent: "exact" as const, + }, + ], + privatePorts: [ + { instanceId, workloadId: `${instanceId}:database`, binding: "primary", port: 23_100 }, + ], }; const result = yield* store.replace(stackId, candidate).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); @@ -453,7 +374,7 @@ describe("atomic stack state", () => { yield* store.initialize(stackId, state()); yield* fs.writeFileString( path.join(root, stackId, "state.json"), - yield* jsonText({ ...state(), format: "supabase-stack-state-v2" }), + yield* jsonText({ ...state(), format: "supabase-stack-state-v1" }), ); const unsupported = yield* store.read(stackId).pipe(Effect.exit); expect(errorOf(unsupported)).toBeInstanceOf(StackStateFormatUnsupportedError); @@ -462,7 +383,7 @@ describe("atomic stack state", () => { yield* jsonText({ ...state(), format: 1 }), ); const malformed = yield* store.read(stackId).pipe(Effect.exit); - expect(errorOf(malformed)).toBeInstanceOf(StackStateInvalidError); + expect(errorOf(malformed)).toBeInstanceOf(StackStateFormatUnsupportedError); }), ), ); @@ -498,8 +419,16 @@ describe("atomic stack state", () => { const oldValue = state(); const newValue = { ...oldValue, - desiredLifecycle: "running" as const, - ports: [{ field: "api" as const, port: 24_321, intent: "exact" as const }], + preparation: "background" as const, + ports: [ + { + owner: "stack" as const, + binding: "api" as const, + address: "127.0.0.1", + port: 24_321, + intent: "exact" as const, + }, + ], secrets: { "secret:test": { policy: "managed" as const, value: "new-value" } }, }; yield* store.initialize(stackId, oldValue); @@ -517,4 +446,82 @@ describe("atomic stack state", () => { }), ), ); + + it.live("serializes concurrent read-modify-write updates without losing fields", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "supabase-stack-update-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + yield* store.initialize(stackId, state()); + + yield* Effect.all( + [ + store.update(stackId, (current) => + Effect.succeed({ + ...current, + ports: [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 24_321, + intent: "exact", + }, + ], + }), + ), + store.update(stackId, (current) => + Effect.succeed({ + ...current, + privatePorts: [ + { instanceId, workloadId: `${instanceId}:rest`, binding: "http", port: 24_322 }, + ], + }), + ), + ], + { concurrency: 2 }, + ); + + expect(yield* store.read(stackId)).toMatchObject({ + ports: [ + { owner: "stack", binding: "api", address: "127.0.0.1", port: 24_321, intent: "exact" }, + ], + privatePorts: [ + { instanceId, workloadId: `${instanceId}:rest`, binding: "http", port: 24_322 }, + ], + }); + }), + ), + ); + + it.live("does not write when an update transform fails or state is missing", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "supabase-stack-update-invalid-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + const original = state(); + yield* store.initialize(stackId, original); + + const failed = yield* store + .update(stackId, () => + Effect.fail(new StackStateInvalidError({ message: "invalid update" })), + ) + .pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackStateInvalidError); + expect(yield* store.read(stackId)).toEqual(original); + + yield* store.cleanup(stackId); + const missing = yield* store + .update(stackId, (current) => Effect.succeed(current)) + .pipe(Effect.exit); + expect(errorOf(missing)).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); }); diff --git a/packages/stack/src/supervisor/CapabilityState.ts b/packages/stack/src/supervisor/CapabilityState.ts deleted file mode 100644 index a5f3b33118..0000000000 --- a/packages/stack/src/supervisor/CapabilityState.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Match, type Cause, type Deferred, type Exit, type Fiber } from "effect"; -import type { ActivationResult, BackendEndpoint } from "../gateway/Gateway.ts"; -import type { GatewayActivationError, StackError } from "../public/Errors.ts"; - -type EndpointState = - | { readonly _tag: "unresolved" } - | { - readonly _tag: "resolving"; - readonly deferred: Deferred.Deferred< - Exit.Exit, - never - >; - } - | { readonly _tag: "resolved"; readonly endpoint: BackendEndpoint }; - -type Retirement = - | { readonly _tag: "disarmed" } - | { - readonly _tag: "armed"; - readonly epoch: symbol; - readonly fiber: Fiber.Fiber; - }; - -type ActivationCompletion = Deferred.Deferred, never>; - -type WorkloadCompletion = Deferred.Deferred, never>; - -type StartingCompletion = - | { readonly _tag: "activation"; readonly deferred: ActivationCompletion } - | { readonly _tag: "workload"; readonly deferred: WorkloadCompletion }; - -export type CapabilityState = - | { readonly _tag: "disabled" } - | { readonly _tag: "stopped" } - | { - readonly _tag: "dormant"; - readonly sessionId: symbol; - readonly traffic: number; - readonly root: boolean; - readonly retirement: { readonly _tag: "disarmed" }; - } - | { - readonly _tag: "starting"; - readonly sessionId: symbol; - readonly traffic: number; - readonly operation: symbol; - readonly completion: StartingCompletion; - readonly prior: Extract; - readonly root: boolean; - readonly retirement: { readonly _tag: "disarmed" }; - } - | { - readonly _tag: "ready"; - readonly sessionId: symbol; - readonly traffic: number; - readonly endpoint: EndpointState; - readonly root: boolean; - readonly retirement: Retirement; - } - | { - readonly _tag: "stopping"; - readonly sessionId: symbol; - readonly traffic: number; - readonly operation: symbol; - readonly completion: Deferred.Deferred, never>; - readonly prior: Extract< - CapabilityState, - { readonly _tag: "dormant" | "ready" | "cleanup-failed" } - >; - readonly root: boolean; - readonly retirement: { readonly _tag: "disarmed" }; - } - | { - readonly _tag: "cleanup-failed"; - readonly sessionId: symbol; - readonly traffic: number; - readonly cause: Cause.Cause; - readonly root: boolean; - }; - -type DormantState = Extract; -type ReadyState = Extract; -type StartingState = Extract; -type StoppingState = Extract; -type CleanupFailedState = Extract; - -export const dormant = (sessionId: symbol, traffic = 0, root = false): DormantState => ({ - _tag: "dormant", - sessionId, - traffic, - root, - retirement: { _tag: "disarmed" }, -}); - -export const beginStarting = ( - prior: DormantState | ReadyState, - operation: symbol, - completion: StartingCompletion, - root = prior.root, -): StartingState => ({ - _tag: "starting", - sessionId: prior.sessionId, - traffic: prior.traffic, - operation, - completion, - prior, - root, - retirement: { _tag: "disarmed" }, -}); - -export const ready = ( - sessionId: symbol, - traffic: number, - root: boolean, - endpoint: EndpointState = { _tag: "unresolved" }, -): ReadyState => ({ - _tag: "ready", - sessionId, - traffic, - endpoint, - root, - retirement: { _tag: "disarmed" }, -}); - -export const completeStarting = ( - state: StartingState, - endpoint: EndpointState = { _tag: "unresolved" }, - root = state.root, -): ReadyState => ready(state.sessionId, state.traffic, root, endpoint); - -export const restoreStarting = (state: StartingState): DormantState | ReadyState => - Match.value(state.prior).pipe( - Match.tag("dormant", (prior) => dormant(prior.sessionId, state.traffic, prior.root)), - Match.tag("ready", (prior) => - ready(prior.sessionId, state.traffic, prior.root, prior.endpoint), - ), - Match.exhaustive, - ); - -export const promoteStartingPrior = (state: StartingState): StartingState => ({ - ...state, - prior: Match.value(state.prior).pipe( - Match.tag("dormant", (prior) => ready(prior.sessionId, state.traffic, prior.root)), - Match.tag("ready", (prior) => - ready(prior.sessionId, state.traffic, prior.root, prior.endpoint), - ), - Match.exhaustive, - ), -}); - -export const dormantFromReady = (state: ReadyState): DormantState => - dormant(state.sessionId, state.traffic, state.root); - -export const beginStopping = ( - prior: DormantState | ReadyState | CleanupFailedState, - operation: symbol, - completion: Deferred.Deferred, never>, - root = prior.root, -): StoppingState => ({ - _tag: "stopping", - sessionId: prior.sessionId, - traffic: prior.traffic, - operation, - completion, - prior, - root, - retirement: { _tag: "disarmed" }, -}); - -export const cleanupFailed = ( - state: StartingState | StoppingState | ReadyState, - cause: Cause.Cause, -): CapabilityState => ({ - _tag: "cleanup-failed", - sessionId: state.sessionId, - traffic: state.traffic, - cause, - root: state.root, -}); - -export const publicCapabilityState = ( - state: CapabilityState, -): "disabled" | "stopped" | "dormant" | "starting" | "ready" | "stopping" | "failed" => - Match.value(state).pipe( - Match.when({ _tag: "disabled" }, () => "disabled" as const), - Match.when({ _tag: "stopped" }, () => "stopped" as const), - Match.when({ _tag: "dormant" }, () => "dormant" as const), - Match.when({ _tag: "starting" }, () => "starting" as const), - Match.when({ _tag: "ready" }, () => "ready" as const), - Match.when({ _tag: "stopping" }, () => "stopping" as const), - Match.when({ _tag: "cleanup-failed" }, () => "failed" as const), - Match.exhaustive, - ); diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index 417ba94172..93acb6d040 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -8,6 +8,8 @@ import { PORT_FIELD_PROTOCOL, type PortField } from "../public/Status.ts"; export interface HostListener { readonly field: PortField; + /** Concrete gateway binding key; distinct instances may share a PortField projection. */ + readonly routeKey?: string; readonly address: string; readonly port: number; readonly close: Effect.Effect; @@ -340,8 +342,6 @@ export const bindHostListenerWithOptions = ( ); }; -export const isHttpPortField = (field: PortField): boolean => PORT_FIELD_PROTOCOL[field] === "http"; - const boundAddress = (server: HttpServer | NetServer, fallback: string): string => { const address = server.address(); return typeof address === "object" && address !== null ? address.address : fallback; diff --git a/packages/stack/src/supervisor/IdleRetirement.ts b/packages/stack/src/supervisor/IdleRetirement.ts new file mode 100644 index 0000000000..620b68469a --- /dev/null +++ b/packages/stack/src/supervisor/IdleRetirement.ts @@ -0,0 +1,29 @@ +import { Duration, Effect, FiberMap } from "effect"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; + +export interface IdleRetirement { + /** Arms or replaces the idle timer for one registered instance. */ + readonly arm: (id: ServiceInstanceId, generation: number, seconds: number) => Effect.Effect; + /** Cancels the idle timer for one registered instance. */ + readonly cancel: (id: ServiceInstanceId) => Effect.Effect; +} + +/** Owns scoped per-instance idle timers and invokes the engine's retirement callback. */ +export const makeIdleRetirement = ( + retire: (id: ServiceInstanceId, generation: number) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const timers = yield* FiberMap.make(); + return { + arm: (id, generation, seconds) => + !Number.isFinite(seconds) || seconds <= 0 + ? FiberMap.remove(timers, id) + : FiberMap.run( + timers, + id, + )( + Effect.sleep(Duration.seconds(seconds)).pipe(Effect.andThen(retire(id, generation))), + ).pipe(Effect.asVoid), + cancel: (id) => FiberMap.remove(timers, id), + } satisfies IdleRetirement; + }); diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 94305bc38a..b90b9a693f 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -1,37 +1,35 @@ import { Context, Crypto, Effect, Exit, FileSystem, Path, Ref, Scope, Semaphore } from "effect"; -import type { LifecycleInput } from "./Lifecycle.ts"; -import type { - ActivationResult, - GatewayRoute, - GatewayProxyRoute, - GatewayRouteRequest, - HttpGatewayListenerOptions, - StackGateway, -} from "../gateway/Gateway.ts"; +import type { GatewayRoute, GatewayProxyRoute } from "../gateway/Gateway.ts"; import type { GatewayActivity } from "../gateway/ActivityTracker.ts"; import { GatewayActivationError, PortUnavailableError, - StackLifecycleConflictError, StackPreparationError, + StackStateInvalidError, type StackError, } from "../public/Errors.ts"; import type { PortField } from "../public/Status.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import type { StackId } from "../public/StackId.ts"; import { routeCatalogFor, type GatewayApiMaterial } from "../gateway/RouteCatalog.ts"; -import { GatewayRouteNotFoundError, makeGateway } from "../gateway/Gateway.ts"; import { - makePortCoordinator, - type ListenerIntents, - type PortReservation, -} from "../state/PortCoordinator.ts"; + GatewayRouteNotFoundError, + type BackendEndpoint, + isGatewayProxyRoute, +} from "../gateway/Gateway.ts"; +import { makeHttpGateway, type HttpGateway } from "../gateway/HttpGateway.ts"; +import { makeTcpGateway, type TcpGateway } from "../gateway/TcpGateway.ts"; +import { makePortCoordinator, type PortReservation } from "../state/PortCoordinator.ts"; import type { HostListener } from "./HostListener.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; -import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; +import { runtimeSpecFor } from "../runtime/WorkloadRuntimeSpec.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import { createExecutionPlan, type ExecutionPlan } from "../model/ExecutionPlan.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; import { bindHeldPort, bindHostListener, hostListenerCoversAddress, - isHttpPortField, type HeldPort, } from "./HostListener.ts"; import { @@ -41,33 +39,47 @@ import { AUTH_SERVICE_ROLE_KEY_SLOT, } from "../state/SecretStore.ts"; -interface SupervisorIngressReservation extends PortReservation { - /** False when this accepted definition already owns the exact listeners and gateway. */ - readonly fresh: boolean; - /** Stable identity for the reservation across non-fresh reacquisition views. */ - readonly ownershipToken: symbol; +export type TrafficAdmissionMode = "normal" | "startup-control"; + +export interface TrafficLease { + readonly release: Effect.Effect; } export interface SupervisorIngress { - /** Reserve durable ports and bind public listeners before workload launch. */ - readonly acquire: ( - input: LifecycleInput, - ) => Effect.Effect; - /** Adopt acquired listeners into HTTP/TCP gateways after workloads are ready. */ - readonly open: ( - input: LifecycleInput, - reservation: SupervisorIngressReservation, - activate: ( - capability: import("../public/Capability.ts").CapabilityName, - ) => Effect.Effect, - activity?: GatewayActivity, - ) => Effect.Effect; - /** Close gateway, accepted sockets, and exact listeners; safe to call repeatedly. */ + /** Close gateways and exact listeners; safe to call repeatedly. */ readonly close: Effect.Effect; + /** Publish the concrete backend bindings returned after one instance starts. */ + readonly publish?: ( + instanceId: ServiceInstanceId, + publications: ReadonlyArray, + ) => Effect.Effect; + /** Bind all admitted lazy listeners and the shared API before lazy workloads start. */ + readonly armLazyIngress?: ( + state: PersistedStackState, + plan: ExecutionPlan, + ) => Effect.Effect; + /** Remove backend bindings after one instance stops or is destroyed. */ + readonly unpublish?: ( + instanceId: ServiceInstanceId, + preserveListener?: boolean, + ) => Effect.Effect; + /** Supplies the lifecycle callback used to wake a dormant instance listener. */ + readonly setInstanceActivator?: ( + activate: (instanceId: ServiceInstanceId) => Effect.Effect, + ) => Effect.Effect; + /** Installs the Supervisor owned atomic traffic admission lease. */ + readonly setTrafficAcquirer?: ( + acquire: ( + instanceId: ServiceInstanceId, + mode?: TrafficAdmissionMode, + ) => Effect.Effect, + ) => Effect.Effect; + /** Reports whether a dormant instance still has a bound demand wake listener. */ + readonly isInstanceWakeable?: (instanceId: ServiceInstanceId) => Effect.Effect; } export interface SupervisorIngressOptions { - readonly stackId: string; + readonly stackId: StackId; readonly stateRoot: string; readonly store: StackStateStore; readonly context: Context.Context; @@ -85,10 +97,10 @@ export interface SupervisorIngressOptions { readonly resolveInternalApiBindAddress?: () => Effect.Effect; /** Resolver may be replaced by the production credential owner. */ readonly apiMaterial?: ( - state: LifecycleInput["state"], + state: PersistedStackState, ) => Effect.Effect; /** Resolves the accepted definition's Auth templates for live local serving. */ - readonly resolveAuthTemplates?: (state: LifecycleInput["state"]) => Effect.Effect< + readonly resolveAuthTemplates?: (state: PersistedStackState) => Effect.Effect< ReadonlyArray<{ readonly id: string; readonly canonicalPath: string; @@ -98,26 +110,8 @@ export interface SupervisorIngressOptions { >; } -const listenerIntents = (input: LifecycleInput): ListenerIntents => { - const usable = new Set(input.plan.routes.map(({ listener }) => listener)); - const select = (field: K): ListenerIntents[K] => - usable.has(field) - ? input.definition.listeners[field] - : { ...input.definition.listeners[field], enabled: false }; - return { - api: select("api"), - database: select("database"), - pooler: select("pooler"), - studio: select("studio"), - mailUi: select("mailUi"), - smtp: select("smtp"), - pop3: select("pop3"), - functionsInspector: select("functionsInspector"), - }; -}; - const defaultApiMaterial = ( - state: LifecycleInput["state"], + state: PersistedStackState, ): Effect.Effect => { const get = (slot: string): string | undefined => state.secrets[slot]?.value; const publishableKey = get(AUTH_PUBLISHABLE_KEY_SLOT); @@ -136,34 +130,38 @@ const defaultApiMaterial = ( return Effect.succeed({ publishableKey, secretKey, anonJwt, serviceRoleJwt }); }; -const routeBackend = ( - input: LifecycleInput, - reservation: SupervisorIngressReservation, - route: Pick, - activation: ActivationResult, -) => { - if (route.binding === undefined) return Effect.succeed(activation.endpoint); - const workloadIds = new Set( - input.plan.workloads - .filter((entry) => entry.capability === route.capability) - .map((entry) => entry.id), +const publishedEndpointFor = ( + publications: ReadonlyArray | undefined, + workload: ExecutionPlan["workloads"][number], + binding: string, +): BackendEndpoint | undefined => + publications?.find( + (publication) => + publication.workloadId === workload.id && + (publication.binding === binding || + (binding === "primary" && + workload.capability === "database" && + publication.binding === "sql:internal")), + )?.endpoint; + +const publicWorkloadFor = ( + plan: ExecutionPlan, + instanceId: ServiceInstanceId, + capability: import("../public/Capability.ts").CapabilityName, + listener: PortField, +): ExecutionPlan["workloads"][number] | undefined => { + const candidates = plan.workloads.filter( + (entry) => entry.instanceId === instanceId && entry.capability === capability, ); - const assignments = reservation.privateAssignments.filter( - (entry) => workloadIds.has(entry.workloadId) && entry.binding === route.binding, + return ( + candidates.find((entry) => entry.readiness.portField === listener) ?? + (candidates.length === 1 ? candidates[0] : undefined) ); - const [assignment, ...additionalAssignments] = assignments; - if (assignment === undefined || additionalAssignments.length > 0) - return Effect.fail( - new GatewayActivationError({ - message: - assignment === undefined - ? "Gateway private binding is unavailable" - : "Gateway private binding is ambiguous", - }), - ); - return Effect.succeed({ host: "127.0.0.1", port: assignment.port }); }; +const publicationKey = (publication: RuntimeBindingPublication): string => + `${publication.workloadId}\u0000${publication.binding}`; + const templateContentType = (extension: string): string => { switch (extension.toLowerCase()) { case ".html": @@ -178,7 +176,7 @@ const templateContentType = (extension: string): string => { } }; -/** Compose PortCoordinator and StackGateway under one Supervisor owner scope. */ +/** Compose the Supervisor owned gateways under one owner scope. */ export const makeSupervisorIngress = ( options: SupervisorIngressOptions, ): Effect.Effect< @@ -190,248 +188,726 @@ export const makeSupervisorIngress = ( const fs = yield* FileSystem.FileSystem; const ownerScope = yield* Scope.Scope; const lock = yield* Semaphore.make(1); - const current = yield* Ref.make< - | { - readonly input: LifecycleInput; - readonly reservation: SupervisorIngressReservation; - readonly scope: Scope.Scope; - readonly gateway?: StackGateway; - } + const published = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const instanceGateways = yield* Ref.make>( + new Map(), + ); + const apiGateway = yield* Ref.make(undefined); + const apiInternalGateway = yield* Ref.make(undefined); + const apiReservation = yield* Ref.make< + { readonly reservation: PortReservation; readonly scope: Scope.Scope } | undefined + >(undefined); + const apiGatewayInstances = yield* Ref.make>(new Set()); + const sharedApiRoutes: GatewayRoute[] = []; + const sharedApiRouteInstances = new Map< + import("../public/Capability.ts").CapabilityName, + ServiceInstanceId + >(); + const armSharedApi = yield* Ref.make(false); + const instanceActivator = yield* Ref.make< + ((instanceId: ServiceInstanceId) => Effect.Effect) | undefined + >(undefined); + const trafficAcquirer = yield* Ref.make< + | (( + instanceId: ServiceInstanceId, + mode?: TrafficAdmissionMode, + ) => Effect.Effect) | undefined >(undefined); + const activityFor = ( + instanceId: ServiceInstanceId, + startupControl = false, + ): GatewayActivity => ({ + track: (_capability, effect) => + Effect.uninterruptibleMask((restore) => + Effect.acquireUseRelease( + restore( + Ref.get(trafficAcquirer).pipe( + Effect.flatMap((acquire) => + acquire === undefined + ? Effect.fail( + new GatewayActivationError({ + message: `Traffic admission is unavailable for ${instanceId}`, + }), + ) + : acquire(instanceId, startupControl ? "startup-control" : "normal").pipe( + Effect.mapError( + (error) => + new GatewayActivationError({ + message: error.message, + cause: error, + }), + ), + ), + ), + ), + ), + () => restore(effect), + (lease) => lease.release, + ), + ), + }); const coordinator = makePortCoordinator({ stateRoot: options.stateRoot, store: options.store, bindHost: options.bindHost ?? bindHostListener, bindPrivate: options.bindPrivate ?? bindHeldPort, }); - const acquire = ( - input: LifecycleInput, - ): Effect.Effect => - lock.withPermit( - Effect.gen(function* () { - const existing = yield* Ref.get(current); - // A Supervisor owns one ingress reservation for its running session. Definition - // changes are rejected while running and a stopped session closes this reservation, - // so a live reservation can always be reused without a configuration fingerprint. - if (existing !== undefined) return { ...existing.reservation, fresh: false }; - const reservationScope = Scope.forkUnsafe(ownerScope); - const reservation = yield* coordinator - .acquire( - options.stackId, - listenerIntents(input), - privateBindingIntentsFor(input.plan, input.state), + const releaseSharedApi = Effect.gen(function* () { + const sharedApi = yield* Ref.get(apiGateway); + if (sharedApi !== undefined) yield* sharedApi.close; + const sharedApiInternal = yield* Ref.get(apiInternalGateway); + if (sharedApiInternal !== undefined) yield* sharedApiInternal.close; + const reservation = yield* Ref.get(apiReservation); + if (reservation !== undefined) yield* Scope.close(reservation.scope, Exit.void); + yield* Ref.set(apiGateway, undefined); + yield* Ref.set(apiInternalGateway, undefined); + yield* Ref.set(apiReservation, undefined); + yield* Ref.set(apiGatewayInstances, new Set()); + sharedApiRoutes.splice(0, sharedApiRoutes.length); + sharedApiRouteInstances.clear(); + }); + const closeUnlocked: Effect.Effect = Effect.gen(function* () { + for (const gateway of (yield* Ref.get(instanceGateways)).values()) yield* gateway.close; + yield* releaseSharedApi; + yield* Ref.set(instanceGateways, new Map()); + yield* Ref.set(published, new Map()); + }); + const close: Effect.Effect = lock.withPermit(closeUnlocked); + const publishUnlocked = ( + instanceId: ServiceInstanceId, + publications: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + // A dormant listener remains bound so its first request can wake the instance. Reusing + // that listener during wake avoids a close/rebind race on the durable public port. + yield* Ref.update(published, (current) => { + const previous = current.get(instanceId) ?? []; + const merged = new Map( + previous.map((publication) => [publicationKey(publication), publication]), + ); + for (const publication of publications) + merged.set(publicationKey(publication), publication); + return new Map(current).set(instanceId, [...merged.values()]); + }); + const existing = yield* Ref.get(instanceGateways); + const state = yield* options.store.read(options.stackId).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + if (state === undefined) + return yield* new StackStateInvalidError({ + message: "Stack state is missing while publishing instance endpoints", + }); + const plan = yield* createExecutionPlan(state.runtime, state.registry).pipe( + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + const gatewayKinds: Readonly< + Record< + string, + { + readonly field: PortField; + readonly capability: import("../public/Capability.ts").CapabilityName; + readonly protocol: "http" | "tcp"; + readonly routeBinding: string; + readonly workloadBinding: string; + } + > + > = { + sql: { + field: "database", + capability: "database", + protocol: "tcp", + routeBinding: "primary", + workloadBinding: "sql:internal", + }, + pooler: { + field: "pooler", + capability: "pooler", + protocol: "tcp", + routeBinding: "primary", + workloadBinding: "primary", + }, + inspector: { + field: "functionsInspector", + capability: "functions", + protocol: "http", + routeBinding: "inspector", + workloadBinding: "inspector", + }, + studio: { + field: "studio", + capability: "studio", + protocol: "http", + routeBinding: "primary", + workloadBinding: "primary", + }, + mailUi: { + field: "mailUi", + capability: "mail", + protocol: "http", + routeBinding: "ui", + workloadBinding: "ui", + }, + smtp: { + field: "smtp", + capability: "mail", + protocol: "tcp", + routeBinding: "smtp", + workloadBinding: "smtp", + }, + pop3: { + field: "pop3", + capability: "mail", + protocol: "tcp", + routeBinding: "pop3", + workloadBinding: "pop3", + }, + }; + const apiEnabled = + plan.routes.some((route) => route.listener === "api" && route.protocol === "http") && + state.listeners.api?.enabled !== false; + const admittedApiInstances = new Set( + state.registry.instances + .filter( + (instance) => instance.config.enabled !== false && instance.intent === "started", ) - .pipe( - Effect.provideContext(options.context), - Effect.provideService(Scope.Scope, reservationScope), - Effect.onExit((exit) => - Exit.isSuccess(exit) ? Effect.void : Scope.close(reservationScope, exit), - ), + .map((instance) => instance.id), + ); + const apiRoutes = + routeCatalogFor( + plan, + apiEnabled ? yield* (options.apiMaterial ?? defaultApiMaterial)(state) : undefined, + ) + .http.get("api") + ?.filter( + (route): route is GatewayProxyRoute & { readonly instanceId: ServiceInstanceId } => + isGatewayProxyRoute(route) && + route.instanceId !== undefined && + admittedApiInstances.has(route.instanceId), + ) ?? []; + const publishSharedApi = (): Effect.Effect => + Effect.gen(function* () { + const apiAssignment = state.ports.find( + (entry) => entry.owner === "stack" && entry.binding === "api", + ); + if (apiAssignment === undefined || apiRoutes.length === 0) return; + let existingGateway = yield* Ref.get(apiGateway); + if ( + existingGateway !== undefined && + (existingGateway.address !== apiAssignment.address || + existingGateway.port !== apiAssignment.port) + ) { + yield* existingGateway.close; + const existingInternalGateway = yield* Ref.get(apiInternalGateway); + if (existingInternalGateway !== undefined) yield* existingInternalGateway.close; + const existingReservation = yield* Ref.get(apiReservation); + if (existingReservation !== undefined) + yield* Scope.close(existingReservation.scope, Exit.void); + yield* Ref.set(apiGateway, undefined); + yield* Ref.set(apiInternalGateway, undefined); + yield* Ref.set(apiReservation, undefined); + existingGateway = undefined; + } + const routeInstances = new Map( + apiRoutes.map((route) => [route.capability, route.instanceId]), + ); + const routeInstanceIds = new Set(apiRoutes.map((route) => route.instanceId)); + const resolveTemplates = options.resolveAuthTemplates; + const templateRoute: GatewayRoute | undefined = + resolveTemplates === undefined + ? undefined + : { + match: (request) => { + const pathname = request.path.split("?", 1)[0] ?? request.path; + return pathname === "/email" || pathname.startsWith("/email/"); + }, + localResponse: (request) => { + const pathname = request.path.split("?", 1)[0] ?? request.path; + if (request.method !== "GET") + return Effect.fail( + new GatewayRouteNotFoundError({ message: "Auth template not found" }), + ); + return resolveTemplates(state).pipe( + Effect.mapError( + () => + new GatewayRouteNotFoundError({ message: "Auth template not found" }), + ), + Effect.flatMap((templates) => { + const template = templates.find( + (entry) => `/email/${entry.id}${entry.extension}` === pathname, + ); + return template === undefined + ? Effect.fail( + new GatewayRouteNotFoundError({ + message: "Auth template not found", + }), + ) + : fs.readFile(template.canonicalPath).pipe( + Effect.mapError( + () => + new GatewayRouteNotFoundError({ + message: "Auth template not found", + }), + ), + Effect.map((body) => ({ + body, + contentType: templateContentType(template.extension), + })), + ); + }), + ); + }, + }; + const routes = apiRoutes.map((route) => { + const workload = publicWorkloadFor(plan, route.instanceId, route.capability, "api"); + const binding = route.binding ?? "primary"; + return { + ...route, + binding, + prepare: () => + Effect.gen(function* () { + let endpoint = + workload === undefined + ? undefined + : publishedEndpointFor( + (yield* Ref.get(published)).get(route.instanceId), + workload, + binding, + ); + if (endpoint === undefined) { + const latestState = yield* options.store.read(options.stackId).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => + new GatewayActivationError({ + message: "Unable to inspect API instance state", + cause: error, + }), + ), + ); + const instance = latestState?.registry.instances.find( + (entry) => entry.id === route.instanceId, + ); + if ( + instance === undefined || + instance.config.enabled === false || + instance.intent !== "started" + ) + return yield* new GatewayActivationError({ + message: `${route.capability} is not admitted for wake`, + }); + const wake = yield* Ref.get(instanceActivator); + if (wake === undefined) + return yield* new GatewayActivationError({ + message: `${route.capability} is dormant`, + }); + yield* wake(route.instanceId); + endpoint = + workload === undefined + ? undefined + : publishedEndpointFor( + (yield* Ref.get(published)).get(route.instanceId), + workload, + binding, + ); + } + return endpoint === undefined + ? yield* new GatewayActivationError({ + message: `${route.capability} backend for ${route.instanceId} is unavailable`, + }) + : { resolveBackend: () => Effect.succeed(endpoint) }; + }).pipe( + Effect.mapError((error) => + error instanceof GatewayActivationError + ? error + : new GatewayActivationError({ message: error.message, cause: error }), + ), + ), + } satisfies GatewayProxyRoute; + }); + const nextRoutes = templateRoute === undefined ? routes : [templateRoute, ...routes]; + sharedApiRoutes.splice(0, sharedApiRoutes.length, ...nextRoutes); + sharedApiRouteInstances.clear(); + for (const [capability, routeInstanceId] of routeInstances) + sharedApiRouteInstances.set(capability, routeInstanceId); + if (existingGateway !== undefined) { + yield* Ref.set(apiGatewayInstances, routeInstanceIds); + return; + } + const reservedListener = (yield* Ref.get( + apiReservation, + ))?.reservation.hostListeners.find( + (entry) => + entry.routeKey === "stack:api" && + entry.field === "api" && + entry.port === apiAssignment.port, ); - const owned: SupervisorIngressReservation = { - ...reservation, - fresh: true, - ownershipToken: Symbol(), + const listener = + reservedListener ?? + (yield* (options.bindHost ?? bindHostListener)( + apiAssignment.address, + apiAssignment.port, + "api", + ).pipe(Effect.provideService(Scope.Scope, ownerScope))); + const internalAddress = options.resolveInternalApiBindAddress + ? yield* options.resolveInternalApiBindAddress() + : undefined; + const internalListener = + internalAddress !== undefined && !hostListenerCoversAddress(listener, internalAddress) + ? yield* (options.bindHost ?? bindHostListener)( + internalAddress, + apiAssignment.port, + "api", + ).pipe(Effect.provideService(Scope.Scope, ownerScope)) + : undefined; + const activity: GatewayActivity = { + track: (capability, effect) => { + const instance = sharedApiRouteInstances.get(capability); + return instance === undefined + ? effect + : activityFor(instance).track(capability, effect); + }, + }; + const gatewayOptions = { + routes: sharedApiRoutes, + activate: (capability: import("../public/Capability.ts").CapabilityName) => + Effect.succeed({ + capability, + endpoint: { host: "127.0.0.1", port: 1 }, + }), + activity, + }; + const gateway = yield* makeHttpGateway({ + listener, + ...gatewayOptions, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const internalGateway = + internalListener === undefined + ? undefined + : yield* makeHttpGateway({ + listener: internalListener, + ...gatewayOptions, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + yield* Ref.set(apiGateway, gateway); + yield* Ref.set(apiInternalGateway, internalGateway); + yield* Ref.set(apiGatewayInstances, routeInstanceIds); + }); + if (!apiEnabled) { + yield* releaseSharedApi; + } else if ( + apiRoutes.length > 0 && + (publications.length > 0 || (yield* Ref.get(armSharedApi))) + ) { + yield* publishSharedApi(); + } + const assignments = + state?.ports.filter( + (entry) => entry.owner === "instance" && entry.instanceId === instanceId, + ) ?? []; + for (const assignment of assignments) { + const kind = gatewayKinds[assignment.binding]; + if (kind === undefined) continue; + if (existing.has(`${instanceId}:${assignment.binding}`)) continue; + const workload = publicWorkloadFor(plan, instanceId, kind.capability, kind.field); + const resolvedWorkload = + workload !== undefined && + Object.keys(runtimeSpecFor(workload)?.bindings ?? {}).includes(kind.workloadBinding) + ? workload + : undefined; + const endpoint = + resolvedWorkload === undefined + ? undefined + : publishedEndpointFor( + (yield* Ref.get(published)).get(instanceId), + resolvedWorkload, + kind.workloadBinding, + ); + const instance = state.registry.instances.find((entry) => entry.id === instanceId); + const lazy = + instance?.intent === "started" && + instance.config.enabled !== false && + plan.activation[instanceId] === "lazy"; + if (endpoint === undefined && !lazy) continue; + const listener = yield* (options.bindHost ?? bindHostListener)( + assignment.address, + assignment.port, + kind.field, + ).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const route: import("../gateway/Gateway.ts").GatewayProxyRoute = { + capability: kind.capability, + instanceId, + binding: kind.routeBinding, + match: () => true, }; - yield* Ref.set(current, { input, reservation: owned, scope: reservationScope }); - return owned; - }), - ); - - const closeCurrent = (entry: { - readonly reservation: SupervisorIngressReservation; - readonly gateway?: StackGateway; - readonly scope: Scope.Scope; - }): Effect.Effect => - Effect.gen(function* () { - if (entry.gateway !== undefined) yield* entry.gateway.close; - yield* Scope.close(entry.scope, Exit.void); + const activate = (_capability: import("../public/Capability.ts").CapabilityName) => + Effect.gen(function* () { + const current = yield* Ref.get(published); + const active = current.get(instanceId); + let activeEndpoint = + resolvedWorkload === undefined + ? undefined + : publishedEndpointFor(active, resolvedWorkload, kind.workloadBinding); + if (activeEndpoint === undefined) { + const activateInstance = yield* Ref.get(instanceActivator); + if (activateInstance === undefined) + return yield* new GatewayActivationError({ + message: `Service instance ${instanceId} is dormant`, + }); + yield* activateInstance(instanceId); + const refreshed = (yield* Ref.get(published)).get(instanceId); + activeEndpoint = + resolvedWorkload === undefined + ? undefined + : publishedEndpointFor(refreshed, resolvedWorkload, kind.workloadBinding); + } + return activeEndpoint === undefined + ? yield* new GatewayActivationError({ + message: `Service instance ${instanceId} did not publish a backend endpoint`, + }) + : { capability: kind.capability, instanceId, endpoint: activeEndpoint }; + }).pipe( + Effect.mapError((error) => + error instanceof GatewayActivationError + ? error + : new GatewayActivationError({ message: error.message, cause: error }), + ), + ); + const gateway = + kind.protocol === "http" + ? yield* makeHttpGateway({ + listener, + routes: [route], + activate, + resolveBackend: () => + Ref.get(published).pipe( + Effect.flatMap((current) => { + const active = + resolvedWorkload === undefined + ? endpoint + : publishedEndpointFor( + current.get(instanceId), + resolvedWorkload, + kind.workloadBinding, + ); + return active === undefined + ? Effect.fail( + new GatewayActivationError({ + message: `Service backend for ${instanceId} is not published`, + }), + ) + : Effect.succeed(active); + }), + ), + activity: activityFor(instanceId, kind.routeBinding === "inspector"), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)) + : yield* makeTcpGateway({ + listener, + routes: [route], + activate, + resolveBackend: () => + Ref.get(published).pipe( + Effect.flatMap((current) => { + const active = + resolvedWorkload === undefined + ? endpoint + : publishedEndpointFor( + current.get(instanceId), + resolvedWorkload, + kind.workloadBinding, + ); + return active === undefined + ? Effect.fail( + new GatewayActivationError({ + message: `Service backend for ${instanceId} is not published`, + }), + ) + : Effect.succeed(active); + }), + ), + activity: activityFor(instanceId), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + yield* Ref.update(instanceGateways, (current) => + new Map(current).set(`${instanceId}:${assignment.binding}`, gateway), + ); + } }); - - const close: Effect.Effect = lock.withPermit( - Effect.gen(function* () { - const entry = yield* Ref.get(current); - if (entry === undefined) return; - yield* closeCurrent(entry); - yield* Ref.set(current, undefined); - }), - ); - - const open = ( - input: LifecycleInput, - reservation: SupervisorIngressReservation, - activate: ( - capability: import("../public/Capability.ts").CapabilityName, - ) => Effect.Effect, - activity?: GatewayActivity, + const publish = ( + instanceId: ServiceInstanceId, + publications: ReadonlyArray, + ): Effect.Effect => + lock.withPermit(publishUnlocked(instanceId, publications)); + const armLazyIngress = ( + state: PersistedStackState, + plan: ExecutionPlan, ): Effect.Effect => lock.withPermit( Effect.gen(function* () { - const entry = yield* Ref.get(current); + const apiEnabled = + state.listeners.api?.enabled !== false && + plan.routes.some((route) => route.listener === "api" && route.protocol === "http"); + const desiredApi = state.ports.find( + (entry) => entry.owner === "stack" && entry.binding === "api", + ); + const existingApi = yield* Ref.get(apiGateway); if ( - entry === undefined || - entry.reservation.ownershipToken !== reservation.ownershipToken + existingApi !== undefined && + (!apiEnabled || + desiredApi === undefined || + existingApi.address !== desiredApi.address || + existingApi.port !== desiredApi.port) ) - return yield* new GatewayActivationError({ - message: "Gateway reservation is no longer current", - }); - if (entry.gateway !== undefined) return; - const intents = listenerIntents(input); - const material = intents.api.enabled - ? yield* (options.apiMaterial ?? defaultApiMaterial)(input.state) - : undefined; - const catalog = routeCatalogFor(input.plan, material); - const resolveTemplates = options.resolveAuthTemplates; - const templateRoute: GatewayRoute | undefined = - resolveTemplates === undefined - ? undefined - : { - match: (request) => { - const pathname = request.path.split("?", 1)[0] ?? request.path; - return pathname === "/email" || pathname.startsWith("/email/"); - }, - localResponse: (request) => { - const pathname = request.path.split("?", 1)[0] ?? request.path; - if (request.method !== "GET") - return Effect.fail( - new GatewayRouteNotFoundError({ message: "Auth template not found" }), - ); - return resolveTemplates(input.state).pipe( - Effect.mapError( - () => new GatewayRouteNotFoundError({ message: "Auth template not found" }), - ), - Effect.flatMap((templates) => { - const template = templates.find( - (entry) => `/email/${entry.id}${entry.extension}` === pathname, - ); - return template === undefined - ? Effect.fail( - new GatewayRouteNotFoundError({ - message: "Auth template not found", - }), - ) - : fs.readFile(template.canonicalPath).pipe( - Effect.mapError( - () => - new GatewayRouteNotFoundError({ - message: "Auth template not found", - }), - ), - Effect.map((body) => ({ - body, - contentType: templateContentType(template.extension), - })), - ); - }), - ); + yield* releaseSharedApi; + const existingReservation = yield* Ref.get(apiReservation); + const reservedApi = existingReservation?.reservation.assignments.api; + if ( + existingReservation !== undefined && + (!apiEnabled || + desiredApi === undefined || + reservedApi === undefined || + reservedApi.address !== desiredApi.address || + reservedApi.port !== desiredApi.port) + ) + yield* releaseSharedApi; + if ( + apiEnabled && + (yield* Ref.get(apiGateway)) === undefined && + (yield* Ref.get(apiReservation)) === undefined + ) { + const reservationScope = Scope.forkUnsafe(ownerScope); + const reservation = yield* coordinator + .acquire( + options.stackId, + [ + { + owner: "stack", + binding: "api", + listenerField: "api", + address: state.listeners.api?.address ?? "127.0.0.1", + port: state.listeners.api?.port ?? "automatic", }, - }; - const http: HttpGatewayListenerOptions[] = reservation.hostListeners - .filter((listener) => isHttpPortField(listener.field)) - .map((listener) => ({ - field: listener.field, - key: listener.field, - options: { - listener, - routes: - listener.field === "api" && templateRoute !== undefined - ? [templateRoute, ...(catalog.http.get(listener.field) ?? [])] - : (catalog.http.get(listener.field) ?? []), - resolveBackend: ( - route: GatewayProxyRoute, - _request: GatewayRouteRequest, - result: ActivationResult, - ) => routeBackend(input, reservation, route, result), - }, - })); - const internalApiAddress = - intents.api.enabled && - reservation.assignments.api !== undefined && - options.resolveInternalApiBindAddress !== undefined - ? yield* options.resolveInternalApiBindAddress() - : undefined; - let internalApi: HostListener | undefined; - if (internalApiAddress !== undefined && reservation.assignments.api !== undefined) { - const covered = reservation.hostListeners.some( - (listener) => - listener.field === "api" && - listener.port === reservation.assignments.api?.port && - hostListenerCoversAddress(listener, internalApiAddress), - ); - if (!covered) { - internalApi = yield* (options.bindHost ?? bindHostListener)( - internalApiAddress, - reservation.assignments.api.port, - "api", - ).pipe(Effect.provideService(Scope.Scope, entry.scope)); - http.push({ - field: "api", - key: "api:internal", - options: { - listener: internalApi, - routes: - templateRoute !== undefined - ? [templateRoute, ...(catalog.http.get("api") ?? [])] - : (catalog.http.get("api") ?? []), - resolveBackend: ( - route: GatewayProxyRoute, - _request: GatewayRouteRequest, - result: ActivationResult, - ) => routeBackend(input, reservation, route, result), - }, - }); - } - } - const tcp = reservation.hostListeners - .filter((listener) => !isHttpPortField(listener.field)) - .map((listener) => ({ - field: listener.field, - options: { - listener, - routes: catalog.tcp.get(listener.field) ?? [], - resolveBackend: ( - route: GatewayProxyRoute, - _request: GatewayRouteRequest, - result: ActivationResult, - ) => routeBackend(input, reservation, route, result), - }, - })); - const gatewayResult = yield* Effect.exit( - makeGateway({ - http, - tcp, - activate: (capability) => - activate(capability).pipe( - Effect.mapError((error) => - error instanceof GatewayActivationError - ? error - : new GatewayActivationError({ - message: error.message, - cause: error, - recovery: - error instanceof StackLifecycleConflictError - ? error.recovery - : undefined, - }), - ), + ], + [], + ) + .pipe( + Effect.provideContext(options.context), + Effect.provideService(Scope.Scope, reservationScope), + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : Scope.close(reservationScope, exit), ), - activity, - }).pipe(Effect.provideService(Scope.Scope, entry.scope)), - ); - if (Exit.isFailure(gatewayResult)) { - if (internalApi !== undefined) yield* internalApi.close.pipe(Effect.ignore); - return yield* Effect.failCause(gatewayResult.cause); + ); + yield* Ref.set(apiReservation, { reservation, scope: reservationScope }); } - const gateway = gatewayResult.value; - yield* Ref.set(current, { - input, - reservation, - scope: entry.scope, - gateway, - }); + const lazyInstanceIds = state.registry.instances + .filter( + (instance) => + instance.config.enabled !== false && + instance.intent === "started" && + plan.activation[instance.id] === "lazy", + ) + .map((instance) => instance.id); + const apiInstanceId = apiEnabled + ? plan.routes.find( + (route) => + route.listener === "api" && + route.protocol === "http" && + route.instanceId !== undefined && + state.registry.instances.some( + (instance) => + instance.id === route.instanceId && + instance.config.enabled !== false && + instance.intent === "started", + ), + )?.instanceId + : undefined; + const instancesToArm = [ + ...new Set( + apiInstanceId === undefined ? lazyInstanceIds : [...lazyInstanceIds, apiInstanceId], + ), + ]; + yield* Ref.set(armSharedApi, true); + yield* Effect.forEach(instancesToArm, (instanceId) => publishUnlocked(instanceId, []), { + discard: true, + }).pipe(Effect.ensuring(Ref.set(armSharedApi, false))); }), ); - - return { acquire, open, close } satisfies SupervisorIngress; + const unpublishUnlocked = ( + instanceId: ServiceInstanceId, + preserveListener = false, + ): Effect.Effect => + Effect.gen(function* () { + if (!preserveListener) { + const gateways = yield* Ref.get(instanceGateways); + const owned = [...gateways.entries()].filter(([key]) => key.startsWith(`${instanceId}:`)); + for (const [key, gateway] of owned) { + yield* gateway.close; + yield* Ref.update(instanceGateways, (current) => { + const next = new Map(current); + next.delete(key); + return next; + }); + } + const sharedApi = yield* Ref.get(apiGateway); + if (sharedApi !== undefined && (yield* Ref.get(apiGatewayInstances)).has(instanceId)) { + const remaining = new Set(yield* Ref.get(apiGatewayInstances)); + remaining.delete(instanceId); + yield* Ref.set(apiGatewayInstances, remaining); + const remainingRoutes = sharedApiRoutes.filter( + (route) => !isGatewayProxyRoute(route) || route.instanceId !== instanceId, + ); + sharedApiRoutes.splice(0, sharedApiRoutes.length, ...remainingRoutes); + for (const [capability, routeInstanceId] of sharedApiRouteInstances) + if (routeInstanceId === instanceId) sharedApiRouteInstances.delete(capability); + if (remaining.size === 0) { + sharedApiRoutes.splice(0, sharedApiRoutes.length); + sharedApiRouteInstances.clear(); + } + } + } + yield* Ref.update(published, (current) => { + const next = new Map(current); + next.delete(instanceId); + return next; + }); + }); + const unpublish = ( + instanceId: ServiceInstanceId, + preserveListener = false, + ): Effect.Effect => + lock.withPermit(unpublishUnlocked(instanceId, preserveListener)); + const setInstanceActivator = ( + activate: (instanceId: ServiceInstanceId) => Effect.Effect, + ): Effect.Effect => Ref.set(instanceActivator, activate); + const setTrafficAcquirer = ( + acquire: ( + instanceId: ServiceInstanceId, + mode?: TrafficAdmissionMode, + ) => Effect.Effect, + ): Effect.Effect => Ref.set(trafficAcquirer, acquire); + const isInstanceWakeable = (instanceId: ServiceInstanceId): Effect.Effect => + Effect.all({ + gateways: Ref.get(instanceGateways), + sharedApiInstances: Ref.get(apiGatewayInstances), + }).pipe( + Effect.map( + ({ gateways, sharedApiInstances }) => + sharedApiInstances.has(instanceId) || + [...gateways.keys()].some((key) => key.startsWith(`${instanceId}:`)), + ), + ); + return { + close, + publish, + armLazyIngress, + unpublish, + setInstanceActivator, + setTrafficAcquirer, + isInstanceWakeable, + } satisfies SupervisorIngress; }); diff --git a/packages/stack/src/supervisor/InstanceEngine.ts b/packages/stack/src/supervisor/InstanceEngine.ts new file mode 100644 index 0000000000..daf2aa13cb --- /dev/null +++ b/packages/stack/src/supervisor/InstanceEngine.ts @@ -0,0 +1,3651 @@ +import { + Cause, + Crypto, + Context, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Path, + PubSub, + Option, + Ref, + Scope, + Semaphore, + Schema, + Stream, + Redacted, +} from "effect"; +import { + createExecutionPlan, + activeExecutionPlan, + dependencyClosure, + type ExecutionPlan, +} from "../model/ExecutionPlan.ts"; +import { + AnalyticsSettingsSchema, + AuthSettingsSchema, + DatabaseSettingsSchema, + FunctionsSettingsSchema, + MailSettingsSchema, + PoolerSettingsSchema, + RealtimeSettingsSchema, + RestSettingsSchema, + StorageSettingsSchema, + StudioSettingsSchema, +} from "../model/capabilities/index.ts"; +import { + registerServiceInstance, + removeServiceInstance, + type PersistedServiceInstance, + type PersistedServiceInstanceFor, + type PersistedServiceRegistry, + type PersistedPendingOperation, + PersistedServiceInstanceSchema, +} from "../model/ServiceRegistry.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import type { + AnyServiceDescriptor, + RedactedServiceSettings, + ServiceDescriptor, + ServiceKind, + PrepareResult, + SnapshotDescriptor, +} from "../public/Service.ts"; +import { + PORT_FIELD_PROTOCOL, + type ServiceFailure, + type ServiceStatus, + type StackRecovery, +} from "../public/Status.ts"; +import { + ServiceNotFoundError, + StackLifecycleConflictError, + StackDestructionError, + StackCleanupError, + StackStateInvalidError, + UnsupportedSnapshotError, + UncertainOperationError, + isStackError, + type LifecycleOutcome, + type StackError, +} from "../public/Errors.ts"; +import type { StackId } from "../public/StackId.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { redactKnownSecrets, resolveSecrets } from "../state/SecretStore.ts"; +import type { InstanceRuntimeInput } from "./Lifecycle.ts"; +import type { SupervisorRuntime } from "./Supervisor.ts"; +import { + fingerprintBootstrapInputs, + fingerprintEffectiveConfig, + type SecretSlotInput, +} from "../model/Compiler.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; +import { makeIdleRetirement } from "./IdleRetirement.ts"; +import type { TrafficAdmissionMode, TrafficLease } from "./Ingress.ts"; +import { portFieldForInstanceBinding } from "./StatusEndpoints.ts"; + +type Mutation = + | "start" + | "sleep" + | "stop" + | "destroy" + | "restart" + | "exportSnapshot" + | "restoreSnapshot"; + +type Phase = ServiceStatus["phase"]; +type StatusUpdate = + | { readonly id: ServiceInstanceId; readonly destroyed: false } + | { readonly id: ServiceInstanceId; readonly destroyed: true }; + +const protocolForInstanceBinding = (binding: string): "http" | "tcp" => { + const field = portFieldForInstanceBinding(binding); + return field === undefined ? "http" : PORT_FIELD_PROTOCOL[field]; +}; + +function redactSettings( + value: PersistedServiceInstanceFor["config"]["settings"], +): RedactedServiceSettings; +function redactSettings(current: unknown): unknown { + if (current === null) return undefined; + if (Array.isArray(current)) + return current.map(redactSettings).filter((item) => item !== undefined); + if (typeof current === "object") { + if ( + current !== null && + Object.hasOwn(current, "slot") && + typeof Reflect.get(current, "slot") === "string" && + Object.keys(current).length === 1 + ) + return { redacted: true }; + return Object.fromEntries( + Object.entries(current).flatMap(([key, item]) => { + const projected = redactSettings(item); + return projected === undefined ? [] : [[key, projected]]; + }), + ); + } + return current; +} + +const restoreSecretSlots = (current: unknown): unknown => { + if (current === null) return undefined; + if (Array.isArray(current)) + return current.map(restoreSecretSlots).filter((item) => item !== undefined); + if (typeof current === "object") { + if ( + current !== null && + Object.hasOwn(current, "slot") && + typeof Reflect.get(current, "slot") === "string" && + Object.keys(current).length === 1 + ) + return Redacted.make(""); + return Object.fromEntries( + Object.entries(current).flatMap(([key, item]) => { + const restored = restoreSecretSlots(item); + return restored === undefined ? [] : [[key, restored]]; + }), + ); + } + return current; +}; + +const validateSettings = ( + service: ServiceKind, + value: unknown, +): Effect.Effect => { + const options = { onExcessProperty: "error" as const }; + switch (service) { + case "database": + return Schema.decodeUnknownEffect(DatabaseSettingsSchema, options)(value).pipe(Effect.asVoid); + case "rest": + return Schema.decodeUnknownEffect(RestSettingsSchema, options)(value).pipe(Effect.asVoid); + case "auth": + return Schema.decodeUnknownEffect(AuthSettingsSchema, options)(value).pipe(Effect.asVoid); + case "realtime": + return Schema.decodeUnknownEffect(RealtimeSettingsSchema, options)(value).pipe(Effect.asVoid); + case "storage": + return Schema.decodeUnknownEffect(StorageSettingsSchema, options)(value).pipe(Effect.asVoid); + case "functions": + return Schema.decodeUnknownEffect( + FunctionsSettingsSchema, + options, + )(value).pipe(Effect.asVoid); + case "studio": + return Schema.decodeUnknownEffect(StudioSettingsSchema, options)(value).pipe(Effect.asVoid); + case "mail": + return Schema.decodeUnknownEffect(MailSettingsSchema, options)(value).pipe(Effect.asVoid); + case "analytics": + return Schema.decodeUnknownEffect( + AnalyticsSettingsSchema, + options, + )(value).pipe(Effect.asVoid); + case "pooler": + return Schema.decodeUnknownEffect(PoolerSettingsSchema, options)(value).pipe(Effect.asVoid); + } +}; + +const projectSettings = ( + stackId: StackId, + service: K, + value: PersistedServiceInstanceFor["config"]["settings"], +): Effect.Effect, StackStateInvalidError> => + validateSettings(service, restoreSecretSlots(value)).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId, + message: `Invalid persisted settings for service ${service}`, + cause: error, + }), + ), + Effect.map(() => redactSettings(value)), + ); + +export interface InstanceEngineOptions { + readonly stackId: StackId; + readonly ownerSessionId: string; + readonly stateStore: StackStateStore; + readonly runtime: Pick< + SupervisorRuntime, + | "start" + | "stop" + | "destroy" + | "prepare" + | "exportSnapshot" + | "restoreSnapshot" + | "recoverSnapshot" + >; + readonly scope: Scope.Scope; + readonly context: import("effect").Context.Context< + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; + readonly publishEndpoints?: ( + instanceId: ServiceInstanceId, + publications: ReadonlyArray, + ) => Effect.Effect; + readonly unpublishEndpoints?: ( + instanceId: ServiceInstanceId, + preserveListener?: boolean, + ) => Effect.Effect; + /** Notifies the owner-level status stream after a durable instance transition. */ + readonly publishStatus?: Effect.Effect; + /** Arms shared lazy ingress after the durable started intent is committed. */ + readonly armLazyIngress?: ( + state: PersistedStackState, + plan: ExecutionPlan, + ) => Effect.Effect; + readonly isInstanceActive?: (instanceId: ServiceInstanceId) => Effect.Effect; + readonly isInstanceWakeable?: (instanceId: ServiceInstanceId) => Effect.Effect; +} + +/** A compiled replacement plus the exact generation that was used to compile it. */ +export interface InstanceRestartCandidate { + readonly instance: PersistedServiceInstance; + readonly secretSlots: ReadonlyArray; + /** Whole-stack restart uses this to preserve lazy and disabled activation policy. */ + readonly startImmediately?: boolean; + readonly desiredIntent?: "started" | "stopped"; + readonly previous: Readonly<{ + readonly state: PersistedStackState; + readonly instance: PersistedServiceInstance; + }>; + readonly admission?: Readonly<{ readonly operationId: string; readonly generation: number }>; +} + +/** Shared stack material committed with a whole restart admission. */ +export interface RestartSharedPatch { + readonly preparation?: PersistedStackState["preparation"]; + readonly security?: PersistedStackState["security"]; + readonly listeners?: PersistedStackState["listeners"]; + readonly ports?: PersistedStackState["ports"]; + readonly secretSlots?: ReadonlyArray; +} + +export interface InstanceEngine { + readonly create: ( + instance: PersistedServiceInstance, + secretSlots?: ReadonlyArray, + ) => Effect.Effect; + readonly get: ( + ref: { readonly id: ServiceInstanceId } | { readonly name: string }, + ) => Effect.Effect; + readonly describe: ( + ref: { readonly id: ServiceInstanceId } | { readonly name: string }, + ) => Effect.Effect; + readonly list: Effect.Effect, StackError>; + readonly status: ( + id: ServiceInstanceId, + ) => Effect.Effect; + readonly followStatus: ( + id: ServiceInstanceId, + ) => Stream.Stream; + readonly start: ( + id: ServiceInstanceId, + ) => Effect.Effect; + /** Atomically admits gateway traffic for one instance and returns its release lease. */ + readonly acquireTraffic: ( + id: ServiceInstanceId, + mode?: TrafficAdmissionMode, + ) => Effect.Effect; + /** Applies one lifecycle operation to every selected registered instance. */ + readonly startAll: ( + ids?: ReadonlyArray, + ) => Effect.Effect, ServiceNotFoundError | StackError>; + readonly sleepAll: ( + ids?: ReadonlyArray, + ) => Effect.Effect, ServiceNotFoundError | StackError>; + readonly stopAll: ( + ids?: ReadonlyArray, + ) => Effect.Effect, ServiceNotFoundError | StackError>; + readonly stop: ( + id: ServiceInstanceId, + ) => Effect.Effect; + readonly sleep: ( + id: ServiceInstanceId, + ) => Effect.Effect; + readonly destroy: ( + id: ServiceInstanceId, + ) => Effect.Effect; + readonly destroyAll: ( + ids?: ReadonlyArray, + ) => Effect.Effect; + readonly prepare: ( + id: ServiceInstanceId, + ) => Effect.Effect; + readonly restart: ( + id: ServiceInstanceId, + candidate?: InstanceRestartCandidate, + ) => Effect.Effect; + readonly restartAll: ( + candidates: ReadonlyArray, + shared?: RestartSharedPatch, + ) => Effect.Effect, ServiceNotFoundError | StackError>; + readonly exportSnapshot: ( + id: ServiceInstanceId, + destination: string, + ) => Effect.Effect< + import("../public/Service.ts").SnapshotDescriptor, + ServiceNotFoundError | StackError + >; + readonly restoreSnapshot: ( + id: ServiceInstanceId, + source: string, + ) => Effect.Effect< + import("../public/Service.ts").SnapshotDescriptor, + ServiceNotFoundError | StackError + >; + /** Reads committed snapshot evidence after an owner crash before clearing its journal. */ + readonly recoverSnapshot?: ( + input: InstanceRuntimeInput, + operation: PersistedPendingOperation, + ) => Effect.Effect; + /** Proves cleanup for operations left fenced by an interrupted owner. */ + readonly recover: Effect.Effect; +} + +const notFound = (id: ServiceInstanceId): ServiceNotFoundError => + new ServiceNotFoundError({ instanceId: id, message: `Service instance ${id} was not found` }); + +const joinExit = (exit: Exit.Exit): Effect.Effect => + Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause); + +const errorFromCause = (cause: Cause.Cause): StackError => { + const value = Cause.squash(cause); + return isStackError(value) + ? value + : new StackStateInvalidError({ message: String(value), cause: value }); +}; + +const serviceFailureFor = ( + id: ServiceInstanceId, + operationId: string, + state: PersistedStackState, + error: StackError, +): ServiceFailure => ({ + tag: error._tag, + message: redactKnownSecrets( + error.message, + Object.values(state.secrets).map(({ value }) => value), + ), + instanceId: id, + operationId, +}); + +const outcomeFor = ( + requested: ReadonlyArray, + affected: ReadonlyArray, + completed: ReadonlyArray, +): LifecycleOutcome => ({ + requested, + affected, + succeeded: affected.filter((_, index) => completed[index] === true), + failed: affected.filter((_, index) => completed[index] !== true), +}); + +const voidExit = (exit: Exit.Exit): Exit.Exit => + Exit.isSuccess(exit) ? Exit.succeed(undefined) : Exit.failCause(exit.cause); + +const findInstance = ( + registry: PersistedServiceRegistry, + ref: { readonly id: ServiceInstanceId } | { readonly name: string }, +): PersistedServiceInstance | undefined => + "id" in ref + ? registry.instances.find((instance) => instance.id === ref.id) + : registry.instances.find((instance) => instance.name === ref.name); + +const descriptorForKind = ( + stackId: StackId, + state: PersistedStackState, + instance: PersistedServiceInstanceFor, +): Effect.Effect, StackStateInvalidError> => + Effect.gen(function* () { + const inputs = instance.initializationInputs; + const initializationServices = ["auth", "storage", "realtime", "analytics", "pooler"] as const; + const initialization = + inputs === null + ? undefined + : { + profileId: inputs.profileId, + recipes: initializationServices.flatMap((service) => { + const recipe = inputs.catalog[service]; + if (recipe === undefined) return []; + const recipeId = `${service}:${recipe.version}`; + const receipt = instance.initialization?.recipes.find( + (entry) => entry.recipeId === recipeId, + ); + return [ + { + service, + recipeId, + artifactIdentity: receipt?.artifactIdentity ?? `${service}@${recipe.version}`, + completed: receipt?.completed ?? false, + }, + ]; + }), + }; + const endpoints = Object.fromEntries( + state.ports + .filter( + (assignment) => assignment.owner === "instance" && assignment.instanceId === instance.id, + ) + .map((assignment) => { + const protocol = protocolForInstanceBinding(assignment.binding); + return [ + assignment.binding, + { + address: assignment.address, + port: assignment.port, + url: `${protocol}://${assignment.address}:${assignment.port}`, + protocol, + }, + ]; + }), + ); + return { + id: instance.id, + service: instance.service, + ...(instance.name === undefined ? {} : { name: instance.name }), + enabled: instance.config.enabled, + config: { + enabled: instance.config.enabled, + activation: instance.config.activation, + idleTimeoutSeconds: instance.config.idleTimeoutSeconds, + version: instance.config.version, + settings: yield* projectSettings(stackId, instance.service, instance.config.settings), + }, + dependencies: instance.dependencies, + snapshotSupport: instance.service === "database" ? "supported" : "unsupported", + endpoints, + ...(instance.artifactIdentity === undefined + ? {} + : { artifactIdentity: instance.artifactIdentity }), + ...(instance.runtimeIdentity === undefined + ? {} + : { runtimeIdentity: instance.runtimeIdentity }), + ...(initialization === undefined ? {} : { initialization }), + ...(instance.initializationInputs === null + ? {} + : { initializationProfileId: instance.initializationInputs.profileId }), + data: instance.data, + ...(instance.bootstrapRecipeId === undefined + ? {} + : { bootstrapRecipeId: instance.bootstrapRecipeId }), + ...(instance.bootstrapInputsId === undefined + ? {} + : { bootstrapInputsId: instance.bootstrapInputsId }), + ...(instance.creationInputsId === undefined + ? {} + : { creationInputsId: instance.creationInputsId }), + }; + }); + +const descriptorFor = ( + stackId: StackId, + state: PersistedStackState, + instance: PersistedServiceInstance, +): Effect.Effect => { + switch (instance.service) { + case "database": + return descriptorForKind<"database">(stackId, state, instance); + case "rest": + return descriptorForKind<"rest">(stackId, state, instance); + case "auth": + return descriptorForKind<"auth">(stackId, state, instance); + case "realtime": + return descriptorForKind<"realtime">(stackId, state, instance); + case "storage": + return descriptorForKind<"storage">(stackId, state, instance); + case "functions": + return descriptorForKind<"functions">(stackId, state, instance); + case "studio": + return descriptorForKind<"studio">(stackId, state, instance); + case "mail": + return descriptorForKind<"mail">(stackId, state, instance); + case "analytics": + return descriptorForKind<"analytics">(stackId, state, instance); + case "pooler": + return descriptorForKind<"pooler">(stackId, state, instance); + } +}; + +const statusFor = ( + state: PersistedStackState, + instance: PersistedServiceInstance, + phase: Phase, + publishedBindings: ReadonlySet = new Set(), + recovery?: StackRecovery, + error?: ServiceFailure, +): ServiceStatus => ({ + id: instance.id, + service: instance.service, + ...(instance.name === undefined ? {} : { name: instance.name }), + enabled: instance.config.enabled, + intent: instance.intent, + phase, + activation: instance.config.activation, + ...(instance.pendingOperation === null + ? {} + : { + pendingOperation: { + id: instance.pendingOperation.id, + kind: instance.pendingOperation.kind, + }, + }), + endpoints: state.ports + .filter( + (assignment) => assignment.owner === "instance" && assignment.instanceId === instance.id, + ) + .map((assignment) => { + const protocol = protocolForInstanceBinding(assignment.binding); + return { + binding: assignment.binding, + protocol, + address: assignment.address, + port: assignment.port, + url: `${protocol}://${assignment.address}:${assignment.port}`, + availability: + phase === "ready" || publishedBindings.has(assignment.binding) + ? ("listening" as const) + : ("planned" as const), + }; + }), + ...(recovery === undefined ? {} : { recovery }), + ...(error === undefined ? {} : { error }), +}); + +const instancePlan = ( + state: PersistedStackState, + id: ServiceInstanceId, +): Effect.Effect => + createExecutionPlan(state.runtime, state.registry, undefined, new Set([id])).pipe( + Effect.map((plan) => activeExecutionPlan(plan, dependencyClosure(plan, [id]))), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + +/** Plans all public and private bindings required by one compiled instance closure. */ +export const plannedInstancePorts = ( + state: PersistedStackState, + instance: PersistedServiceInstance, +): Effect.Effect, StackError> => { + return createExecutionPlan(state.runtime, state.registry, undefined, new Set([instance.id])).pipe( + Effect.flatMap((plan) => { + const occupied = new Set([ + ...state.ports.map((entry) => entry.port), + ...state.privatePorts.map((entry) => entry.port), + ]); + const endpointEntries = Object.entries(instance.config.endpoints).flatMap( + ([binding, endpoint]) => + endpoint === undefined || endpoint.enabled === false ? [] : [{ binding, endpoint }], + ); + const ports = [...state.ports]; + const privatePorts = [...state.privatePorts]; + let offset = 0; + const automaticPort = (identity: string): number => { + let candidate = 20_000; + for (const character of identity) + candidate = (candidate * 33 + character.charCodeAt(0)) % 12_000; + return 20_000 + candidate; + }; + const api = state.listeners.api; + if ( + api?.enabled === true && + !ports.some((entry) => entry.owner === "stack" && entry.binding === "api") + ) { + const requestedPort = api.port; + if (requestedPort !== undefined && occupied.has(requestedPort)) + return Effect.fail( + new StackStateInvalidError({ + message: `Port ${requestedPort} is already assigned to another service instance`, + }), + ); + let port = requestedPort ?? automaticPort(`${state.identity.stackName}:api`); + if (requestedPort === undefined) + while (occupied.has(port)) { + port = port === 31_999 ? 20_000 : port + 1; + } + occupied.add(port); + ports.push({ + owner: "stack", + binding: "api", + address: api.address ?? "127.0.0.1", + port, + intent: requestedPort === undefined ? "automatic" : "exact", + }); + } + for (const { binding, endpoint } of endpointEntries) { + const existing = ports.some( + (entry) => + entry.owner === "instance" && + entry.instanceId === instance.id && + entry.binding === binding, + ); + if (existing) continue; + const requestedPort = typeof endpoint.port === "number" ? endpoint.port : undefined; + if (requestedPort !== undefined && occupied.has(requestedPort)) + return Effect.fail( + new StackStateInvalidError({ + message: `Port ${requestedPort} is already assigned to another service instance`, + }), + ); + let port = + typeof endpoint.port === "number" + ? endpoint.port + : automaticPort(`${instance.id}:${binding}:${offset++}`); + if (requestedPort === undefined) while (occupied.has(port)) port += 1; + occupied.add(port); + ports.push({ + owner: "instance", + instanceId: instance.id, + binding, + address: endpoint.address ?? "127.0.0.1", + port, + intent: typeof endpoint.port === "number" ? "exact" : "automatic", + }); + } + for (const intent of privateBindingIntentsFor(plan, state)) { + if ( + privatePorts.some( + (entry) => + entry.instanceId === intent.instanceId && + entry.workloadId === intent.workloadId && + entry.binding === intent.binding, + ) + ) + continue; + let port = automaticPort( + `${intent.instanceId}:${intent.workloadId}:${intent.binding}:${offset++}`, + ); + while (occupied.has(port)) port = port === 31_999 ? 20_000 : port + 1; + occupied.add(port); + privatePorts.push({ ...intent, port }); + } + return Effect.succeed({ ports, privatePorts }); + }), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); +}; + +const changedEndpointBindings = ( + previous: PersistedServiceInstance["config"]["endpoints"], + next: PersistedServiceInstance["config"]["endpoints"], +): ReadonlySet => { + const previousEntries = new Map(Object.entries(previous)); + const nextEntries = new Map(Object.entries(next)); + const bindings = new Set([...previousEntries.keys(), ...nextEntries.keys()]); + return new Set( + [...bindings].filter( + (binding) => + JSON.stringify(previousEntries.get(binding)) !== JSON.stringify(nextEntries.get(binding)), + ), + ); +}; + +export const makeInstanceEngine = (options: InstanceEngineOptions): Effect.Effect => + Effect.gen(function* () { + const phases = yield* Ref.make>(new Map()); + const failures = yield* Ref.make>(new Map()); + const publishedBindings = yield* Ref.make>>( + new Map(), + ); + const statusUpdates = yield* PubSub.unbounded(); + const locks = new Map(); + const metadataAdmission = yield* Semaphore.make(1); + const lockFor = (id: ServiceInstanceId): Effect.Effect => + Effect.sync(() => { + const current = locks.get(id); + if (current !== undefined) return current; + const created = Semaphore.makeUnsafe(1); + locks.set(id, created); + return created; + }); + const read = (): Effect.Effect => + options.stateStore.read(options.stackId).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: error.message, + cause: error, + }), + ), + Effect.flatMap((state) => + state === undefined + ? Effect.fail( + new StackStateInvalidError({ + stackId: options.stackId, + message: "Stack state is missing", + }), + ) + : Effect.succeed(state), + ), + ); + const current = (id: ServiceInstanceId) => + read().pipe( + Effect.flatMap((state) => { + const instance = state.registry.instances.find((entry) => entry.id === id); + return instance === undefined + ? Effect.fail(notFound(id)) + : Effect.succeed({ state, instance }); + }), + ); + const descriptor = ( + state: PersistedStackState, + instance: PersistedServiceInstance, + ): Effect.Effect => + fingerprintEffectiveConfig(instance, state.security, state.secrets).pipe( + Effect.provideService(Crypto.Crypto, Context.get(options.context, Crypto.Crypto)), + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: "Unable to fingerprint service configuration", + cause: error, + }), + ), + Effect.flatMap((effectiveConfigFingerprint) => + descriptorFor(options.stackId, state, instance).pipe( + Effect.map((projected) => ({ + ...projected, + ...(effectiveConfigFingerprint === undefined ? {} : { effectiveConfigFingerprint }), + })), + ), + ), + ); + const phaseFor = ( + id: ServiceInstanceId, + instance: PersistedServiceInstance, + ): Effect.Effect => + Ref.get(phases).pipe( + Effect.map( + (all) => all.get(id) ?? (instance.intent === "started" ? "recovery" : "stopped"), + ), + ); + const sleepRef = yield* Ref.make< + (id: ServiceInstanceId, generation: number) => Effect.Effect + >(() => Effect.void); + const idleRetirement = yield* makeIdleRetirement((id, generation) => + Ref.get(sleepRef).pipe( + Effect.flatMap((sleepInstance) => sleepInstance(id, generation)), + Effect.ignore, + ), + ).pipe(Effect.provideService(Scope.Scope, options.scope)); + const cancelIdle = (id: ServiceInstanceId) => idleRetirement.cancel(id); + const armIdle = (id: ServiceInstanceId): Effect.Effect => + current(id).pipe( + Effect.flatMap(({ instance }) => + phaseFor(id, instance).pipe( + Effect.flatMap((phase) => { + const timeout = instance.config.idleTimeoutSeconds; + return phase === "ready" && typeof timeout === "number" + ? idleRetirement.arm(id, instance.revisions.intent, timeout) + : cancelIdle(id); + }), + ), + ), + ); + // Batch lifecycle requests fence every member during their short admission window. The + // runtime work remains per-instance, but unrelated operations must observe the batch claim + // before one member starts waiting on a slow backend. + type CoordinationClaim = { + readonly token?: symbol; + readonly active: number; + readonly mutation?: Mutation; + readonly startupControlAllowed?: boolean; + readonly completion?: Deferred.Deferred, never>; + }; + const batchClaims = yield* Ref.make>( + new Map(), + ); + const operationFibers = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const recovery = yield* Ref.make>(new Map()); + const completeClaim = ( + id: ServiceInstanceId, + token: symbol | undefined, + exit: Exit.Exit, + completion?: Deferred.Deferred, never>, + ) => + token === undefined + ? Effect.void + : completion !== undefined + ? Deferred.succeed(completion, voidExit(exit)).pipe(Effect.asVoid) + : Ref.get(batchClaims).pipe( + Effect.flatMap((claims) => { + const claim = claims.get(id); + return claim?.token === token && claim.completion !== undefined + ? Deferred.succeed(claim.completion, voidExit(exit)).pipe(Effect.asVoid) + : Effect.void; + }), + ); + const claimBatch = ( + ids: ReadonlyArray, + mutation: Mutation, + rejectActive = false, + startupControlAllowed = false, + allowRecoveryCleanup = false, + allowPendingStarts = false, + ): Effect.Effect => + metadataAdmission.withPermit( + Effect.gen(function* () { + const token = Symbol("instance-batch"); + const recoveryIds = allowRecoveryCleanup + ? yield* Ref.get(recovery).pipe(Effect.map((recoveries) => new Set(recoveries.keys()))) + : new Set(); + const pendingStartIds = allowPendingStarts + ? yield* read().pipe( + Effect.map( + (state) => + new Set( + state.registry.instances + .filter( + (instance) => + instance.pendingOperation?.kind === "start" || + instance.pendingOperation?.kind === "restart", + ) + .map((instance) => instance.id), + ), + ), + ) + : new Set(); + const completions = yield* Effect.forEach(ids, () => + Deferred.make>(), + ); + const selected = new Set(ids); + if (mutation === "stop" || mutation === "destroy") { + const state = yield* read(); + for (const id of ids) { + const dependent = state.registry.instances.find( + (entry) => + !selected.has(entry.id) && + Object.values(entry.dependencies).includes(id) && + (mutation === "destroy" || entry.intent === "started"), + ); + if (dependent !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: + mutation === "destroy" + ? `Service instance ${id} has dependent ${dependent.id}` + : `Service instance ${id} has active dependents`, + }); + } + } + return yield* Effect.gen(function* () { + const claimed = yield* Ref.modify(batchClaims, (current) => { + if ( + ids.some((id) => { + const claim = current.get(id); + const pendingStartupClaim = + pendingStartIds.has(id) && + (claim?.mutation === "start" || claim?.mutation === "restart") && + claim.startupControlAllowed === true; + return ( + claim !== undefined && + ((claim.token !== undefined && !pendingStartupClaim) || + (rejectActive && claim.active > 0)) + ); + }) + ) + return [false, current] as const; + const next = new Map(current); + for (const [index, id] of ids.entries()) { + const existing = current.get(id); + const pendingStartupClaim = + pendingStartIds.has(id) && + (existing?.mutation === "start" || existing?.mutation === "restart") && + existing.startupControlAllowed === true; + if (startupControlAllowed && pendingStartupClaim) continue; + next.set(id, { + token, + active: current.get(id)?.active ?? 0, + mutation, + ...(startupControlAllowed ? { startupControlAllowed: true } : {}), + ...(completions[index] !== undefined ? { completion: completions[index] } : {}), + }); + } + return [true, next] as const; + }); + if (!claimed) { + const blocked = yield* Ref.get(batchClaims).pipe( + Effect.map((current) => + ids.find((id) => { + const claim = current.get(id); + const pendingStartupClaim = + pendingStartIds.has(id) && + (claim?.mutation === "start" || claim?.mutation === "restart") && + claim.startupControlAllowed === true; + return ( + claim !== undefined && + ((claim.token !== undefined && !pendingStartupClaim) || + (rejectActive && claim.active > 0)) + ); + }), + ), + ); + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + ...(blocked === undefined ? {} : { instanceId: blocked }), + message: "A selected service instance is already part of another batch operation", + }); + } + const verified = yield* options.stateStore + .update(options.stackId, (state) => { + const selected = state.registry.instances.filter((instance) => + ids.includes(instance.id), + ); + return selected.length === ids.length && + selected.every( + (instance) => + instance.pendingOperation === null || + recoveryIds.has(instance.id) || + pendingStartIds.has(instance.id), + ) + ? Effect.succeed(state) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: "A selected service instance changed during batch admission", + }), + ); + }) + .pipe(Effect.provideContext(options.context), Effect.asVoid, Effect.exit); + if (Exit.isFailure(verified)) return yield* Effect.failCause(verified.cause); + return token; + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : Effect.forEach(completions, (completion) => + Deferred.succeed(completion, voidExit(exit)).pipe(Effect.asVoid), + ).pipe( + Effect.andThen( + Ref.update(batchClaims, (current) => { + const next = new Map(current); + for (const id of ids) { + const claim = next.get(id); + if (claim?.token !== token) continue; + if (claim.active === 0) next.delete(id); + else next.set(id, { active: claim.active }); + } + return next; + }), + ), + ), + ), + ); + }), + ); + const releaseBatch = (ids: ReadonlyArray, token: symbol) => + Ref.update(batchClaims, (current) => { + const next = new Map(current); + for (const id of ids) { + const claim = next.get(id); + if (claim?.token !== token) continue; + if (claim.active === 0) next.delete(id); + else next.set(id, { active: claim.active }); + } + return next; + }); + const acquireTraffic = ( + id: ServiceInstanceId, + mode: TrafficAdmissionMode = "normal", + ): Effect.Effect => + Effect.suspend(() => + Effect.gen(function* () { + if (yield* Ref.get(recovery).pipe(Effect.map((recoveries) => recoveries.has(id)))) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is fenced by a recovery failure`, + }); + const claim = yield* Ref.get(batchClaims).pipe(Effect.map((claims) => claims.get(id))); + let completedToken: symbol | undefined; + let completedCompletion: + | Deferred.Deferred, never> + | undefined; + if (claim?.token !== undefined && mode === "normal") { + if (claim.completion === undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is changing lifecycle state`, + }); + yield* Deferred.await(claim.completion).pipe(Effect.flatMap(joinExit)); + completedToken = claim.token; + completedCompletion = claim.completion; + const settled = yield* current(id); + const settledPhase = yield* phaseFor(id, settled.instance); + if ( + !settled.instance.config.enabled || + settled.instance.intent !== "started" || + (settledPhase !== "ready" && settledPhase !== "dormant") + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is no longer ready after lifecycle settlement`, + }); + } + const admitted = yield* Ref.modify(batchClaims, (current) => { + const currentClaim = current.get(id); + if ( + currentClaim?.token !== undefined && + (mode === "normal" || currentClaim.startupControlAllowed !== true) && + (currentClaim.token !== completedToken || + currentClaim.completion !== completedCompletion) + ) + return [false, current] as const; + const next = new Map(current); + next.set(id, { ...currentClaim, active: (currentClaim?.active ?? 0) + 1 }); + return [true, next] as const; + }); + if (!admitted) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is changing lifecycle state`, + }); + // A traffic lease makes the instance non-idle. Cancel any timer that was armed + // before admission so releasing this lease starts a fresh idle interval. + yield* cancelIdle(id); + return { + release: Ref.update(batchClaims, (current) => { + const claim = current.get(id); + if (claim === undefined || claim.active === 0) return current; + const next = new Map(current); + if (claim.active === 1 && claim.token === undefined) next.delete(id); + else next.set(id, { ...claim, active: claim.active - 1 }); + return next; + }).pipe(Effect.andThen(armIdle(id).pipe(Effect.ignore))), + } satisfies TrafficLease; + }), + ); + const withDependencyTraffic = ( + input: InstanceRuntimeInput, + operation: Effect.Effect, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + yield* Effect.forEach(Object.values(input.instance.dependencies), (dependencyId) => + Effect.uninterruptibleMask((restore) => + Effect.acquireRelease( + restore( + acquireTraffic(dependencyId).pipe( + Effect.mapError((error) => + error instanceof ServiceNotFoundError + ? new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: dependencyId, + message: `Dependency ${dependencyId} disappeared during startup`, + }) + : error, + ), + ), + ), + (lease) => lease.release, + ), + ), + ); + return yield* operation; + }), + ); + const setPhase = (id: ServiceInstanceId, phase: Phase) => + Ref.update(phases, (all) => new Map(all).set(id, phase)).pipe( + Effect.andThen(PubSub.publish(statusUpdates, { id, destroyed: false })), + Effect.andThen(options.publishStatus ?? Effect.void), + ); + const clearFailure = (id: ServiceInstanceId) => + Ref.update(failures, (all) => { + const next = new Map(all); + next.delete(id); + return next; + }); + const retainFailure = ( + id: ServiceInstanceId, + operationId: string, + state: PersistedStackState, + error: StackError, + ) => + Ref.update(failures, (all) => + new Map(all).set(id, serviceFailureFor(id, operationId, state, error)), + ); + const markRecovery = ( + id: ServiceInstanceId, + pending: PersistedPendingOperation, + error: StackError, + ): Effect.Effect => + Ref.update(recovery, (recoveries) => { + const next = new Map(recoveries); + next.set(id, { + operation: + pending.kind === "destroy" || + pending.kind === "restoreSnapshot" || + pending.kind === "exportSnapshot" + ? "destroy" + : "stop", + message: `Recovery of ${pending.kind} operation ${pending.id} failed: ${error.message}`, + }); + return next; + }).pipe(Effect.andThen(setPhase(id, "recovery")), Effect.ignore); + const joinStartExit = ( + id: ServiceInstanceId, + exit: Exit.Exit, + ): Effect.Effect => + Exit.isSuccess(exit) + ? Effect.succeed(exit.value) + : Cause.hasInterruptsOnly(exit.cause) + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} was superseded during startup`, + }), + ) + : Effect.failCause(exit.cause); + const publishBindings = ( + id: ServiceInstanceId, + publications: ReadonlyArray, + ): Effect.Effect => + (options.publishEndpoints === undefined + ? Effect.void + : options.publishEndpoints(id, publications) + ).pipe( + Effect.andThen( + Ref.update(publishedBindings, (current) => { + if (publications.length === 0) return current; + const next = new Map(current); + const existing = next.get(id) ?? new Set(); + next.set(id, new Set([...existing, ...publications.map(({ binding }) => binding)])); + return next; + }), + ), + ); + const unpublishBindings = ( + id: ServiceInstanceId, + preserveListener = false, + ): Effect.Effect => + (options.unpublishEndpoints === undefined + ? Effect.void + : options.unpublishEndpoints(id, preserveListener) + ).pipe( + Effect.andThen( + Ref.update(publishedBindings, (current) => { + const next = new Map(current); + next.delete(id); + return next; + }), + ), + ); + const startAndPublish = ( + input: InstanceRuntimeInput, + ): Effect.Effect, StackError> => + options.runtime.start(input).pipe( + Effect.flatMap((publications) => + publishBindings(input.instance.id, publications).pipe( + Effect.as(publications), + Effect.catch((publicationError) => + Effect.gen(function* () { + const runtimeCleanup = yield* options.runtime.stop(input).pipe(Effect.exit); + const ingressCleanup = yield* unpublishBindings(input.instance.id).pipe( + Effect.exit, + ); + const cleanupExit = Exit.isFailure(runtimeCleanup) + ? runtimeCleanup + : Exit.isFailure(ingressCleanup) + ? ingressCleanup + : undefined; + if (cleanupExit !== undefined) { + const cleanupError = errorFromCause(cleanupExit.cause); + return yield* new StackCleanupError({ + message: `Startup publication failed and cleanup was not proven: ${cleanupError.message}`, + cause: cleanupError, + }); + } + return yield* publicationError; + }), + ), + ), + ), + ); + const run = ( + id: ServiceInstanceId, + mutation: Mutation, + operation: (input: InstanceRuntimeInput) => Effect.Effect, + settle: ( + state: PersistedStackState, + instance: PersistedServiceInstance, + value: A, + ) => Effect.Effect, + skip?: (current: { + readonly state: PersistedStackState; + readonly instance: PersistedServiceInstance; + }) => Effect.Effect, + shouldSkip?: (current: { + readonly state: PersistedStackState; + readonly instance: PersistedServiceInstance; + }) => Effect.Effect, + preflight?: (current: { + readonly state: PersistedStackState; + readonly instance: PersistedServiceInstance; + }) => Effect.Effect, + batchToken?: symbol, + afterSettle?: (value: A) => Effect.Effect, + replacement?: InstanceRestartCandidate, + skipIdleCancel?: boolean, + ): Effect.Effect => + Effect.flatMap(lockFor(id), (lock) => { + // Snapshot ownership is a hard fence in both directions. Do this read before taking + // the instance semaphore so a lifecycle request fails promptly instead of queueing behind + // an export/restore that may be waiting on a caller-owned barrier. + const rejectSnapshotConflict = + mutation === "exportSnapshot" || mutation === "restoreSnapshot" + ? Effect.void + : current(id).pipe( + Effect.flatMap(({ instance }) => + instance.pendingOperation?.kind === "exportSnapshot" || + instance.pendingOperation?.kind === "restoreSnapshot" + ? Ref.get(recovery).pipe( + Effect.flatMap((recoveries) => + mutation === "destroy" && recoveries.get(id)?.operation === "destroy" + ? Effect.void + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Service instance ${id} has a snapshot operation in progress`, + }), + ), + ), + ) + : Effect.void, + ), + ); + const rejectBatchConflict = Ref.get(batchClaims).pipe( + Effect.flatMap((claims) => { + const claim = claims.get(id); + const owner = claim?.token; + return owner === undefined || + owner === batchToken || + owner === handoffToken || + (batchToken === undefined && mutation === "start" && claim?.mutation === "start") + ? Effect.void + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is part of another batch operation`, + }), + ); + }), + ); + const execute = (input: InstanceRuntimeInput) => + Effect.gen(function* () { + const { instance } = input; + const operationId = input.operation.id; + yield* setPhase( + id, + mutation === "start" || mutation === "restart" ? "starting" : "stopping", + ); + return yield* operation(input).pipe( + Effect.flatMap((value) => + options.stateStore + .update(options.stackId, (state) => { + const current = state.registry.instances.find((entry) => entry.id === id); + const pending = current?.pendingOperation; + if ( + current === undefined || + pending === null || + pending?.id !== operationId || + pending.generation !== instance.revisions.intent + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Service instance ${id} changed during ${mutation}`, + }), + ); + return settle(state, current, value); + }) + .pipe( + Effect.provideContext(options.context), + Effect.andThen( + Ref.update(recovery, (recoveries) => { + const next = new Map(recoveries); + next.delete(id); + return next; + }), + ), + Effect.andThen(clearFailure(id)), + Effect.flatMap(() => + afterSettle === undefined ? Effect.succeed(value) : afterSettle(value), + ), + ), + ), + Effect.tap(() => + setPhase( + id, + mutation === "start" || mutation === "restart" + ? mutation === "restart" && replacement?.startImmediately === false + ? replacement.desiredIntent === "stopped" + ? "stopped" + : "dormant" + : "ready" + : mutation === "sleep" + ? "dormant" + : "stopped", + ), + ), + Effect.tap(() => + skipIdleCancel + ? Effect.void + : mutation === "start" || mutation === "restart" + ? armIdle(id) + : cancelIdle(id), + ), + Effect.catchCause((cause) => { + const errorOption = Cause.findErrorOption(cause); + if (Option.isNone(errorOption) && !Cause.hasDies(cause)) + return Effect.failCause(cause); + const error = Option.isSome(errorOption) + ? errorOption.value + : errorFromCause(cause); + const retainJournal = + Cause.hasDies(cause) || + Cause.hasInterrupts(cause) || + error instanceof StackCleanupError || + error instanceof UncertainOperationError || + mutation === "stop" || + mutation === "destroy" || + mutation === "sleep"; + const cleanup = retainJournal + ? Effect.void + : options.stateStore + .update(options.stackId, (state) => { + const current = state.registry.instances.find((entry) => entry.id === id); + const pending = current?.pendingOperation; + if ( + current === undefined || + pending === null || + pending?.id !== operationId || + pending.generation !== instance.revisions.intent + ) + return Effect.succeed(state); + return Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === id + ? { + ...entry, + ...(mutation === "start" ? { intent: "stopped" as const } : {}), + pendingOperation: null, + } + : entry, + ), + }, + }); + }) + .pipe(Effect.provideContext(options.context), Effect.asVoid); + const markFailure = + retainJournal && input.instance.pendingOperation !== null + ? markRecovery(id, input.instance.pendingOperation, error) + : Effect.void; + const bookkeeping = cleanup.pipe( + Effect.andThen(retainFailure(id, operationId, input.state, error)), + Effect.andThen(markFailure), + Effect.andThen(retainJournal ? Effect.void : setPhase(id, "failed")), + ); + return Effect.exit(bookkeeping).pipe( + Effect.flatMap((exit) => + Exit.isFailure(exit) + ? Effect.failCause(Cause.combine(cause, exit.cause)) + : Effect.failCause(cause), + ), + ); + }), + ); + }); + const ownerBody = metadataAdmission + .withPermit( + Effect.gen(function* () { + const admitted = yield* current(id); + const recoveryCleanup = + mutation === "stop" || mutation === "destroy" + ? yield* Ref.get(recovery).pipe(Effect.map((recoveries) => recoveries.get(id))) + : undefined; + if (mutation === "stop" && recoveryCleanup?.operation === "destroy") + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} requires destroy recovery before activation can be unfenced`, + }); + const replacementEnabled = replacement?.instance.config.enabled; + const startsWorkload = + mutation === "start" || + (mutation === "restart" && + replacement?.startImmediately !== false && + replacement?.desiredIntent !== "stopped"); + if ( + startsWorkload && + (replacementEnabled ?? admitted.instance.config.enabled) === false + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Disabled service instance ${id} cannot be started`, + }); + if ( + startsWorkload && + (yield* Ref.get(recovery).pipe(Effect.map((recoveries) => recoveries.has(id)))) + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is fenced by a recovery failure`, + }); + if (startsWorkload || replacement !== undefined) { + const claims = yield* Ref.get(batchClaims); + const dependencies = + replacement?.instance.dependencies ?? admitted.instance.dependencies; + const blockedDependency = [...claims].find( + ([dependencyId, claim]) => + Object.values(dependencies).includes(dependencyId) && + claim?.mutation !== undefined && + claim.mutation !== "start" && + claim.token !== batchToken, + )?.[0]; + if (blockedDependency !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: blockedDependency, + message: `Dependency ${blockedDependency} is changing lifecycle state`, + }); + } + if ( + (mutation === "exportSnapshot" || mutation === "restoreSnapshot") && + (admitted.instance.intent !== "stopped" || + admitted.instance.pendingOperation !== null) + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Snapshot operation requires stopped service instance ${id}`, + }); + if (preflight !== undefined) yield* preflight(admitted); + if (skip !== undefined && shouldSkip !== undefined && (yield* shouldSkip(admitted))) + return { kind: "value" as const, value: yield* skip(admitted) }; + // Admit the intent and endpoint plan in one transaction. Once the journal is written, + // every later failure path below must settle that same operation rather than strand it. + const preadmitted = replacement?.admission; + const operationId = + preadmitted?.operationId ?? + (yield* Context.get(options.context, Crypto.Crypto).randomUUIDv4.pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: `Unable to allocate operation identity: ${error.message}`, + cause: error, + }), + ), + )); + const accepted = yield* preadmitted !== undefined + ? read().pipe( + Effect.flatMap((state) => { + const instance = state.registry.instances.find((entry) => entry.id === id); + const pending = instance?.pendingOperation; + return instance !== undefined && + pending?.id === preadmitted.operationId && + pending.generation === preadmitted.generation + ? Effect.succeed(state) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} no longer owns its admitted restart`, + }), + ); + }), + ) + : options.stateStore + .update(options.stackId, (state) => { + const instance = state.registry.instances.find((entry) => entry.id === id); + if (instance === undefined) return Effect.fail(notFound(id)); + if ( + replacement !== undefined && + (mutation !== "restart" || + replacement.previous.instance.id !== instance.id || + replacement.previous.instance.revisions.config !== + instance.revisions.config || + replacement.previous.instance.revisions.intent !== + instance.revisions.intent) + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} changed before its restart was admitted`, + }), + ); + if (instance.pendingOperation !== null && !recoveryCleanup) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Service instance ${id} already has a pending operation`, + }), + ); + const generation = instance.revisions.intent + 1; + const pendingOperation = { + id: operationId, + kind: mutation, + generation, + ownerSessionId: options.ownerSessionId, + phase: "running" as const, + }; + const nextInstanceValue = + mutation === "restart" && replacement !== undefined + ? { + ...replacement.instance, + id: instance.id, + service: instance.service, + intent: replacement.desiredIntent ?? ("started" as const), + resources: instance.resources, + data: instance.data, + revisions: { + ...instance.revisions, + config: instance.revisions.config + 1, + intent: generation, + }, + pendingOperation, + } + : mutation === "start" + ? { + ...instance, + intent: "started" as const, + revisions: { ...instance.revisions, intent: generation }, + pendingOperation, + } + : mutation === "stop" || mutation === "destroy" + ? { + ...instance, + intent: "stopped" as const, + revisions: { ...instance.revisions, intent: generation }, + pendingOperation, + } + : { + ...instance, + revisions: { ...instance.revisions, intent: generation }, + pendingOperation, + }; + const nextInstance = Schema.decodeUnknownEffect( + PersistedServiceInstanceSchema, + )(nextInstanceValue).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: `Restarted service instance failed validation: ${String(error)}`, + cause: error, + }), + ), + ); + return nextInstance.pipe( + Effect.flatMap((resolvedInstance) => { + const nextState = { + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === id ? resolvedInstance : entry, + ), + }, + }; + const withSecrets = + mutation === "restart" && replacement !== undefined + ? resolveSecrets( + { + declarations: [ + ...Object.entries(state.secrets) + .filter( + ([slot]) => + !replacement.secretSlots.some( + (candidate) => candidate.slot === slot, + ), + ) + .map(([slot, entry]) => ({ + slot, + policy: entry.policy, + value: Redacted.make(entry.value), + })), + ...replacement.secretSlots, + ], + }, + Object.fromEntries( + Object.entries(state.secrets).filter( + ([slot]) => + !replacement.secretSlots.some( + (candidate) => candidate.slot === slot, + ), + ), + ), + "stopped", + ).pipe( + Effect.provideContext(options.context), + Effect.map((resolved) => ({ + ...nextState, + secrets: resolved.persisted, + })), + ) + : Effect.succeed(nextState); + return withSecrets.pipe( + Effect.flatMap((resolved) => { + if (mutation !== "start" && mutation !== "restart") + return Effect.succeed(resolved); + const changed = + mutation === "restart" + ? changedEndpointBindings( + replacement?.previous.instance.config.endpoints ?? + instance.config.endpoints, + resolvedInstance.config.endpoints, + ) + : new Set(); + const replanningState = + changed.size === 0 + ? resolved + : { + ...resolved, + ports: resolved.ports.filter( + (assignment) => + assignment.owner !== "instance" || + assignment.instanceId !== id || + !changed.has(assignment.binding), + ), + }; + return plannedInstancePorts(replanningState, resolvedInstance).pipe( + Effect.map((ports) => ({ ...resolved, ...ports })), + ); + }), + ); + }), + ); + }) + .pipe( + Effect.provideContext(options.context), + Effect.mapError((error) => + error instanceof ServiceNotFoundError || isStackError(error) + ? error + : new StackStateInvalidError({ + stackId: options.stackId, + message: String(error), + cause: error, + }), + ), + ); + const preparedState = accepted; + const instance = preparedState.registry.instances.find((entry) => entry.id === id); + if (instance === undefined) return yield* notFound(id); + const plan = yield* instancePlan(preparedState, id); + const publishStartupBindings = + options.publishEndpoints === undefined + ? undefined + : (publications: ReadonlyArray) => + current(id).pipe( + Effect.flatMap(({ instance: currentInstance }) => + currentInstance.pendingOperation?.id === operationId && + currentInstance.pendingOperation.generation === instance.revisions.intent + ? publishBindings(id, publications).pipe( + Effect.andThen(setPhase(id, "starting")), + ) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Startup publication ${operationId} is no longer owned by this operation`, + }), + ), + ), + ); + const input: InstanceRuntimeInput = { + stackId: options.stackId, + state: preparedState, + instance, + plan, + operation: { id: operationId, generation: instance.revisions.intent }, + publishStartupBindings, + }; + return { kind: "input" as const, input }; + }), + ) + .pipe( + Effect.flatMap((admitted) => + admitted.kind === "input" ? execute(admitted.input) : Effect.succeed(admitted.value), + ), + ); + const admittedOwner = + mutation === "exportSnapshot" || mutation === "restoreSnapshot" + ? lock + .withPermitsIfAvailable(1)(ownerBody) + .pipe( + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Service instance ${id} has another operation in progress`, + }), + ), + ), + ) + : lock.withPermit(ownerBody); + let handoffToken: symbol | undefined; + let supersededStart: + | { + readonly fiber: Fiber.Fiber; + readonly pending: PersistedPendingOperation; + } + | undefined; + const ownClaim = + batchToken !== undefined + ? Effect.sync(() => ({ + token: handoffToken ?? batchToken, + owned: handoffToken !== undefined, + })) + : Ref.get(batchClaims).pipe( + Effect.flatMap((claims) => { + const existing = claims.get(id)?.token; + if (handoffToken !== undefined) + return Effect.succeed({ token: handoffToken, owned: true }); + if (existing !== undefined) { + return mutation === "start" + ? Effect.succeed({ token: existing, owned: false }) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is already changing lifecycle state`, + }), + ); + } + return Ref.get(recovery).pipe( + Effect.flatMap((recoveries) => + claimBatch( + [id], + mutation, + mutation === "sleep", + mutation === "start" || mutation === "restart", + (mutation === "stop" || mutation === "destroy") && recoveries.has(id), + false, + ), + ), + Effect.map((token) => ({ token, owned: true })), + ); + }), + ); + const supersedeStart = ( + restore: (effect: Effect.Effect) => Effect.Effect, + ) => + mutation === "stop" || mutation === "destroy" + ? Effect.gen(function* () { + const permit = yield* restore(metadataAdmission.take(1)); + return yield* Effect.gen(function* () { + const admitted = yield* restore(current(id)); + const { instance } = admitted; + if (preflight !== undefined) yield* preflight(admitted); + if ( + instance.pendingOperation?.kind !== "start" && + instance.pendingOperation?.kind !== "restart" + ) + return; + const pending = instance.pendingOperation; + const oldToken = yield* restore( + Ref.get(batchClaims).pipe(Effect.map((claims) => claims.get(id)?.token)), + ); + const fiber = yield* restore( + Ref.get(operationFibers).pipe(Effect.map((fibers) => fibers.get(id))), + ); + if (fiber === undefined) { + const recoverable = yield* Ref.get(recovery).pipe( + Effect.map((recoveries) => recoveries.has(id)), + ); + if (!recoverable) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has a pending startup without a live owner`, + }); + return; + } + const replacementToken = Symbol("instance-stop-handoff"); + const replacementCompletion = yield* Deferred.make>(); + const handedOff = yield* Ref.modify(batchClaims, (claims) => { + const claim = claims.get(id); + const canHandoff = + claim?.token !== undefined && + claim.token === oldToken && + (claim.startupControlAllowed === true || + (batchToken !== undefined && claim.token === batchToken)); + return canHandoff + ? [ + true, + new Map(claims).set(id, { + token: replacementToken, + active: claim.active, + mutation, + completion: + batchToken !== undefined && claim.token === batchToken + ? (claim.completion ?? replacementCompletion) + : replacementCompletion, + }), + ] + : [false, claims]; + }); + if (!handedOff) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is already changing lifecycle state`, + }); + handoffToken = replacementToken; + supersededStart = { fiber, pending }; + }).pipe(Effect.ensuring(metadataAdmission.release(permit).pipe(Effect.asVoid))); + }) + : Effect.void; + return rejectSnapshotConflict.pipe( + Effect.flatMap(() => + Effect.uninterruptibleMask((restore) => + supersedeStart(restore).pipe( + Effect.andThen(rejectBatchConflict), + Effect.flatMap(() => + supersededStart === undefined ? restore(ownClaim) : ownClaim, + ), + Effect.flatMap(({ token, owned }) => + Effect.gen(function* () { + let ownerFiber: Fiber.Fiber | undefined; + const claimCompletion = yield* Ref.get(batchClaims).pipe( + Effect.map((claims) => { + const claim = claims.get(id); + return claim?.token === token ? claim.completion : undefined; + }), + ); + const handoff = + supersededStart === undefined + ? Effect.void + : Effect.gen(function* () { + const superseded = supersededStart; + if (superseded === undefined) return; + const { fiber, pending } = superseded; + yield* restore(Fiber.interrupt(fiber)); + yield* restore( + options.stateStore + .update(options.stackId, (state) => { + const currentInstance = state.registry.instances.find( + (entry) => entry.id === id, + ); + const currentPending = currentInstance?.pendingOperation; + return currentPending?.id === pending.id && + currentPending.generation === pending.generation + ? Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === id + ? { ...entry, pendingOperation: null } + : entry, + ), + }, + }) + : Effect.succeed(state); + }) + .pipe(Effect.provideContext(options.context), Effect.asVoid), + ).pipe( + Effect.catch((error) => + markRecovery(id, pending, error).pipe( + Effect.andThen(Effect.fail(error)), + ), + ), + ); + }); + const owner = handoff.pipe( + Effect.andThen(admittedOwner), + Effect.onExit((exit) => + completeClaim(id, token, exit, claimCompletion).pipe( + Effect.andThen( + Ref.update(operationFibers, (fibers) => { + const next = new Map(fibers); + if (next.get(id) === ownerFiber) next.delete(id); + return next; + }), + ), + ), + ), + Effect.ensuring(owned ? releaseBatch([id], token) : Effect.void), + ); + const fiber = yield* Effect.forkIn(restore(owner), options.scope, { + startImmediately: false, + }); + ownerFiber = fiber; + yield* Ref.update(operationFibers, (fibers) => { + const next = new Map(fibers); + next.set(id, fiber); + return next; + }); + return yield* restore(Fiber.join(fiber)); + }), + ), + ), + ), + ), + ); + }); + const get = (ref: { readonly id: ServiceInstanceId } | { readonly name: string }) => + read().pipe( + Effect.flatMap((state) => { + const instance = findInstance(state.registry, ref); + return instance === undefined + ? Effect.fail( + new ServiceNotFoundError({ + message: `Service instance ${"id" in ref ? ref.id : ref.name} was not found`, + ...("id" in ref ? { instanceId: ref.id } : {}), + }), + ) + : Effect.succeed(instance); + }), + ); + const list = read().pipe( + Effect.flatMap((state) => + Effect.forEach(state.registry.instances, (instance) => descriptor(state, instance)), + ), + ); + const describe = (ref: { readonly id: ServiceInstanceId } | { readonly name: string }) => + read().pipe( + Effect.flatMap((state) => { + const instance = findInstance(state.registry, ref); + return instance === undefined + ? Effect.fail( + new ServiceNotFoundError({ + message: `Service instance ${"id" in ref ? ref.id : ref.name} was not found`, + ...("id" in ref ? { instanceId: ref.id } : {}), + }), + ) + : descriptor(state, instance); + }), + ); + const status = (id: ServiceInstanceId) => + current(id).pipe( + Effect.flatMap(({ state, instance }) => + phaseFor(id, instance).pipe( + Effect.flatMap((phase) => + Ref.get(publishedBindings).pipe( + Effect.flatMap((bindings) => + Ref.get(recovery).pipe( + Effect.flatMap((recoveries) => + Ref.get(failures).pipe( + Effect.map((errors) => + statusFor( + state, + instance, + phase, + bindings.get(id) ?? new Set(), + recoveries.get(id), + errors.get(id), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + const followStatus = (id: ServiceInstanceId) => + Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(statusUpdates); + const initial = yield* status(id); + const latest = yield* Ref.make(initial); + const updates = Stream.fromSubscription(subscription).pipe( + Stream.filter((updated) => updated.id === id), + Stream.takeUntil((updated) => updated.destroyed), + Stream.mapEffect((updated) => + updated.destroyed + ? Ref.get(latest) + : status(id).pipe( + Effect.tap((next) => Ref.set(latest, next)), + Effect.catchTag("ServiceNotFoundError", () => Ref.get(latest)), + ), + ), + ); + return Stream.concat(Stream.succeed(initial), updates); + }), + ); + const startsInFlight = new Map< + ServiceInstanceId, + Deferred.Deferred, never> + >(); + const startOperationBody = ( + id: ServiceInstanceId, + batchToken?: symbol, + ): Effect.Effect => + run( + id, + "start", + (input) => + Effect.gen(function* () { + yield* Effect.forEach(Object.values(input.instance.dependencies), (dependencyId) => + current(dependencyId).pipe( + Effect.flatMap(({ instance: dependency }) => + phaseFor(dependency.id, dependency).pipe( + Effect.flatMap((phase) => + phase === "ready" + ? Effect.void + : input.state.registry.instances.some( + (entry) => + entry.id === dependency.id && + (entry.pendingOperation?.kind === "stop" || + entry.pendingOperation?.kind === "destroy"), + ) + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: dependency.id, + message: `Dependency ${dependency.id} was superseded during startup`, + }), + ) + : startOperation(dependency.id, batchToken).pipe(Effect.asVoid), + ), + ), + ), + ), + ); + const publications = yield* withDependencyTraffic(input, startAndPublish(input)); + return publications; + }).pipe(Effect.andThen(status(id))), + (state, instance) => + Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === instance.id ? { ...entry, pendingOperation: null } : entry, + ), + }, + }), + () => status(id), + ({ instance }) => phaseFor(id, instance).pipe(Effect.map((phase) => phase === "ready")), + undefined, + batchToken, + ); + const startOperation = (id: ServiceInstanceId, batchToken?: symbol) => + Effect.suspend(() => { + const existing = startsInFlight.get(id); + if (existing !== undefined) + return Deferred.await(existing).pipe( + Effect.flatMap((exit) => joinStartExit(id, exit)), + Effect.andThen(status(id)), + ); + return Effect.uninterruptibleMask((restore) => + Deferred.make>().pipe( + Effect.flatMap((completion) => { + startsInFlight.set(id, completion); + const owner = startOperationBody(id, batchToken).pipe( + Effect.onExit((exit) => + Deferred.succeed(completion, exit).pipe( + Effect.andThen( + Effect.sync(() => { + if (startsInFlight.get(id) === completion) startsInFlight.delete(id); + }), + ), + ), + ), + ); + return Effect.forkIn(restore(owner), options.scope, { startImmediately: false }).pipe( + Effect.flatMap((fiber) => + restore(Fiber.await(fiber)).pipe( + Effect.flatMap((exit) => joinStartExit(id, exit)), + ), + ), + ); + }), + ), + ); + }); + const start = (id: ServiceInstanceId) => startOperation(id).pipe(Effect.andThen(status(id))); + const stop = (id: ServiceInstanceId, batchToken?: symbol) => + run( + id, + "stop", + (input) => + options.runtime.stop(input).pipe(Effect.andThen(unpublishBindings(input.instance.id))), + (state, instance) => + Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === instance.id ? { ...entry, pendingOperation: null } : entry, + ), + }, + }), + undefined, + undefined, + ({ state, instance }) => + state.registry.instances.some( + (dependent) => + dependent.intent === "started" && + batchToken === undefined && + Object.values(dependent.dependencies).includes(instance.id), + ) + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active dependents`, + }), + ) + : Effect.void, + batchToken, + ).pipe(Effect.andThen(status(id))); + const sleep = ( + id: ServiceInstanceId, + batchToken?: symbol, + expectedGeneration?: number, + fromIdle?: boolean, + ) => + run( + id, + "sleep", + (input) => + options.runtime + .stop(input) + .pipe( + Effect.andThen(unpublishBindings(input.instance.id, true)), + Effect.andThen(status(id)), + ), + (state, instance) => + Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === instance.id ? { ...entry, pendingOperation: null } : entry, + ), + }, + }), + () => status(id), + ({ instance }) => phaseFor(id, instance).pipe(Effect.map((phase) => phase === "dormant")), + ({ state, instance }) => + phaseFor(id, instance).pipe( + Effect.flatMap((phase) => + expectedGeneration !== undefined && instance.revisions.intent !== expectedGeneration + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Idle retirement for service instance ${id} is stale`, + }), + ) + : phase === "stopped" + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} is stopped and cannot be put to sleep`, + }), + ) + : Effect.forEach( + state.registry.instances.filter( + (dependent) => + dependent.intent === "started" && + dependent.id !== id && + Object.values(dependent.dependencies).includes(id), + ), + (dependent) => + phaseFor(dependent.id, dependent).pipe( + Effect.map( + (dependentPhase) => + dependentPhase !== "stopped" && dependentPhase !== "dormant", + ), + ), + ).pipe( + Effect.map((active) => active.some(Boolean)), + Effect.flatMap((active) => + active + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active dependents`, + }), + ) + : ( + options.isInstanceWakeable?.(instance.id) ?? Effect.succeed(true) + ).pipe( + Effect.flatMap((wakeable) => + wakeable + ? Effect.void + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has no demand-wake route`, + }), + ), + ), + ), + ), + ), + ), + ), + batchToken, + () => status(id), + undefined, + fromIdle, + ).pipe(Effect.andThen(status(id))); + yield* Ref.set(sleepRef, (id, generation) => + sleep(id, undefined, generation, true).pipe(Effect.asVoid), + ); + const destroy = (id: ServiceInstanceId, batchToken?: symbol) => + run( + id, + "destroy", + (input) => + (input.plan.workloads.some((workload) => workload.instanceId === id) + ? options.runtime.destroy(input) + : Effect.void + ).pipe(Effect.andThen(unpublishBindings(input.instance.id))), + (state, instance) => + removeServiceInstance(state.registry, instance.id).pipe( + Effect.map((registry) => ({ + ...state, + registry, + ports: state.ports.filter( + (assignment) => + assignment.owner !== "instance" || assignment.instanceId !== instance.id, + ), + privatePorts: state.privatePorts.filter( + (assignment) => assignment.instanceId !== instance.id, + ), + })), + Effect.mapError((error) => new StackStateInvalidError({ message: error.message })), + ), + undefined, + undefined, + ({ state, instance }) => { + const dependent = state.registry.instances.find((entry) => + Object.values(entry.dependencies).includes(instance.id), + ); + return dependent === undefined + ? Effect.void + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: instance.id, + message: `Service instance ${instance.id} has dependent ${dependent.id}`, + }), + ); + }, + batchToken, + ).pipe( + Effect.andThen(PubSub.publish(statusUpdates, { id, destroyed: true })), + Effect.andThen(options.publishStatus ?? Effect.void), + Effect.asVoid, + ); + const prepare = (id: ServiceInstanceId) => + current(id).pipe( + Effect.flatMap(({ state, instance }) => + instancePlan(state, id).pipe( + Effect.flatMap((plan) => + Context.get(options.context, Crypto.Crypto).randomUUIDv4.pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: `Unable to allocate operation identity: ${error.message}`, + cause: error, + }), + ), + Effect.flatMap((operationId) => + Effect.forkIn( + options.runtime.prepare({ + stackId: options.stackId, + state, + instance, + plan, + operation: { id: operationId, generation: 0 }, + }), + options.scope, + { startImmediately: true }, + ), + ), + Effect.flatMap(Fiber.join), + Effect.flatMap((result) => + fingerprintEffectiveConfig(instance, state.security, state.secrets).pipe( + Effect.provideService( + Crypto.Crypto, + Context.get(options.context, Crypto.Crypto), + ), + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: "Unable to fingerprint prepared service configuration", + cause: error, + }), + ), + Effect.map((effectiveConfigFingerprint) => ({ + ...result, + instances: result.instances.map((entry) => + entry.id === instance.id + ? { + ...entry, + ...(effectiveConfigFingerprint === undefined + ? {} + : { effectiveConfigFingerprint }), + } + : entry, + ), + })), + ), + ), + ), + ), + ), + ), + ); + type RestartPhaseSchedule = { + readonly candidates: ReadonlyArray; + readonly stopped: ReadonlyMap< + ServiceInstanceId, + Deferred.Deferred, never> + >; + readonly ready: ReadonlyMap< + ServiceInstanceId, + Deferred.Deferred, never> + >; + }; + const awaitPhase = ( + completions: ReadonlyMap< + ServiceInstanceId, + Deferred.Deferred, never> + >, + ids: ReadonlyArray, + ): Effect.Effect => + Effect.forEach( + ids, + (dependency) => { + const completion = completions.get(dependency); + return completion === undefined + ? Effect.void + : Deferred.await(completion).pipe( + Effect.flatMap((exit) => joinStartExit(dependency, exit)), + ); + }, + { discard: true }, + ); + const restartOperation = ( + id: ServiceInstanceId, + replacement: InstanceRestartCandidate | undefined, + batchToken?: symbol, + schedule?: RestartPhaseSchedule, + ) => + run( + id, + "restart", + (input) => + Effect.gen(function* () { + const previousInput = + replacement === undefined + ? input + : { + ...input, + state: replacement.previous.state, + instance: replacement.previous.instance, + plan: yield* instancePlan(replacement.previous.state, id), + }; + const newDependencies = + replacement === undefined ? [] : Object.values(input.instance.dependencies); + const dependents = + schedule === undefined + ? [] + : schedule.candidates + .filter((candidate) => + Object.values(candidate.previous.instance.dependencies).includes(id), + ) + .map((candidate) => candidate.instance.id); + const stopped = yield* Effect.exit( + (schedule === undefined + ? Effect.void + : awaitPhase(schedule.stopped, dependents) + ).pipe( + Effect.andThen( + options.runtime.stop(previousInput).pipe( + Effect.mapError( + (error) => + new StackCleanupError({ + message: `Restart teardown failed and cleanup was not proven: ${error.message}`, + cause: error, + }), + ), + ), + ), + Effect.andThen(unpublishBindings(input.instance.id)), + ), + ); + const stoppedCompletion = schedule?.stopped.get(id); + if (stoppedCompletion !== undefined) + yield* Deferred.succeed(stoppedCompletion, stopped); + if (!Exit.isSuccess(stopped)) { + const readyCompletion = schedule?.ready.get(id); + if (readyCompletion !== undefined) yield* Deferred.succeed(readyCompletion, stopped); + return yield* joinExit(stopped); + } + const startImmediately = replacement?.startImmediately !== false; + const started = yield* Effect.exit( + (schedule === undefined + ? Effect.void + : awaitPhase( + schedule.ready, + newDependencies.filter((dependency) => schedule.ready.has(dependency)), + ) + ).pipe( + Effect.andThen( + startImmediately + ? withDependencyTraffic(input, startAndPublish(input)) + : Effect.succeed([] as ReadonlyArray), + ), + ), + ); + const readyCompletion = schedule?.ready.get(id); + if (readyCompletion !== undefined) { + const readyExit = Exit.isSuccess(started) + ? Exit.succeed(undefined) + : Exit.failCause(started.cause); + yield* Deferred.succeed(readyCompletion, readyExit); + } + yield* joinExit(started); + }), + (state, instance) => + Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === instance.id + ? { + ...entry, + intent: replacement?.desiredIntent ?? ("started" as const), + pendingOperation: null, + } + : entry, + ), + }, + }), + undefined, + undefined, + ({ state, instance }) => + state.registry.instances.some( + (dependent) => + dependent.intent === "started" && + batchToken === undefined && + Object.values(dependent.dependencies).includes(instance.id), + ) + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active dependents`, + }), + ) + : Effect.void, + batchToken, + undefined, + replacement, + ).pipe(Effect.andThen(status(id))); + const restart = (id: ServiceInstanceId, replacement?: InstanceRestartCandidate) => + restartOperation(id, replacement); + const requireSnapshotSupport = (id: ServiceInstanceId) => + current(id).pipe( + Effect.flatMap(({ instance }) => + instance.service === "database" + ? Effect.void + : Effect.fail( + new UnsupportedSnapshotError({ + instanceId: id, + service: instance.service, + message: `Service instance ${id} does not support snapshots`, + }), + ), + ), + ); + const exportSnapshot = (id: ServiceInstanceId, destination: string) => + requireSnapshotSupport(id).pipe( + Effect.andThen( + run( + id, + "exportSnapshot", + (input) => options.runtime.exportSnapshot(input, { destination }), + (state, instance) => + Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === instance.id ? { ...entry, pendingOperation: null } : entry, + ), + }, + }), + ), + ), + ); + const restoreSnapshot = (id: ServiceInstanceId, source: string) => + requireSnapshotSupport(id).pipe( + Effect.andThen( + run( + id, + "restoreSnapshot", + (input) => options.runtime.restoreSnapshot(input, { source }), + (state, instance, value) => + Effect.succeed({ + ...state, + registry: { + ...state.registry, + instances: state.registry.instances.map((entry) => + entry.id === instance.id + ? { + ...entry, + pendingOperation: null, + data: { origin: "restored" as const, snapshot: value }, + } + : entry, + ), + }, + }), + ), + ), + ); + const recover: Effect.Effect = read().pipe( + Effect.flatMap((state) => + Effect.forEach( + state.registry.instances.filter((instance) => instance.pendingOperation !== null), + (instance) => + Effect.gen(function* () { + const pending = instance.pendingOperation; + if (pending === null) return; + if (pending.kind === "exportSnapshot" || pending.kind === "restoreSnapshot") { + const recovered: Effect.Effect = + options.runtime.recoverSnapshot === undefined + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: instance.id, + message: `Snapshot operation ${pending.id} has no recovery evidence reader`, + }), + ) + : options.runtime + .recoverSnapshot( + { + stackId: options.stackId, + state, + instance, + plan: yield* instancePlan(state, instance.id), + operation: { id: pending.id, generation: pending.generation }, + }, + pending, + ) + .pipe( + Effect.flatMap((snapshot) => + pending.phase === "complete" && + pending.kind === "restoreSnapshot" && + snapshot === undefined + ? Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: instance.id, + message: `Restore operation ${pending.id} has no committed snapshot manifest`, + }), + ) + : Effect.succeed(snapshot), + ), + ); + yield* recovered.pipe( + Effect.flatMap((snapshot) => + options.stateStore.update(options.stackId, (current) => { + const currentInstance = current.registry.instances.find( + (entry) => entry.id === instance.id, + ); + return currentInstance?.pendingOperation?.id === pending.id && + currentInstance.pendingOperation.generation === pending.generation + ? Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === instance.id + ? { + ...entry, + pendingOperation: null, + ...(snapshot === undefined + ? pending.kind === "restoreSnapshot" + ? { data: { origin: "absent" as const } } + : {} + : { data: { origin: "restored" as const, snapshot } }), + } + : entry, + ), + }, + }) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: instance.id, + message: `Recovery operation ${pending.id} was superseded`, + }), + ); + }), + ), + Effect.provideContext(options.context), + Effect.asVoid, + ); + return; + } + const plan = yield* instancePlan(state, instance.id); + const input: InstanceRuntimeInput = { + stackId: options.stackId, + state, + instance, + plan, + operation: { id: pending.id, generation: pending.generation }, + }; + if (pending.kind === "destroy") yield* options.runtime.destroy(input); + else yield* options.runtime.stop(input); + yield* options.stateStore + .update(options.stackId, (current) => { + const currentInstance = current.registry.instances.find( + (entry) => entry.id === instance.id, + ); + if ( + currentInstance === undefined || + currentInstance.pendingOperation?.id !== pending.id || + currentInstance.pendingOperation.generation !== pending.generation + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: instance.id, + message: `Recovery operation ${pending.id} was superseded`, + }), + ); + if (pending.kind === "destroy") + return removeServiceInstance(current.registry, instance.id).pipe( + Effect.map((registry) => ({ + ...current, + registry, + ports: current.ports.filter( + (assignment) => + assignment.owner !== "instance" || + assignment.instanceId !== instance.id, + ), + privatePorts: current.privatePorts.filter( + (assignment) => assignment.instanceId !== instance.id, + ), + })), + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: error.message, + cause: error, + }), + ), + ); + return Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === instance.id ? { ...entry, pendingOperation: null } : entry, + ), + }, + }); + }) + .pipe(Effect.provideContext(options.context), Effect.asVoid); + }).pipe( + Effect.catch((error) => { + const pending = instance.pendingOperation; + return pending === null ? Effect.void : markRecovery(instance.id, pending, error); + }), + ), + { discard: true }, + ), + ), + ); + const selectedIds = ( + mutation: Mutation, + requested: ReadonlyArray | undefined, + ): Effect.Effect, ServiceNotFoundError | StackError> => + requested !== undefined && requested.length === 0 + ? Effect.succeed([]) + : read().pipe( + Effect.flatMap( + ( + state, + ): Effect.Effect< + ReadonlyArray, + ServiceNotFoundError | StackError + > => + Effect.gen(function* () { + const recoveries = yield* Ref.get(recovery); + const ids = [ + ...new Set( + requested ?? + state.registry.instances + .filter((instance) => + mutation === "destroy" + ? true + : mutation === "start" + ? instance.config.enabled && instance.config.activation === "eager" + : mutation === "stop" + ? true + : instance.intent === "started", + ) + .map((instance) => instance.id), + ), + ]; + for (const id of ids) + if (!state.registry.instances.some((entry) => entry.id === id)) + return yield* notFound(id); + for (const id of ids) { + const instance = state.registry.instances.find((entry) => entry.id === id); + const pending = instance?.pendingOperation; + const canSupersede = + (mutation === "stop" || mutation === "destroy") && + (pending?.kind === "start" || + pending?.kind === "restart" || + (instance !== undefined && recoveries.has(instance.id))); + const canJoinStart = mutation === "start" && pending?.kind === "start"; + if (pending !== null && pending !== undefined && !canSupersede && !canJoinStart) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Service instance ${id} already has a pending operation`, + }); + if ( + (mutation === "start" || mutation === "restart") && + instance !== undefined && + !instance.config.enabled + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Disabled service instance ${id} cannot be started`, + }); + } + if (mutation === "sleep") { + const selected = new Set(ids); + for (const id of ids) { + const activeDependent = yield* Effect.forEach( + state.registry.instances.filter( + (dependent) => + dependent.intent === "started" && + !selected.has(dependent.id) && + Object.values(dependent.dependencies).includes(id), + ), + (dependent) => + phaseFor(dependent.id, dependent).pipe( + Effect.map( + (dependentPhase) => + dependentPhase !== "stopped" && dependentPhase !== "dormant", + ), + ), + ).pipe(Effect.map((active) => active.some(Boolean))); + if (activeDependent) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active dependents`, + }); + const active = yield* options.isInstanceActive?.(id) ?? Effect.succeed(false); + if (active) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active traffic and cannot sleep`, + }); + const wakeable = yield* ( + options.isInstanceWakeable?.(id) ?? Effect.succeed(true) + ); + if (!wakeable) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has no demand-wake route`, + }); + } + } + if (mutation === "stop" || mutation === "restart") { + const selected = new Set(ids); + for (const id of ids) { + if ( + state.registry.instances.some( + (dependent) => + dependent.intent === "started" && + !selected.has(dependent.id) && + Object.values(dependent.dependencies).includes(id), + ) + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active dependents`, + }); + } + } + if (mutation === "destroy") { + const selected = new Set(ids); + for (const id of ids) { + const dependent = state.registry.instances.find( + (entry) => + !selected.has(entry.id) && Object.values(entry.dependencies).includes(id), + ); + if (dependent !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has dependent ${dependent.id}`, + }); + } + } + if (ids.length === 0) return ids; + return yield* createExecutionPlan( + state.runtime, + state.registry, + undefined, + new Set(ids), + ).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ message: error.message, cause: error }), + ), + Effect.as(ids), + ); + }), + ), + ); + const orderedIds = ( + ids: ReadonlyArray, + reverse: boolean, + ): Effect.Effect, StackError> => + read().pipe( + Effect.flatMap((state) => + createExecutionPlan(state.runtime, state.registry, undefined, new Set(ids)).pipe( + Effect.map((plan) => { + const selected = new Set(ids); + const ordered = plan.startOrder.filter((id) => selected.has(id)); + return reverse ? [...ordered].reverse() : ordered; + }), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ), + ), + ); + const runBatch = ( + ids: ReadonlyArray, + mutation: Mutation, + operation: (token: symbol) => Effect.Effect, + rejectActive = false, + startupControlAllowed = false, + allowPendingStarts = false, + allowRecoveryCleanup = false, + ): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const token = yield* restore( + claimBatch( + ids, + mutation, + rejectActive, + startupControlAllowed, + allowRecoveryCleanup, + allowPendingStarts, + ), + ); + const owner = operation(token).pipe( + Effect.onExit((exit) => + Ref.get(batchClaims).pipe( + Effect.flatMap((claims) => + Effect.forEach(ids, (id) => { + const completion = claims.get(id); + return completion?.token === token && completion.completion !== undefined + ? Deferred.succeed(completion.completion, voidExit(exit)).pipe(Effect.asVoid) + : Effect.void; + }), + ), + Effect.andThen(releaseBatch(ids, token)), + ), + ), + ); + const fiber = yield* Effect.forkIn(restore(owner), options.scope, { + startImmediately: true, + }); + return yield* restore(Fiber.join(fiber)); + }), + ); + const collectStatuses = ( + requested: ReadonlyArray, + affected: ReadonlyArray, + operation: ( + id: ServiceInstanceId, + ) => Effect.Effect, + concurrent = false, + ): Effect.Effect, ServiceNotFoundError | StackError> => + Effect.forEach( + affected, + (id) => Effect.exit(operation(id)), + concurrent ? { concurrency: "unbounded" as const } : undefined, + ).pipe( + Effect.flatMap((outcomes) => { + const completed = outcomes.map(Exit.isSuccess); + const values = outcomes.flatMap((outcome) => + Exit.isSuccess(outcome) ? [outcome.value] : [], + ); + const failure = outcomes.find((outcome) => Exit.isFailure(outcome)); + if (failure === undefined || !Exit.isFailure(failure)) return Effect.succeed(values); + const error = errorFromCause(failure.cause); + return Effect.fail( + error instanceof ServiceNotFoundError + ? error + : new StackLifecycleConflictError({ + stackId: options.stackId, + ...(error instanceof StackLifecycleConflictError && error.instanceId !== undefined + ? { instanceId: error.instanceId } + : {}), + message: error.message, + cause: error, + outcome: { ...outcomeFor(requested, affected, completed), statuses: values }, + }), + ); + }), + ); + const restartAll = ( + candidates: ReadonlyArray, + shared?: RestartSharedPatch, + ): Effect.Effect, ServiceNotFoundError | StackError> => { + const ids: ReadonlyArray = candidates.map( + (candidate) => candidate.instance.id, + ); + if (new Set(ids).size !== ids.length) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: "A restart batch cannot contain duplicate service instances", + }), + ); + const work = runBatch( + ids, + "restart", + (batchToken) => + Effect.gen(function* () { + const operationIds = yield* Effect.forEach(candidates, () => + Context.get(options.context, Crypto.Crypto).randomUUIDv4.pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: `Unable to allocate operation identity: ${error.message}`, + cause: error, + }), + ), + ), + ); + const committed = yield* metadataAdmission.withPermit( + options.stateStore + .update( + options.stackId, + (state): Effect.Effect => + Effect.gen(function* () { + const claims = yield* Ref.get(batchClaims); + const selected = new Set(ids); + for (const candidate of candidates) { + const blockedDependency = Object.values( + candidate.instance.dependencies, + ).find((dependencyId) => { + const claim = claims.get(dependencyId); + return ( + !selected.has(dependencyId) && + claim?.mutation !== undefined && + claim.mutation !== "start" && + claim.token !== batchToken + ); + }); + if (blockedDependency !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: blockedDependency, + message: `Dependency ${blockedDependency} is changing lifecycle state`, + }); + } + const currentInstances = candidates.map((candidate) => + state.registry.instances.find( + (entry) => entry.id === candidate.instance.id, + ), + ); + for (const [index, candidate] of candidates.entries()) { + const current = currentInstances[index]; + if (current === undefined) return yield* notFound(candidate.instance.id); + if ( + current.pendingOperation !== null || + current.service !== candidate.instance.service || + current.revisions.config !== + candidate.previous.instance.revisions.config || + current.revisions.intent !== candidate.previous.instance.revisions.intent + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: candidate.instance.id, + message: `Service instance ${candidate.instance.id} changed before its restart batch was admitted`, + }); + } + const replacements = yield* Effect.forEach( + candidates, + ( + candidate, + index, + ): Effect.Effect< + PersistedServiceInstance, + ServiceNotFoundError | StackError + > => { + const current = currentInstances[index]; + if (current === undefined) + return Effect.fail(notFound(candidate.instance.id)); + const operationId = operationIds[index]; + if (operationId === undefined) + return Effect.fail( + new StackStateInvalidError({ + stackId: options.stackId, + message: `Restart batch operation identity is missing for ${candidate.instance.id}`, + }), + ); + return Schema.decodeUnknownEffect(PersistedServiceInstanceSchema)({ + ...candidate.instance, + id: current.id, + service: current.service, + intent: candidate.desiredIntent ?? ("started" as const), + resources: current.resources, + data: current.data, + revisions: { + ...current.revisions, + config: current.revisions.config + 1, + intent: current.revisions.intent + 1, + }, + pendingOperation: { + id: operationId, + kind: "restart" as const, + generation: current.revisions.intent + 1, + ownerSessionId: options.ownerSessionId, + phase: "running" as const, + }, + }).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: `Restarted service instance failed validation: ${String(error)}`, + cause: error, + }), + ), + ); + }, + ); + const replacedIds = new Set(ids); + const replacementsById = new Map( + replacements.map((replacement) => [replacement.id, replacement]), + ); + const changedBindingsById = new Map>( + candidates.map((candidate) => [ + candidate.instance.id, + changedEndpointBindings( + candidate.previous.instance.config.endpoints, + candidate.instance.config.endpoints, + ), + ]), + ); + const registry = { + ...state.registry, + instances: state.registry.instances.map((entry) => { + return replacementsById.get(entry.id) ?? entry; + }), + }; + const secretSlotMap = new Map(); + for (const secretSlot of [ + ...(shared?.secretSlots ?? []), + ...candidates.flatMap((candidate) => candidate.secretSlots), + ]) + secretSlotMap.set(secretSlot.slot, secretSlot); + const secretSlots = [...secretSlotMap.values()]; + const slotIds = new Set(secretSlots.map((slot) => slot.slot)); + const declarations = [ + ...Object.entries(state.secrets) + .filter(([slot]) => !slotIds.has(slot)) + .map(([slot, entry]) => ({ + slot, + policy: entry.policy, + value: Redacted.make(entry.value), + })), + ...secretSlots, + ]; + const resolved = yield* resolveSecrets( + { declarations }, + Object.fromEntries( + Object.entries(state.secrets).filter(([slot]) => !slotIds.has(slot)), + ), + "stopped", + ).pipe(Effect.provideContext(options.context)); + let nextState: PersistedStackState = { + ...state, + ...(shared?.preparation === undefined + ? {} + : { preparation: shared.preparation }), + ...(shared?.security === undefined ? {} : { security: shared.security }), + ...(shared?.listeners === undefined ? {} : { listeners: shared.listeners }), + registry, + secrets: resolved.persisted, + ports: (shared?.ports ?? state.ports).filter((assignment) => { + if (assignment.owner !== "instance") return true; + if (!replacedIds.has(assignment.instanceId)) return true; + return !changedBindingsById + .get(assignment.instanceId) + ?.has(assignment.binding); + }), + privatePorts: state.privatePorts, + }; + for (const replacement of replacements) { + const ports = yield* plannedInstancePorts(nextState, replacement); + nextState = { ...nextState, ...ports }; + } + return nextState; + }), + ) + .pipe( + Effect.provideContext(options.context), + Effect.mapError((error) => + error instanceof ServiceNotFoundError || isStackError(error) + ? error + : new StackStateInvalidError({ + stackId: options.stackId, + message: String(error), + cause: error, + }), + ), + ), + ); + const admitted = yield* Effect.forEach( + candidates, + ( + candidate, + index, + ): Effect.Effect => { + const current = committed.registry.instances.find( + (entry) => entry.id === candidate.instance.id, + ); + const pending = current?.pendingOperation; + const operationId = operationIds[index]; + if ( + current === undefined || + pending === null || + pending === undefined || + operationId === undefined || + pending.id !== operationId || + pending.generation !== current.revisions.intent + ) + return Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: candidate.instance.id, + message: `Service instance ${candidate.instance.id} lost its restart admission`, + }), + ); + return Effect.succeed({ + ...candidate, + admission: { operationId, generation: pending.generation }, + }); + }, + ); + const stopped = new Map< + ServiceInstanceId, + Deferred.Deferred, never> + >(); + const ready = new Map< + ServiceInstanceId, + Deferred.Deferred, never> + >(); + for (const candidate of admitted) { + stopped.set( + candidate.instance.id, + yield* Deferred.make>(), + ); + ready.set(candidate.instance.id, yield* Deferred.make>()); + } + const schedule: RestartPhaseSchedule = { candidates: admitted, stopped, ready }; + const restartOne = (candidate: InstanceRestartCandidate) => { + const id = candidate.instance.id; + const complete = ( + exit: Exit.Exit, + completion: Deferred.Deferred, never> | undefined, + ) => + completion === undefined + ? Effect.void + : Deferred.succeed(completion, voidExit(exit)).pipe(Effect.asVoid); + return restartOperation(id, candidate, batchToken, schedule).pipe( + Effect.onExit((exit) => + complete(exit, stopped.get(id)).pipe( + Effect.andThen(complete(exit, ready.get(id))), + ), + ), + ); + }; + const outcomes = yield* Effect.forEach( + admitted, + (candidate) => Effect.exit(restartOne(candidate)), + { concurrency: "unbounded" }, + ); + const values: ServiceStatus[] = []; + const completed = outcomes.map(Exit.isSuccess); + for (const outcome of outcomes) if (Exit.isSuccess(outcome)) values.push(outcome.value); + const failure = outcomes.find((outcome) => Exit.isFailure(outcome)); + if (failure !== undefined && Exit.isFailure(failure)) { + const error = errorFromCause(failure.cause); + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + ...(error instanceof StackLifecycleConflictError && error.instanceId !== undefined + ? { instanceId: error.instanceId } + : {}), + message: error.message, + cause: error, + outcome: outcomeFor(ids, ids, completed), + }); + } + return values; + }), + false, + true, + ); + // Once the batch journal is committed, its owner continues independently of the request + // waiter. This prevents cancellation between candidates from stranding admitted journals. + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const fiber = yield* Effect.forkIn(work, options.scope, { startImmediately: true }); + return yield* restore(Fiber.join(fiber)); + }), + ); + }; + const startAll = (requested?: ReadonlyArray) => { + const startSelected = selectedIds("start", requested).pipe( + Effect.flatMap((ids) => + runBatch( + ids, + "start", + (token) => + orderedIds(ids, false).pipe( + Effect.flatMap((ordered) => + collectStatuses(ids, ordered, (id) => startOperation(id, token), true), + ), + ), + false, + true, + true, + ), + ), + ); + if (requested !== undefined) return startSelected; + const markLazyStarted = read().pipe( + Effect.flatMap((state) => { + const lazyIds = state.registry.instances + .filter( + (instance) => + instance.config.enabled && + instance.config.activation === "lazy" && + instance.intent === "stopped" && + instance.pendingOperation === null, + ) + .map((instance) => instance.id); + return metadataAdmission + .withPermit( + options.stateStore.update(options.stackId, (current) => + Effect.gen(function* () { + const claims = yield* Ref.get(batchClaims); + for (const id of lazyIds) { + const instance = current.registry.instances.find((entry) => entry.id === id); + const blockedDependency = + instance === undefined + ? undefined + : Object.values(instance.dependencies).find((dependencyId) => { + const claim = claims.get(dependencyId); + return claim?.mutation !== undefined && claim.mutation !== "start"; + }); + if (blockedDependency !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: blockedDependency, + message: `Dependency ${blockedDependency} is changing lifecycle state`, + }); + } + const withStartedIntents = { + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((instance) => + lazyIds.includes(instance.id) && + instance.intent === "stopped" && + instance.pendingOperation === null && + instance.config.activation === "lazy" + ? { ...instance, intent: "started" as const } + : instance, + ), + }, + }; + let planned = withStartedIntents; + for (const instance of planned.registry.instances.filter( + (entry) => + entry.config.enabled && + entry.config.activation === "lazy" && + entry.intent === "started" && + entry.pendingOperation === null, + )) { + const ports = yield* plannedInstancePorts(planned, instance); + planned = { ...planned, ...ports }; + } + return planned; + }), + ), + ) + .pipe( + Effect.provideContext(options.context), + Effect.flatMap((updated) => + Effect.gen(function* () { + if (options.armLazyIngress !== undefined) { + const plan = yield* createExecutionPlan(updated.runtime, updated.registry).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: error.message, + cause: error, + }), + ), + ); + yield* options.armLazyIngress(updated, plan); + } + yield* Effect.forEach( + lazyIds.filter((id) => + updated.registry.instances.some( + (instance) => + instance.id === id && + instance.intent === "started" && + instance.pendingOperation === null && + instance.config.activation === "lazy", + ), + ), + (id) => setPhase(id, "dormant"), + { discard: true }, + ); + }), + ), + ); + }), + ); + return startSelected.pipe( + Effect.flatMap((statuses) => markLazyStarted.pipe(Effect.map(() => statuses))), + ); + }; + const sleepAll = (requested?: ReadonlyArray) => + selectedIds("sleep", requested).pipe( + Effect.flatMap((ids) => + runBatch( + ids, + "sleep", + (token) => + orderedIds(ids, true).pipe( + Effect.flatMap((ordered) => + collectStatuses(ids, ordered, (id) => sleep(id, token)), + ), + ), + true, + false, + false, + false, + ), + ), + ); + const stopAll = (requested?: ReadonlyArray) => + selectedIds("stop", requested).pipe( + Effect.flatMap((ids) => + runBatch( + ids, + "stop", + (token) => + orderedIds(ids, true).pipe( + Effect.flatMap((ordered) => collectStatuses(ids, ordered, (id) => stop(id, token))), + ), + false, + false, + true, + true, + ), + ), + ); + const destroyOrder = ( + ids: ReadonlyArray, + ): Effect.Effect, StackError> => + read().pipe( + Effect.flatMap((state) => + Effect.sync(() => { + const remaining = new Set(ids); + const ordered: ServiceInstanceId[] = []; + while (remaining.size > 0) { + const candidate = state.registry.instances.find( + (instance) => + remaining.has(instance.id) && + !state.registry.instances.some( + (dependent) => + remaining.has(dependent.id) && + Object.values(dependent.dependencies).includes(instance.id), + ), + ); + if (candidate === undefined) + return new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Cannot destroy service instances with cyclic dependencies", + }); + ordered.push(candidate.id); + remaining.delete(candidate.id); + } + return ordered; + }).pipe( + Effect.flatMap((value) => + value instanceof StackLifecycleConflictError + ? Effect.fail(value) + : Effect.succeed(value), + ), + ), + ), + ); + const destroyAll = (requested?: ReadonlyArray) => + selectedIds("destroy", requested).pipe( + Effect.flatMap((ids) => + runBatch( + ids, + "destroy", + (token) => + destroyOrder(ids).pipe( + Effect.flatMap((ordered) => + Effect.forEach(ordered, (id) => Effect.exit(destroy(id, token))).pipe( + Effect.flatMap((outcomes) => { + const completed = outcomes.map(Exit.isSuccess); + const failure = outcomes.find((outcome) => Exit.isFailure(outcome)); + if (failure === undefined || !Exit.isFailure(failure)) return Effect.void; + const error = errorFromCause(failure.cause); + return read().pipe( + Effect.flatMap((state) => { + const removed = ids.filter( + (id) => + !state.registry.instances.some((instance) => instance.id === id), + ); + const retained = ids.filter((id) => + state.registry.instances.some((instance) => instance.id === id), + ); + return Effect.fail( + new StackDestructionError({ + message: error.message, + cause: error, + outcome: { + ...outcomeFor(ids, ordered, completed), + removed, + retained, + }, + }), + ); + }), + ); + }), + ), + ), + ), + false, + false, + true, + true, + ), + ), + ); + const create = ( + instance: PersistedServiceInstance, + secretSlots: ReadonlyArray = [], + ) => + metadataAdmission + .withPermit( + options.stateStore + .update(options.stackId, (state) => + Effect.gen(function* () { + const claims = yield* Ref.get(batchClaims); + const blockedDependency = Object.values(instance.dependencies).find( + (dependencyId) => { + const claim = claims.get(dependencyId); + return claim?.mutation !== undefined && claim.mutation !== "start"; + }, + ); + if (blockedDependency !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: blockedDependency, + message: `Dependency ${blockedDependency} is changing lifecycle state`, + }); + const registry = yield* registerServiceInstance(state.registry, instance); + const newSlots = new Set(secretSlots.map((slot) => slot.slot)); + const declarations = [ + ...Object.entries(state.secrets) + .filter(([slot]) => !newSlots.has(slot)) + .map(([slot, entry]) => ({ + slot, + policy: entry.policy, + value: Redacted.make(entry.value), + })), + ...secretSlots, + ]; + const resolved = yield* resolveSecrets( + { declarations }, + state.secrets, + "stopped", + ).pipe(Effect.provideContext(options.context)); + const bootstrapInputsId = yield* fingerprintBootstrapInputs( + instance, + state.security, + resolved.persisted, + ).pipe( + Effect.provideService(Crypto.Crypto, Context.get(options.context, Crypto.Crypto)), + ); + const materialized = + bootstrapInputsId === undefined ? instance : { ...instance, bootstrapInputsId }; + const next = { + ...state, + registry: { + ...registry, + instances: registry.instances.map((entry) => + entry.id === materialized.id ? materialized : entry, + ), + }, + secrets: resolved.persisted, + }; + const ports = yield* plannedInstancePorts(next, materialized); + return { ...next, ...ports }; + }), + ) + .pipe( + Effect.provideContext(options.context), + Effect.mapError((error) => + error instanceof ServiceNotFoundError || isStackError(error) + ? error + : new StackStateInvalidError({ + stackId: options.stackId, + message: error.message, + cause: error, + }), + ), + Effect.flatMap((state) => { + const created = state.registry.instances.find((entry) => entry.id === instance.id); + return created === undefined + ? Effect.fail( + new StackStateInvalidError({ + stackId: options.stackId, + message: `Created service instance ${instance.id} is missing from the registry`, + }), + ) + : descriptor(state, created); + }), + ), + ) + .pipe( + Effect.tap(() => + PubSub.publish(statusUpdates, { id: instance.id, destroyed: false }).pipe( + Effect.andThen(options.publishStatus ?? Effect.void), + ), + ), + ); + return { + create, + get, + describe, + list, + status, + followStatus, + start, + acquireTraffic, + startAll, + sleepAll, + stopAll, + stop, + sleep, + destroy, + destroyAll, + prepare, + restart, + restartAll, + exportSnapshot, + restoreSnapshot, + recover, + } satisfies InstanceEngine; + }); diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 7dfeb80132..9a2754b694 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -2,6 +2,7 @@ import { Cause, Config, Crypto, + Data, Effect, Exit, FileSystem, @@ -22,7 +23,8 @@ import { resolveStackPaths } from "../state/Paths.ts"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; import { readOwnerMetadata, - StackRuntimeEnvironment, + ownerLockExists, + waitForOwnerRelease, type OwnerMetadata, type StackRuntimeEnvironmentValue, } from "../state/Ownership.ts"; @@ -81,14 +83,6 @@ export const defaultRuntimeEnvironment: Effect.Effect = - Effect.serviceOption(StackRuntimeEnvironment).pipe( - Effect.flatMap((configured) => - Option.isSome(configured) ? Effect.succeed(configured.value) : defaultRuntimeEnvironment, - ), - ); - type ReadinessResult = | { readonly kind: "ready"; readonly stackId: StackId; readonly ownerSessionId: string } | { readonly kind: "ownership-conflict"; readonly message: string } @@ -106,6 +100,10 @@ export interface EnsureSupervisorResult { } const mapFailure = (message: string) => new StackStateInvalidError({ message }); +class PreAdmissionOwnerLoss extends Data.TaggedError("PreAdmissionOwnerLoss")<{ + readonly stackId: StackId; + readonly ownerSessionId: string; +}> {} const SUPERVISOR_READINESS_TIMEOUT_MS = 30_000; const SUPERVISOR_REREAD_INTERVAL_MS = 25; const SUPERVISOR_READINESS_REREAD_TIMES = Math.floor( @@ -161,8 +159,11 @@ const validateCompatibleOwner = ( metadata: OwnerMetadata, ): Effect.Effect< void, - StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, - Scope.Scope + | StackOwnershipConflictError + | StackStateInvalidError + | StackUpgradeRequiredError + | PreAdmissionOwnerLoss, + Scope.Scope | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const probeExit = yield* makeControlClient(metadata.endpoint, { @@ -171,8 +172,15 @@ const validateCompatibleOwner = ( }).probe.pipe(Effect.exit); if (Exit.isFailure(probeExit)) { const failure = Cause.findErrorOption(probeExit.cause); - if (Option.isSome(failure) && isMaintenanceTransportFailure(failure.value)) + if (Option.isSome(failure) && isMaintenanceTransportFailure(failure.value)) { + const leaseHeld = yield* ownerLockExists(options.environment.stateRoot, options.stackId); + if (leaseHeld) + return yield* new PreAdmissionOwnerLoss({ + stackId: options.stackId, + ownerSessionId: metadata.ownerSessionId, + }); return yield* mapFailure("Unable to probe existing Supervisor: transport unavailable"); + } return yield* new StackOwnershipConflictError({ message: "Existing Supervisor probe failed", stackId: options.stackId, @@ -243,7 +251,10 @@ const launchAndAwait = ( paths: { readonly stackRoot: string }, ): Effect.Effect< EnsureSupervisorResult, - StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, + | StackOwnershipConflictError + | StackStateInvalidError + | StackUpgradeRequiredError + | PreAdmissionOwnerLoss, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { @@ -303,17 +314,18 @@ const launchAndAwait = ( return yield* mapFailure("Supervisor readiness metadata identity mismatch"); return { kind: "ready", metadata } satisfies ChildResult; }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(mapFailure(error.message)))); - const terminateLaunch: Effect.Effect = Effect.all([ - Effect.ignore(child.kill()), - Fiber.interrupt(ownerFiber), - ]).pipe(Effect.asVoid); + // The detached child owns its startup resources. Closing this caller's readiness + // observation can race ownership publication, so it must never kill the child after + // another client could have joined the owner. The child handles readiness-channel + // failure at its own ownership boundary. + const stopWatchingLaunch = Fiber.interrupt(ownerFiber); const childResult = yield* readiness.pipe( Effect.timeoutOrElse({ duration: SUPERVISOR_READINESS_TIMEOUT_MS, orElse: () => Effect.fail(mapFailure("Supervisor did not publish readiness in time")), }), - Effect.tapError(() => terminateLaunch), - Effect.onInterrupt(() => terminateLaunch), + Effect.tapError(() => stopWatchingLaunch), + Effect.onInterrupt(() => stopWatchingLaunch), ); if (childResult.kind === "ownership-conflict") { const current = yield* readOwnerMetadata( @@ -337,7 +349,7 @@ const launchAndAwait = ( }), ); } - return yield* Fiber.join(ownerFiber).pipe( + const owner = yield* Fiber.join(ownerFiber).pipe( Effect.timeoutOrElse({ duration: 5_000, orElse: () => @@ -352,12 +364,12 @@ const launchAndAwait = ( ), ), }), - Effect.map((owner) => ({ owner, launched: false }) satisfies EnsureSupervisorResult), ); + yield* validateCompatibleOwner(options, owner); + return { owner, launched: false } satisfies EnsureSupervisorResult; } yield* Fiber.interrupt(ownerFiber); if (childResult.kind === "failed") { - yield* terminateLaunch; return yield* mapFailure(childResult.message); } return { @@ -407,7 +419,10 @@ export const ensureSupervisor = ( // A published document with an unavailable control endpoint may be a // crashed owner. Let the child arbitrate through the OS-held lease; // malformed metadata remains fail-closed in readOwnerMetadata. - Effect.catchTag("StackStateInvalidError", () => Effect.succeed(false)), + Effect.catchTags({ + StackStateInvalidError: () => Effect.succeed(false), + PreAdmissionOwnerLoss: () => Effect.succeed(false), + }), ); if (compatible) return { owner: existing, launched: false } satisfies EnsureSupervisorResult; } @@ -441,7 +456,58 @@ export const ensureSupervisor = ( mapFailure(`Unable to prepare owner runtime directory: ${error.message}`), ), ); - const owner = yield* launchAndAwait(options, payload, { stackRoot: paths.stackRoot }); - if (!owner.launched) yield* validateCompatibleOwner(options, owner.owner); - return owner; + const launch = () => launchAndAwait(options, payload, { stackRoot: paths.stackRoot }); + const launchAfterHandoff = ( + loss: PreAdmissionOwnerLoss, + ): Effect.Effect< + EnsureSupervisorResult, + StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + > => + waitForOwnerRelease( + options.environment.stateRoot, + options.stackId, + options.environment, + loss.ownerSessionId, + ).pipe( + Effect.andThen( + launch().pipe( + Effect.catchTag("PreAdmissionOwnerLoss", () => + Effect.fail( + new StackOwnershipConflictError({ + message: "Supervisor handoff did not settle before relaunch", + stackId: options.stackId, + }), + ), + ), + ), + ), + ); + const launchWithHandoff: Effect.Effect< + EnsureSupervisorResult, + StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + > = launch().pipe(Effect.catchTag("PreAdmissionOwnerLoss", launchAfterHandoff)); + return yield* launchWithHandoff.pipe( + Effect.catchTag("StackOwnershipConflictError", (conflict) => + Effect.gen(function* () { + const current = yield* readOwnerMetadata( + options.environment.stateRoot, + options.stackId, + options.environment, + ); + const leaseHeld = yield* ownerLockExists(options.environment.stateRoot, options.stackId); + // A missing document with no lease is a completed pre-admission handoff; + // the failed child did not issue an RPC and may safely arbitrate again. + if (!leaseHeld) return current === undefined ? yield* launchWithHandoff : yield* conflict; + yield* waitForOwnerRelease( + options.environment.stateRoot, + options.stackId, + options.environment, + current?.ownerSessionId, + ); + return yield* launchWithHandoff; + }), + ), + ); }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index a47fab5257..71ce0fbfec 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -1,28 +1,13 @@ -import { Cause, Crypto, Effect, Exit, FileSystem, Path, Predicate, Redacted } from "effect"; -import type { StackDefinition, CompiledStack, SecretSlotInput } from "../model/Compiler.ts"; -import { compileStack, rebuildExecutionPlan, sameDefinition } from "../model/Compiler.ts"; +import type { Effect } from "effect"; +import type { StackDefinition } from "../model/Compiler.ts"; import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; -import type { StackConfig } from "../public/Config.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; import type { StackId } from "../public/StackId.ts"; -import { - StackLifecycleConflictError, - StackMustBeStoppedError, - StackStateInvalidError, - type StackError, -} from "../public/Errors.ts"; +import type { StackError } from "../public/Errors.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; import type { PersistedSecretValues, PersistedStackState } from "../state/StackState.ts"; -import type { StackStateStore } from "../state/StackStateStore.ts"; -import { - resolveSecrets, - type SecretCandidate, - type SecretDeclaration, -} from "../state/SecretStore.ts"; -/** - * The runtime-facing contract has no Docker/native concepts. Concrete drivers own resources; - * this controller owns accepted durable intent and lifecycle transitions. - */ +/** Runtime input retained for ingress and artifact preparation boundaries. */ export interface LifecycleInput { readonly stackId: StackId; readonly state: PersistedStackState; @@ -31,405 +16,18 @@ export interface LifecycleInput { readonly plan: ExecutionPlan; } -export interface LifecycleBackend { - /** Must complete all runtime/resource validation before the controller writes accepted intent. */ - readonly preflight: (input: LifecycleInput) => Effect.Effect; - /** Applies the desired lifecycle to runtime resources for one accepted definition. */ - readonly launch: ( - input: LifecycleInput, - session: "fresh" | "current", - ) => Effect.Effect; - /** Removes runtime resources while retaining durable state/data (stop path). */ - readonly cleanup: Effect.Effect; - /** Removes all exact runtime resources and persistent data (destroy path). */ - readonly destroyData: Effect.Effect; -} - -export type CleanupOutcome = - | { readonly _tag: "proven" } - | { readonly _tag: "unproven"; readonly cause: Cause.Cause }; - -export type LifecycleLaunchResult = - | { readonly _tag: "started"; readonly rollback: Effect.Effect } - | { - readonly _tag: "failed"; - readonly cause: Cause.Cause; - readonly cleanup: CleanupOutcome; - }; - -type LifecycleStartOutcome = - | { readonly _tag: "started"; readonly state: PersistedStackState } - | { - readonly _tag: "failed"; - readonly cause: Cause.Cause; - readonly cleanup: CleanupOutcome; - readonly durable: "stopped" | "unsafe"; - }; - -interface LifecycleStartOptions { - readonly config?: StackConfig; - /** A new Supervisor recovering running intent begins a fresh runtime session. */ - readonly freshSession?: boolean; -} - -export interface LifecycleController { - readonly start: ( - options?: LifecycleStartOptions, - ) => Effect.Effect; - readonly stop: Effect.Effect; - readonly destroy: Effect.Effect; -} - -type LifecycleRequirements = Crypto.Crypto | FileSystem.FileSystem | Path.Path; - -export interface LifecycleControllerOptions { +/** Exact per-instance context passed to runtime lifecycle and snapshot operations. */ +export interface InstanceRuntimeInput { readonly stackId: StackId; - readonly runtime: StackRuntime; - readonly stateStore: StackStateStore; - readonly backend: LifecycleBackend; -} - -interface Candidate { - readonly definition: StackDefinition; - readonly secrets: PersistedSecretValues; + readonly state: PersistedStackState; + readonly instance: PersistedServiceInstance; readonly plan: ExecutionPlan; + readonly operation: { + readonly id: string; + readonly generation: number; + }; + /** Publishes inspector bindings only after this operation still owns the pending journal. */ + readonly publishStartupBindings?: ( + bindings: ReadonlyArray, + ) => Effect.Effect; } - -const missingState = (stackId: StackId): StackStateInvalidError => - new StackStateInvalidError({ - stackId, - message: "Stack state is missing; refusing lifecycle mutation", - }); - -const lifecycleConflict = (message: string): StackLifecycleConflictError => - new StackLifecycleConflictError({ message }); - -const declarationsFromPersisted = (secrets: PersistedSecretValues): SecretCandidate => ({ - declarations: Object.entries(secrets).map(([slot, entry]) => ({ - slot, - policy: entry.policy, - ...(entry.policy === "passthrough" ? { value: Redacted.make(entry.value) } : {}), - })), -}); - -const declarationsFromCompiled = (compiled: CompiledStack): SecretCandidate => ({ - declarations: compiled.secrets.map((entry: SecretSlotInput): SecretDeclaration => ({ - slot: entry.slot, - policy: entry.policy, - ...(entry.value === undefined ? {} : { value: entry.value }), - ...(entry.generator === undefined ? {} : { generator: entry.generator }), - })), -}); - -const sameSecrets = (left: PersistedSecretValues, right: PersistedSecretValues): boolean => { - const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)); - const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)); - if (leftEntries.length !== rightEntries.length) return false; - return leftEntries.every(([slot, value], index) => { - const other = rightEntries[index]; - return ( - other !== undefined && - slot === other[0] && - value.policy === other[1].policy && - value.value === other[1].value - ); - }); -}; - -const materializeCandidate = ( - state: PersistedStackState, - runtime: StackRuntime, - config: StackConfig | undefined, -): Effect.Effect => - Effect.gen(function* () { - if (config === undefined && state.definition !== undefined) { - const plan = yield* rebuildExecutionPlan(runtime, state.definition); - const resolved = yield* resolveSecrets( - declarationsFromPersisted(state.secrets), - state.secrets, - state.desiredLifecycle, - ); - return { - definition: state.definition, - secrets: resolved.persisted, - plan, - }; - } - const compiled = yield* compileStack( - { - projectRoot: state.identity.projectRoot, - runtime, - config, - }, - state.definition === undefined ? undefined : { definition: state.definition }, - ); - const resolved = yield* resolveSecrets( - declarationsFromCompiled(compiled), - state.secrets, - state.desiredLifecycle, - ); - return { - definition: compiled.definition, - secrets: resolved.persisted, - plan: compiled.executionPlan, - }; - }); - -const lifecycleInput = ( - stackId: StackId, - state: PersistedStackState, - candidate: Candidate, -): LifecycleInput => ({ - stackId, - state, - definition: candidate.definition, - secrets: candidate.secrets, - plan: candidate.plan, -}); - -const stateWithCandidate = ( - state: PersistedStackState, - candidate: Candidate, - desiredLifecycle: PersistedStackState["desiredLifecycle"], -): PersistedStackState => ({ - ...state, - desiredLifecycle, - definition: candidate.definition, - secrets: candidate.secrets, -}); - -/** Creates one Supervisor-local lifecycle owner. Mutable coordination is allocated per Effect run. */ -export const makeLifecycleController = ( - options: LifecycleControllerOptions, -): Effect.Effect => - Effect.sync(() => { - const read = (): Effect.Effect => - options.stateStore - .read(options.stackId) - .pipe( - Effect.flatMap((state) => - state === undefined - ? Effect.fail(missingState(options.stackId)) - : Effect.succeed(state), - ), - ); - const persistNonRunningAfterFailure = ( - primary: Cause.Cause, - restore: { - readonly cleanup: boolean; - readonly restoreLifecycle: "stopped" | "unconfigured"; - }, - ): Effect.Effect => - Effect.gen(function* () { - const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); - let cause = primary; - let durable: "stopped" | "unsafe" = "unsafe"; - if (Exit.isFailure(current)) { - cause = Cause.combine(cause, current.cause); - } else if (current.value === undefined) { - cause = Cause.combine(cause, Cause.fail(missingState(options.stackId))); - } else { - const persisted = yield* options.stateStore - .replace(options.stackId, { - ...current.value, - desiredLifecycle: restore.restoreLifecycle, - }) - .pipe(Effect.exit); - if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); - else durable = "stopped"; - } - let cleanupOutcome: CleanupOutcome = { _tag: "proven" }; - if (restore.cleanup) { - const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); - if (Exit.isFailure(cleaned)) { - cleanupOutcome = { _tag: "unproven", cause: cleaned.cause }; - cause = Cause.combine(cause, cleaned.cause); - } - } - return { _tag: "failed", cause, cleanup: cleanupOutcome, durable }; - }); - - const startOutcome = ( - startOptions?: LifecycleStartOptions, - ): Effect.Effect => { - const supplied = startOptions?.config; - const failed = ( - cause: Cause.Cause, - durable: "stopped" | "unsafe", - ): LifecycleStartOutcome => ({ - _tag: "failed", - cause, - cleanup: { _tag: "proven" }, - durable, - }); - return Effect.gen(function* () { - const initialRead = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); - if (Exit.isFailure(initialRead)) return failed(initialRead.cause, "unsafe"); - if (initialRead.value === undefined) - return failed(Cause.fail(missingState(options.stackId)), "stopped"); - const initial = initialRead.value; - if (initial.desiredLifecycle === "destroying") - return yield* lifecycleConflict("Stack is being destroyed"); - - const freshSession = - initial.desiredLifecycle === "running" && startOptions?.freshSession === true; - const materialized = yield* materializeCandidate(initial, initial.runtime, supplied).pipe( - Effect.exit, - ); - if (Exit.isFailure(materialized)) { - if (freshSession) - return yield* persistNonRunningAfterFailure(materialized.cause, { - cleanup: false, - restoreLifecycle: "stopped", - }); - return failed( - materialized.cause, - initial.desiredLifecycle === "running" ? "unsafe" : "stopped", - ); - } - const candidate = materialized.value; - if (initial.desiredLifecycle === "running") { - if ( - supplied !== undefined && - (initial.definition === undefined || - !sameDefinition(candidate.definition, initial.definition)) - ) { - const error = new StackMustBeStoppedError({ - stackId: options.stackId, - message: "Running stack input changed; stop the stack before applying it", - guidance: "Use stop() followed by start() to apply stopped-time changes", - }); - if (freshSession) - return yield* persistNonRunningAfterFailure(Cause.fail(error), { - cleanup: false, - restoreLifecycle: "stopped", - }); - return failed(Cause.fail(error), "unsafe"); - } - if (supplied !== undefined && !sameSecrets(candidate.secrets, initial.secrets)) { - const error = new StackMustBeStoppedError({ - stackId: options.stackId, - message: "Running stack secrets changed; stop the stack before applying them", - guidance: "Use stop() followed by start() to apply stopped-time changes", - }); - if (freshSession) - return yield* persistNonRunningAfterFailure(Cause.fail(error), { - cleanup: false, - restoreLifecycle: "stopped", - }); - return failed(Cause.fail(error), "unsafe"); - } - if (freshSession) { - const preflighted = yield* options.backend - .preflight(lifecycleInput(options.stackId, initial, candidate)) - .pipe(Effect.exit); - if (Exit.isFailure(preflighted)) - return yield* persistNonRunningAfterFailure(preflighted.cause, { - cleanup: false, - restoreLifecycle: "stopped", - }); - } - const launched = yield* options.backend - .launch( - lifecycleInput(options.stackId, initial, candidate), - freshSession ? "fresh" : "current", - ) - .pipe(Effect.exit); - if (Exit.isFailure(launched) && freshSession) - return yield* persistNonRunningAfterFailure(launched.cause, { - cleanup: true, - restoreLifecycle: "stopped", - }); - if (Exit.isFailure(launched)) return failed(launched.cause, "unsafe"); - if (Predicate.isTagged(launched.value, "failed")) { - if (freshSession) - return yield* persistNonRunningAfterFailure(launched.value.cause, { - cleanup: true, - restoreLifecycle: "stopped", - }); - return { - _tag: "failed", - cause: launched.value.cause, - cleanup: launched.value.cleanup, - durable: "unsafe", - }; - } - return { _tag: "started", state: initial }; - } - - const preflighted = yield* options.backend - .preflight(lifecycleInput(options.stackId, initial, candidate)) - .pipe(Effect.exit); - if (Exit.isFailure(preflighted)) return failed(preflighted.cause, "stopped"); - const next = stateWithCandidate(initial, candidate, "running"); - const persisted = yield* options.stateStore - .replace(options.stackId, next) - .pipe(Effect.exit); - if (Exit.isFailure(persisted)) return failed(persisted.cause, "unsafe"); - const started = yield* options.backend - .launch(lifecycleInput(options.stackId, next, candidate), "fresh") - .pipe(Effect.exit); - if (Exit.isSuccess(started)) { - if (Predicate.isTagged(started.value, "failed")) - return yield* persistNonRunningAfterFailure(started.value.cause, { - cleanup: true, - restoreLifecycle: - initial.desiredLifecycle === "unconfigured" ? "unconfigured" : "stopped", - }); - return { _tag: "started", state: next }; - } - - return yield* persistNonRunningAfterFailure(started.cause, { - cleanup: true, - restoreLifecycle: - initial.desiredLifecycle === "unconfigured" ? "unconfigured" : "stopped", - }); - }); - }; - - const stop: Effect.Effect = - Effect.suspend(() => - Effect.gen(function* () { - const current = yield* read(); - if (current.desiredLifecycle === "unconfigured") { - // Even an unconfigured stack may have exact runtime remnants from an interrupted - // first start. Stop is the explicit retry boundary for that cleanup. - yield* options.backend.cleanup; - return current; - } - if (current.desiredLifecycle === "destroying") - return yield* lifecycleConflict("Stack is being destroyed"); - const stopped: PersistedStackState = - current.desiredLifecycle === "stopped" - ? current - : { ...current, desiredLifecycle: "stopped" }; - if (stopped !== current) yield* options.stateStore.replace(options.stackId, stopped); - yield* options.backend.cleanup; - return stopped; - }), - ); - - const destroy: Effect.Effect = Effect.suspend(() => - Effect.gen(function* () { - const current = yield* read(); - const destroying: PersistedStackState = - current.desiredLifecycle === "destroying" - ? current - : { ...current, desiredLifecycle: "destroying" }; - if (destroying !== current) yield* options.stateStore.replace(options.stackId, destroying); - yield* destroyRuntime; - }), - ); - - const destroyRuntime: Effect.Effect = Effect.gen( - function* () { - // Destructive cleanup is the single runtime teardown path. It is exact and idempotent, - // so it also handles an unconfigured state left behind by an interrupted first start. - // Persisted configuration need not compile in order to remove runtime remnants. - yield* options.backend.destroyData; - yield* options.stateStore.cleanup(options.stackId); - }, - ); - - return { start: startOutcome, stop, destroy } satisfies LifecycleController; - }); diff --git a/packages/stack/src/supervisor/ServiceCredentials.ts b/packages/stack/src/supervisor/ServiceCredentials.ts new file mode 100644 index 0000000000..edba4079c3 --- /dev/null +++ b/packages/stack/src/supervisor/ServiceCredentials.ts @@ -0,0 +1,203 @@ +import { Effect, Redacted } from "effect"; +import type { EffectStackCredentials } from "../public/Credentials.ts"; +import type { ServiceCredentials } from "../public/Service.ts"; +import type { CapabilityName } from "../public/Capability.ts"; +import type { StackError } from "../public/Errors.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { + AUTH_ANON_KEY_SLOT, + AUTH_PUBLISHABLE_KEY_SLOT, + AUTH_SECRET_KEY_SLOT, + AUTH_SERVICE_ROLE_KEY_SLOT, +} from "../state/SecretStore.ts"; + +const secret = (state: PersistedStackState, slot: string): string | undefined => + state.secrets[slot]?.value; + +const apiCredentials = ( + state: PersistedStackState, +): + | { + readonly publishableKey: string; + readonly secretKey: string; + readonly anonJwt: string; + readonly serviceRoleJwt: string; + } + | undefined => { + if (state.listeners.api?.enabled !== true) return undefined; + const publishableKey = secret(state, AUTH_PUBLISHABLE_KEY_SLOT); + const secretKey = secret(state, AUTH_SECRET_KEY_SLOT); + const anonJwt = secret(state, AUTH_ANON_KEY_SLOT); + const serviceRoleJwt = secret(state, AUTH_SERVICE_ROLE_KEY_SLOT); + return publishableKey === undefined || + secretKey === undefined || + anonJwt === undefined || + serviceRoleJwt === undefined + ? undefined + : { publishableKey, secretKey, anonJwt, serviceRoleJwt }; +}; + +const databaseCredentials = ( + state: PersistedStackState, + instance: PersistedServiceInstance | undefined, +): { readonly url: string; readonly password: string } | undefined => { + if (instance?.service !== "database" || !instance.config.enabled) return undefined; + const passwordRef = instance.config.passwordSecretRef; + const password = passwordRef === undefined ? undefined : secret(state, passwordRef); + const sql = state.ports.find( + (assignment) => + assignment.owner === "instance" && + assignment.instanceId === instance.id && + assignment.binding === "sql", + ); + return password === undefined || sql === undefined + ? undefined + : { + url: `postgresql://postgres:${encodeURIComponent(password)}@${sql.address}:${sql.port}/postgres`, + password, + }; +}; + +const storageCredentials = ( + state: PersistedStackState, + instance: PersistedServiceInstance | undefined, +): + | { + readonly endpoint: string; + readonly region: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; + } + | undefined => { + if (instance?.service !== "storage" || !instance.config.enabled) return undefined; + const protocol = instance.config.settings.s3_protocol; + if ( + protocol === null || + protocol.enabled !== true || + protocol.region === null || + protocol.access_key_id === null || + protocol.secret_access_key === null + ) + return undefined; + const slot = protocol.secret_access_key.slot; + const secretAccessKey = secret(state, slot); + const api = state.ports.find( + (assignment) => assignment.owner === "stack" && assignment.binding === "api", + ); + return secretAccessKey === undefined || api === undefined + ? undefined + : { + endpoint: `http://${api.address}:${api.port}/storage/v1/s3`, + region: protocol.region, + accessKeyId: protocol.access_key_id, + secretAccessKey, + }; +}; + +/** Projects enabled, fully materialized stack credentials without inventing missing values. */ +export const projectStackCredentials = ( + state: PersistedStackState, +): Effect.Effect => + Effect.sync(() => { + const database = databaseCredentials( + state, + state.registry.instances.find( + (instance) => instance.id === state.registry.defaultInstanceIds.database, + ), + ); + const api = apiCredentials(state); + const storage = storageCredentials( + state, + state.registry.instances.find( + (instance) => instance.id === state.registry.defaultInstanceIds.storage, + ), + ); + return { + ...(database === undefined + ? {} + : { + database: { + url: Redacted.make(database.url), + password: Redacted.make(database.password), + }, + }), + ...(api === undefined + ? {} + : { + api: { + publishableKey: api.publishableKey, + secretKey: Redacted.make(api.secretKey), + anonJwt: api.anonJwt, + serviceRoleJwt: Redacted.make(api.serviceRoleJwt), + }, + }), + ...(storage === undefined + ? {} + : { + storage: { + endpoint: storage.endpoint, + region: storage.region, + accessKeyId: storage.accessKeyId, + secretAccessKey: Redacted.make(storage.secretAccessKey), + }, + }), + } satisfies EffectStackCredentials; + }); + +/** Projects credentials for one registered instance, including shared API credentials for Functions. */ +export function projectServiceCredentials( + state: PersistedStackState, + instance: Extract, +): Effect.Effect, StackError>; +export function projectServiceCredentials( + state: PersistedStackState, + instance: Extract, +): Effect.Effect, StackError>; +export function projectServiceCredentials( + state: PersistedStackState, + instance: Extract, +): Effect.Effect, StackError>; +export function projectServiceCredentials( + state: PersistedStackState, + instance: PersistedServiceInstance, +): Effect.Effect, StackError>; +export function projectServiceCredentials( + state: PersistedStackState, + instance: PersistedServiceInstance, +): Effect.Effect, StackError> { + switch (instance.service) { + case "database": { + const value = databaseCredentials(state, instance); + return Effect.succeed(value); + } + case "functions": { + const value = apiCredentials(state); + return Effect.succeed( + instance.config.enabled && value !== undefined + ? { + publishableKey: value.publishableKey, + secretKey: value.secretKey, + anonJwt: value.anonJwt, + serviceRoleJwt: value.serviceRoleJwt, + } + : { kind: "none" }, + ); + } + case "storage": { + const value = storageCredentials(state, instance); + return Effect.succeed( + value === undefined + ? { kind: "none" } + : { + endpoint: value.endpoint, + region: value.region, + accessKeyId: value.accessKeyId, + secretAccessKey: value.secretAccessKey, + }, + ); + } + default: + return Effect.succeed({ kind: "none" }); + } +} diff --git a/packages/stack/src/supervisor/SessionLauncher.ts b/packages/stack/src/supervisor/SessionLauncher.ts deleted file mode 100644 index 39e7aaf749..0000000000 --- a/packages/stack/src/supervisor/SessionLauncher.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { Cause, Data, Deferred, Effect, Exit, Match, Ref, Semaphore } from "effect"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; -import type { StackId } from "../public/StackId.ts"; -import { - RuntimeDriverError, - type RuntimeDriver, - type RuntimeWorkloadKey, -} from "../runtime/RuntimeDriver.ts"; - -interface SessionWorkload { - readonly key: RuntimeWorkloadKey; - readonly workload: PlannedWorkload; -} - -/** A cleanup failure retains ownership because at least one remove was unproven. */ -export class SessionCleanupError extends Data.TaggedError("SessionCleanupError")<{ - readonly cause: Cause.Cause; -}> {} - -export interface SessionLauncher { - /** Starts the supplied dependency closure as dependencies complete. */ - readonly launch: ( - plan: ExecutionPlan, - cancellation?: Deferred.Deferred, - ) => Effect.Effect; - /** Stops and removes every workload started in this session in reverse order. */ - readonly stop: Effect.Effect; - readonly stopCapabilities: ( - capabilities: ReadonlySet, - ) => Effect.Effect; - /** Clears the session after stack-wide runtime cleanup has completed. */ - readonly clear: Effect.Effect; -} - -/** Resources created by one launch attempt and a rollback scoped to that attempt. */ -interface SessionLaunch { - readonly rollback: Effect.Effect; -} - -export type SessionCleanupOutcome = - | { readonly _tag: "proven" } - | { - readonly _tag: "unproven"; - readonly cause: Cause.Cause; - }; - -export type SessionLaunchOutcome = - | { readonly _tag: "started"; readonly launch: SessionLaunch } - | { - readonly _tag: "failed"; - readonly cause: Cause.Cause; - readonly cleanup: SessionCleanupOutcome; - }; - -const START_CONCURRENCY = 4; - -const keyFor = (stackId: StackId, workload: PlannedWorkload): RuntimeWorkloadKey => ({ - stackId, - workloadId: workload.id, -}); - -type SessionError = RuntimeDriverError | SessionCleanupError; - -const combine = ( - primary: Cause.Cause, - cleanup: Cause.Cause, -): Cause.Cause => - cleanup.reasons.length === 0 ? primary : Cause.combine(primary, cleanup); - -const joinExit = (result: Exit.Exit): Effect.Effect => - Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - -/** - * Owns only the workloads started by the current Supervisor session. A launch starts - * dependency-ready workloads as soon as their prerequisites complete and a failure cleans - * that attempt's resources. - */ -export const makeSessionLauncher = (options: { - readonly stackId: StackId; - readonly driver: RuntimeDriver; -}): Effect.Effect => - Effect.gen(function* () { - const session = yield* Ref.make>([]); - const cleanup = ( - entries: ReadonlyArray, - ): Effect.Effect => - Effect.gen(function* () { - let cleanupCause: Cause.Cause = Cause.empty; - for (const entry of [...entries].reverse()) { - const stopped = yield* Effect.exit(options.driver.stop(entry.key)); - if (Exit.isFailure(stopped)) cleanupCause = combine(cleanupCause, stopped.cause); - const removed = yield* Effect.exit(options.driver.remove(entry.key)); - if (Exit.isFailure(removed)) cleanupCause = combine(cleanupCause, removed.cause); - else - yield* Ref.update(session, (current) => - current.filter((candidate) => candidate.key.workloadId !== entry.key.workloadId), - ); - } - return cleanupCause.reasons.length === 0 - ? ({ _tag: "proven" } satisfies SessionCleanupOutcome) - : ({ _tag: "unproven", cause: cleanupCause } satisfies SessionCleanupOutcome); - }); - - const launch = ( - plan: ExecutionPlan, - cancellation?: Deferred.Deferred, - ): Effect.Effect => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const cancel = cancellation ?? (yield* Deferred.make()); - const attempted: SessionWorkload[] = []; - const planEntries = plan.workloads.map((workload) => ({ - key: keyFor(options.stackId, workload), - workload, - })); - const ready = new Set((yield* Ref.get(session)).map(({ key }) => key.workloadId)); - const remaining = planEntries.filter((entry) => !ready.has(entry.workload.id)); - const unresolved = new Set(remaining.map((entry) => entry.workload.id)); - const graphReady = new Set(ready); - // Reject cycles before creating fibers that would otherwise wait forever. - while (unresolved.size > 0) { - const completed = [...unresolved].filter((id) => { - const entry = planEntries.find((candidate) => candidate.workload.id === id); - return ( - entry !== undefined && - entry.workload.dependencies.every((dependency) => graphReady.has(dependency)) - ); - }); - if (completed.length === 0) { - const failure = new RuntimeDriverError({ - message: "No workload is ready to start; dependencies are unsatisfied", - stackId: options.stackId, - }); - const cleaned = yield* cleanup(attempted); - return { - _tag: "failed", - cause: Match.value(cleaned).pipe( - Match.when({ _tag: "unproven" }, (value) => - combine(Cause.fail(failure), value.cause), - ), - Match.when({ _tag: "proven" }, () => Cause.fail(failure)), - Match.exhaustive, - ), - cleanup: cleaned, - } satisfies SessionLaunchOutcome; - } - completed.forEach((id) => unresolved.delete(id)); - completed.forEach((id) => graphReady.add(id)); - } - const startPermit = yield* Semaphore.make(START_CONCURRENCY); - const completions = new Map< - string, - Deferred.Deferred, never> - >(); - for (const entry of remaining) - completions.set( - entry.workload.id, - yield* Deferred.make>(), - ); - const startOne = (entry: SessionWorkload): Effect.Effect => { - const startBody = Effect.gen(function* () { - yield* Effect.forEach( - entry.workload.dependencies, - (dependency) => { - if (ready.has(dependency)) return Effect.void; - const completion = completions.get(dependency); - if (completion === undefined) - return Effect.fail( - new RuntimeDriverError({ - message: `Dependency ${dependency} is not part of the launch plan`, - stackId: options.stackId, - workloadId: entry.workload.id, - }), - ); - return Deferred.await(completion).pipe(Effect.flatMap(joinExit)); - }, - { discard: true }, - ); - yield* startPermit.withPermit( - Effect.gen(function* () { - // Record before entering the driver: a driver may acquire a resource and then - // fail or be interrupted before its start effect returns. - attempted.push(entry); - yield* Ref.update(session, (current) => - current.some((candidate) => candidate.key.workloadId === entry.key.workloadId) - ? current - : [...current, entry], - ); - yield* options.driver.start(entry.key, entry.workload); - }), - ); - }); - const cancelled = Deferred.await(cancel).pipe( - Effect.andThen( - Effect.fail( - new RuntimeDriverError({ - message: "Launch cancelled while another lifecycle prerequisite failed", - stackId: options.stackId, - workloadId: entry.workload.id, - }), - ), - ), - ); - const completion = completions.get(entry.workload.id); - return Effect.raceFirst(startBody, cancelled).pipe( - Effect.onExit((result) => - completion === undefined ? Effect.void : Deferred.succeed(completion, result), - ), - ); - }; - const outcome = yield* Effect.exit( - restore( - Effect.forEach(remaining, startOne, { concurrency: "unbounded", discard: true }), - ), - ); - if (Exit.isSuccess(outcome)) { - return { - _tag: "started", - launch: { rollback: cleanup(attempted) }, - } satisfies SessionLaunchOutcome; - } - const cleaned = yield* cleanup(attempted); - return { - _tag: "failed", - cause: Match.value(cleaned).pipe( - Match.when({ _tag: "unproven" }, (value) => combine(outcome.cause, value.cause)), - Match.when({ _tag: "proven" }, () => outcome.cause), - Match.exhaustive, - ), - cleanup: cleaned, - } satisfies SessionLaunchOutcome; - }), - ); - const cleanupOrFail = (entries: ReadonlyArray) => - cleanup(entries).pipe( - Effect.flatMap((outcome) => - Match.value(outcome).pipe( - Match.when({ _tag: "proven" }, () => Effect.void), - Match.when({ _tag: "unproven" }, (value) => - Effect.fail(new SessionCleanupError({ cause: value.cause })), - ), - Match.exhaustive, - ), - ), - ); - const stop = Effect.suspend(() => Ref.get(session).pipe(Effect.flatMap(cleanupOrFail))); - const stopCapabilities = ( - capabilities: ReadonlySet, - ) => - Ref.get(session).pipe( - Effect.flatMap((entries) => - cleanupOrFail(entries.filter(({ workload }) => capabilities.has(workload.capability))), - ), - ); - return { - launch, - stop, - stopCapabilities, - clear: Ref.set(session, []), - } satisfies SessionLauncher; - }); diff --git a/packages/stack/src/supervisor/StatusEndpoints.ts b/packages/stack/src/supervisor/StatusEndpoints.ts new file mode 100644 index 0000000000..b128848cc3 --- /dev/null +++ b/packages/stack/src/supervisor/StatusEndpoints.ts @@ -0,0 +1,53 @@ +import { + PORT_FIELD_PROTOCOL, + type PortField, + type StackEndpoint, + type StackStatus, +} from "../public/Status.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; + +const INSTANCE_BINDINGS: Readonly> = { + sql: "database", + inspector: "functionsInspector", + studio: "studio", + pooler: "pooler", + mailUi: "mailUi", + smtp: "smtp", + pop3: "pop3", +}; + +export const portFieldForInstanceBinding = (binding: string): PortField | undefined => + INSTANCE_BINDINGS[binding]; + +const endpointFor = ( + field: PortField, + assignment: { readonly address: string; readonly port: number }, +): StackEndpoint => { + const protocol = PORT_FIELD_PROTOCOL[field]; + return { + protocol, + address: assignment.address, + port: assignment.port, + url: `${protocol}://${assignment.address}:${assignment.port}`, + }; +}; + +/** Projects stack API and designated default-instance endpoints for public status. */ +export const statusEndpointsFor = (state: PersistedStackState): StackStatus["endpoints"] => { + const endpoints: Partial> = {}; + const api = state.ports.find( + (assignment) => assignment.owner === "stack" && assignment.binding === "api", + ); + if (api !== undefined && state.listeners.api?.enabled !== false) + endpoints.api = endpointFor("api", api); + + for (const assignment of state.ports) { + if (assignment.owner !== "instance") continue; + const instance = state.registry.instances.find(({ id }) => id === assignment.instanceId); + if (instance === undefined || instance.config.enabled === false) continue; + if (state.registry.defaultInstanceIds[instance.service] !== instance.id) continue; + const field = portFieldForInstanceBinding(assignment.binding); + if (field !== undefined) endpoints[field] = endpointFor(field, assignment); + } + return endpoints; +}; diff --git a/packages/stack/src/supervisor/StatusProjection.integration.test.ts b/packages/stack/src/supervisor/StatusProjection.integration.test.ts deleted file mode 100644 index eb15a1ee88..0000000000 --- a/packages/stack/src/supervisor/StatusProjection.integration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import type { PersistedStackState } from "../state/StackState.ts"; -import { compileStack } from "../model/Compiler.ts"; -import { deriveStackId } from "../identity/Identity.ts"; -import type { ObservedWorkload } from "../runtime/RuntimeDriver.ts"; -import { ready } from "./CapabilityState.ts"; -import type { SupervisorSnapshot } from "./SupervisorState.ts"; -import { statusForSnapshot } from "./StatusProjection.ts"; - -const identity = { - projectRoot: "/tmp/status-projection", - branchContext: "ordinary-workspace", - stackName: "status-projection", -} as const; - -const snapshotFor = (): SupervisorSnapshot => { - const sessionId = Symbol("session"); - return { - stack: { _tag: "running" }, - sessionId, - plan: undefined, - capabilities: new Map([["studio", ready(sessionId, 0, false)]]), - }; -}; - -describe("status projection", () => { - it.effect("retains later workload errors when an earlier failure has no text", () => - Effect.gen(function* () { - const id = yield* deriveStackId(identity); - const compiled = yield* compileStack({ - projectRoot: identity.projectRoot, - runtime: { kind: "native" }, - config: {}, - }); - const state: PersistedStackState = { - format: "supabase-stack-state-v1", - identity, - runtime: { kind: "native" }, - desiredLifecycle: "running", - definition: compiled.definition, - ports: [], - privatePorts: [], - secrets: {}, - }; - const observed: ReadonlyArray = [ - { stackId: id, workloadId: "studio:studio", state: "failed" }, - { - stackId: id, - workloadId: "studio:pgmeta", - state: "failed", - error: "pg-meta failed", - }, - ]; - - const status = yield* statusForSnapshot( - id, - state, - { _tag: "available", workloads: observed }, - snapshotFor(), - ); - const studio = status.capabilities.find((capability) => capability.name === "studio"); - - expect(studio).toEqual({ - name: "studio", - activation: "lazy", - state: "failed", - error: "studio:pgmeta: pg-meta failed", - }); - - const allFailures = yield* statusForSnapshot( - id, - state, - { - _tag: "available", - workloads: [ - { - stackId: id, - workloadId: "studio:studio", - state: "failed", - error: "studio failed", - }, - { - stackId: id, - workloadId: "studio:pgmeta", - state: "failed", - error: "pg-meta failed", - }, - ], - }, - snapshotFor(), - ); - - expect(allFailures.capabilities.find((capability) => capability.name === "studio")).toEqual({ - name: "studio", - activation: "lazy", - state: "failed", - error: "studio:studio: studio failed; studio:pgmeta: pg-meta failed", - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/packages/stack/src/supervisor/StatusProjection.ts b/packages/stack/src/supervisor/StatusProjection.ts index 4b5be1d528..e2fa2a8f61 100644 --- a/packages/stack/src/supervisor/StatusProjection.ts +++ b/packages/stack/src/supervisor/StatusProjection.ts @@ -1,122 +1,108 @@ -import { Cause, Effect, Predicate } from "effect"; +import { Effect } from "effect"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; import type { StackId } from "../public/StackId.ts"; import { PORT_FIELD_PROTOCOL, - type ArtifactPreparationStatus, + type InstanceArtifactPreparationStatus, + type ServiceStatus, type StackStatus, } from "../public/Status.ts"; -import type { ObservedWorkload } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { recoveryForState, type SupervisorSnapshot } from "./SupervisorState.ts"; -import { publicCapabilityState } from "./CapabilityState.ts"; -import { publicPhase } from "./SupervisorTransitions.ts"; +import { portFieldForInstanceBinding, statusEndpointsFor } from "./StatusEndpoints.ts"; -export type ActualPhase = "stopped" | "starting" | "running" | "stopping" | "destroying"; - -export type ObservedStatus = - | { readonly _tag: "available"; readonly workloads: ReadonlyArray } - | { readonly _tag: "unavailable" }; - -const observedForCapability = ( - name: CapabilityName, - observed: ReadonlyArray, -): ReadonlyArray => - observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); - -const observedFailureError = (failures: ReadonlyArray): string | undefined => { - if (failures.length === 1) return failures[0]?.error; - const details = failures.flatMap((entry) => - entry.error === undefined ? [] : [`${entry.workloadId}: ${entry.error}`], - ); - return details.length === 0 ? undefined : details.join("; "); -}; - -/** Projects one authoritative Supervisor snapshot while preserving observed runtime failures. */ -export const statusForSnapshot = ( +/** Projects status from the durable registry when no supervisor is connected. */ +export const statusForPersistedState = ( id: StackId, state: PersistedStackState, - observedStatus: ObservedStatus, - snapshot: SupervisorSnapshot, - artifacts: ReadonlyArray = [], + artifacts: ReadonlyArray = [], ): Effect.Effect => Effect.sync(() => { - const definition = state.definition; - const observed: ReadonlyArray = Predicate.isTagged( - observedStatus, - "available", - ) - ? observedStatus.workloads - : []; - const observationAvailable = Predicate.isTagged(observedStatus, "available"); - const capabilities = CAPABILITY_NAMES.map((name) => { - const control = snapshot.capabilities.get(name); - const configured = definition?.capabilities[name]; - const observedEntries = observedForCapability(name, observed); - const observedFailures = observedEntries.filter((entry) => entry.state === "failed"); - const observedError = observedFailureError(observedFailures); - const projected = - control === undefined - ? configured === undefined || !configured.enabled - ? "disabled" - : "stopped" - : publicCapabilityState(control); - const observedStarting = observedEntries.some((entry) => entry.state === "starting"); - const observedUnready = observedEntries.some((entry) => entry.state !== "ready"); - const capability = - observedFailures.length > 0 && projected === "ready" - ? "failed" - : projected === "ready" && observedStarting - ? "starting" - : projected === "ready" && - observationAvailable && - observedEntries.length === 0 && - configured?.enabled === true - ? "stopped" - : projected === "ready" && observedEntries.length > 0 && observedUnready - ? "stopped" - : projected; - const cleanupError = Predicate.isTagged(control, "cleanup-failed") - ? Cause.pretty(control.cause) - : undefined; + const statusForInstance = (instance: PersistedStackState["registry"]["instances"][number]) => { + const pending = instance.pendingOperation; + const phase: ServiceStatus["phase"] = + pending?.kind === "start" || pending?.kind === "restart" + ? "starting" + : pending?.kind === "sleep" || pending?.kind === "stop" || pending?.kind === "destroy" + ? "stopping" + : instance.intent === "started" + ? "ready" + : "stopped"; + const endpoints = state.ports + .filter( + (assignment) => assignment.owner === "instance" && assignment.instanceId === instance.id, + ) + .map((assignment) => { + const field = portFieldForInstanceBinding(assignment.binding); + const protocol = field === undefined ? "http" : PORT_FIELD_PROTOCOL[field]; + return { + binding: assignment.binding, + protocol, + address: assignment.address, + port: assignment.port, + url: `${protocol}://${assignment.address}:${assignment.port}`, + availability: phase === "ready" ? ("listening" as const) : ("planned" as const), + }; + }); return { + id: instance.id, + service: instance.service, + ...(instance.name === undefined ? {} : { name: instance.name }), + enabled: instance.config.enabled, + intent: instance.intent, + phase, + activation: instance.config.activation, + ...(pending === null ? {} : { pendingOperation: { id: pending.id, kind: pending.kind } }), + endpoints, + } satisfies ServiceStatus; + }; + + const instances = state.registry.instances.map(statusForInstance); + const defaultStatus = new Map(); + for (const status of instances) + if (state.registry.defaultInstanceIds[status.service] === status.id) + defaultStatus.set(status.service, status); + const capabilities = CAPABILITY_NAMES.flatMap((name) => { + const current = defaultStatus.get(name); + if (current === undefined) return []; + const stateValue = !current.enabled + ? ("disabled" as const) + : current.phase === "ready" + ? ("ready" as const) + : current.phase === "starting" + ? ("starting" as const) + : current.phase === "stopping" + ? ("stopping" as const) + : current.phase === "dormant" + ? ("dormant" as const) + : ("stopped" as const); + return { + id: current.id, name, - activation: - configured?.activation ?? (name === "database" ? ("eager" as const) : ("lazy" as const)), - state: capability, - ...(cleanupError === undefined && observedError === undefined - ? {} - : { error: cleanupError ?? observedError }), + activation: current.activation, + state: stateValue, }; }); - const versions: Partial> = {}; - if (definition !== undefined) - for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; - const endpoints = state.ports.reduce((result, assignment) => { - const protocol = PORT_FIELD_PROTOCOL[assignment.field]; - const listener = definition?.listeners[assignment.field]; - return { - ...result, - [assignment.field]: { - protocol, - address: listener?.address ?? "127.0.0.1", - port: assignment.port, - url: `${protocol}://${listener?.address ?? "127.0.0.1"}:${assignment.port}`, - }, - }; - }, {}); - const recovery = recoveryForState(snapshot.stack); - const phase = publicPhase(snapshot.stack); + const endpoints = statusEndpointsFor(state); + const desiredLifecycle = state.registry.instances.some( + (instance) => instance.intent === "started", + ) + ? "running" + : state.registry.instances.length === 0 + ? "unconfigured" + : "stopped"; return { id, - lifecycle: - phase === "stopped" && state.desiredLifecycle === "unconfigured" ? "unconfigured" : phase, - desiredLifecycle: state.desiredLifecycle, + lifecycle: desiredLifecycle, + desiredLifecycle, runtime: state.runtime, endpoints, - versions, + versions: Object.fromEntries( + state.registry.instances + .filter((instance) => state.registry.defaultInstanceIds[instance.service] === instance.id) + .map((instance) => [instance.service, instance.config.version]), + ), capabilities, artifacts, - ...(recovery === undefined ? {} : { recovery }), + instances, } satisfies StackStatus; }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index d7f3b3bd39..bc63cd06c2 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -1,73 +1,54 @@ import { - Cause, Context, Crypto, Deferred, - Duration, Effect, - Exit, FileSystem, - Fiber, - FiberSet, - Match, - Option, Path, - Predicate, - Redacted, - Ref, - Semaphore, + PlatformError, Scope, + PubSub, + Ref, + Schema, + Stream, } from "effect"; -import { rebuildExecutionPlan } from "../model/Compiler.ts"; -import { - activeExecutionPlan, - dependencyClosure, - eagerCapabilities, - type ExecutionPlan, -} from "../model/ExecutionPlan.ts"; +import { compileServiceInstance, compileServiceRestart, compileStack } from "../model/Compiler.ts"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; -import type { StackConfig } from "../public/Config.ts"; +import type { StackRestartPayload } from "../public/Config.ts"; import { GatewayActivationError, - ContainerEngineError, InvalidLogCursorError, + OwnerRetiringError, StackLifecycleConflictError, - StackNotRunningError, - StackRuntimeError, StackCleanupError, + StackDestructionError, StackStateInvalidError, + ServiceNotFoundError, + UncertainOperationError, isStackError, - isStackErrorTag, - type StackErrorTag, type StackError, } from "../public/Errors.ts"; -import type { ArtifactPreparationStatus, StackStatus } from "../public/Status.ts"; +import type { + InstanceArtifactPreparationStatus, + ServiceStatus, + StackStatus, +} from "../public/Status.ts"; import type { StackId } from "../public/StackId.ts"; import type { LogQuery, StackLogBatch } from "../public/Logs.ts"; +import type { + AnyEffectServiceConfig, + PrepareResult, + SnapshotDescriptor, +} from "../public/Service.ts"; +import { EffectCreateServiceOptionsSchema } from "../public/Service.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; -import { - RuntimeDriverError, - type ObservedWorkload, - type RuntimeDriver, -} from "../runtime/RuntimeDriver.ts"; -import { withLeftoverPersistentDataGuidance } from "../runtime/Diagnostics.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { isMissingStateRemnantError, type StackStateStore } from "../state/StackStateStore.ts"; -import { - makeSessionLauncher, - SessionCleanupError, - type SessionCleanupOutcome, - type SessionLaunchOutcome, - type SessionLauncher, -} from "./SessionLauncher.ts"; -import { - makeLifecycleController, - type CleanupOutcome, - type LifecycleLaunchResult, - type LifecycleBackend, - type LifecycleInput, -} from "./Lifecycle.ts"; -import { EMPTY_LOG_CURSOR, selectLogBatch, type LogStore } from "./LogStore.ts"; +import type { ServiceRestartPayload } from "../public/Service.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import type { InstanceRuntimeInput, LifecycleInput } from "./Lifecycle.ts"; +import { selectLogBatch, type LogStore } from "./LogStore.ts"; import type { SupervisorIngress } from "./Ingress.ts"; import { STACK_RPC_RELEASE, @@ -75,76 +56,65 @@ import { type StackRpcError, type StackRpcHandlers, } from "../control/StackRpc.ts"; -import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; -import { statusForSnapshot, type ActualPhase, type ObservedStatus } from "./StatusProjection.ts"; -import { - recoveryForState, - type LifecycleKind, - type SupervisorSnapshot, -} from "./SupervisorState.ts"; import { - AUTH_ANON_KEY_SLOT, - AUTH_PUBLISHABLE_KEY_SLOT, - AUTH_SECRET_KEY_SLOT, - AUTH_SERVICE_ROLE_KEY_SLOT, - DATABASE_INTERNAL_PASSWORD_SLOT, -} from "../state/SecretStore.ts"; - + PrepareResultSchema, + ServiceCredentialsSchema, + ServiceDescriptorListSchema, + ServiceDescriptorSchema, + ServiceStatusSchema, + SnapshotDescriptorSchema, +} from "../control/ServiceProtocol.ts"; +import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; import type { ActivationResult } from "../gateway/Gateway.ts"; -import { makeGatewayActivity } from "../gateway/ActivityTracker.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { RpcPrefaceLease } from "../control/ControlServer.ts"; +import { statusEndpointsFor } from "./StatusEndpoints.ts"; import { - admitLifecycle, - admitActivation, - activeLifecycle, - activationGate, - beginTraffic as transitionBeginTraffic, - beginRetirement as transitionBeginRetirement, - armRetirement as transitionArmRetirement, - claimWorkloads as transitionClaimWorkloads, - completeDormantCleanup as transitionCompleteDormantCleanup, - endTraffic as transitionEndTraffic, - enterCapabilityCleanup as transitionEnterCapabilityCleanup, - initializeSession, - promoteActivationSet as transitionPromoteActivationSet, - publicPhase, - planIdleTimer as transitionPlanIdleTimer, - settleLifecycleOwner, - settleRetirementOwner, - settleActivationTerminal as transitionSettleActivationTerminal, - readySet as transitionReadySet, - setRootSet as transitionSetRootSet, - settleCapabilityCleanup as transitionSettleCapabilityCleanup, - disarmAllRetirements as transitionDisarmAllRetirements, - type ActivationOwner, - type ActivationToken, - type ActivationClaims, - type ActivationTerminalOutcome, - type ActivationExit, - type ClaimedWorkload, - type CommandResult, - type CleanupHandle, - type EndpointExit, - isCleanupCandidate, - matchesActivationOwner, - type StartupHandle, - type SettlementOwner, - type SnapshotTransition, - type TransitionNotification, -} from "./SupervisorTransitions.ts"; + makeInstanceEngine, + type InstanceEngine, + type InstanceRestartCandidate, + type RestartSharedPatch, +} from "./InstanceEngine.ts"; +import { projectServiceCredentials, projectStackCredentials } from "./ServiceCredentials.ts"; /** Runtime construction is injected so catalog/artifact resolution can evolve independently. */ export interface SupervisorRuntime { readonly driver: RuntimeDriver; readonly preflight: (input: LifecycleInput) => Effect.Effect; - /** Prepares artifacts before launching a newly selected workload closure. */ - readonly prepare: ( + /** Prepares one admitted instance and its exact workload closure. */ + readonly prepare: (input: InstanceRuntimeInput) => Effect.Effect; + /** Legacy whole-stack artifact preparation used by the current stack lifecycle path. */ + readonly prepareArtifacts: ( input: LifecycleInput, selected: ReadonlySet, ) => Effect.Effect; + /** Starts one admitted instance and returns its private backend bindings. */ + readonly start: ( + input: InstanceRuntimeInput, + ) => Effect.Effect, StackError>; + /** Stops one admitted instance while retaining durable data. */ + readonly stop: (input: InstanceRuntimeInput) => Effect.Effect; + /** Destroys one admitted instance and its exact owned data/resources. */ + readonly destroy: (input: InstanceRuntimeInput) => Effect.Effect; + /** Exports one stopped instance's owned data to a new destination. */ + readonly exportSnapshot: ( + input: InstanceRuntimeInput, + options: { readonly destination: string }, + ) => Effect.Effect; + /** Restores one stopped instance's owned data from a validated source. */ + readonly restoreSnapshot: ( + input: InstanceRuntimeInput, + options: { readonly source: string }, + ) => Effect.Effect; + /** Reads committed snapshot metadata when an owner resumes a completed restore journal. */ + readonly recoverSnapshot?: ( + input: InstanceRuntimeInput, + operation: import("../model/ServiceRegistry.ts").PersistedPendingOperation, + ) => Effect.Effect; /** Best-effort preparation of lazy artifacts after a stack reaches running. */ readonly prefetch: (state: PersistedStackState) => Effect.Effect; /** Current in-memory preparation state; completed cache entries outlive the session. */ - readonly artifacts: Effect.Effect>; + readonly artifacts: Effect.Effect>; readonly activate: ( capability: CapabilityName, input: LifecycleInput, @@ -155,17 +125,20 @@ export interface SupervisorRuntime { } export interface Supervisor { + /** Owner-scoped registry and per-instance lifecycle engine. */ + readonly instances: InstanceEngine; readonly status: Effect.Effect; + readonly followStatus: Stream.Stream; readonly start: (options?: { - readonly config?: StackConfig; + readonly services?: ReadonlyArray; }) => Effect.Effect; readonly destroy: Effect.Effect; - /** Wipes Postgres data for the running stack and bootstraps a fresh cluster. */ - readonly resetDatabase: Effect.Effect; /** Completes after a successful stop or destroy shutdown signal. */ readonly shutdown: Effect.Effect; /** Shuts down only when durable state is absent or cleanly non-running. */ readonly shutdownIfIdle: Effect.Effect; + /** Acquires a short-lived witness for an RPC connection before its first request. */ + readonly acquireRpcPreface: Effect.Effect; readonly logs: (query?: LogQuery) => Effect.Effect; readonly activate: ( capability: CapabilityName, @@ -185,1353 +158,871 @@ export type SupervisorOptions = { readonly runtime: SupervisorRuntime; }; -const RESET_DATABASE_BOUNCE_CAPABILITIES: ReadonlySet = new Set([ - "auth", - "storage", - "realtime", - "pooler", - "analytics", -]); - -const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); -const credentialsUnavailable = rpcError( - "StackNotRunningError", - "Stack credentials are unavailable", -); -const stateErrorMessage = (error: StackError | { readonly message?: string }): string => - typeof error.message === "string" ? error.message : "Stack operation failed"; - -const sessionCleanupMessage = (error: SessionCleanupError): string => { - const failure = Cause.findErrorOption(error.cause); - if (Option.isSome(failure)) { - if (failure.value instanceof RuntimeDriverError) { - const workload = - failure.value.workloadId === undefined ? "" : ` for ${failure.value.workloadId}`; - return `Session cleanup is unresolved${workload}: ${failure.value.message}`; - } - return sessionCleanupMessage(failure.value); - } - const details = Cause.pretty(error.cause); - return details.length === 0 - ? "Session cleanup is unresolved" - : `Session cleanup is unresolved: ${details}`; -}; - -const credentialHost = (address: string): string => - address.includes(":") && !address.startsWith("[") ? `[${address}]` : address; - -const mapRuntimeError = (error: unknown): StackError => { - if (error instanceof StackStateInvalidError) return error; - if (error instanceof ContainerEngineError) return error; - if (error instanceof RuntimeDriverError && isStackError(error.cause)) return error.cause; - if (error instanceof SessionCleanupError) - return new StackCleanupError({ message: sessionCleanupMessage(error), cause: error }); - return new StackRuntimeError({ - message: withLeftoverPersistentDataGuidance( - error instanceof Error ? error.message : String(error), - ), - cause: error, - }); -}; - -const mapCleanupError = (error: unknown): StackError => { - if (error instanceof StackStateInvalidError) return error; - if (error instanceof SessionCleanupError) - return new StackCleanupError({ message: sessionCleanupMessage(error), cause: error }); - return new StackCleanupError({ - message: error instanceof Error ? error.message : String(error), - cause: error, - }); -}; - -const combineCleanupOutcome = (left: CleanupOutcome, right: CleanupOutcome): CleanupOutcome => - Predicate.isTagged(left, "proven") && Predicate.isTagged(right, "proven") - ? { _tag: "proven" } - : { - _tag: "unproven", - cause: Cause.combine( - Predicate.isTagged(left, "unproven") ? left.cause : Cause.empty, - Predicate.isTagged(right, "unproven") ? right.cause : Cause.empty, - ), - }; +const rpcError = ( + tag: StackRpcError["tag"], + message: string, + fields?: Partial< + Pick< + StackRpcError, + | "stackId" + | "instanceId" + | "ownerSessionId" + | "operationId" + | "expectedCreationInputsId" + | "mutation" + | "outcome" + > + >, +): StackRpcError => ({ tag, message, ...fields }); +const stateErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Stack operation failed"; const rpcTag = (error: StackError): StackRpcError["tag"] => error._tag; -const maintenanceStackErrorTag = (error: unknown): StackErrorTag | undefined => - Predicate.hasProperty(error, "_tag") && - typeof error._tag === "string" && - isStackErrorTag(error._tag) - ? error._tag - : undefined; - -/** Compose one owner process around the durable lifecycle controller and a runtime driver. */ +const rpcErrorFor = (error: StackError): StackRpcError => + rpcError( + rpcTag(error), + stateErrorMessage(error), + error instanceof OwnerRetiringError + ? { stackId: error.stackId, ownerSessionId: error.ownerSessionId } + : error instanceof UncertainOperationError + ? { + stackId: error.stackId, + ...(error.instanceId === undefined ? {} : { instanceId: error.instanceId }), + ...(error.operationId === undefined ? {} : { operationId: error.operationId }), + ...(error.expectedCreationInputsId === undefined + ? {} + : { expectedCreationInputsId: error.expectedCreationInputsId }), + mutation: error.mutation, + } + : error instanceof StackLifecycleConflictError + ? { + ...(error.stackId === undefined ? {} : { stackId: error.stackId }), + ...(error.instanceId === undefined ? {} : { instanceId: error.instanceId }), + ...(error.outcome === undefined ? {} : { outcome: error.outcome }), + } + : error instanceof StackDestructionError && error.outcome !== undefined + ? { outcome: error.outcome } + : undefined, + ); +/** Composes one owner process around the registered instance lifecycle engine. */ export const makeSupervisor = ( options: SupervisorOptions, ): Effect.Effect => Effect.gen(function* () { const read = () => options.stateStore.read(options.stackId).pipe(Effect.provideContext(options.context)); - const initial = yield* read().pipe( - Effect.catchIf(isMissingStateRemnantError, () => - options.stateStore - .recoverRuntimeRemnant(options.stackId) - .pipe(Effect.provideContext(options.context), Effect.asVoid), - ), - ); + const initial = yield* read(); if (initial === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + + const ownerScope = yield* Effect.scope; + const statusUpdates = yield* PubSub.unbounded(); + type AdmissionMode = "accepting" | "destroying" | "retiring"; + type AdmissionState = { + readonly mode: AdmissionMode; + readonly revision: number; + readonly hasAdmitted: boolean; + readonly active: number; + readonly prefaced: number; + readonly destroyToken?: symbol; + }; + type AdmissionResult = true | AdmissionMode; + const admission = yield* Ref.make({ + mode: "accepting", + revision: 0, + hasAdmitted: false, + active: 0, + prefaced: 0, + }); const runtime = options.runtime; - const launcher: SessionLauncher = yield* makeSessionLauncher({ + const publishStatus = PubSub.publish(statusUpdates, undefined).pipe(Effect.asVoid); + const instances = yield* makeInstanceEngine({ stackId: options.stackId, - driver: runtime.driver, - }); - const machine = yield* Ref.make({ - stack: - initial.desiredLifecycle === "destroying" - ? { _tag: "destroy-required", evidence: { _tag: "persisted-intent" } } - : { _tag: "stopped", session: "uninitialized" }, - sessionId: Symbol("stack-session"), - plan: undefined, - capabilities: new Map(), + ownerSessionId: options.ownerSessionId, + stateStore: options.stateStore, + runtime, + scope: ownerScope, + context: options.context, + publishEndpoints: runtime.ingress.publish, + unpublishEndpoints: runtime.ingress.unpublish, + publishStatus, + armLazyIngress: runtime.ingress.armLazyIngress, + isInstanceWakeable: runtime.ingress.isInstanceWakeable, }); - const currentPhase = (): Effect.Effect => - Ref.get(machine).pipe(Effect.map((snapshot) => publicPhase(snapshot.stack))); - const activeCommand = (): Effect.Effect> => - Ref.get(machine).pipe(Effect.map(({ stack }) => activeLifecycle(stack))); - type ActivationHandler = ( - capability: CapabilityName, - ) => Effect.Effect; - // The ingress opens during an explicit lifecycle operation. A one-shot handoff keeps a request - // waiting for the handler instead of exposing a construction-time race. - const activationHandler = yield* Deferred.make(); - const initializeActivationInAdmission = (input: LifecycleInput) => - Effect.gen(function* () { - const eager = eagerCapabilities(input.plan); - const startup = new Map(); - for (const name of eager) - startup.set(name, { - completion: yield* Deferred.make, never>(), - operation: Symbol("startup"), - }); - const sessionId = Symbol("stack-session"); - const transition = initializeSession(yield* Ref.get(machine), input, sessionId, startup); - yield* applyTransitionInAdmission(transition); - yield* Ref.set( - idleTimeouts, - new Map( - CAPABILITY_NAMES.map((name) => [ - name, - input.definition.capabilities[name].idleTimeoutSeconds, - ]), - ), - ); - }); - const initializeActivation = (input: LifecycleInput) => - admission.withPermit(initializeActivationInAdmission(input)); - const resetForSession = (input: LifecycleInput) => initializeActivation(input); - const observe = () => - runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapRuntimeError)); - const observedForStatus = () => - Ref.get(machine).pipe( - Effect.flatMap(({ stack }) => { - const available = (workloads: ReadonlyArray): ObservedStatus => ({ - _tag: "available", - workloads, - }); - const fallback: Effect.Effect = observe().pipe( - Effect.map(available), - Effect.orElseSucceed(() => ({ _tag: "unavailable" as const })), - ); - return Match.value(stack).pipe( - Match.when({ _tag: "stopped" }, () => Effect.succeed(available([]))), - Match.when({ _tag: "running" }, () => observe().pipe(Effect.map(available))), - Match.when({ _tag: "starting", prior: { _tag: "running" } }, () => - observe().pipe(Effect.map(available)), - ), - Match.when({ _tag: "starting" }, () => fallback), - Match.when({ _tag: "stopping" }, () => fallback), - Match.when({ _tag: "destroying" }, () => fallback), - Match.when({ _tag: "stop-required" }, () => fallback), - Match.when({ _tag: "start-recovery" }, () => fallback), - Match.when({ _tag: "destroy-required" }, () => fallback), - Match.exhaustive, - ); - }), - ); - - const snapshot = (): Effect.Effect => - Effect.gen(function* () { - const state = yield* read(); - if (state === undefined) - return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - const status = yield* statusForSnapshot( - options.stackId, - state, - yield* observedForStatus(), - yield* Ref.get(machine), - yield* runtime.artifacts, - ); - return status; - }); - - // Admission rejects every overlapping lifecycle operation while execution serializes - // lifecycle and activation work against runtime access. - const admission = yield* Semaphore.make(1); - // Activation and lifecycle operations share one execution gate so they cannot race cleanup. - const execution = yield* Semaphore.make(1); - const supervisorScope = yield* Effect.scope; - const ownedFibers = yield* FiberSet.make().pipe( - Effect.provideService(Scope.Scope, supervisorScope), - ); - const backgroundPreparation = yield* Ref.make | undefined>(undefined); - const startBackgroundPreparation = (state: PersistedStackState): Effect.Effect => - Effect.gen(function* () { - const existing = yield* Ref.get(backgroundPreparation); - if (existing !== undefined) return; - const fiber = yield* Effect.forkIn(runtime.prefetch(state), supervisorScope, { - startImmediately: true, - }); - yield* Ref.set(backgroundPreparation, fiber); - }); - const joinExit = (result: Exit.Exit): Effect.Effect => - Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - const notify = (notification: TransitionNotification): Effect.Effect => - Match.value(notification).pipe( - Match.tag("endpoint", (value) => Deferred.succeed(value.completion, value.result)), - Match.tag("activation", (value) => Deferred.succeed(value.completion, value.result)), - Match.tag("stopping", (value) => Deferred.succeed(value.completion, value.result)), - Match.tag("lifecycle", (value) => Deferred.succeed(value.completion, value.result)), - Match.tag("workload", (value) => Deferred.succeed(value.completion, value.result)), - Match.exhaustive, - ); - const idleTimeouts = yield* Ref.make>(new Map()); + yield* instances.recover; + if (runtime.ingress.setInstanceActivator !== undefined) + yield* runtime.ingress.setInstanceActivator((id) => instances.start(id).pipe(Effect.asVoid)); + if (runtime.ingress.setTrafficAcquirer !== undefined) + yield* runtime.ingress.setTrafficAcquirer(instances.acquireTraffic); - const readySet = (): Effect.Effect> => - Ref.get(machine).pipe( - Effect.map( - (snapshot) => - new Set( - [...snapshot.capabilities].flatMap(([name, state]) => - Predicate.isTagged(state, "ready") ? [name] : [], - ), - ), - ), - ); - const selectedSet = (): Effect.Effect> => - Ref.get(machine).pipe( - Effect.map( - (snapshot) => - new Set( - [...snapshot.capabilities].flatMap(([name, state]) => - Predicate.isTagged(state, "ready") || Predicate.isTagged(state, "starting") - ? [name] - : [], - ), - ), - ), - ); - const setReadySetInAdmission = (names: ReadonlySet): Effect.Effect => - Effect.gen(function* () { - const transition = transitionReadySet(yield* Ref.get(machine), names); - yield* applyTransitionInAdmission(transition); + const ownerRetiring = () => + new OwnerRetiringError({ + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + message: "The stack owner is retiring", }); - const setReadySet = (names: ReadonlySet): Effect.Effect => - admission.withPermit(Effect.uninterruptible(setReadySetInAdmission(names))); - const promoteActivationSet = ( - names: ReadonlySet, - activationOwner: CapabilityName, - plan: ExecutionPlan, - ): Effect.Effect => - admission.withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - const transition = transitionPromoteActivationSet( - yield* Ref.get(machine), - names, - activationOwner, - plan, - ); - yield* applyTransitionInAdmission(transition); - }), - ), - ); - const claimWorkloadsInAdmission = ( - names: ReadonlySet, - ): Effect.Effect> => - Effect.gen(function* () { - const handles = new Map(); - for (const name of names) { - handles.set(name, { - completion: yield* Deferred.make, never>(), - operation: Symbol("dependency-startup"), + const admissionFailure = (mode: AdmissionMode): StackError => + mode === "retiring" + ? ownerRetiring() + : new StackLifecycleConflictError({ + stackId: options.stackId, + message: "The stack owner is busy destroying", }); - } - const transition = transitionClaimWorkloads(yield* Ref.get(machine), names, handles); - yield* applyTransitionInAdmission(transition); - return transition.claimed; - }); - const settleActivationTerminalInAdmission = ( - owner: ActivationOwner, - claims: ActivationClaims, - outcome: ActivationTerminalOutcome, - ): Effect.Effect => - Effect.gen(function* () { - const transition = transitionSettleActivationTerminal( - yield* Ref.get(machine), - owner, - claims, - outcome, - ); - yield* applyTransitionInAdmission(transition); - }); - const settleActivationTerminal = ( - owner: ActivationOwner, - claims: ActivationClaims, - outcome: ActivationTerminalOutcome, - ): Effect.Effect => - admission.withPermit(settleActivationTerminalInAdmission(owner, claims, outcome)); - const setRootSetInAdmission = (names: ReadonlySet): Effect.Effect => - Effect.gen(function* () { - yield* applyTransitionInAdmission(transitionSetRootSet(yield* Ref.get(machine), names)); - }); - const setRootSet = (names: ReadonlySet): Effect.Effect => - admission.withPermit(setRootSetInAdmission(names)); - const enterCapabilityCleanupInAdmission = (): Effect.Effect< - ReadonlyMap - > => - Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const handles = new Map(); - for (const [name, state] of snapshot.capabilities) - if (isCleanupCandidate(state)) - handles.set(name, { - operation: Symbol("cleanup"), - completion: yield* Deferred.make, never>(), - }); - const transition = transitionEnterCapabilityCleanup(snapshot, handles); - yield* applyTransitionInAdmission(transition); - return handles; - }); - const settleCapabilityCleanupInAdmission = ( - handles: ReadonlyMap, - result: Exit.Exit, - ): Effect.Effect => - Effect.gen(function* () { - const transition = transitionSettleCapabilityCleanup( - yield* Ref.get(machine), - result, - handles, - ); - yield* applyTransitionInAdmission(transition); - }); - const completeDormantCleanupInAdmission = (): Effect.Effect => + const acquireRpcPreface: Effect.Effect = Effect.uninterruptible( Effect.gen(function* () { - yield* applyTransitionInAdmission( - transitionCompleteDormantCleanup(yield* Ref.get(machine)), - ); - }); - const completeDormantCleanup = (): Effect.Effect => - admission.withPermit(completeDormantCleanupInAdmission()); - const appendIdleLog = (message: string): Effect.Effect => - options.runtime.logStore - .append({ - source: "supervisor", - stream: "internal", - message, - }) - .pipe( - Effect.catchTag("LogStoreError", (error) => Effect.logWarning(message, error)), - Effect.asVoid, + const result = yield* Ref.modify( + admission, + (state): readonly [AdmissionResult, AdmissionState] => + state.mode === "accepting" + ? ([ + true, + { + ...state, + revision: state.revision + 1, + prefaced: state.prefaced + 1, + }, + ] as const) + : ([state.mode, state] as const), ); - - function retireIdle( - capability: CapabilityName, - epoch: symbol, - ): Effect.Effect { - return execution.withPermit( + if (result !== true) return yield* admissionFailure(result); + let released = false; + return { + release: Effect.uninterruptible( + Effect.suspend(() => { + if (released) return Effect.void; + released = true; + return Ref.update(admission, (state) => ({ + ...state, + revision: state.revision + 1, + prefaced: Math.max(0, state.prefaced - 1), + })); + }), + ), + } satisfies RpcPrefaceLease; + }), + ); + const admit = (effect: Effect.Effect): Effect.Effect => + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const completion = yield* Deferred.make, never>(); - const operation = Symbol("retirement"); - const retire = Effect.gen(function* () { - const fenced = yield* admission.withPermit( - Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const transition = transitionBeginRetirement( - snapshot, - capability, - operation, - completion, - epoch, - ); - yield* applyTransitionInAdmission(transition); - return transition.admitted; - }), - ); - if (!fenced) return false; - yield* launcher - .stopCapabilities(new Set([capability])) - .pipe(Effect.mapError(mapCleanupError)); - return true; - }).pipe( - Effect.onExit((result) => - settleOwner({ - _tag: "retirement", - capability, - operation, - completion, - result, - }), + const result = yield* Ref.modify( + admission, + (state): readonly [AdmissionResult, AdmissionState] => + state.mode === "accepting" + ? ([ + true, + { + ...state, + revision: state.revision + 1, + hasAdmitted: true, + active: state.active + 1, + }, + ] as const) + : ([state.mode, state] as const), + ); + if (result !== true) return yield* admissionFailure(result); + return yield* restore(effect).pipe( + Effect.ensuring( + Ref.update(admission, (state) => ({ + ...state, + revision: state.revision + 1, + active: Math.max(0, state.active - 1), + })), ), ); - const stopped = yield* Effect.exit(retire); - if (Exit.isFailure(stopped)) { - const logged = yield* appendIdleLog( - `Failed to stop ${capability} after inactivity: ${Cause.pretty(stopped.cause)}`, - ).pipe(Effect.exit); - if (Exit.isFailure(logged)) - return yield* Effect.failCause(Cause.combine(stopped.cause, logged.cause)); - if (Cause.hasInterrupts(stopped.cause) || Cause.hasDies(stopped.cause)) - return yield* Effect.failCause(stopped.cause); - return false; - } - if (!stopped.value) return false; - - yield* appendIdleLog(`Stopped ${capability} after inactivity`); - return true; }), ); - } - - function armIdleTimerInAdmission(capability: CapabilityName): Effect.Effect { - return Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const timeouts = yield* Ref.get(idleTimeouts); - const plan = transitionPlanIdleTimer(snapshot, timeouts, capability); - if (plan === undefined) return; - const token = Symbol(); - yield* Effect.uninterruptibleMask(() => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const fiber = yield* Effect.forkIn( - Deferred.await(started).pipe( - Effect.andThen( - Effect.sleep(Duration.seconds(plan.timeout)).pipe( - Effect.andThen( - FiberSet.run(ownedFibers, retireIdle(capability, token), { - startImmediately: true, - }).pipe(Effect.asVoid), - ), - ), - ), - ), - supervisorScope, - { startImmediately: true }, - ); - const transition = transitionArmRetirement(snapshot, plan, token, fiber); - yield* applyTransitionInAdmission(transition); - yield* Deferred.succeed(started, undefined); - }), - ); - }); - } - - function reevaluateIdleTimersInAdmission(): Effect.Effect { - return readySet().pipe( - Effect.flatMap((capabilities) => - Effect.forEach(capabilities, armIdleTimerInAdmission, { - discard: true, - }), - ), - ); - } - - function armIdleTimer(capability: CapabilityName): Effect.Effect { - return admission.withPermit(armIdleTimerInAdmission(capability)); - } - - const cancelIdleTimers: Effect.Effect = Effect.gen(function* () { - const timers = yield* admission.withPermit( + const admitStream = (stream: Stream.Stream) => + Stream.unwrap( Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const transition = transitionDisarmAllRetirements(snapshot); - yield* applyTransitionInAdmission(transition); - return transition.timers; + const result = yield* Ref.modify( + admission, + (state): readonly [AdmissionResult, AdmissionState] => + state.mode === "accepting" + ? ([true, { ...state, revision: state.revision + 1, hasAdmitted: true }] as const) + : ([state.mode, state] as const), + ); + if (result !== true) return yield* admissionFailure(result); + return stream; }), ); - yield* Effect.forEach(timers, (fiber) => Fiber.interrupt(fiber), { - concurrency: "unbounded", - discard: true, - }); - }); - - type TrafficLease = Readonly<{ readonly sessionId: symbol }>; - const beginTraffic = (capability: CapabilityName): Effect.Effect => + const serviceStatus = (id: ServiceInstanceId) => instances.status(id); + const rootEndpoint = statusEndpointsFor; + const capabilityState = (status: ServiceStatus | undefined) => { + if (status === undefined || !status.enabled) return "disabled" as const; + return status.phase === "dormant" + ? ("dormant" as const) + : status.phase === "starting" + ? ("starting" as const) + : status.phase === "ready" + ? ("ready" as const) + : status.phase === "stopping" + ? ("stopping" as const) + : status.phase === "failed" || status.phase === "recovery" + ? ("failed" as const) + : ("stopped" as const); + }; + const snapshot = (): Effect.Effect => Effect.gen(function* () { - const acquired = yield* admission.withPermit( - Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const transition = transitionBeginTraffic(snapshot, capability); - yield* applyTransitionInAdmission(transition); - return { fiber: transition.timer, lease: { sessionId: snapshot.sessionId } }; - }), + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const statuses = yield* Effect.forEach( + state.registry.instances, + (instance) => serviceStatus(instance.id), + { concurrency: "unbounded" }, + ); + const byService = new Map(); + for (const status of statuses) { + if (state.registry.defaultInstanceIds[status.service] === status.id) + byService.set(status.service, status); + } + const capabilities = CAPABILITY_NAMES.flatMap((name) => { + const current = byService.get(name); + if (current === undefined) return []; + return { + id: current.id, + name, + activation: current.activation, + state: capabilityState(current), + ...(current.error === undefined ? {} : { error: current.error.message }), + }; + }); + const active = statuses.some( + (status) => + status.phase === "ready" || + status.phase === "starting" || + status.phase === "dormant" || + status.phase === "stopping", ); - if (acquired.fiber !== undefined) yield* Fiber.interrupt(acquired.fiber); - return acquired.lease; + const desired = state.registry.instances.some((instance) => instance.intent === "started") + ? "running" + : state.registry.instances.length === 0 + ? "unconfigured" + : "stopped"; + const lifecycle = active + ? "running" + : desired === "unconfigured" + ? "unconfigured" + : "stopped"; + const versions: Partial> = {}; + for (const instance of state.registry.instances) + if (state.registry.defaultInstanceIds[instance.service] === instance.id) + versions[instance.service] = instance.config.version; + return { + id: options.stackId, + lifecycle, + desiredLifecycle: desired, + runtime: state.runtime, + endpoints: rootEndpoint(state), + versions, + capabilities, + artifacts: yield* runtime.artifacts, + instances: statuses, + } satisfies StackStatus; }); - const endTraffic = (capability: CapabilityName, lease: TrafficLease): Effect.Effect => + const status = snapshot(); + const followStatus = Stream.unwrap( Effect.gen(function* () { - const shouldArm = yield* admission.withPermit( - Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const transition = transitionEndTraffic(snapshot, capability, lease.sessionId); - yield* applyTransitionInAdmission(transition); - return transition.shouldArm; - }), + const subscription = yield* PubSub.subscribe(statusUpdates); + const initialStatus = yield* snapshot(); + return Stream.concat( + Stream.succeed(initialStatus), + Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => snapshot())), ); - if (shouldArm) yield* armIdleTimer(capability); - }); + }), + ); - const activity = yield* makeGatewayActivity({ begin: beginTraffic, end: endTraffic }); - const ingressActivate = ( - capability: CapabilityName, - ): Effect.Effect => - Effect.gen(function* () { - // A request can reach the adopted gateway while the owning start operation is still - // installing its workloads. Wait for that shared lifecycle result before attempting lazy - // activation; otherwise the state check below would turn a valid cold request into 503. - const lifecycle = yield* activeCommand(); - if (lifecycle?.kind === "start") { - const started = yield* Deferred.await(lifecycle.result); - yield* joinExit(started); - } - const handler = yield* Deferred.await(activationHandler); - return yield* handler(capability); - }); - const ensureActivationStateAllowed = (): Effect.Effect< - PersistedStackState, - GatewayActivationError | StackError - > => + const selected = (ids: ReadonlyArray | undefined) => + ids === undefined ? instances.startAll() : instances.startAll(ids); + const start = (input?: { readonly services?: ReadonlyArray }) => + selected(input?.services).pipe(Effect.andThen(publishStatus), Effect.andThen(snapshot())); + const sleep = (input?: { readonly services?: ReadonlyArray }) => + instances + .sleepAll(input?.services) + .pipe(Effect.andThen(publishStatus), Effect.andThen(snapshot())); + const stop = (input?: { readonly services?: ReadonlyArray }) => + instances + .stopAll(input?.services) + .pipe(Effect.andThen(publishStatus), Effect.andThen(snapshot())); + const restart = (input?: StackRestartPayload): Effect.Effect => Effect.gen(function* () { const state = yield* read(); if (state === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - if (state.desiredLifecycle !== "running") - return yield* new StackNotRunningError({ - message: "Stack must be running before activation", - }); - return state; - }); - const shutdownSignal = yield* Deferred.make(); - const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); - const ensureAcceptingOperations = Deferred.poll(shutdownSignal).pipe( - Effect.flatMap((shutdown) => - Option.isNone(shutdown) - ? Effect.void - : Effect.fail( - new StackLifecycleConflictError({ - stackId: options.stackId, - message: "Stack owner is shutting down", - }), - ), - ), - ); - const submitLifecycle = ( - kind: LifecycleKind, - effect: Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const owned = yield* admission.withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - yield* ensureAcceptingOperations; - const deferred = yield* Deferred.make, never>(); - const snapshot = yield* Ref.get(machine); - const admitted = admitLifecycle(snapshot, kind, deferred, Symbol(kind)); - if (Predicate.isTagged(admitted, "rejected")) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: - admitted.reason === "stop-required" - ? "Exact runtime cleanup is required; retry stop before starting" - : admitted.reason === "destroy-required" - ? "Destructive cleanup is required; retry destroy before proceeding" - : `Lifecycle operation ${admitted.activeKind ?? kind} is already active`, - recovery: - admitted.reason === "stop-required" || admitted.reason === "destroy-required" - ? recoveryForState(snapshot.stack) - : undefined, - }); - yield* applyTransitionInAdmission(admitted); - const finish = (result: Exit.Exit) => - Effect.gen(function* () { - const operation = Exit.isSuccess(result) - ? result.value + if (input === undefined || input.services === undefined) { + if (input?.config !== undefined) { + const candidate = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config: input.config, + registry: state.registry, + }).pipe(Effect.provideContext(options.context)); + const endpointIntent = (listener: { + readonly enabled: boolean; + readonly address: string; + readonly port: "automatic" | number; + }) => + listener.enabled + ? { + address: listener.address, + port: listener.port === "automatic" ? ("auto" as const) : listener.port, + } + : { enabled: false as const }; + const listeners = input.config.listeners; + const databaseEndpoint = + listeners?.database === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.database); + const functionsInspectorEndpoint = + listeners?.functionsInspector === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.functionsInspector); + const studioEndpoint = + listeners?.studio === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.studio); + const smtpEndpoint = + listeners?.smtp === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.smtp); + const pop3Endpoint = + listeners?.pop3 === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.pop3); + const mailUiEndpoint = + listeners?.mailUi === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.mailUi); + const poolerEndpoint = + listeners?.pooler === undefined + ? undefined + : endpointIntent(candidate.definition.listeners.pooler); + const configFor = (service: CapabilityName): AnyEffectServiceConfig => { + switch (service) { + case "database": + return databaseEndpoint === undefined + ? (candidate.sourceConfig.capabilities?.database ?? {}) : { - _tag: "failed" as const, - cause: result.cause, - cleanup: { _tag: "unproven" as const, cause: result.cause }, - durable: "unsafe" as const, + ...candidate.sourceConfig.capabilities?.database, + endpoints: { sql: databaseEndpoint }, }; - yield* settleOwner({ - _tag: "lifecycle", - completion: deferred, - result: operation, - }); - return operation; - }); - let entered = false; - const owner = Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const cancelled = yield* restore(cancelIdleTimers).pipe(Effect.exit); - if (Exit.isFailure(cancelled)) { - yield* finish(Exit.failCause(cancelled.cause)); - return yield* Effect.failCause(cancelled.cause); - } - const result = yield* restore( - execution.withPermit( - Effect.uninterruptibleMask((inner) => - Effect.gen(function* () { - entered = true; - const result = yield* inner(effect).pipe(Effect.exit); - const operation = yield* finish(result); - return yield* joinExit( - Predicate.isTagged(operation, "failed") - ? Exit.failCause(operation.cause) - : Exit.succeed(operation), - ); - }), - ), - ), - ).pipe(Effect.exit); - if (!entered && Exit.isFailure(result)) - yield* finish(Exit.failCause(result.cause)); - return yield* joinExit(result); - }), - ); - const ownerFiber = yield* FiberSet.run(ownedFibers, owner, { - startImmediately: true, - }); - // Effect rc112 has no safe Fiber.poll; FiberSet returns an already-interrupted fiber when closed. - const ownerExit = yield* Effect.sync(() => ownerFiber.pollUnsafe()); - if ( - ownerExit !== undefined && - Exit.isFailure(ownerExit) && - Cause.hasInterruptsOnly(ownerExit.cause) - ) { - const conflict = new StackLifecycleConflictError({ - stackId: options.stackId, - message: "Stack owner scope is closed", - }); - const cause = Cause.fail(conflict); - yield* settleOwnerInAdmission({ - _tag: "lifecycle", - completion: deferred, - result: { - _tag: "failed", - cause, - cleanup: { _tag: "unproven", cause }, - durable: "unsafe", - }, - }); + case "functions": + return functionsInspectorEndpoint === undefined + ? (candidate.sourceConfig.capabilities?.functions ?? {}) + : { + ...candidate.sourceConfig.capabilities?.functions, + endpoints: { inspector: functionsInspectorEndpoint }, + }; + case "studio": + return studioEndpoint === undefined + ? (candidate.sourceConfig.capabilities?.studio ?? {}) + : { + ...candidate.sourceConfig.capabilities?.studio, + endpoints: { studio: studioEndpoint }, + }; + case "mail": { + const endpoints = { + ...(smtpEndpoint === undefined ? {} : { smtp: smtpEndpoint }), + ...(pop3Endpoint === undefined ? {} : { pop3: pop3Endpoint }), + ...(mailUiEndpoint === undefined ? {} : { mailUi: mailUiEndpoint }), + }; + return Object.keys(endpoints).length === 0 + ? (candidate.sourceConfig.capabilities?.mail ?? {}) + : { ...candidate.sourceConfig.capabilities?.mail, endpoints }; + } + case "pooler": + return poolerEndpoint === undefined + ? (candidate.sourceConfig.capabilities?.pooler ?? {}) + : { + ...candidate.sourceConfig.capabilities?.pooler, + endpoints: { pooler: poolerEndpoint }, + }; + case "rest": + return candidate.sourceConfig.capabilities?.rest ?? {}; + case "auth": + return candidate.sourceConfig.capabilities?.auth ?? {}; + case "realtime": + return candidate.sourceConfig.capabilities?.realtime ?? {}; + case "storage": + return candidate.sourceConfig.capabilities?.storage ?? {}; + case "analytics": + return candidate.sourceConfig.capabilities?.analytics ?? {}; } - return deferred; - }), - ), - ); - return yield* joinExit(yield* Deferred.await(owned)); - }); - - const launchBackend = ( - input: LifecycleInput, - session: "fresh" | "current", - selectedOverride?: ReadonlySet, - ): Effect.Effect => - Effect.gen(function* () { - if (session === "fresh") yield* resetForSession(input); - const selected = selectedOverride ?? (yield* selectedSet()); - const plan = activeExecutionPlan(input.plan, selected); - const reservation = yield* runtime.ingress.acquire(input); - const launchCancellation = yield* Deferred.make(); - const preparedFiber = yield* Effect.forkChild( - runtime.prepare(input, selected).pipe(Effect.exit), - { - startImmediately: true, - }, - ); - const launchFiber = yield* Effect.forkChild(launcher.launch(plan, launchCancellation), { - startImmediately: true, - }); - const first = yield* Effect.raceFirst( - Fiber.await(preparedFiber).pipe( - Effect.map((value) => ({ _tag: "prepared", value }) as const), - ), - Fiber.await(launchFiber).pipe( - Effect.map((value) => ({ _tag: "launch", value }) as const), - ), - ); - let prepared: Exit.Exit; - let launch: SessionLaunchOutcome; - const preparedExit = (value: Exit.Exit, never>) => - Exit.isSuccess(value) ? value.value : Exit.failCause(value.cause); - const launchOutcome = ( - value: Exit.Exit, - ): SessionLaunchOutcome => - Exit.isSuccess(value) - ? value.value - : { - _tag: "failed", - cause: value.cause, - cleanup: { _tag: "unproven", cause: value.cause }, - }; - if (Predicate.isTagged(first, "prepared")) { - prepared = preparedExit(first.value); - if (Exit.isFailure(prepared)) yield* Deferred.succeed(launchCancellation, undefined); - launch = launchOutcome(yield* Fiber.await(launchFiber)); - } else { - launch = launchOutcome(first.value); - if (Predicate.isTagged(launch, "failed")) { - yield* Fiber.interrupt(preparedFiber); - prepared = preparedExit(yield* Fiber.await(preparedFiber)); + }; + const candidates: InstanceRestartCandidate[] = []; + for (const instance of state.registry.instances) { + if (state.registry.defaultInstanceIds[instance.service] !== instance.id) continue; + const compiled = yield* compileServiceRestart(instance, configFor(instance.service), { + projectRoot: state.identity.projectRoot, + path: Context.get(options.context, Path.Path), + runtime: state.runtime, + }).pipe(Effect.provideContext(options.context)); + candidates.push({ + instance: compiled.instance, + secretSlots: compiled.secretSlots, + startImmediately: + candidate.definition.capabilities[instance.service].enabled && + candidate.definition.capabilities[instance.service].activation === "eager", + desiredIntent: candidate.definition.capabilities[instance.service].enabled + ? "started" + : "stopped", + previous: { state, instance }, + }); + } + const apiConfigured = input.config.listeners?.api !== undefined; + const api = candidate.definition.listeners.api; + const candidateSigning = candidate.definition.security.jwt.signing; + const security = + candidateSigning === null + ? state.security + : { + jwt: { + issuer: candidate.definition.security.jwt.issuer, + expirySeconds: candidate.definition.security.jwt.expirySeconds, + signing: candidateSigning, + }, + }; + const retainedPorts = state.ports.filter( + (assignment) => assignment.owner !== "stack" || assignment.binding !== "api", + ); + const ports = !apiConfigured + ? state.ports + : api.enabled && typeof api.port === "number" + ? [ + ...retainedPorts, + { + owner: "stack" as const, + binding: "api" as const, + address: api.address, + port: api.port, + intent: "exact" as const, + }, + ] + : retainedPorts; + const shared: RestartSharedPatch = { + preparation: candidate.definition.preparation, + security, + listeners: !apiConfigured + ? state.listeners + : candidate.definition.listeners.api.enabled + ? { + api: { + enabled: true, + address: candidate.definition.listeners.api.address, + ...(typeof candidate.definition.listeners.api.port === "number" + ? { port: candidate.definition.listeners.api.port } + : {}), + }, + } + : { api: { enabled: false } }, + ports, + secretSlots: candidate.secrets, + }; + yield* instances.restartAll(candidates, shared); } else { - prepared = preparedExit(yield* Fiber.await(preparedFiber)); + yield* instances.stopAll(); } - } - const mapSessionCleanup = (cleanup: SessionCleanupOutcome): CleanupOutcome => - Match.value(cleanup).pipe( - Match.when({ _tag: "proven" }, () => ({ _tag: "proven" as const })), - Match.when({ _tag: "unproven" }, (value) => ({ - _tag: "unproven" as const, - cause: Cause.map(value.cause, mapRuntimeError), - })), - Match.exhaustive, - ); - if (Exit.isFailure(prepared)) { - const closed = reservation.fresh - ? yield* runtime.ingress.close.pipe(Effect.mapError(mapRuntimeError), Effect.exit) - : Exit.succeed(undefined); - let cause: Cause.Cause = Cause.map(prepared.cause, mapRuntimeError); - let workloadCleanup: CleanupOutcome = { _tag: "proven" }; - if (Predicate.isTagged(launch, "started")) { - workloadCleanup = mapSessionCleanup(yield* launch.launch.rollback); - if (Predicate.isTagged(workloadCleanup, "unproven")) - cause = Cause.combine(cause, workloadCleanup.cause); - } else { - const launchCause = Cause.map(launch.cause, mapRuntimeError); - cause = Cause.combine(cause, launchCause); - workloadCleanup = mapSessionCleanup(launch.cleanup); + yield* instances.startAll(); + } else { + const ids = [...new Set(input.services)]; + const updates = input.updates ?? []; + for (const update of updates) { + if (!ids.includes(update.id)) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: update.id, + message: "Selected restart update is outside the selected instances", + }); } - const closedCleanup: CleanupOutcome = Exit.isFailure(closed) - ? { _tag: "unproven", cause: closed.cause } - : { _tag: "proven" }; - const cleanup = combineCleanupOutcome(workloadCleanup, closedCleanup); - if (Predicate.isTagged(closedCleanup, "unproven")) - cause = Cause.combine(cause, closedCleanup.cause); - return { - _tag: "failed", - cause, - cleanup, - } satisfies LifecycleLaunchResult; - } - if (Predicate.isTagged(launch, "failed")) { - const closed = reservation.fresh - ? yield* runtime.ingress.close.pipe(Effect.mapError(mapRuntimeError), Effect.exit) - : Exit.succeed(undefined); - const launchCause = Cause.map(launch.cause, mapRuntimeError); - let cause: Cause.Cause = launchCause; - const workloadCleanup = mapSessionCleanup(launch.cleanup); - const closedCleanup: CleanupOutcome = Exit.isFailure(closed) - ? { _tag: "unproven", cause: closed.cause } - : { _tag: "proven" }; - const cleanup = combineCleanupOutcome(workloadCleanup, closedCleanup); - if (Predicate.isTagged(closedCleanup, "unproven")) - cause = Cause.combine(cause, closedCleanup.cause); - return { - _tag: "failed", - cause, - cleanup, - } satisfies LifecycleLaunchResult; - } - const rollback: Effect.Effect = Effect.gen(function* () { - const workload = yield* launch.launch.rollback; - const closed = reservation.fresh - ? yield* runtime.ingress.close.pipe(Effect.mapError(mapRuntimeError), Effect.exit) - : Exit.succeed(undefined); - const workloadOutcome = mapSessionCleanup(workload); - const closedOutcome: CleanupOutcome = Exit.isSuccess(closed) - ? { _tag: "proven" } - : { _tag: "unproven", cause: closed.cause }; - return combineCleanupOutcome(workloadOutcome, closedOutcome); - }); - const opened = yield* runtime.ingress - .open(input, reservation, ingressActivate, activity) - .pipe(Effect.exit); - if (Exit.isFailure(opened)) { - const rolledBack = yield* rollback; - const cause = Predicate.isTagged(rolledBack, "unproven") - ? Cause.combine(opened.cause, rolledBack.cause) - : opened.cause; - return { - _tag: "failed", - cause, - cleanup: rolledBack, - } satisfies LifecycleLaunchResult; + if (new Set(updates.map((update) => update.id)).size !== updates.length) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Selected restart contains duplicate updates", + }); + const selected = new Set(ids); + for (const id of ids) { + const current = state.registry.instances.find((entry) => entry.id === id); + if (current === undefined) + return yield* new ServiceNotFoundError({ + instanceId: id, + message: `Service instance ${id} was not found`, + }); + if (current.pendingOperation !== null) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} already has a pending operation`, + }); + if ( + state.registry.instances.some( + (dependent) => + dependent.intent === "started" && + !selected.has(dependent.id) && + Object.values(dependent.dependencies).includes(id), + ) + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Service instance ${id} has active dependents`, + }); + } + const updatesById = new Map(updates.map((update) => [update.id, update])); + const candidates: InstanceRestartCandidate[] = []; + for (const id of ids) { + const current = state.registry.instances.find((entry) => entry.id === id); + if (current === undefined) + return yield* new ServiceNotFoundError({ + instanceId: id, + message: `Service instance ${id} was not found`, + }); + const update = updatesById.get(id); + if (update === undefined) { + candidates.push({ + instance: current, + secretSlots: [], + previous: { state, instance: current }, + }); + continue; + } + if (current.service !== update.service) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: `Restart service kind ${update.service} does not match registered ${current.service}`, + }); + if (update.config === undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + instanceId: id, + message: "Selected restart update requires service configuration", + }); + const compiled = yield* compileServiceRestart(current, update.config, { + projectRoot: state.identity.projectRoot, + path: Context.get(options.context, Path.Path), + runtime: state.runtime, + }).pipe(Effect.provideContext(options.context)); + candidates.push({ + instance: compiled.instance, + secretSlots: compiled.secretSlots, + previous: { state, instance: current }, + }); + } + yield* instances.restartAll(candidates); } - return { _tag: "started", rollback } satisfies LifecycleLaunchResult; - }); - - const cleanupRuntime = (destroy: boolean): Effect.Effect => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - let handles: ReadonlyMap | undefined; - const result = yield* restore( - Effect.gen(function* () { - yield* admission.withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - handles = yield* enterCapabilityCleanupInAdmission(); - }), + yield* publishStatus; + return yield* snapshot(); + }).pipe( + Effect.provideContext(options.context), + Effect.mapError((error) => + isStackError(error) + ? error + : new StackStateInvalidError({ + stackId: options.stackId, + message: `Invalid restart request: ${String(error)}`, + cause: error, + }), + ), + ); + const destroy = ( + services?: ReadonlyArray, + ): Effect.Effect => + Effect.suspend(() => { + const destroyToken = Symbol("destroy-operation"); + return Effect.uninterruptible( + Effect.gen(function* () { + if (services === undefined) { + const result = yield* Ref.modify( + admission, + (state): readonly [AdmissionResult, AdmissionState] => + state.mode === "accepting" && state.active === 0 + ? ([ + true, + { + ...state, + mode: "destroying" as const, + destroyToken, + hasAdmitted: true, + revision: state.revision + 1, + }, + ] as const) + : ([state.mode, state] as const), + ); + if (result !== true) return yield* admissionFailure(result); + } + yield* instances.destroyAll(services); + if (services === undefined) + yield* runtime.driver.cleanup({ stackId: options.stackId, destroy: true }).pipe( + Effect.mapError( + (error) => + new StackCleanupError({ + message: "Unable to clean up stack runtime resources", + cause: error, + }), ), ); - const background = yield* Ref.get(backgroundPreparation); - if (background !== undefined) yield* Fiber.interrupt(background); - yield* Ref.set(backgroundPreparation, undefined); - const ingress = yield* runtime.ingress.close.pipe( - Effect.mapError(mapCleanupError), - Effect.exit, + if (services === undefined) + yield* options.stateStore + .cleanup(options.stackId) + .pipe(Effect.provideContext(options.context)); + if (services === undefined) + yield* Ref.update(admission, (state) => + state.mode === "destroying" && state.destroyToken === destroyToken + ? { + ...state, + mode: "retiring" as const, + destroyToken: undefined, + revision: state.revision + 1, + } + : state, ); - const launched = destroy - ? Exit.succeed(undefined) - : yield* launcher.stop.pipe(Effect.mapError(mapCleanupError), Effect.exit); - const driver = yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy }) - .pipe(Effect.mapError(mapCleanupError), Effect.exit); - let cause: Cause.Cause = Cause.empty; - for (const outcome of [ingress, launched, driver]) - if (Exit.isFailure(outcome)) cause = Cause.combine(cause, outcome.cause); - if (cause.reasons.length > 0) return yield* Effect.failCause(cause); - }), - ).pipe(Effect.exit); - if (handles !== undefined) - yield* admission.withPermit(settleCapabilityCleanupInAdmission(handles, result)); - if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); - yield* completeDormantCleanup(); - if (destroy) yield* launcher.clear; - yield* setRootSet(new Set()); - }), - ); - const backend: LifecycleBackend = { - preflight: runtime.preflight, - launch: launchBackend, - cleanup: cleanupRuntime(false), - destroyData: cleanupRuntime(true), - }; - const controller = yield* makeLifecycleController({ - stackId: options.stackId, - runtime: initial.runtime, - stateStore: options.stateStore, - backend, - }).pipe(Effect.provideContext(options.context)); - const status = snapshot(); + yield* publishStatus; + }).pipe( + Effect.tapError((error) => + !(error instanceof StackCleanupError || error instanceof UncertainOperationError) + ? Ref.update(admission, (state) => + state.mode === "destroying" && state.destroyToken === destroyToken + ? { + ...state, + mode: "accepting" as const, + destroyToken: undefined, + revision: state.revision + 1, + } + : state, + ) + : Effect.void, + ), + ), + ); + }); + const logs = (query?: LogQuery) => + runtime.logStore + .read(query?.cursor === undefined ? undefined : { cursor: query.cursor }) + .pipe( + Effect.mapError((error) => + error instanceof InvalidLogCursorError + ? error + : new StackStateInvalidError({ message: error.message, cause: error }), + ), + Effect.map((scanned) => ({ ...selectLogBatch(scanned, query), running: true })), + ); - type PreparedActivation = Readonly<{ - readonly input: LifecycleInput; - readonly selected: ReadonlySet; - }>; - const prepareActivation = ( - owner: ActivationOwner, - ): Effect.Effect => + const credentials: Effect.Effect = read().pipe( + Effect.flatMap((state) => + state === undefined + ? Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })) + : projectStackCredentials(state), + ), + ); + + const activate: Supervisor["activate"] = (capability) => Effect.gen(function* () { - const state = yield* ensureActivationStateAllowed(); - const definition = state.definition; - if (definition === undefined || !definition.capabilities[owner.capability].enabled) + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const id = state.registry.defaultInstanceIds[capability]; + if (id === undefined) return yield* new GatewayActivationError({ - message: `Capability ${owner.capability} is not enabled`, + message: `Capability ${capability} is not enabled`, + }); + const current = yield* instances.start(id); + const endpoint = current.endpoints.find((entry) => entry.availability === "listening"); + if (endpoint === undefined) + return yield* new GatewayActivationError({ + message: `Service ${id} has no listening endpoint`, }); - const plan = yield* rebuildExecutionPlan(state.runtime, definition).pipe( - Effect.provideContext(options.context), - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), - ); return { - input: { - stackId: options.stackId, - state, - definition, - secrets: state.secrets, - plan, - }, - selected: new Set([ - ...(yield* readySet()), - ...dependencyClosure(plan, [owner.capability]), - ]), + capability, + instanceId: id, + endpoint: { host: endpoint.address, port: endpoint.port }, }; }); - const runActivationOwner = (owner: ActivationOwner): Effect.Effect => - Effect.gen(function* () { - let entered = false; - let claims: ActivationClaims = { _tag: "none" }; - const activation = Effect.gen(function* () { - const fence = yield* admission.withPermit( - Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - return { - ownerMatches: matchesActivationOwner(snapshot, owner), - gate: activationGate(snapshot, options.stackId), - }; - }), - ); - if (!fence.ownerMatches) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: Predicate.isTagged(owner, "endpoint") - ? "Endpoint activation was superseded by a lifecycle transition" - : "Lazy activation was superseded by a lifecycle transition", - }); - if (Predicate.isTagged(fence.gate, "rejected")) return yield* fence.gate.error; - - const prepared = yield* prepareActivation(owner); - yield* admission.withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - const claimed = yield* claimWorkloadsInAdmission(prepared.selected); - claims = { - _tag: "claimed", - claimed, - affected: prepared.selected, - }; - return claimed; - }), - ), - ); - const launched = yield* launchBackend(prepared.input, "current", prepared.selected).pipe( - Effect.exit, - ); - if (Exit.isFailure(launched)) - return { - _tag: "failed" as const, - cause: launched.cause, - cleanup: { _tag: "unproven" as const, cause: launched.cause }, - } satisfies ActivationTerminalOutcome; - if (Predicate.isTagged(launched.value, "failed")) - return { - _tag: "failed" as const, - cause: launched.value.cause, - cleanup: launched.value.cleanup, - } satisfies ActivationTerminalOutcome; - yield* setReadySet(prepared.selected); - const activated = yield* runtime - .activate(owner.capability, prepared.input) - .pipe(Effect.exit); - if (Exit.isFailure(activated)) { - const rolledBack = yield* launched.value.rollback; - const cause = Predicate.isTagged(rolledBack, "unproven") - ? Cause.combine(activated.cause, rolledBack.cause) - : activated.cause; - return { - _tag: "failed" as const, - cause, - cleanup: rolledBack, - } satisfies ActivationTerminalOutcome; - } - yield* promoteActivationSet(prepared.selected, owner.capability, prepared.input.plan); - return { - _tag: "succeeded" as const, - value: { capability: owner.capability, endpoint: activated.value }, - } satisfies ActivationTerminalOutcome; - }); - const fiber = Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const result = yield* restore( - execution.withPermit( - Effect.uninterruptibleMask((inner) => - Effect.gen(function* () { - entered = true; - const result = yield* inner(activation).pipe(Effect.exit); - const outcome: ActivationTerminalOutcome = Exit.isSuccess(result) - ? result.value - : { - _tag: "failed", - cause: result.cause, - cleanup: Predicate.isTagged(claims, "none") - ? { _tag: "proven" } - : { _tag: "unproven", cause: result.cause }, - }; - yield* settleActivationTerminal(owner, claims, outcome); - return yield* Predicate.isTagged(outcome, "failed") - ? Effect.failCause(outcome.cause) - : Effect.succeed(outcome.value); - }), - ), - ), - ).pipe(Effect.exit); - if (!entered && Exit.isFailure(result)) - yield* settleActivationTerminal( - owner, - { _tag: "none" }, - { _tag: "failed", cause: result.cause, cleanup: { _tag: "proven" } }, - ); - return yield* joinExit(result); - }), - ); - const ownerFiber = yield* FiberSet.run(ownedFibers, fiber, { startImmediately: true }); - const ownerExit = yield* Effect.sync(() => ownerFiber.pollUnsafe()); - if ( - ownerExit !== undefined && - Exit.isFailure(ownerExit) && - Cause.hasInterruptsOnly(ownerExit.cause) - ) - yield* settleActivationTerminalInAdmission( - owner, - { _tag: "none" }, - { - _tag: "failed", - cause: Cause.fail( - new StackLifecycleConflictError({ + const toRpc = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.mapError((error) => + rpcErrorFor( + isStackError(error) + ? error + : new StackStateInvalidError({ stackId: options.stackId, - message: "Stack owner scope is closed", + message: error instanceof Error ? error.message : String(error), + cause: error, }), - ), - cleanup: { _tag: "proven" }, - }, - ); - }); - const applyTransitionInAdmission = (transition: SnapshotTransition): Effect.Effect => - Effect.gen(function* () { - yield* Ref.set(machine, transition.snapshot); - if (transition.reconcile === "all-ready") yield* reevaluateIdleTimersInAdmission(); - yield* Effect.forEach(transition.notifications, notify, { discard: true }); - }); - const settleOwnerInAdmission = (owner: SettlementOwner): Effect.Effect => - Effect.gen(function* () { - const snapshot = yield* Ref.get(machine); - const settlement = Match.value(owner).pipe( - Match.when({ _tag: "lifecycle" }, (event) => settleLifecycleOwner(snapshot, event)), - Match.when({ _tag: "retirement" }, (event) => settleRetirementOwner(snapshot, event)), - Match.exhaustive, - ); - yield* applyTransitionInAdmission(settlement); - }); - const settleOwner = (owner: SettlementOwner): Effect.Effect => - admission.withPermit(settleOwnerInAdmission(owner)); - const activate: Supervisor["activate"] = (capability) => - Effect.gen(function* () { - const token = yield* admission.withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - const decision = admitActivation( - yield* Ref.get(machine), - capability, - options.stackId, - ); - if (Predicate.isTagged(decision, "rejected")) return yield* decision.error; - if (Predicate.isTagged(decision, "respond")) return decision.token; - if (Predicate.isTagged(decision, "endpoint-owner")) { - const endpoint = yield* Deferred.make(); - const owner: ActivationOwner = { - _tag: "endpoint", - capability: decision.capability, - endpoint, - priorRoot: decision.priorRoot, - }; - yield* applyTransitionInAdmission(decision.transition(endpoint)); - yield* runActivationOwner(owner); - return { - _tag: "endpoint", - capability, - result: endpoint, - } satisfies ActivationToken; - } - const activation = yield* Deferred.make(); - const owner: ActivationOwner = { - _tag: "activation", - capability: decision.capability, - completion: activation, - }; - yield* applyTransitionInAdmission( - decision.transition(Symbol("activation"), activation), - ); - yield* runActivationOwner(owner); - return { _tag: "deferred", result: activation } satisfies ActivationToken; - }), - ), - ); - return yield* Match.value(token).pipe( - Match.when({ _tag: "deferred" }, (event) => - Deferred.await(event.result).pipe(Effect.flatMap(joinExit)), - ), - Match.when({ _tag: "await" }, (event) => - Effect.gen(function* () { - const completed = yield* Deferred.await(event.result); - if (Exit.isFailure(completed)) return yield* Effect.failCause(completed.cause); - return yield* activate(capability); - }), ), - Match.when({ _tag: "endpoint" }, (event) => - Deferred.await(event.result).pipe( - Effect.flatMap(joinExit), - Effect.map((endpoint) => ({ capability: event.capability, endpoint })), + ), + ); + const decodeRpc = ( + schema: S, + effect: Effect.Effect, + ): Effect.Effect => { + return effect.pipe( + Effect.flatMap((value) => + Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + stackId: options.stackId, + message: `Invalid RPC result: ${String(error)}`, + cause: error, + }), ), ), - Match.when({ _tag: "exit" }, (event) => joinExit(event.result)), - Match.exhaustive, - ); - }); - yield* Deferred.succeed(activationHandler, activate); - - const startOperation = (startOptions?: { - readonly config?: StackConfig; - }): Effect.Effect => - Effect.gen(function* () { - const admitted = (yield* Ref.get(machine)).stack; - if (Predicate.isTagged(admitted, "start-recovery")) - return { - _tag: "failed", - cause: admitted.cause, - cleanup: { _tag: "unproven", cause: admitted.cause }, - durable: "unsafe", - } satisfies CommandResult; - const freshSession = - Predicate.isTagged(admitted, "starting") && - Predicate.isTagged(admitted.prior, "stopped") && - admitted.prior.session === "uninitialized"; - if (freshSession) { - const cleaned = yield* backend.cleanup.pipe(Effect.exit); - if (Exit.isFailure(cleaned)) { - return yield* Effect.failCause(cleaned.cause); - } - } - const started = yield* controller - .start({ - config: startOptions?.config, - freshSession, - }) - .pipe(Effect.provideContext(options.context), Effect.exit); - if (Exit.isFailure(started)) { - return { - _tag: "failed", - cause: started.cause, - cleanup: { _tag: "unproven", cause: started.cause }, - durable: "unsafe", - } satisfies CommandResult; - } - if (Predicate.isTagged(started.value, "failed")) { - return { - _tag: "failed", - cause: started.value.cause, - cleanup: started.value.cleanup, - durable: started.value.durable, - } satisfies CommandResult; - } - yield* setReadySet(yield* selectedSet()); - yield* startBackgroundPreparation(started.value.state); - return { _tag: "succeeded" } satisfies CommandResult; - }); - const start = (startOptions?: { readonly config?: StackConfig }) => - Effect.gen(function* () { - yield* submitLifecycle("start", startOperation(startOptions)); - return yield* snapshot(); - }); - const resetDatabaseOperation = (): Effect.Effect => + ), + Effect.mapError((error) => + rpcErrorFor( + isStackError(error) + ? error + : new StackStateInvalidError({ + stackId: options.stackId, + message: `Invalid RPC result: ${String(error)}`, + cause: error, + }), + ), + ), + ); + }; + const servicesCreate = (payload: unknown) => Effect.gen(function* () { - const notRunning = new StackNotRunningError({ - stackId: options.stackId, - message: "Stack is not running", - }); - const rejectNotRunning = { - _tag: "failed" as const, - cause: Cause.fail(notRunning), - cleanup: { _tag: "proven" as const }, - durable: "stopped" as const, - } satisfies CommandResult; - const failedWithoutMutation = (cause: Cause.Cause): CommandResult => ({ - _tag: "failed", - cause, - cleanup: { _tag: "proven" }, - durable: "unsafe", - }); - const failedAfterMutation = (cause: Cause.Cause): CommandResult => ({ - _tag: "failed", - cause, - cleanup: { _tag: "unproven", cause }, - durable: "unsafe", - }); - const control = (yield* Ref.get(machine)).stack; - if ( - !Predicate.isTagged(control, "starting") || - !Predicate.isTagged(control.prior, "running") - ) - return rejectNotRunning; const state = yield* read(); - if (state === undefined || state.definition === undefined) - return failedWithoutMutation( - Cause.fail(new StackStateInvalidError({ message: "Stack state is missing" })), - ); - const status = yield* snapshot(); - const database = status.capabilities.find((capability) => capability.name === "database"); - if (database?.state !== "ready") - return { - ...rejectNotRunning, - cause: Cause.fail( - new StackNotRunningError({ - stackId: options.stackId, - message: "Database is not running", + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const input = yield* Schema.decodeUnknownEffect(EffectCreateServiceOptionsSchema)( + payload, + ).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ + message: "Invalid service creation request", + cause: error, }), - ), - }; - const plan = - (yield* Ref.get(machine)).plan ?? - (yield* rebuildExecutionPlan(state.runtime, state.definition).pipe( - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), - )); - const bounceNames = new Set( - status.capabilities.flatMap((capability) => - capability.state === "ready" && RESET_DATABASE_BOUNCE_CAPABILITIES.has(capability.name) - ? [capability.name] - : [], ), ); - const bounce = plan.workloads.filter((workload) => bounceNames.has(workload.capability)); - const databaseWorkload = plan.workloads.find( - (workload) => workload.id === "database:database", - ); - if (databaseWorkload === undefined) - return failedWithoutMutation( - Cause.fail(new StackStateInvalidError({ message: "Database workload is missing" })), - ); - const stopped = yield* launcher - .stopCapabilities(new Set(["database", ...bounceNames])) - .pipe(Effect.mapError(mapRuntimeError), Effect.exit); - if (Exit.isFailure(stopped)) return failedAfterMutation(stopped.cause); - const wiped = yield* runtime.driver - .wipePersistentData({ stackId: options.stackId, workloadId: databaseWorkload.id }) - .pipe(Effect.mapError(mapRuntimeError), Effect.exit); - if (Exit.isFailure(wiped)) return failedAfterMutation(wiped.cause); - const launched = yield* launcher.launch({ - ...plan, - workloads: [databaseWorkload, ...bounce], - }); - if (Predicate.isTagged(launched, "failed")) { - // Wipe emptied PGDATA; persist first-create so stop → start does not skip schema init. - const launchCause = Cause.map(launched.cause, mapRuntimeError); - const current = yield* read().pipe(Effect.exit); - let cause = launchCause; - if (Exit.isFailure(current)) { - cause = Cause.combine(cause, current.cause); - } else if (current.value === undefined) { - cause = Cause.combine( - cause, - Cause.fail(new StackStateInvalidError({ message: "Stack state is missing" })), - ); - } else { - const persisted = yield* options.stateStore - .replace(options.stackId, { - ...current.value, - desiredLifecycle: "unconfigured", - }) - .pipe(Effect.provideContext(options.context), Effect.exit); - if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); - } - return failedAfterMutation(cause); - } - return { _tag: "succeeded" } satisfies CommandResult; - }); - const resetDatabase = Effect.gen(function* () { - const control = (yield* Ref.get(machine)).stack; - if (!Predicate.isTagged(control, "running")) - return yield* new StackNotRunningError({ - stackId: options.stackId, - message: "Stack is not running", - }); - yield* submitLifecycle("start", resetDatabaseOperation()); - return yield* snapshot(); - }); - const stopOperation = (): Effect.Effect => + const compiled = yield* compileServiceInstance(input, { + projectRoot: state.identity.projectRoot, + path: Context.get(options.context, Path.Path), + registry: state.registry, + runtime: state.runtime, + }).pipe(Effect.provideContext(options.context)); + return yield* instances.create(compiled.instance, compiled.secretSlots); + }).pipe(Effect.provideContext(options.context)); + const serviceRestart = (payload: ServiceRestartPayload) => Effect.gen(function* () { - const result = yield* controller.stop.pipe( - Effect.provideContext(options.context), - Effect.exit, - ); - if (Exit.isFailure(result)) - return { - _tag: "failed", - cause: result.cause, - cleanup: { _tag: "unproven", cause: result.cause }, - durable: "unsafe", - } satisfies CommandResult; - return { _tag: "succeeded" } satisfies CommandResult; - }); - const signalShutdownIfIdle = (): Effect.Effect => + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const current = state.registry.instances.find((entry) => entry.id === payload.id); + if (current === undefined) + return yield* new StackStateInvalidError({ + message: `Service instance ${payload.id} was not found`, + }); + if (payload.config === undefined) return yield* instances.restart(payload.id); + const compiled = yield* compileServiceRestart(current, payload.config, { + projectRoot: state.identity.projectRoot, + path: Context.get(options.context, Path.Path), + runtime: state.runtime, + }).pipe(Effect.provideContext(options.context)); + const candidate: InstanceRestartCandidate = { + instance: compiled.instance, + secretSlots: compiled.secretSlots, + previous: { state, instance: current }, + }; + return yield* instances.restart(payload.id, candidate); + }).pipe(Effect.provideContext(options.context)); + const serviceCredentials = ({ id }: { readonly id: ServiceInstanceId }) => Effect.gen(function* () { - const lifecycle = yield* activeCommand(); - if (lifecycle !== undefined) { - yield* Deferred.await(lifecycle.result); - return yield* signalShutdownIfIdle(); + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const instance = state.registry.instances.find((entry) => entry.id === id); + if (instance === undefined) + return yield* new ServiceNotFoundError({ + instanceId: id, + message: `Service instance ${id} was not found`, + }); + switch (instance.service) { + case "database": + return yield* projectServiceCredentials(state, instance); + case "functions": + return yield* projectServiceCredentials(state, instance); + case "storage": + return yield* projectServiceCredentials(state, instance); + default: + return { kind: "none" as const }; } - yield* admission.withPermit( - Effect.gen(function* () { - // Recheck ownership after admission: a lifecycle may have started between the - // initial observation and this critical section. Keep the permit while making the - // final state/phase decision and signalling shutdown so no new start can slip in. - if ((yield* activeCommand()) !== undefined) return; - const state = yield* read().pipe(Effect.exit); - if (Exit.isFailure(state)) return; - const machineState = (yield* Ref.get(machine)).stack; - if ( - Predicate.isTagged(machineState, "stopped") && - (state.value === undefined || - state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured") - ) - yield* signalShutdown; - }), + }).pipe(Effect.provideContext(options.context)); + const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ + servicesCreate: (payload) => + decodeRpc(ServiceDescriptorSchema, admit(servicesCreate(payload))), + servicesGet: (payload) => + decodeRpc(ServiceDescriptorSchema, admit(instances.describe(payload))), + servicesList: () => decodeRpc(ServiceDescriptorListSchema, admit(instances.list)), + serviceStatus: ({ id }) => decodeRpc(ServiceStatusSchema, admit(serviceStatus(id))), + serviceFollowStatus: ({ id }) => + admitStream(instances.followStatus(id)).pipe(Stream.mapError(rpcErrorFor)), + serviceStart: ({ id }) => decodeRpc(ServiceStatusSchema, admit(instances.start(id))), + serviceSleep: ({ id }) => decodeRpc(ServiceStatusSchema, admit(instances.sleep(id))), + serviceStop: ({ id }) => decodeRpc(ServiceStatusSchema, admit(instances.stop(id))), + serviceDestroy: ({ id }) => toRpc(admit(instances.destroy(id))), + servicePrepare: ({ id }) => decodeRpc(PrepareResultSchema, admit(instances.prepare(id))), + serviceRestart: (payload) => decodeRpc(ServiceStatusSchema, admit(serviceRestart(payload))), + serviceCredentials: (payload) => + decodeRpc(ServiceCredentialsSchema, admit(serviceCredentials(payload))), + serviceLogs: ({ id, query }) => toRpc(admit(logs({ ...query, services: [id] }))), + serviceExportSnapshot: ({ id, destination }) => + decodeRpc(SnapshotDescriptorSchema, admit(instances.exportSnapshot(id, destination))), + serviceRestoreSnapshot: ({ id, source }) => + decodeRpc(SnapshotDescriptorSchema, admit(instances.restoreSnapshot(id, source))), + status: () => toRpc(admit(status)), + followStatus: () => admitStream(followStatus).pipe(Stream.mapError(rpcErrorFor)), + credentials: () => toRpc(admit(credentials)), + start: (payload) => toRpc(admit(start(payload))), + sleep: (payload) => toRpc(admit(sleep(payload))), + stop: (payload) => toRpc(admit(stop(payload))), + restart: (payload) => toRpc(admit(restart(payload))), + destroy: ({ services }) => + toRpc(services === undefined ? destroy() : admit(destroy(services))), + logs: (query) => toRpc(admit(logs(query))), + }); + const shutdownSignal = yield* Deferred.make(); + const shutdownIfIdle = Effect.gen(function* () { + const before = yield* Ref.get(admission); + if ( + before.active > 0 || + before.prefaced > 0 || + !before.hasAdmitted || + before.mode === "destroying" + ) + return; + const state = yield* read(); + const clean = + state === undefined || + state.registry.instances.every( + (instance) => instance.intent === "stopped" && instance.pendingOperation === null, ); - }); - const shutdownIfIdle = signalShutdownIfIdle(); - const stopWithShutdown = submitLifecycle("stop", stopOperation()); - const operation = (effect: Effect.Effect) => - effect.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); - const destroyOperation: Effect.Effect = Effect.gen(function* () { - const result = yield* controller.destroy.pipe( - Effect.provideContext(options.context), - Effect.exit, + if (!clean) return; + const retired = yield* Ref.modify(admission, (current) => + current.revision === before.revision && + current.hasAdmitted && + current.active === 0 && + current.prefaced === 0 && + (current.mode === "accepting" || current.mode === "retiring") + ? ([ + true, + { ...current, mode: "retiring" as const, revision: current.revision + 1 }, + ] as const) + : ([false, current] as const), ); - if (Exit.isFailure(result)) - return { - _tag: "failed", - cause: result.cause, - cleanup: { _tag: "unproven", cause: result.cause }, - durable: "unsafe", - } satisfies CommandResult; - return { _tag: "succeeded" } satisfies CommandResult; + if (retired) { + yield* Deferred.succeed(shutdownSignal, undefined); + } + }).pipe(Effect.ignoreCause); + const stopWithShutdown = Effect.suspend(() => { + return admit(stop()); }); - const destroy = submitLifecycle("destroy", destroyOperation).pipe(Effect.asVoid); - const logs = (query?: LogQuery): Effect.Effect => - Effect.gen(function* () { - // Capture lifecycle phase before reading the log store. A stopping snapshot stays live - // and gives followers one more poll rather than racing a final batch. - const phaseAtRead = yield* currentPhase(); - const cursor = - query?.cursor?.opaque === EMPTY_LOG_CURSOR.opaque ? undefined : query?.cursor; - const scanned = yield* runtime.logStore - .read(cursor === undefined ? undefined : { cursor }) - .pipe( - Effect.mapError((error) => - error instanceof InvalidLogCursorError - ? error - : new StackStateInvalidError({ message: error.message, cause: error }), - ), - ); - const selected = selectLogBatch(scanned, query); - const running = phaseAtRead !== "stopped"; - return { - ...selected, - running, - } satisfies StackLogBatch; - }); const maintenanceHandlers = { probe: Effect.succeed({ ok: true, @@ -1541,146 +1032,24 @@ export const makeSupervisor = ( rpcRelease: STACK_RPC_RELEASE, } satisfies MaintenanceResponse), stop: stopWithShutdown.pipe( - Effect.provideContext(options.context), Effect.as({ ok: true, op: "stop" } satisfies MaintenanceResponse), - Effect.catch((error) => { - const stackErrorTag = maintenanceStackErrorTag(error); - return Effect.succeed({ + Effect.catch((error) => + Effect.succeed({ ok: false, - error: { - tag: "operation-failed", - message: stateErrorMessage(error), - ...(stackErrorTag === undefined ? {} : { stackErrorTag }), - }, - } satisfies MaintenanceResponse); - }), + error: { tag: "operation-failed", message: stateErrorMessage(error) }, + } satisfies MaintenanceResponse), + ), ), }; - const credentials: Effect.Effect = Effect.gen( - function* () { - const state = yield* read().pipe( - Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), - ); - const actualPhase = yield* currentPhase(); - if ( - state === undefined || - actualPhase !== "running" || - state.desiredLifecycle !== "running" - ) - return yield* Effect.fail(credentialsUnavailable); - - const definition = state.definition; - const databaseListener = definition?.listeners.database; - const databaseAssignment = state.ports.find(({ field }) => field === "database"); - if (definition === undefined) - return yield* Effect.fail( - rpcError("InvalidStackConfigError", "Stack credentials require a stack definition"), - ); - if ( - databaseListener === undefined || - !databaseListener.enabled || - databaseAssignment === undefined - ) - return yield* Effect.fail( - rpcError( - "InvalidStackConfigError", - "Stack credentials require an enabled database listener and assigned database port", - ), - ); - - const requiredSecret = (slot: string): Effect.Effect => { - const value = state.secrets[slot]?.value; - return value === undefined || value.length === 0 - ? Effect.fail( - rpcError("StackSecretMismatchError", "Required stack credential is unavailable"), - ) - : Effect.succeed(value); - }; - - const databasePassword = yield* requiredSecret(DATABASE_INTERNAL_PASSWORD_SLOT); - const databaseHost = credentialHost(databaseListener.address); - const databaseUrl = `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent( - databasePassword, - )}@${databaseHost}:${databaseAssignment.port}/postgres`; - - const auth = definition.capabilities.auth; - const api = auth.enabled - ? yield* Effect.gen(function* () { - const publishableKey = yield* requiredSecret(AUTH_PUBLISHABLE_KEY_SLOT); - const secretKey = yield* requiredSecret(AUTH_SECRET_KEY_SLOT); - const anonJwt = yield* requiredSecret(AUTH_ANON_KEY_SLOT); - const serviceRoleJwt = yield* requiredSecret(AUTH_SERVICE_ROLE_KEY_SLOT); - return { - publishableKey, - secretKey: Redacted.make(secretKey), - anonJwt, - serviceRoleJwt: Redacted.make(serviceRoleJwt), - }; - }) - : undefined; - - const base: EffectStackCredentials = { - database: { - url: Redacted.make(databaseUrl), - password: Redacted.make(databasePassword), - }, - ...(api === undefined ? {} : { api }), - }; - const storage = definition.capabilities.storage; - const s3 = storage.settings.s3_protocol; - if (!storage.enabled || s3 === null || s3 === undefined || s3.enabled !== true) return base; - - const apiListener = definition.listeners.api; - const apiAssignment = state.ports.find(({ field }) => field === "api"); - if (apiListener === undefined || !apiListener.enabled || apiAssignment === undefined) - return yield* Effect.fail( - rpcError( - "InvalidStackConfigError", - "Stack credentials require an enabled API listener and assigned API port", - ), - ); - const accessKeyId = s3.access_key_id; - const region = s3.region; - if ( - accessKeyId === null || - accessKeyId === undefined || - accessKeyId.length === 0 || - region === null || - region === undefined || - region.length === 0 - ) - return yield* Effect.fail( - rpcError("StackStateInvalidError", "Storage credentials are unavailable"), - ); - const secretAccessKey = yield* requiredSecret( - "secret:storage.settings.s3_protocol.secret_access_key", - ); - return { - ...base, - storage: { - endpoint: `http://${credentialHost(apiListener.address)}:${apiAssignment.port}/storage/v1/s3`, - region, - accessKeyId, - secretAccessKey: Redacted.make(secretAccessKey), - }, - } satisfies EffectStackCredentials; - }, - ); - const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ - status: () => operation(status), - credentials: () => credentials, - start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), - destroy: () => operation(destroy), - resetDatabase: () => operation(resetDatabase), - logs: (query: LogQuery) => operation(logs(query)), - }); return { + instances, status, + followStatus, start, - resetDatabase, - destroy, + destroy: destroy(), shutdown: Deferred.await(shutdownSignal), shutdownIfIdle, + acquireRpcPreface, logs, activate, maintenanceHandlers, diff --git a/packages/stack/src/supervisor/SupervisorState.ts b/packages/stack/src/supervisor/SupervisorState.ts deleted file mode 100644 index 339b20abf0..0000000000 --- a/packages/stack/src/supervisor/SupervisorState.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Cause, Match, Option, Predicate, type Deferred, type Exit } from "effect"; -import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; -import type { StackError } from "../public/Errors.ts"; -import type { StackRecovery } from "../public/Status.ts"; -import type { CapabilityName } from "../public/Capability.ts"; -import type { CapabilityState } from "./CapabilityState.ts"; - -type StableStackState = - | { readonly _tag: "stopped"; readonly session: "uninitialized" | "initialized" } - | { readonly _tag: "running" }; - -type RecoveryState = - | { - readonly _tag: "stop-required"; - readonly cause: Cause.Cause; - } - | { - readonly _tag: "destroy-required"; - readonly evidence: - | { readonly _tag: "persisted-intent" } - | { readonly _tag: "failed"; readonly cause: Cause.Cause }; - }; - -type StoppablePriorState = - | StableStackState - | Extract; - -type StartRecoveryState = { - readonly _tag: "start-recovery"; - readonly attempt: symbol; - readonly completion: LifecycleCompletion; - readonly cause: Cause.Cause; -}; - -export type LifecycleKind = "start" | "stop" | "destroy"; -type LifecycleCompletion = Deferred.Deferred, never>; - -const causeMessage = (cause: Cause.Cause, fallback: string): string => { - const error = Cause.findErrorOption(cause); - return Option.isSome(error) && error.value.message.length > 0 ? error.value.message : fallback; -}; - -export const recoveryForState = (state: StackControlState): StackRecovery | undefined => - Match.value(state).pipe( - Match.when({ _tag: "stop-required" }, (value) => ({ - operation: "stop" as const, - message: causeMessage( - value.cause, - "Runtime cleanup is required; retry stop before proceeding", - ), - })), - Match.when({ _tag: "destroy-required" }, (value) => ({ - operation: "destroy" as const, - message: Predicate.isTagged(value.evidence, "failed") - ? causeMessage( - value.evidence.cause, - "Destructive cleanup is required; retry destroy before proceeding", - ) - : "Destructive cleanup is required; retry destroy before proceeding", - })), - Match.when({ _tag: "stopped" }, () => undefined), - Match.when({ _tag: "running" }, () => undefined), - Match.when({ _tag: "starting" }, () => undefined), - Match.when({ _tag: "stopping" }, () => undefined), - Match.when({ _tag: "destroying" }, () => undefined), - Match.when({ _tag: "start-recovery" }, () => undefined), - Match.exhaustive, - ); - -export type StackControlState = - | StableStackState - | RecoveryState - | StartRecoveryState - | { - readonly _tag: "starting"; - readonly attempt: symbol; - readonly completion: LifecycleCompletion; - readonly prior: StableStackState; - } - | { - readonly _tag: "stopping"; - readonly attempt: symbol; - readonly completion: LifecycleCompletion; - readonly prior: StoppablePriorState; - } - | { - readonly _tag: "destroying"; - readonly attempt: symbol; - readonly completion: LifecycleCompletion; - readonly prior: StableStackState | RecoveryState; - }; - -export type SupervisorSnapshot = Readonly<{ - readonly stack: StackControlState; - readonly sessionId: symbol; - readonly plan: ExecutionPlan | undefined; - readonly capabilities: ReadonlyMap; -}>; - -export const isTransitioning = ( - state: StackControlState, -): state is Extract => - Predicate.isTagged("starting")(state) || - Predicate.isTagged("stopping")(state) || - Predicate.isTagged("destroying")(state) || - Predicate.isTagged("start-recovery")(state); diff --git a/packages/stack/src/supervisor/SupervisorTransitions.ts b/packages/stack/src/supervisor/SupervisorTransitions.ts deleted file mode 100644 index 76b5ba9ecc..0000000000 --- a/packages/stack/src/supervisor/SupervisorTransitions.ts +++ /dev/null @@ -1,1228 +0,0 @@ -import { Cause, Deferred, Exit, Fiber, Match, Predicate } from "effect"; -import type { ActivationResult } from "../gateway/Gateway.ts"; -import type { StackId } from "../public/StackId.ts"; -import { - GatewayActivationError, - StackLifecycleConflictError, - StackNotRunningError, - type StackError, -} from "../public/Errors.ts"; -import type { CleanupOutcome, LifecycleInput } from "./Lifecycle.ts"; -import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; -import { - dependencyClosure, - eagerCapabilities, - type ExecutionPlan, -} from "../model/ExecutionPlan.ts"; -import { - beginStarting, - beginStopping, - cleanupFailed, - completeStarting, - dormant, - dormantFromReady, - promoteStartingPrior, - ready, - restoreStarting, - type CapabilityState, -} from "./CapabilityState.ts"; -import { - isTransitioning, - type LifecycleKind, - recoveryForState, - type StackControlState, - type SupervisorSnapshot, -} from "./SupervisorState.ts"; - -type ActivationFailure = GatewayActivationError | StackError; -export type ActivationExit = Exit.Exit; -export type EndpointExit = Exit.Exit; -export type CommandResult = - | { readonly _tag: "succeeded" } - | { - readonly _tag: "failed"; - readonly cause: Cause.Cause; - readonly cleanup: CleanupOutcome; - readonly durable: "stopped" | "unsafe"; - }; -type RetirementExit = Exit.Exit; - -export type TransitionNotification = - | { - readonly _tag: "endpoint"; - readonly completion: Deferred.Deferred; - readonly result: EndpointExit; - } - | { - readonly _tag: "activation"; - readonly completion: Deferred.Deferred; - readonly result: ActivationExit; - } - | { - readonly _tag: "stopping"; - readonly completion: Deferred.Deferred, never>; - readonly result: Exit.Exit; - } - | { - readonly _tag: "lifecycle"; - readonly completion: Deferred.Deferred, never>; - readonly result: Exit.Exit; - } - | { - readonly _tag: "workload"; - readonly completion: Deferred.Deferred, never>; - readonly result: Exit.Exit; - }; - -export type SnapshotTransition = Readonly<{ - readonly snapshot: SupervisorSnapshot; - readonly notifications: ReadonlyArray; - readonly reconcile: "all-ready" | "none"; -}>; - -type TransitionState = - | Extract - | Extract - | Extract; - -type LifecycleAdmission = - | (SnapshotTransition & { readonly _tag: "accepted" }) - | { - readonly _tag: "rejected"; - readonly reason: LifecycleAdmissionReason; - readonly activeKind?: LifecycleKind; - }; - -type LifecycleAdmissionReason = "lifecycle-transition" | "stop-required" | "destroy-required"; - -/** Projects control state into the phase visible to the supervisor and public projection. */ -export const publicPhase = ( - state: StackControlState, -): "stopped" | "starting" | "running" | "stopping" | "destroying" => { - return Match.value(state).pipe( - Match.when({ _tag: "stopped" }, () => "stopped" as const), - Match.when({ _tag: "running" }, () => "running" as const), - Match.when({ _tag: "starting", prior: { _tag: "running" } }, () => "running" as const), - Match.when({ _tag: "starting" }, () => "starting" as const), - Match.tag("stopping", "start-recovery", "stop-required", () => "stopping" as const), - Match.tag("destroying", "destroy-required", () => "destroying" as const), - Match.exhaustive, - ); -}; - -type ActiveLifecycle = Readonly<{ - readonly kind: LifecycleKind; - readonly result: Deferred.Deferred, never>; -}>; - -export const activeLifecycle = (state: StackControlState): ActiveLifecycle | undefined => - Match.value(state).pipe( - Match.tag("starting", "start-recovery", (value) => ({ - kind: "start" as const, - result: value.completion, - })), - Match.tag("stopping", (value) => ({ kind: "stop" as const, result: value.completion })), - Match.tag("destroying", (value) => ({ kind: "destroy" as const, result: value.completion })), - Match.tag("stopped", "running", "stop-required", "destroy-required", () => undefined), - Match.exhaustive, - ); - -/** Decides lifecycle admission once while the supervisor admission permit is held. */ -export const admitLifecycle = ( - snapshot: SupervisorSnapshot, - kind: LifecycleKind, - completion: Deferred.Deferred, never>, - attempt: symbol, -): LifecycleAdmission => { - const state = snapshot.stack; - const activeKind = activeLifecycle(state)?.kind; - const rejected = (reason: LifecycleAdmissionReason): LifecycleAdmission => ({ - _tag: "rejected", - reason, - ...(activeKind === undefined ? {} : { activeKind }), - }); - const accepted = (next: TransitionState): LifecycleAdmission => ({ - _tag: "accepted", - snapshot: { ...snapshot, stack: next }, - notifications: [], - reconcile: "none", - }); - return Match.value(kind).pipe( - Match.when("start", () => - Match.value(state).pipe( - Match.tag("stopped", "running", (prior) => - accepted({ _tag: "starting", attempt, completion, prior }), - ), - Match.tag("stop-required", () => rejected("stop-required")), - Match.tag("destroy-required", () => rejected("destroy-required")), - Match.tag("starting", "start-recovery", "stopping", "destroying", () => - rejected("lifecycle-transition"), - ), - Match.exhaustive, - ), - ), - Match.when("stop", () => - Match.value(state).pipe( - Match.tag("stopped", "running", "stop-required", (prior) => - accepted({ _tag: "stopping", attempt, completion, prior }), - ), - Match.tag("destroy-required", () => rejected("destroy-required")), - Match.tag("starting", "start-recovery", "stopping", "destroying", () => - rejected("lifecycle-transition"), - ), - Match.exhaustive, - ), - ), - Match.when("destroy", () => - Match.value(state).pipe( - Match.tag("stopped", "running", "stop-required", "destroy-required", (prior) => - accepted({ _tag: "destroying", attempt, completion, prior }), - ), - Match.tag("starting", "start-recovery", "stopping", "destroying", () => - rejected("lifecycle-transition"), - ), - Match.exhaustive, - ), - ), - Match.exhaustive, - ); -}; - -export type ActivationToken = - | { readonly _tag: "exit"; readonly result: ActivationExit } - | { readonly _tag: "deferred"; readonly result: Deferred.Deferred } - | { - readonly _tag: "endpoint"; - readonly capability: CapabilityName; - readonly result: Deferred.Deferred; - } - | { - readonly _tag: "await"; - readonly result: Deferred.Deferred, never>; - }; - -type ActivationDecision = - | { readonly _tag: "respond"; readonly token: ActivationToken } - | { - readonly _tag: "endpoint-owner"; - readonly capability: CapabilityName; - readonly priorRoot: boolean; - readonly transition: (endpoint: Deferred.Deferred) => SnapshotTransition; - } - | { - readonly _tag: "activation-owner"; - readonly capability: CapabilityName; - readonly transition: ( - operation: symbol, - completion: Deferred.Deferred, - ) => SnapshotTransition; - } - | { readonly _tag: "rejected"; readonly error: GatewayActivationError | StackError }; - -type ActivationGate = - | { readonly _tag: "accepted" } - | { readonly _tag: "rejected"; readonly error: GatewayActivationError | StackError }; - -export const activationGate = (snapshot: SupervisorSnapshot, stackId: StackId): ActivationGate => { - const inProgress = (kind: LifecycleKind): ActivationGate => ({ - _tag: "rejected", - error: new StackLifecycleConflictError({ - stackId, - message: `Cannot activate while ${kind} is in progress`, - }), - }); - return Match.value(snapshot.stack).pipe( - Match.when({ _tag: "running" }, () => ({ _tag: "accepted" as const })), - Match.when({ _tag: "stopped" }, () => ({ - _tag: "rejected" as const, - error: new StackNotRunningError({ message: "Stack must be running before activation" }), - })), - Match.when({ _tag: "starting" }, () => inProgress("start")), - Match.when({ _tag: "stopping" }, () => inProgress("stop")), - Match.when({ _tag: "destroying" }, () => inProgress("destroy")), - Match.when({ _tag: "stop-required" }, (state) => ({ - _tag: "rejected" as const, - error: new StackLifecycleConflictError({ - stackId, - message: "Exact runtime cleanup is required; retry stop before activating", - recovery: recoveryForState(state), - }), - })), - Match.when({ _tag: "start-recovery" }, () => inProgress("start")), - Match.when({ _tag: "destroy-required" }, (state) => ({ - _tag: "rejected" as const, - error: new StackLifecycleConflictError({ - stackId, - message: "Destructive cleanup is required; retry destroy before activating", - recovery: recoveryForState(state), - }), - })), - Match.exhaustive, - ); -}; - -export const admitActivation = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, - stackId: StackId, -): ActivationDecision => { - const gate = activationGate(snapshot, stackId); - if (Predicate.isTagged(gate, "rejected")) return gate; - const current = snapshot.capabilities.get(capability); - if (current === undefined) - return { - _tag: "rejected", - error: new GatewayActivationError({ - message: `Capability ${capability} is unavailable in this session`, - }), - }; - return Match.value(current).pipe( - Match.tag("disabled", () => ({ - _tag: "rejected" as const, - error: new GatewayActivationError({ message: `Capability ${capability} is not enabled` }), - })), - Match.tag("stopped", () => ({ - _tag: "rejected" as const, - error: new StackNotRunningError({ message: "Stack must be running before activation" }), - })), - Match.tag("cleanup-failed", () => ({ - _tag: "rejected" as const, - error: new StackLifecycleConflictError({ - stackId, - message: `Capability ${capability} cleanup failed; retry stop before activating`, - }), - })), - Match.tag("starting", (state) => - Match.value(state.completion).pipe( - Match.tag("activation", (completion) => ({ - _tag: "respond" as const, - token: { _tag: "deferred" as const, result: completion.deferred }, - })), - Match.tag("workload", (completion) => ({ - _tag: "respond" as const, - token: { _tag: "await" as const, result: completion.deferred }, - })), - Match.exhaustive, - ), - ), - Match.tag("stopping", (state) => ({ - _tag: "respond" as const, - token: { _tag: "await" as const, result: state.completion }, - })), - Match.tag("ready", (state) => - Match.value(state.endpoint).pipe( - Match.tag("resolved", (endpointState) => ({ - _tag: "respond" as const, - token: { - _tag: "exit" as const, - result: Exit.succeed({ capability, endpoint: endpointState.endpoint }), - }, - })), - Match.tag("resolving", (endpointState) => ({ - _tag: "respond" as const, - token: { _tag: "endpoint" as const, capability, result: endpointState.deferred }, - })), - Match.tag("unresolved", () => { - return { - _tag: "endpoint-owner" as const, - capability, - priorRoot: state.root, - transition: (endpoint: Deferred.Deferred) => ({ - snapshot: beginEndpointResolution(snapshot, capability, endpoint), - notifications: [], - reconcile: "none" as const, - }), - }; - }), - Match.exhaustive, - ), - ), - Match.tag("dormant", (state) => { - return { - _tag: "activation-owner" as const, - capability, - transition: (operation: symbol, completion: Deferred.Deferred) => ({ - snapshot: beginActivation(snapshot, capability, state, operation, completion), - notifications: [], - reconcile: "none" as const, - }), - }; - }), - Match.exhaustive, - ); -}; - -export type ActivationOwner = - | { - readonly _tag: "endpoint"; - readonly capability: CapabilityName; - readonly endpoint: Deferred.Deferred; - readonly priorRoot: boolean; - } - | { - readonly _tag: "activation"; - readonly capability: CapabilityName; - readonly completion: Deferred.Deferred; - }; -export type ActivationClaims = - | { readonly _tag: "none" } - | { - readonly _tag: "claimed"; - readonly claimed: ReadonlyArray; - readonly affected: ReadonlySet; - }; -export type ActivationTerminalOutcome = - | { readonly _tag: "succeeded"; readonly value: ActivationResult } - | { - readonly _tag: "failed"; - readonly cause: Cause.Cause; - readonly cleanup: CleanupOutcome; - }; -type RetirementOwner = { - readonly _tag: "retirement"; - readonly capability: CapabilityName; - readonly operation: symbol; - readonly completion: Deferred.Deferred, never>; - readonly result: RetirementExit; -}; -type LifecycleOwner = { - readonly _tag: "lifecycle"; - readonly completion: Deferred.Deferred, never>; - readonly result: CommandResult; -}; -export type SettlementOwner = RetirementOwner | LifecycleOwner; - -export const matchesActivationOwner = ( - snapshot: SupervisorSnapshot, - owner: ActivationOwner, -): boolean => { - const current = snapshot.capabilities.get(owner.capability); - return Match.value(owner).pipe( - Match.tag( - "endpoint", - (event) => - Predicate.isTagged(current, "ready") && - Predicate.isTagged(current.endpoint, "resolving") && - current.endpoint.deferred === event.endpoint, - ), - Match.tag( - "activation", - (event) => - Predicate.isTagged(current, "starting") && - Predicate.isTagged(current.completion, "activation") && - current.completion.deferred === event.completion, - ), - Match.exhaustive, - ); -}; - -export type StartupHandle = Readonly<{ - readonly completion: Deferred.Deferred, never>; - readonly operation: symbol; -}>; - -export const initializeSession = ( - snapshot: SupervisorSnapshot, - input: LifecycleInput, - sessionId: symbol, - startup: ReadonlyMap, -): SnapshotTransition => { - const roots = new Set( - CAPABILITY_NAMES.filter( - (name) => - input.definition.capabilities[name].enabled && input.plan.activation[name] === "eager", - ), - ); - const eager = eagerCapabilities(input.plan); - const capabilities = new Map(); - for (const name of CAPABILITY_NAMES) { - const configured = input.definition.capabilities[name]; - const handle = startup.get(name); - if (!configured.enabled) capabilities.set(name, { _tag: "disabled" }); - else if (eager.has(name) && handle !== undefined) - capabilities.set( - name, - beginStarting( - dormant(sessionId), - handle.operation, - { _tag: "workload", deferred: handle.completion }, - roots.has(name), - ), - ); - else capabilities.set(name, dormant(sessionId)); - } - return { - snapshot: { ...snapshot, sessionId, plan: input.plan, capabilities }, - notifications: [], - reconcile: "none", - }; -}; - -export const readySet = ( - snapshot: SupervisorSnapshot, - names: ReadonlySet, -): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - const notifications: Array = []; - for (const [name, state] of capabilities) { - if (Predicate.isTagged(state, "dormant") && names.has(name)) - capabilities.set(name, ready(state.sessionId, state.traffic, state.root)); - else if ( - Predicate.isTagged(state, "starting") && - Predicate.isTagged(state.completion, "workload") && - names.has(name) - ) { - notifications.push({ - _tag: "workload", - completion: state.completion.deferred, - result: Exit.void, - }); - capabilities.set(name, completeStarting(state)); - } - } - return { snapshot: { ...snapshot, capabilities }, notifications, reconcile: "none" }; -}; - -export const promoteActivationSet = ( - snapshot: SupervisorSnapshot, - names: ReadonlySet, - activationOwner: CapabilityName, - plan: ExecutionPlan, -): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - for (const name of names) { - const state = capabilities.get(name); - if ( - name !== activationOwner && - Predicate.isTagged(state, "starting") && - Predicate.isTagged(state.completion, "activation") - ) - capabilities.set(name, promoteStartingPrior(state)); - } - return { - snapshot: { ...snapshot, plan, capabilities }, - notifications: [], - reconcile: "none", - }; -}; - -export type ClaimedWorkload = Readonly<{ - readonly name: CapabilityName; - readonly completion: Deferred.Deferred, never>; - readonly prior: Extract; -}>; - -export const claimWorkloads = ( - snapshot: SupervisorSnapshot, - names: ReadonlySet, - handles: ReadonlyMap, -): SnapshotTransition & { readonly claimed: ReadonlyArray } => { - const capabilities = new Map(snapshot.capabilities); - const claimed: Array = []; - for (const name of names) { - const current = capabilities.get(name); - const handle = handles.get(name); - if (!Predicate.isTagged(current, "dormant") || handle === undefined) continue; - claimed.push({ name, completion: handle.completion, prior: current }); - capabilities.set( - name, - beginStarting(current, handle.operation, { - _tag: "workload", - deferred: handle.completion, - }), - ); - } - return { - snapshot: { ...snapshot, capabilities }, - claimed, - notifications: [], - reconcile: "none", - }; -}; - -const beginEndpointResolution = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, - endpoint: Deferred.Deferred, -): SupervisorSnapshot => { - const current = snapshot.capabilities.get(capability); - if (!Predicate.isTagged(current, "ready")) return snapshot; - return { - ...snapshot, - capabilities: new Map(snapshot.capabilities).set(capability, { - ...current, - root: true, - endpoint: { _tag: "resolving", deferred: endpoint }, - }), - }; -}; - -const beginActivation = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, - prior: Extract, - operation: symbol, - completion: Deferred.Deferred, -): SupervisorSnapshot => ({ - ...snapshot, - capabilities: new Map(snapshot.capabilities).set( - capability, - beginStarting(prior, operation, { _tag: "activation", deferred: completion }, true), - ), -}); - -export const setRootSet = ( - snapshot: SupervisorSnapshot, - names: ReadonlySet, -): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - for (const [name, state] of capabilities) - capabilities.set( - name, - Match.value(state).pipe( - Match.tag("disabled", "stopped", (value) => value), - Match.tag("dormant", "starting", "ready", "stopping", "cleanup-failed", (value) => ({ - ...value, - root: names.has(name), - })), - Match.exhaustive, - ), - ); - return { snapshot: { ...snapshot, capabilities }, notifications: [], reconcile: "none" }; -}; - -type TrafficTransition = SnapshotTransition & - Readonly<{ - readonly timer: Fiber.Fiber | undefined; - readonly shouldArm: boolean; - }>; - -export const beginTraffic = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, -): TrafficTransition => { - const current = snapshot.capabilities.get(capability); - const timer = - Predicate.isTagged(current, "ready") && Predicate.isTagged(current.retirement, "armed") - ? current.retirement.fiber - : undefined; - if (Predicate.isTagged(current, "ready")) { - const next = { - ...current, - traffic: current.traffic + 1, - retirement: { _tag: "disarmed" as const }, - }; - return { - snapshot: { ...snapshot, capabilities: new Map(snapshot.capabilities).set(capability, next) }, - notifications: [], - reconcile: "none", - timer, - shouldArm: false, - }; - } - if ( - Predicate.isTagged(current, "dormant") || - Predicate.isTagged(current, "starting") || - Predicate.isTagged(current, "stopping") || - Predicate.isTagged(current, "cleanup-failed") - ) { - const next = { ...current, traffic: current.traffic + 1 }; - return { - snapshot: { ...snapshot, capabilities: new Map(snapshot.capabilities).set(capability, next) }, - notifications: [], - reconcile: "none", - timer: undefined, - shouldArm: false, - }; - } - return { snapshot, notifications: [], reconcile: "none", timer: undefined, shouldArm: false }; -}; - -export const endTraffic = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, - sessionId: symbol, -): TrafficTransition => { - if (snapshot.sessionId !== sessionId) - return { snapshot, notifications: [], reconcile: "none", timer: undefined, shouldArm: false }; - const current = snapshot.capabilities.get(capability); - if ( - !Predicate.isTagged(current, "dormant") && - !Predicate.isTagged(current, "starting") && - !Predicate.isTagged(current, "ready") && - !Predicate.isTagged(current, "stopping") && - !Predicate.isTagged(current, "cleanup-failed") - ) - return { snapshot, notifications: [], reconcile: "none", timer: undefined, shouldArm: false }; - const next = { ...current, traffic: Math.max(0, current.traffic - 1) }; - return { - snapshot: { ...snapshot, capabilities: new Map(snapshot.capabilities).set(capability, next) }, - notifications: [], - reconcile: "none", - timer: undefined, - shouldArm: current.traffic <= 1, - }; -}; - -const canRetire = ( - plan: ExecutionPlan, - roots: ReadonlySet, - capability: CapabilityName, -): boolean => - ![...roots].some( - (root) => root !== capability && dependencyClosure(plan, [root]).has(capability), - ); - -const rootSet = (snapshot: SupervisorSnapshot): ReadonlySet => - new Set( - [...snapshot.capabilities].flatMap(([name, state]) => - "root" in state && state.root ? [name] : [], - ), - ); - -const idleTimeout = ( - timeouts: ReadonlyMap, - plan: ExecutionPlan, - capability: CapabilityName, -): number | false => - plan.activation[capability] === "lazy" ? (timeouts.get(capability) ?? false) : false; - -type IdleCandidate = Readonly<{ - readonly capability: CapabilityName; - readonly plan: ExecutionPlan; - readonly state: Extract; -}>; - -const idleCandidate = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, -): IdleCandidate | undefined => { - if (!Predicate.isTagged(snapshot.stack, "running")) return undefined; - const plan = snapshot.plan; - const state = snapshot.capabilities.get(capability); - if (plan === undefined || !Predicate.isTagged(state, "ready") || state.traffic !== 0) - return undefined; - return canRetire(plan, rootSet(snapshot), capability) ? { capability, plan, state } : undefined; -}; - -type IdleTimerPlan = IdleCandidate & Readonly<{ readonly timeout: number }>; - -export const planIdleTimer = ( - snapshot: SupervisorSnapshot, - timeouts: ReadonlyMap, - capability: CapabilityName, -): IdleTimerPlan | undefined => { - const candidate = idleCandidate(snapshot, capability); - if (candidate === undefined || !Predicate.isTagged(candidate.state.retirement, "disarmed")) - return undefined; - const timeout = idleTimeout(timeouts, candidate.plan, capability); - return timeout === false ? undefined : { ...candidate, timeout }; -}; - -export const beginRetirement = ( - snapshot: SupervisorSnapshot, - capability: CapabilityName, - operation: symbol, - completion: Deferred.Deferred, never>, - epoch: symbol, -): SnapshotTransition & { readonly admitted: boolean } => { - const candidate = idleCandidate(snapshot, capability); - const current = snapshot.capabilities.get(capability); - if ( - candidate === undefined || - !Predicate.isTagged(candidate.state.retirement, "armed") || - candidate.state.retirement.epoch !== epoch - ) { - if ( - Predicate.isTagged(current, "ready") && - Predicate.isTagged(current.retirement, "armed") && - current.retirement.epoch === epoch - ) { - return { - snapshot: { - ...snapshot, - capabilities: new Map(snapshot.capabilities).set(capability, { - ...current, - retirement: { _tag: "disarmed" }, - }), - }, - notifications: [], - reconcile: "none", - admitted: false, - }; - } - return { snapshot, notifications: [], reconcile: "none", admitted: false }; - } - return { - snapshot: { - ...snapshot, - capabilities: new Map(snapshot.capabilities).set( - capability, - beginStopping(candidate.state, operation, completion, false), - ), - }, - notifications: [], - reconcile: "none", - admitted: true, - }; -}; - -export const armRetirement = ( - snapshot: SupervisorSnapshot, - plan: IdleTimerPlan, - epoch: symbol, - fiber: Fiber.Fiber, -): SnapshotTransition => { - return { - snapshot: { - ...snapshot, - capabilities: new Map(snapshot.capabilities).set(plan.capability, { - ...plan.state, - retirement: { _tag: "armed", epoch, fiber }, - }), - }, - notifications: [], - reconcile: "none", - }; -}; - -export const disarmAllRetirements = ( - snapshot: SupervisorSnapshot, -): SnapshotTransition & { readonly timers: ReadonlyArray> } => { - const timers: Array> = []; - const capabilities = new Map(snapshot.capabilities); - for (const [name, state] of capabilities) - if (Predicate.isTagged(state, "ready") && Predicate.isTagged(state.retirement, "armed")) { - timers.push(state.retirement.fiber); - capabilities.set(name, { ...state, retirement: { _tag: "disarmed" } }); - } - return { - snapshot: { ...snapshot, capabilities }, - notifications: [], - reconcile: "none", - timers, - }; -}; - -export type CleanupHandle = Readonly<{ - readonly operation: symbol; - readonly completion: Deferred.Deferred, never>; -}>; - -export const isCleanupCandidate = ( - state: CapabilityState, -): state is Extract => - Predicate.isTagged(state, "ready") || Predicate.isTagged(state, "cleanup-failed"); - -export const enterCapabilityCleanup = ( - snapshot: SupervisorSnapshot, - handles: ReadonlyMap, -): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - for (const [name, state] of capabilities) { - const handle = handles.get(name); - if (handle !== undefined && isCleanupCandidate(state)) - capabilities.set(name, beginStopping(state, handle.operation, handle.completion)); - } - return { snapshot: { ...snapshot, capabilities }, notifications: [], reconcile: "none" }; -}; - -export const settleCapabilityCleanup = ( - snapshot: SupervisorSnapshot, - result: Exit.Exit, - handles: ReadonlyMap, -): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - const notifications: Array = []; - for (const [name, state] of capabilities) { - if (!Predicate.isTagged(state, "stopping")) continue; - const handle = handles.get(name); - if ( - handle === undefined || - handle.operation !== state.operation || - handle.completion !== state.completion - ) - continue; - capabilities.set( - name, - Exit.isSuccess(result) ? { _tag: "stopped" } : cleanupFailed(state, result.cause), - ); - notifications.push({ _tag: "stopping", completion: state.completion, result }); - } - return { snapshot: { ...snapshot, capabilities }, notifications, reconcile: "none" }; -}; - -export const completeDormantCleanup = (snapshot: SupervisorSnapshot): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - for (const [name, state] of capabilities) - if (Predicate.isTagged(state, "dormant")) capabilities.set(name, { _tag: "stopped" }); - return { snapshot: { ...snapshot, capabilities }, notifications: [], reconcile: "none" }; -}; - -const settleStartingCapabilities = ( - snapshot: SupervisorSnapshot, - cause: Cause.Cause, - cleanup: CleanupOutcome, - durable: "stopped" | "unsafe", -): SnapshotTransition => { - const capabilities = new Map(snapshot.capabilities); - const result = Exit.failCause(cause); - const notifications: Array = []; - for (const [name, state] of capabilities) { - if (!Predicate.isTagged(state, "starting") || !Predicate.isTagged(state.completion, "workload")) - continue; - capabilities.set( - name, - Predicate.isTagged(cleanup, "proven") - ? durable === "stopped" - ? { _tag: "stopped" } - : state.prior - : cleanupFailed(state, cause), - ); - notifications.push({ _tag: "workload", completion: state.completion.deferred, result }); - } - return { snapshot: { ...snapshot, capabilities }, notifications, reconcile: "none" }; -}; - -/** Applies one activation terminal event, including claims, roots and owner completion. */ -export const settleActivationTerminal = ( - snapshot: SupervisorSnapshot, - owner: ActivationOwner, - claims: ActivationClaims, - outcome: ActivationTerminalOutcome, -): SnapshotTransition => { - const failed = Predicate.isTagged(outcome, "failed"); - const result = failed ? Exit.failCause(outcome.cause) : Exit.void; - const ownerMatches = matchesActivationOwner(snapshot, owner); - const notification: TransitionNotification = Predicate.isTagged(owner, "endpoint") - ? { - _tag: "endpoint", - completion: owner.endpoint, - result: !failed ? Exit.succeed(outcome.value.endpoint) : Exit.failCause(outcome.cause), - } - : { - _tag: "activation", - completion: owner.completion, - result: !failed ? Exit.succeed(outcome.value) : Exit.failCause(outcome.cause), - }; - if (!ownerMatches) return { snapshot, notifications: [notification], reconcile: "all-ready" }; - - const cleanup = failed ? outcome.cleanup : { _tag: "proven" as const }; - const claimed = Predicate.isTagged(claims, "claimed") ? claims.claimed : []; - const affected = Predicate.isTagged(claims, "claimed") - ? claims.affected - : new Set(); - const capabilities = new Map(snapshot.capabilities); - const notifications: Array = []; - if (failed) { - for (const entry of claimed) { - const current = capabilities.get(entry.name); - if ( - Predicate.isTagged(current, "starting") && - Predicate.isTagged(current.completion, "workload") && - current.completion.deferred === entry.completion - ) { - capabilities.set( - entry.name, - Predicate.isTagged(cleanup, "unproven") - ? cleanupFailed(current, cleanup.cause) - : dormant(entry.prior.sessionId, current.traffic, entry.prior.root), - ); - notifications.push({ _tag: "workload", completion: entry.completion, result }); - } else if ( - Predicate.isTagged(current, "ready") && - current.sessionId === entry.prior.sessionId - ) { - capabilities.set( - entry.name, - Predicate.isTagged(cleanup, "unproven") - ? cleanupFailed(current, cleanup.cause) - : dormantFromReady(current), - ); - } - } - if (Predicate.isTagged(cleanup, "unproven")) - for (const name of affected) { - if (name === owner.capability) continue; - const current = capabilities.get(name); - if ( - !Predicate.isTagged(current, "starting") || - !Predicate.isTagged(current.completion, "activation") - ) - continue; - capabilities.set(name, cleanupFailed(current, cleanup.cause)); - notifications.push({ - _tag: "activation", - completion: current.completion.deferred, - result: Exit.failCause(cleanup.cause), - }); - } - } - const current = capabilities.get(owner.capability); - Match.value(owner).pipe( - Match.tag("endpoint", (event) => { - if ( - !Predicate.isTagged(current, "ready") || - !Predicate.isTagged(current.endpoint, "resolving") - ) - return; - capabilities.set( - event.capability, - failed - ? { ...current, root: event.priorRoot, endpoint: { _tag: "unresolved" } } - : { ...current, endpoint: { _tag: "resolved", endpoint: outcome.value.endpoint } }, - ); - }), - Match.tag("activation", (event) => { - if ( - !Predicate.isTagged(current, "starting") || - !Predicate.isTagged(current.completion, "activation") - ) - return; - capabilities.set( - event.capability, - failed - ? Predicate.isTagged(cleanup, "unproven") - ? cleanupFailed(current, cleanup.cause) - : Predicate.isTagged(snapshot.stack, "stopped") - ? { _tag: "stopped" } - : restoreStarting(current) - : completeStarting(current, { _tag: "resolved", endpoint: outcome.value.endpoint }, true), - ); - }), - Match.exhaustive, - ); - const next = - failed && Predicate.isTagged(cleanup, "unproven") - ? stopRecoverySnapshot({ ...snapshot, capabilities }, cleanup.cause) - : { ...snapshot, capabilities }; - notifications.push(notification); - return { snapshot: next, notifications, reconcile: "all-ready" }; -}; - -const stopRecoverySnapshot = ( - snapshot: SupervisorSnapshot, - cause: Cause.Cause, -): SupervisorSnapshot => - Match.value(snapshot.stack).pipe( - Match.tag("running", () => ({ - ...snapshot, - stack: { _tag: "stop-required" as const, cause }, - })), - Match.when({ _tag: "starting", prior: { _tag: "running" } }, (state) => ({ - ...snapshot, - stack: { - _tag: "start-recovery" as const, - cause, - attempt: state.attempt, - completion: state.completion, - }, - })), - Match.tag( - "stopped", - "stop-required", - "destroy-required", - "start-recovery", - "starting", - "stopping", - "destroying", - () => snapshot, - ), - Match.exhaustive, - ); - -export const settleRetirementOwner = ( - snapshot: SupervisorSnapshot, - owner: RetirementOwner, -): SnapshotTransition => { - const current = snapshot.capabilities.get(owner.capability); - if ( - Predicate.isTagged(current, "stopping") && - current.operation === owner.operation && - current.completion === owner.completion - ) { - if (Exit.isSuccess(owner.result) && owner.result.value) { - const next: CapabilityState = { - _tag: "dormant", - sessionId: current.sessionId, - traffic: current.traffic, - root: false, - retirement: { _tag: "disarmed" }, - }; - return { - snapshot: { - ...snapshot, - capabilities: new Map(snapshot.capabilities).set(owner.capability, next), - }, - notifications: [ - { - _tag: "stopping", - completion: owner.completion, - result: Exit.map(owner.result, () => undefined), - }, - ], - reconcile: "all-ready", - }; - } - if (Exit.isSuccess(owner.result)) - return { - snapshot, - notifications: [{ _tag: "stopping", completion: owner.completion, result: Exit.void }], - reconcile: "none", - }; - return { - snapshot: { - ...stopRecoverySnapshot(snapshot, owner.result.cause), - capabilities: new Map(snapshot.capabilities).set( - owner.capability, - cleanupFailed(current, owner.result.cause), - ), - }, - notifications: [ - { - _tag: "stopping", - completion: owner.completion, - result: Exit.failCause(owner.result.cause), - }, - ], - reconcile: "none", - }; - } - return { - snapshot, - notifications: [ - { - _tag: "stopping", - completion: owner.completion, - result: Exit.isSuccess(owner.result) ? Exit.void : Exit.failCause(owner.result.cause), - }, - ], - reconcile: "none", - }; -}; - -const commandResultExit = (operation: CommandResult): Exit.Exit => - Predicate.isTagged(operation, "failed") ? Exit.failCause(operation.cause) : Exit.void; - -const settleStartingFailure = ( - state: Extract, - operation: Extract, -): StackControlState => - Match.value(state.prior).pipe( - Match.tag("running", (prior) => - Match.value(operation.cleanup).pipe( - Match.tag("unproven", () => ({ _tag: "stop-required" as const, cause: operation.cause })), - Match.tag("proven", () => prior), - Match.exhaustive, - ), - ), - Match.tag("stopped", () => - Match.value(operation.cleanup).pipe( - Match.tag("proven", () => - operation.durable === "stopped" - ? { _tag: "stopped" as const, session: "initialized" as const } - : { _tag: "stop-required" as const, cause: operation.cause }, - ), - Match.tag("unproven", () => ({ _tag: "stop-required" as const, cause: operation.cause })), - Match.exhaustive, - ), - ), - Match.exhaustive, - ); - -export const settleLifecycleOwner = ( - snapshot: SupervisorSnapshot, - owner: LifecycleOwner, -): SnapshotTransition => { - const current = snapshot.stack; - const operation = owner.result; - const matches = isTransitioning(current) && current.completion === owner.completion; - if (!matches) - return { - snapshot, - notifications: [ - { - _tag: "lifecycle", - completion: owner.completion, - result: commandResultExit(operation), - }, - ], - reconcile: "all-ready", - }; - const completionCause = Match.value(current).pipe( - Match.tag("start-recovery", (state) => - Match.value(operation).pipe( - Match.tag("succeeded", () => state.cause), - Match.tag("failed", (failure) => Cause.combine(state.cause, failure.cause)), - Match.exhaustive, - ), - ), - Match.tag("starting", () => undefined), - Match.tag("stopping", () => undefined), - Match.tag("destroying", () => undefined), - Match.exhaustive, - ); - const completion: Exit.Exit = - completionCause === undefined ? commandResultExit(operation) : Exit.failCause(completionCause); - const next: StackControlState = Match.value(current).pipe( - Match.tag("start-recovery", (state) => ({ - _tag: "stop-required" as const, - cause: Match.value(operation).pipe( - Match.tag("succeeded", () => state.cause), - Match.tag("failed", (failure) => Cause.combine(state.cause, failure.cause)), - Match.exhaustive, - ), - })), - Match.tag("starting", (state) => - Match.value(operation).pipe( - Match.tag("succeeded", () => ({ _tag: "running" as const })), - Match.tag("failed", (failure) => settleStartingFailure(state, failure)), - Match.exhaustive, - ), - ), - Match.tag("stopping", () => - Match.value(operation).pipe( - Match.tag("succeeded", () => ({ - _tag: "stopped" as const, - session: "initialized" as const, - })), - Match.tag("failed", (failure) => ({ - _tag: "stop-required" as const, - cause: failure.cause, - })), - Match.exhaustive, - ), - ), - Match.tag("destroying", () => - Match.value(operation).pipe( - Match.tag("succeeded", () => ({ - _tag: "stopped" as const, - session: "initialized" as const, - })), - Match.tag("failed", (failure) => ({ - _tag: "destroy-required" as const, - evidence: { _tag: "failed" as const, cause: failure.cause }, - })), - Match.exhaustive, - ), - ), - Match.exhaustive, - ); - const startup = - Predicate.isTagged(operation, "failed") && - (Predicate.isTagged(current, "starting") || Predicate.isTagged(current, "start-recovery")) - ? settleStartingCapabilities(snapshot, operation.cause, operation.cleanup, operation.durable) - : undefined; - return { - snapshot: { - ...snapshot, - stack: next, - ...(startup === undefined ? {} : { capabilities: startup.snapshot.capabilities }), - }, - notifications: [ - ...(startup?.notifications ?? []), - { _tag: "lifecycle", completion: owner.completion, result: completion }, - ], - reconcile: "all-ready", - }; -}; diff --git a/packages/stack/src/supervisor/SupervisorTransitions.unit.test.ts b/packages/stack/src/supervisor/SupervisorTransitions.unit.test.ts deleted file mode 100644 index 68f4a49158..0000000000 --- a/packages/stack/src/supervisor/SupervisorTransitions.unit.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Predicate } from "effect"; -import type { ActivationResult, BackendEndpoint } from "../gateway/Gateway.ts"; -import { StackRuntimeError, type StackError } from "../public/Errors.ts"; -import { isStackId } from "../public/StackId.ts"; -import { - beginStarting, - beginStopping, - dormant, - ready, - type CapabilityState, -} from "./CapabilityState.ts"; -import { - activationGate, - admitActivation, - admitLifecycle, - endTraffic, - settleActivationTerminal, - settleLifecycleOwner, - settleRetirementOwner, - type ActivationExit, - type EndpointExit, -} from "./SupervisorTransitions.ts"; - -const snapshotFor = (capability: CapabilityState) => ({ - stack: { _tag: "running" as const }, - sessionId: Symbol("session"), - plan: undefined, - capabilities: new Map([["rest" as const, capability]]), -}); - -describe("supervisor transitions", () => { - it.effect("rejects activation during a stopped lifecycle", () => - Effect.gen(function* () { - const value = "a".repeat(64); - if (!isStackId(value)) return yield* Effect.die("invalid stack id fixture"); - const result = activationGate( - { - ...snapshotFor(ready(Symbol("session"), 0, false)), - stack: { _tag: "stopped", session: "initialized" }, - }, - value, - ); - expect(Predicate.isTagged(result, "rejected")).toBe(true); - if (Predicate.isTagged(result, "rejected")) - expect(result.error.message).toBe("Stack must be running before activation"); - }), - ); - - it.effect("uses the start-in-progress diagnostic during start recovery", () => - Effect.gen(function* () { - const value = "a".repeat(64); - if (!isStackId(value)) return yield* Effect.die("invalid stack id fixture"); - const cause = Cause.fail(new StackRuntimeError({ message: "cleanup pending" })); - const completion = yield* Deferred.make, never>(); - const result = activationGate( - { - ...snapshotFor(ready(Symbol("session"), 0, false)), - stack: { _tag: "start-recovery", attempt: Symbol("start"), completion, cause }, - }, - value, - ); - expect(Predicate.isTagged(result, "rejected")).toBe(true); - if (Predicate.isTagged(result, "rejected")) - expect(result.error.message).toBe("Cannot activate while start is in progress"); - }), - ); - - it.effect("rejects commands requiring recovery before admission", () => - Effect.gen(function* () { - const completion = yield* Deferred.make, never>(); - const cause = Cause.fail(new StackRuntimeError({ message: "cleanup pending" })); - const base = snapshotFor(ready(Symbol("session"), 0, false)); - const stop = admitLifecycle( - { ...base, stack: { _tag: "stop-required", cause } }, - "start", - completion, - Symbol("start"), - ); - const destroy = admitLifecycle( - { - ...base, - stack: { _tag: "destroy-required", evidence: { _tag: "failed", cause } }, - }, - "start", - completion, - Symbol("start"), - ); - const stopFromStopRequired = admitLifecycle( - { ...base, stack: { _tag: "stop-required", cause } }, - "stop", - completion, - Symbol("stop"), - ); - const stopFromDestroyRequired = admitLifecycle( - { - ...base, - stack: { _tag: "destroy-required", evidence: { _tag: "failed", cause } }, - }, - "stop", - completion, - Symbol("stop"), - ); - const destroyFromDestroyRequired = admitLifecycle( - { - ...base, - stack: { _tag: "destroy-required", evidence: { _tag: "failed", cause } }, - }, - "destroy", - completion, - Symbol("destroy"), - ); - expect(Predicate.isTagged(stop, "rejected") && stop.reason).toBe("stop-required"); - expect(Predicate.isTagged(destroy, "rejected") && destroy.reason).toBe("destroy-required"); - expect(Predicate.isTagged("accepted")(stopFromStopRequired)).toBe(true); - if (Predicate.isTagged("accepted")(stopFromStopRequired)) { - expect(stopFromStopRequired.snapshot.stack._tag).toBe("stopping"); - if (Predicate.isTagged("stopping")(stopFromStopRequired.snapshot.stack)) - expect(stopFromStopRequired.snapshot.stack.completion).toBe(completion); - } - expect(Predicate.isTagged("rejected")(stopFromDestroyRequired)).toBe(true); - if (Predicate.isTagged("rejected")(stopFromDestroyRequired)) - expect(stopFromDestroyRequired.reason).toBe("destroy-required"); - expect(Predicate.isTagged("accepted")(destroyFromDestroyRequired)).toBe(true); - if (Predicate.isTagged("accepted")(destroyFromDestroyRequired)) { - expect(destroyFromDestroyRequired.snapshot.stack._tag).toBe("destroying"); - if (Predicate.isTagged("destroying")(destroyFromDestroyRequired.snapshot.stack)) - expect(destroyFromDestroyRequired.snapshot.stack.completion).toBe(completion); - } - }), - ); - - it.effect("claims endpoint resolution from one pure activation admission", () => - Effect.gen(function* () { - const value = "a".repeat(64); - if (!isStackId(value)) return yield* Effect.die("invalid stack id fixture"); - const endpoint = yield* Deferred.make(); - const decision = admitActivation( - snapshotFor(ready(Symbol("session"), 0, false)), - "rest", - value, - ); - expect(Predicate.isTagged(decision, "endpoint-owner")).toBe(true); - if (Predicate.isTagged(decision, "endpoint-owner")) { - const current = decision.transition(endpoint).snapshot.capabilities.get("rest"); - expect(Predicate.isTagged(current, "ready")).toBe(true); - if (Predicate.isTagged(current, "ready")) - expect(current.endpoint).toEqual({ _tag: "resolving", deferred: endpoint }); - } - }), - ); - - it.effect("restores the saved root on a matching endpoint failure", () => - Effect.gen(function* () { - const endpoint = yield* Deferred.make, never>(); - const current = ready(Symbol("session"), 3, true, { _tag: "resolving", deferred: endpoint }); - const cause = Cause.fail(new StackRuntimeError({ message: "activation failed" })); - const settlement = settleActivationTerminal( - snapshotFor(current), - { - _tag: "endpoint", - capability: "rest", - endpoint, - priorRoot: false, - }, - { _tag: "none" }, - { _tag: "failed", cause, cleanup: { _tag: "proven" } }, - ); - const next = settlement.snapshot.capabilities.get("rest"); - expect(Predicate.isTagged("ready")(next)).toBe(true); - if (Predicate.isTagged("ready")(next)) { - expect(next.root).toBe(false); - expect(next.traffic).toBe(3); - expect(next.endpoint).toEqual({ _tag: "unresolved" }); - } - expect(settlement.reconcile).toBe("all-ready"); - }), - ); - - it.effect("rejects a deferred-mismatched endpoint completion", () => - Effect.gen(function* () { - const ownerEndpoint = yield* Deferred.make(); - const currentEndpoint = yield* Deferred.make(); - const snapshot = snapshotFor( - ready(Symbol("session"), 0, false, { _tag: "resolving", deferred: currentEndpoint }), - ); - const cause = Cause.fail(new StackRuntimeError({ message: "stale endpoint" })); - const settlement = settleActivationTerminal( - snapshot, - { _tag: "endpoint", capability: "rest", endpoint: ownerEndpoint, priorRoot: false }, - { _tag: "none" }, - { _tag: "failed", cause, cleanup: { _tag: "proven" } }, - ); - expect(settlement.snapshot).toBe(snapshot); - expect(settlement.notifications[0]).toMatchObject({ - _tag: "endpoint", - completion: ownerEndpoint, - result: Exit.failCause(cause), - }); - }), - ); - - it.effect("publishes a stale completion without replacing the newer state", () => - Effect.gen(function* () { - const endpoint = yield* Deferred.make, never>(); - const newer = ready(Symbol("newer"), 1, false); - const snapshot = snapshotFor(newer); - const endpointResult = { host: "127.0.0.1", port: 54321 }; - const settlement = settleActivationTerminal( - snapshot, - { - _tag: "endpoint", - capability: "rest", - endpoint, - priorRoot: true, - }, - { _tag: "none" }, - { - _tag: "succeeded", - value: { capability: "rest", endpoint: endpointResult }, - }, - ); - expect(settlement.snapshot).toBe(snapshot); - expect(settlement.snapshot.capabilities.get("rest")).toBe(newer); - const notification = settlement.notifications[0]; - if (notification !== undefined && Predicate.isTagged("endpoint")(notification)) { - expect(notification.completion).toBe(endpoint); - expect(notification.result).toEqual(Exit.succeed(endpointResult)); - } else expect.fail("expected endpoint notification"); - expect(settlement.reconcile).toBe("all-ready"); - }), - ); - - it.effect("ignores a stale unproven activation failure without claims", () => - Effect.gen(function* () { - const completion = yield* Deferred.make(); - const newer = ready(Symbol("newer"), 1, false); - const currentCompletion = yield* Deferred.make(); - const current = beginStarting(newer, Symbol("current"), { - _tag: "activation", - deferred: currentCompletion, - }); - const snapshot = { - ...snapshotFor(newer), - capabilities: new Map([["rest" as const, current]]), - }; - const cause = Cause.fail(new StackRuntimeError({ message: "stale activation failed" })); - // Execution serializes a live activation claim through settlement; an unmatched - // owner is stale/already-settled evidence and cannot impose recovery on its replacement. - const settlement = settleActivationTerminal( - snapshot, - { _tag: "activation", capability: "rest", completion }, - { _tag: "none" }, - { _tag: "failed", cause, cleanup: { _tag: "unproven", cause } }, - ); - - expect(settlement.snapshot).toBe(snapshot); - expect(settlement.snapshot.capabilities.get("rest")).toBe(current); - expect(settlement.snapshot.stack).toEqual({ _tag: "running" }); - const notification = settlement.notifications[0]; - if (notification !== undefined && Predicate.isTagged("activation")(notification)) { - expect(notification.completion).toBe(completion); - expect(notification.result).toEqual(Exit.failCause(cause)); - } else expect.fail("expected activation notification"); - }), - ); - - it.effect("does not restore claims into a newer session", () => - Effect.gen(function* () { - const endpoint = yield* Deferred.make(); - const completion = yield* Deferred.make, never>(); - const prior = dormant(Symbol("old-session")); - const current = ready(Symbol("new-session"), 0, false, { - _tag: "resolving", - deferred: endpoint, - }); - const claimedCurrent = ready(Symbol("new-session"), 0, false); - const snapshot = { - ...snapshotFor(current), - capabilities: new Map([ - ["rest" as const, current], - ["studio" as const, claimedCurrent], - ]), - }; - const cause = Cause.fail(new StackRuntimeError({ message: "activation failed" })); - const settlement = settleActivationTerminal( - snapshot, - { _tag: "endpoint", capability: "rest", endpoint, priorRoot: false }, - { - _tag: "claimed", - claimed: [{ name: "studio", completion, prior }], - affected: new Set(["studio"]), - }, - { _tag: "failed", cause, cleanup: { _tag: "proven" } }, - ); - expect(settlement.snapshot).not.toBe(snapshot); - expect(settlement.snapshot.capabilities.get("rest")).toMatchObject({ - _tag: "ready", - endpoint: { _tag: "unresolved" }, - }); - expect(settlement.snapshot.capabilities.get("studio")).toBe(claimedCurrent); - }), - ); - - it.effect("preserves lifecycle start recovery after retirement failure", () => - Effect.gen(function* () { - const lifecycleCompletion = yield* Deferred.make, never>(); - const retirementCompletion = yield* Deferred.make, never>(); - const operation = Symbol("retirement"); - const failure = new StackRuntimeError({ message: "retirement failed" }); - const cause = Cause.fail(failure); - const capability = beginStopping( - ready(Symbol("session"), 0, false), - operation, - retirementCompletion, - ); - const snapshot = { - ...snapshotFor(ready(Symbol("session"), 0, false)), - stack: { - _tag: "starting" as const, - attempt: Symbol("start"), - completion: lifecycleCompletion, - prior: { _tag: "running" as const }, - }, - capabilities: new Map([["rest" as const, capability]]), - }; - const settlement = settleRetirementOwner(snapshot, { - _tag: "retirement", - capability: "rest", - operation, - completion: retirementCompletion, - result: Exit.fail(failure), - }); - - expect(settlement.snapshot.stack).toEqual({ - _tag: "start-recovery", - attempt: snapshot.stack.attempt, - completion: lifecycleCompletion, - cause, - }); - }), - ); - - it.effect("preserves a stopping capability when settlement reports no retirement", () => - Effect.gen(function* () { - const completion = yield* Deferred.make, never>(); - const current = ready(Symbol("session"), 1, true); - const operation = Symbol("retirement"); - const capability = beginStopping(current, operation, completion); - const snapshot = { - ...snapshotFor(current), - capabilities: new Map([["rest" as const, capability]]), - }; - const settlement = settleRetirementOwner(snapshot, { - _tag: "retirement", - capability: "rest", - operation, - completion, - result: Exit.succeed(false), - }); - expect(settlement.snapshot.capabilities.get("rest")).toBe(capability); - expect(settlement.notifications).toEqual([ - { _tag: "stopping", completion, result: Exit.void }, - ]); - }), - ); - - it.effect("retires a capability only after retirement succeeds", () => - Effect.gen(function* () { - const completion = yield* Deferred.make, never>(); - const current = ready(Symbol("session"), 1, true); - const operation = Symbol("retirement"); - const capability = beginStopping(current, operation, completion); - const snapshot = { - ...snapshotFor(current), - capabilities: new Map([["rest" as const, capability]]), - }; - const settlement = settleRetirementOwner(snapshot, { - _tag: "retirement", - capability: "rest", - operation, - completion, - result: Exit.succeed(true), - }); - expect(settlement.snapshot.capabilities.get("rest")).toMatchObject({ - _tag: "dormant", - sessionId: current.sessionId, - root: false, - }); - expect(settlement.notifications).toEqual([ - { _tag: "stopping", completion, result: Exit.void }, - ]); - }), - ); - - it.effect("publishes a stale retirement result without changing state", () => - Effect.gen(function* () { - const currentCompletion = yield* Deferred.make, never>(); - const staleCompletion = yield* Deferred.make, never>(); - const current = beginStopping( - ready(Symbol("session"), 1, true), - Symbol("current"), - currentCompletion, - ); - const snapshot = { - ...snapshotFor(ready(Symbol("session"), 1, true)), - capabilities: new Map([["rest" as const, current]]), - }; - const settlement = settleRetirementOwner(snapshot, { - _tag: "retirement", - capability: "rest", - operation: Symbol("stale"), - completion: staleCompletion, - result: Exit.succeed(true), - }); - expect(settlement.snapshot).toBe(snapshot); - expect(settlement.notifications).toEqual([ - { _tag: "stopping", completion: staleCompletion, result: Exit.void }, - ]); - }), - ); - - it.effect("settles every starting failure disposition", () => - Effect.gen(function* () { - const cause = Cause.fail(new StackRuntimeError({ message: "start failed" })); - const scenarios = [ - { - name: "running-proven", - prior: ready(Symbol("running"), 0, false), - cleanup: { _tag: "proven" as const }, - durable: "stopped" as const, - stack: { _tag: "running" as const }, - capability: "stopped" as const, - }, - { - name: "running-unproven", - prior: ready(Symbol("running"), 0, false), - cleanup: { _tag: "unproven" as const, cause }, - durable: "unsafe" as const, - stack: { _tag: "stop-required" as const, cause }, - capability: "cleanup-failed" as const, - }, - { - name: "stopped-proven", - prior: dormant(Symbol("stopped")), - cleanup: { _tag: "proven" as const }, - durable: "stopped" as const, - stack: { _tag: "stopped" as const, session: "initialized" as const }, - capability: "stopped" as const, - }, - { - name: "stopped-unsafe", - prior: dormant(Symbol("stopped")), - cleanup: { _tag: "proven" as const }, - durable: "unsafe" as const, - stack: { _tag: "stop-required" as const, cause }, - capability: "dormant" as const, - }, - ]; - for (const scenario of scenarios) { - const lifecycleCompletion = yield* Deferred.make, never>(); - const workloadCompletion = yield* Deferred.make, never>(); - const current = beginStarting(scenario.prior, Symbol(scenario.name), { - _tag: "workload", - deferred: workloadCompletion, - }); - const snapshot = { - ...snapshotFor(current), - stack: { - _tag: "starting" as const, - attempt: Symbol(scenario.name), - completion: lifecycleCompletion, - prior: Predicate.isTagged(scenario.prior, "ready") - ? { _tag: "running" as const } - : { _tag: "stopped" as const, session: "initialized" as const }, - }, - }; - const settlement = settleLifecycleOwner(snapshot, { - _tag: "lifecycle", - completion: lifecycleCompletion, - result: { _tag: "failed", cause, cleanup: scenario.cleanup, durable: scenario.durable }, - }); - expect(settlement.snapshot.stack).toEqual(scenario.stack); - expect(settlement.snapshot.capabilities.get("rest")).toMatchObject({ - _tag: scenario.capability, - }); - expect(settlement.notifications).toHaveLength(2); - expect(settlement.notifications[0]).toMatchObject({ - _tag: "workload", - completion: workloadCompletion, - result: Exit.failCause(cause), - }); - expect(settlement.notifications[1]).toMatchObject({ - _tag: "lifecycle", - completion: lifecycleCompletion, - result: Exit.failCause(cause), - }); - } - }), - ); - - it.effect( - "restores the prior running capability when cleanup is proven but durability is unsafe", - () => - Effect.gen(function* () { - const cause = Cause.fail(new StackRuntimeError({ message: "state missing" })); - const lifecycleCompletion = yield* Deferred.make, never>(); - const workloadCompletion = yield* Deferred.make, never>(); - const prior = ready(Symbol("running"), 0, false); - const current = beginStarting(prior, Symbol("start"), { - _tag: "workload", - deferred: workloadCompletion, - }); - const snapshot = { - ...snapshotFor(current), - stack: { - _tag: "starting" as const, - attempt: Symbol("start"), - completion: lifecycleCompletion, - prior: { _tag: "running" as const }, - }, - }; - - const settlement = settleLifecycleOwner(snapshot, { - _tag: "lifecycle", - completion: lifecycleCompletion, - result: { _tag: "failed", cause, cleanup: { _tag: "proven" }, durable: "unsafe" }, - }); - - expect(settlement.snapshot.stack).toEqual({ _tag: "running" }); - expect(settlement.snapshot.capabilities.get("rest")).toBe(prior); - }), - ); - - it.effect("ignores traffic release from an older session", () => - Effect.sync(() => { - const snapshot = snapshotFor(ready(Symbol("current"), 1, false)); - const transition = endTraffic(snapshot, "rest", Symbol("old")); - - expect(transition.snapshot).toBe(snapshot); - expect(transition.shouldArm).toBe(false); - }), - ); - - it.effect("publishes a stale start recovery completion with its original result", () => - Effect.gen(function* () { - const oldCompletion = yield* Deferred.make, never>(); - const currentCompletion = yield* Deferred.make, never>(); - const cause = Cause.fail(new StackRuntimeError({ message: "new operation failed" })); - const snapshot = { - ...snapshotFor(ready(Symbol("session"), 0, false)), - stack: { - _tag: "start-recovery" as const, - attempt: Symbol("new-operation"), - completion: currentCompletion, - cause, - }, - }; - const settlement = settleLifecycleOwner(snapshot, { - _tag: "lifecycle", - completion: oldCompletion, - result: { _tag: "succeeded" }, - }); - expect(settlement.snapshot).toBe(snapshot); - const notification = settlement.notifications[0]; - if (notification !== undefined && Predicate.isTagged("lifecycle")(notification)) { - expect(notification.completion).toBe(oldCompletion); - expect(notification.result).toEqual(Exit.void); - expect(Exit.isSuccess(notification.result)).toBe(true); - } else { - expect.fail("expected lifecycle notification"); - } - }), - ); - - it.effect("keeps the original failure for a matching start recovery", () => - Effect.gen(function* () { - const completion = yield* Deferred.make, never>(); - const cause = Cause.fail(new StackRuntimeError({ message: "original failure" })); - const snapshot = { - ...snapshotFor(ready(Symbol("session"), 0, false)), - stack: { - _tag: "start-recovery" as const, - attempt: Symbol("operation"), - completion, - cause, - }, - }; - const settlement = settleLifecycleOwner(snapshot, { - _tag: "lifecycle", - completion, - result: { _tag: "succeeded" }, - }); - expect(settlement.snapshot.stack).toEqual({ _tag: "stop-required", cause }); - const notification = settlement.notifications[0]; - if (notification !== undefined && Predicate.isTagged("lifecycle")(notification)) - expect(notification.result).toEqual(Exit.failCause(cause)); - else expect.fail("expected lifecycle notification"); - }), - ); - - it.effect("stops a queued activation that settles after the stack stopped", () => - Effect.gen(function* () { - const completion = yield* Deferred.make, never>(); - const sessionId = Symbol("session"); - const current = beginStarting(dormant(sessionId), Symbol("operation"), { - _tag: "activation", - deferred: completion, - }); - const snapshot = { - stack: { _tag: "stopped" as const, session: "initialized" as const }, - sessionId, - plan: undefined, - capabilities: new Map([["rest" as const, current]]), - }; - const cause = Cause.fail(new StackRuntimeError({ message: "activation rejected" })); - const settlement = settleActivationTerminal( - snapshot, - { - _tag: "activation", - capability: "rest", - completion, - }, - { _tag: "none" }, - { _tag: "failed", cause, cleanup: { _tag: "proven" } }, - ); - - expect(settlement.snapshot.capabilities.get("rest")).toEqual({ _tag: "stopped" }); - const notification = settlement.notifications[0]; - if (notification !== undefined && Predicate.isTagged("activation")(notification)) { - expect(notification.completion).toBe(completion); - expect(notification.result).toEqual(Exit.failCause(cause)); - } else { - expect.fail("expected activation notification"); - } - }), - ); -}); diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index fe573a421f..0dbe1cf6a0 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -2,7 +2,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, - Crypto, Deferred, Effect, Exit, @@ -10,26 +9,14 @@ import { Fiber, Option, Path, - Predicate, - Redacted, + Ref, Result, Schema, Sink, - Scope, Stream, } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import { - createStack, - findStack, - inspectStack, - openStack, - listStacks, -} from "../public/EffectStack.ts"; -import type { StackStatus } from "../public/Status.ts"; -import type { CreateStackError } from "../public/Errors.ts"; -import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { createStack, findStack, openStack } from "../public/EffectStack.ts"; import { defaultRuntimeEnvironment, ensureSupervisor, @@ -40,22 +27,16 @@ import { import { StackIdSchema } from "../public/StackId.ts"; import type { StackId } from "../public/StackId.ts"; import { - acquireOwnership, controlEndpointFor, - publishOwnership, readOwnerMetadata, StackRuntimeEnvironment, } from "../state/Ownership.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; +import { makeControlClient } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; -import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; +import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; -import { - StackOwnershipConflictError, - StackPreparationError, - StackRuntimeMismatchError, -} from "../public/Errors.ts"; +import { StackOwnershipConflictError, StackRuntimeMismatchError } from "../public/Errors.ts"; import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver, @@ -134,9 +115,6 @@ const stopOwner = (id: StackId) => yield* Fiber.join(watcher); }); -const quoteModuleSpecifier = (value: string): string => - `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; - const fakeContainerEngine = (kind: "docker" | "podman", calls: string[]): ContainerEngine => ({ kind, preflight: Effect.succeed({ host: "host.containers.internal" }), @@ -159,6 +137,7 @@ const fakeContainerEngine = (kind: "docker" | "podman", calls: string[]): Contai removeVolume: () => Effect.void, createContainer: () => Effect.die("unused"), copyToContainer: () => Effect.void, + execContainer: () => Effect.void, startContainer: () => Effect.void, waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, @@ -190,7 +169,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { platform: "windows", tempRoot: project, }; - const stack = yield* createStack({ projectRoot: project }); + const stack = yield* createStack({ initialConfig: {}, projectRoot: project }); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); const owner = { @@ -248,6 +227,47 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("does not kill a detached launch child when its caller is interrupted", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ initialConfig: {}, projectRoot: project }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const killCalls = yield* Ref.make(0); + const readinessObserved = yield* Deferred.make(); + const child = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Ref.update(killCalls, (calls) => calls + 1), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => + Stream.fromEffect(Deferred.succeed(readinessObserved, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.never), + ), + unref: Effect.succeed(Effect.void), + }); + const spawner = ChildProcessSpawner.make(() => Effect.succeed(child)); + const launch = yield* Effect.forkChild( + ensureSupervisor({ + stackId: stack.id, + stateStore: store, + environment: env, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + { startImmediately: true }, + ); + yield* Deferred.await(readinessObserved); + yield* Fiber.interrupt(launch); + expect(yield* Ref.get(killCalls)).toBe(0); + }), + ), + ); + it.live("persists Docker for an omitted container engine without probing", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -255,9 +275,11 @@ describe("managed stack handles", { timeout: 30_000 }, () => { isInstalled: () => Effect.die("resolver must not be called"), resolve: () => Effect.die("resolver must not be called"), }; - yield* createStack({ projectRoot: project, runtime: { kind: "container" } }).pipe( - Effect.provideService(ContainerEngineResolver, resolver), - ); + yield* createStack({ + initialConfig: {}, + projectRoot: project, + runtime: { kind: "container" }, + }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); expect( (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, ).toEqual({ kind: "container", engine: "docker" }); @@ -277,7 +299,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { }), resolve: () => Effect.die("engine construction must not run during create"), }; - yield* createStack({ projectRoot: project }).pipe( + yield* createStack({ initialConfig: {}, projectRoot: project }).pipe( Effect.provideService(ContainerEngineResolver, resolver), ); expect(calls).toEqual(["docker"]); @@ -300,7 +322,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { }), resolve: () => Effect.die("engine construction must not run during create"), }; - yield* createStack({ projectRoot: project }).pipe( + yield* createStack({ initialConfig: {}, projectRoot: project }).pipe( Effect.provideService(ContainerEngineResolver, resolver), ); expect(calls).toEqual(["docker"]); @@ -319,6 +341,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { resolve: () => Effect.die("resolver must not be called"), }; yield* createStack({ + initialConfig: {}, projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); @@ -333,6 +356,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { withRuntimeRoot((project) => Effect.gen(function* () { const created = yield* createStack({ + initialConfig: {}, projectRoot: project, runtime: { kind: "container", engine: "podman" }, }); @@ -340,7 +364,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { isInstalled: () => Effect.die("resolver must not be called"), resolve: () => Effect.die("resolver must not be called"), }; - const reopened = yield* createStack({ projectRoot: project }).pipe( + const reopened = yield* createStack({ initialConfig: {}, projectRoot: project }).pipe( Effect.provideService(ContainerEngineResolver, resolver), ); expect(reopened.id).toBe(created.id); @@ -364,11 +388,16 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), }; const stack = yield* createStack({ + initialConfig: {}, projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); - const prepared = yield* stack.prepare({ capabilities: ["database"] }); - expect(prepared.capabilities).toHaveLength(1); + const database = (yield* stack.status).instances.find( + (instance) => instance.service === "database", + ); + if (database === undefined) throw new Error("Database instance is missing"); + const prepared = yield* stack.prepare({ services: [database.id] }); + expect(prepared.instances).toHaveLength(1); expect(calls).toEqual(["podman:probe", `podman:inspect:${databaseRelease.containerImage}`]); expect(dockerCalls).toEqual([]); }), @@ -379,6 +408,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { withRuntimeRoot((project) => Effect.gen(function* () { const created = yield* createStack({ + initialConfig: {}, projectRoot: project, runtime: { kind: "container", engine: "podman" }, }); @@ -398,10 +428,12 @@ describe("managed stack handles", { timeout: 30_000 }, () => { withRuntimeRoot((project) => Effect.gen(function* () { yield* createStack({ + initialConfig: {}, projectRoot: project, runtime: { kind: "container", engine: "docker" }, }); const result = yield* createStack({ + initialConfig: {}, projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe( @@ -422,7 +454,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { it.live("does not probe an explicitly native identity", () => withRuntimeRoot((project) => - createStack({ projectRoot: project, runtime: { kind: "native" } }).pipe( + createStack({ initialConfig: {}, projectRoot: project, runtime: { kind: "native" } }).pipe( Effect.provideService(ContainerEngineResolver, { isInstalled: () => Effect.die("native stack must not resolve a container engine"), resolve: () => Effect.die("native stack must not resolve a container engine"), @@ -430,407 +462,4 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ), ); - - it.live("creates an unconfigured stack without reading config or starting workloads", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project }); - const status = yield* stack.status; - expect(status.lifecycle).toBe("unconfigured"); - expect(status.desiredLifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("preserves preparation failures returned by the owner start RPC", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const crypto = yield* Crypto.Crypto; - const identity = yield* resolveStackIdentity({ projectRoot: project }); - const stackId = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - yield* store.initialize(stackId, { - format: "supabase-stack-state-v1", - identity, - runtime: { kind: "native" }, - desiredLifecycle: "stopped", - ports: [], - privatePorts: [], - secrets: {}, - }); - const ownerSessionId = yield* crypto.randomUUIDv4; - const lease = yield* acquireOwnership({ - stateRoot: env.stateRoot, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - environment: env, - }); - yield* publishOwnership(lease); - const status: StackStatus = { - id: stackId, - lifecycle: "stopped", - desiredLifecycle: "stopped", - runtime: { kind: "native" }, - endpoints: {}, - versions: {}, - artifacts: [], - capabilities: CAPABILITY_NAMES.map((name) => ({ - name, - activation: "eager", - state: "stopped", - })), - }; - const handlers: StackRpcHandlers = { - status: () => Effect.succeed(status), - credentials: () => - Effect.succeed({ - database: { - url: Redacted.make("postgres://localhost"), - password: Redacted.make("secret"), - }, - api: { - publishableKey: "publishable", - secretKey: Redacted.make("secret"), - anonJwt: "anon", - serviceRoleJwt: Redacted.make("service"), - }, - }), - start: () => - Effect.fail({ tag: "StackPreparationError", message: "artifact is incomplete" }), - destroy: () => Effect.void, - resetDatabase: () => Effect.succeed(status), - logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), - }; - yield* startControlServer({ - endpoint: lease.metadata.endpoint, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - rpcHandlers: handlers, - }); - const stack = yield* openStack(stackId); - const failed = yield* stack.start({ config: {} }).pipe(Effect.exit); - const error = Exit.isFailure(failed) - ? Option.getOrUndefined(Cause.findErrorOption(failed.cause)) - : undefined; - yield* lease.release; - expect(Exit.isFailure(failed)).toBe(true); - expect(error).toBeInstanceOf(StackPreparationError); - expect(error).toMatchObject({ message: "artifact is incomplete" }); - }), - ), - ); - - it.live("waits for the owner control socket to close before destroy resolves", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const crypto = yield* Crypto.Crypto; - const identity = yield* resolveStackIdentity({ projectRoot: project }); - const stackId = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - yield* store.initialize(stackId, { - format: "supabase-stack-state-v1", - identity, - runtime: { kind: "native" }, - desiredLifecycle: "stopped", - ports: [], - privatePorts: [], - secrets: {}, - }); - const ownerSessionId = yield* crypto.randomUUIDv4; - const lease = yield* acquireOwnership({ - stateRoot: env.stateRoot, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - environment: env, - }); - yield* publishOwnership(lease); - const ownerScope = yield* Scope.make(); - const status: StackStatus = { - id: stackId, - lifecycle: "stopped", - desiredLifecycle: "stopped", - runtime: { kind: "native" }, - endpoints: {}, - versions: {}, - artifacts: [], - capabilities: CAPABILITY_NAMES.map((name) => ({ - name, - activation: "eager", - state: "stopped", - })), - }; - const destroyStarted = yield* Deferred.make(); - const responseRelease = yield* Deferred.make(); - const callbackStarted = yield* Deferred.make(); - const callbackRelease = yield* Deferred.make(); - const callbackCompleted = yield* Deferred.make(); - const destroyDone = yield* Deferred.make(); - yield* startControlServer({ - endpoint: lease.metadata.endpoint, - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - rpcHandlers: { - status: () => Effect.succeed(status), - credentials: () => - Effect.fail({ tag: "StackNotRunningError" as const, message: "not running" }), - start: () => Effect.succeed(status), - destroy: () => - Deferred.succeed(destroyStarted, undefined).pipe( - Effect.andThen(Deferred.await(responseRelease)), - Effect.asVoid, - ), - resetDatabase: () => Effect.succeed(status), - logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), - }, - onShutdownReady: Deferred.succeed(callbackStarted, undefined).pipe( - Effect.andThen(Deferred.await(callbackRelease)), - Effect.andThen(Deferred.succeed(callbackCompleted, undefined)), - Effect.asVoid, - ), - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* openStack(stackId); - const destroyFiber = yield* Effect.forkChild( - stack.destroy.pipe(Effect.andThen(Deferred.succeed(destroyDone, undefined))), - { startImmediately: true }, - ); - yield* Deferred.await(destroyStarted); - yield* Deferred.succeed(responseRelease, undefined); - yield* Deferred.await(callbackStarted); - expect(yield* Deferred.isDone(destroyDone)).toBe(false); - yield* Deferred.succeed(callbackRelease, undefined); - yield* Deferred.await(callbackCompleted); - yield* Scope.close(ownerScope, Exit.void); - yield* Fiber.join(destroyFiber); - yield* lease.release; - }), - ), - ); - - it.live("concurrent equivalent creates join one owner", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const [first, second] = yield* Effect.all( - [createStack({ projectRoot: project }), createStack({ projectRoot: project })], - { concurrency: 2 }, - ); - expect(second.id).toBe(first.id); - expect((yield* second.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("discovery never creates an identity", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const found = yield* findStack({ projectRoot: project }); - expect(Option.isNone(found)).toBe(true); - const absentId = StackIdSchema.make("f".repeat(64)); - const absent = yield* inspectStack(absentId).pipe(Effect.exit); - expect(Exit.isFailure(absent)).toBe(true); - }), - ), - ); - - it.live("openStack is observational and rejects unknown ids", () => - withRuntimeRoot((_project) => - Effect.gen(function* () { - const result = yield* openStack(StackIdSchema.make("0".repeat(64))).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - }), - ), - ); - - it.live("filters read-only discovery by project root", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const other = path.join(path.dirname(project), "other-project"); - yield* fs.makeDirectory(other); - const first = yield* createStack({ projectRoot: project }); - yield* createStack({ projectRoot: other }); - const filtered = yield* listStacks({ projectRoot: project }); - expect(filtered.map((entry) => entry.id)).toEqual([first.id]); - }), - ), - ); - - it.live("concurrent creates preserve the published state across an advisory read race", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const env = yield* StackRuntimeEnvironment; - const identity = yield* resolveStackIdentity({ projectRoot: project }); - const stackId = yield* deriveStackId(identity); - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId }); - const path = yield* Path.Path; - const registryLock = path.join(path.resolve(env.stateRoot), ".stack-registry.lock"); - const writerReady = yield* Deferred.make(); - const releaseWriter = yield* Deferred.make(); - const writerPublished = yield* Deferred.make(); - const firstFs: FileSystem.FileSystem = { - ...fs, - rename: (from, to) => - to === paths.stateDocument - ? Deferred.succeed(writerReady, undefined).pipe( - Effect.andThen(Deferred.await(releaseWriter)), - Effect.andThen(fs.rename(from, to)), - Effect.tap(() => Deferred.succeed(writerPublished, undefined)), - ) - : fs.rename(from, to), - }; - const secondFs: FileSystem.FileSystem = { - ...fs, - readFileString: (candidate, encoding) => { - if (candidate === registryLock) - return Deferred.succeed(releaseWriter, undefined).pipe( - Effect.andThen(fs.readFileString(candidate, encoding)), - ); - if (candidate !== paths.stateDocument) return fs.readFileString(candidate, encoding); - return fs - .readFileString(candidate, encoding) - .pipe( - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Deferred.succeed(releaseWriter, undefined).pipe( - Effect.andThen(Deferred.await(writerPublished)), - Effect.andThen(Effect.fail(error)), - ) - : Effect.fail(error), - ), - ); - }, - }; - const create = (fileSystem: FileSystem.FileSystem) => - createStack({ projectRoot: project }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - const first = yield* Effect.forkChild( - create(firstFs).pipe( - Effect.catchCause((cause) => - Deferred.failCause(writerReady, cause).pipe(Effect.andThen(Effect.failCause(cause))), - ), - ), - { startImmediately: true }, - ); - yield* Deferred.await(writerReady); - const second = yield* Effect.forkChild(create(secondFs), { startImmediately: true }); - const [firstHandle, secondHandle] = yield* Effect.all( - [Fiber.join(first), Fiber.join(second)], - { - concurrency: 2, - }, - ); - expect(secondHandle.id).toBe(firstHandle.id); - expect((yield* secondHandle.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("concurrent caller processes share one stack identity after exit", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const path = yield* Path.Path; - const supabaseHome = path.dirname(project); - const stackModule = new URL("../public/EffectStack.ts", import.meta.url).href; - const encodedStackModule = quoteModuleSpecifier(stackModule); - // bare imports in a `node -e` script resolve from cwd, so run from the package root - const cwd = path.resolve(import.meta.dirname, "../.."); - const script = ` - const { Effect } = await import("effect"); - const { NodeServices } = await import("@effect/platform-node"); - const { createStack } = await import(${encodedStackModule}); - const stack = await Effect.runPromise( - Effect.scoped( - createStack({ projectRoot: process.argv[1], runtime: { kind: "native" } }).pipe( - Effect.provide(NodeServices.layer), - ), - ), - ); - process.stdout.write(stack.id); - `; - const spawnCaller = () => - Effect.gen(function* () { - const child = yield* ChildProcess.make( - process.execPath, - ["--input-type=module", "-e", script, project], - { - cwd, - env: { SUPABASE_HOME: supabaseHome }, - extendEnv: true, - stdout: "pipe", - stderr: "pipe", - }, - ); - const [chunks, stderrChunks, code] = yield* Effect.all( - [Stream.runCollect(child.stdout), Stream.runCollect(child.stderr), child.exitCode], - { concurrency: 3 }, - ); - const bytes = new Uint8Array(chunks.reduce((sum, value) => sum + value.byteLength, 0)); - let offset = 0; - for (const value of chunks) { - bytes.set(value, offset); - offset += value.byteLength; - } - const stderrBytes = new Uint8Array( - stderrChunks.reduce((sum, value) => sum + value.byteLength, 0), - ); - offset = 0; - for (const value of stderrChunks) { - stderrBytes.set(value, offset); - offset += value.byteLength; - } - const stderr = new TextDecoder().decode(stderrBytes); - return { id: new TextDecoder().decode(bytes), code, stderr }; - }); - const [first, second] = yield* Effect.all([spawnCaller(), spawnCaller()], { - concurrency: 2, - }); - expect(first.code, first.stderr).toBe(0); - expect(second.code, second.stderr).toBe(0); - expect(first.id).toBe(second.id); - const attached = yield* openStack(StackIdSchema.make(first.id)); - expect((yield* attached.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live( - "maintenance stop keeps an unconfigured owner usable without fabricating lifecycle state", - () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project }); - yield* stack.stop; - const status = yield* stack.status; - expect(status.lifecycle).toBe("unconfigured"); - }), - ), - ); }); diff --git a/packages/stack/src/supervisor/idle-retirement.integration.test.ts b/packages/stack/src/supervisor/idle-retirement.integration.test.ts new file mode 100644 index 0000000000..4c4ff741c2 --- /dev/null +++ b/packages/stack/src/supervisor/idle-retirement.integration.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Ref } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import { makeIdleRetirement } from "./IdleRetirement.ts"; + +const first = ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"); +const second = ServiceInstanceIdSchema.make("22222222-2222-4222-8222-222222222222"); + +describe("idle retirement timers", () => { + it.effect("fires only after the current arm delay", () => + Effect.scoped( + Effect.gen(function* () { + const retired = yield* Ref.make>([]); + const timers = yield* makeIdleRetirement((id, generation) => + Ref.update(retired, (ids) => [...ids, `${id}:${generation}`]), + ); + yield* timers.arm(first, 1, 5); + yield* TestClock.adjust("4 seconds"); + expect(yield* Ref.get(retired)).toEqual([]); + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(retired)).toEqual([`${first}:1`]); + }), + ), + ); + + it.effect("rearming replaces the old timer and cancellation prevents retirement", () => + Effect.scoped( + Effect.gen(function* () { + const retired = yield* Ref.make>([]); + const timers = yield* makeIdleRetirement((id, generation) => + Ref.update(retired, (ids) => [...ids, `${id}:${generation}`]), + ); + yield* timers.arm(first, 1, 5); + yield* TestClock.adjust("4 seconds"); + yield* timers.arm(first, 2, 5); + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(retired)).toEqual([]); + yield* TestClock.adjust("4 seconds"); + expect(yield* Ref.get(retired)).toEqual([`${first}:2`]); + yield* timers.arm(second, 1, 5); + yield* timers.cancel(second); + yield* timers.arm( + ServiceInstanceIdSchema.make("33333333-3333-4333-8333-333333333333"), + 1, + 5, + ); + yield* timers.arm( + ServiceInstanceIdSchema.make("33333333-3333-4333-8333-333333333333"), + 2, + 0, + ); + yield* TestClock.adjust("5 seconds"); + expect(yield* Ref.get(retired)).toEqual([`${first}:2`]); + }), + ), + ); + + it.effect("keeps timers independent and ignores disabled timeout values", () => + Effect.scoped( + Effect.gen(function* () { + const retired = yield* Ref.make>([]); + const timers = yield* makeIdleRetirement((id, generation) => + Ref.update(retired, (ids) => [...ids, `${id}:${generation}`]), + ); + yield* timers.arm(first, 1, 1); + yield* timers.arm(second, 1, 2); + yield* timers.arm( + ServiceInstanceIdSchema.make("33333333-3333-4333-8333-333333333333"), + 1, + 0, + ); + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(retired)).toEqual([`${first}:1`]); + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(retired)).toEqual([`${first}:1`, `${second}:1`]); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 0c83dfd1c0..5716f873b5 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -1,821 +1,640 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Context, Crypto, Effect, Exit, FileSystem, Option, Path, Ref, Scope } from "effect"; +import { Context, Crypto, Deferred, Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Native server is required for the backend fixture. +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- TCP servers are required for the gateway fixture. import { - createServer as createHttpServer, - request as requestHttp, - type IncomingMessage, - type ServerResponse, - // oxlint-disable-next-line effecttsgo/node-builtin-import -- fixture needs Node request clients and server instances to exercise ingress forwarding and adoption. -} from "node:http"; -import type { Duplex } from "node:stream"; + createConnection, + createServer as createTcpServer, + type Server as TcpServer, +} from "node:net"; +import { compileStack, createExecutionPlan, seedServiceRegistry } from "../model/Compiler.ts"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; -import { compileStack } from "../model/Compiler.ts"; -import { excludeStackCapabilities } from "../model/Exclusions.ts"; -import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import { - GatewayActivationError, - PortUnavailableError, - StackLifecycleConflictError, - StackPreparationError, -} from "../public/Errors.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { resolveSecrets } from "../state/SecretStore.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { makeSupervisorIngress } from "./Ingress.ts"; -import { bindHostListener } from "./HostListener.ts"; -import type { HostListener } from "./HostListener.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; - -const identity: StackIdentity = { - projectRoot: "/tmp/supabase-ingress", - branchContext: "ordinary-workspace", - stackName: "ingress", -}; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { BackendEndpoint } from "../gateway/Gateway.ts"; +import { makeSupervisorIngress, type SupervisorIngressOptions } from "./Ingress.ts"; +import { bindHostListener, type HostListener } from "./HostListener.ts"; +import type { PortField } from "../public/Status.ts"; const run = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -const bindPrivate = (_address: string, port: number, _binding: string) => - Effect.succeed({ port, close: Effect.void }); - -const closeServer = (server: ReturnType): Effect.Effect => - Effect.callback((resume) => { - if (!server.listening) return resume(Effect.void); - server.close(() => resume(Effect.void)); - }); + Effect.scoped(effect).pipe( + Effect.provide(Layer.merge(NodeServices.layer, FetchHttpClient.layer)), + ); -const listenBackend = (server: ReturnType) => +const listenBackend = (body = "backend-ready") => Effect.acquireRelease( - Effect.callback((resume) => { + Effect.callback((resume) => { + const server = createServer((_request: IncomingMessage, response: ServerResponse) => { + response.statusCode = 200; + response.end(body); + }); server.once("error", (error) => resume(Effect.fail(error))); - server.listen(0, "127.0.0.1", () => resume(Effect.void)); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + return Effect.sync(() => { + if (server.listening) server.close(); + }); }), - () => closeServer(server), - ).pipe(Effect.as(server)); + (server) => + Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }), + ); + +const request = (endpoint: BackendEndpoint, path: string) => + HttpClient.get( + new URL( + path, + `http://${endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}`, + ), + ).pipe( + Effect.flatMap((response) => + response.text.pipe(Effect.map((body) => ({ status: response.status, body }))), + ), + ); + +const listenTcpBackend = (body: string) => + Effect.gen(function* () { + const received = yield* Deferred.make(); + const server = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const server = createTcpServer((socket) => { + socket.once("data", () => + Deferred.doneUnsafe( + received, + Effect.map(Effect.void, () => undefined), + ), + ); + socket.once("data", () => socket.end(body)); + }); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }), + ); + return { server, received }; + }); + +const tcpRequest = (endpoint: BackendEndpoint) => + Effect.callback((resume) => { + let settled = false; + const socket = createConnection({ host: endpoint.host, port: endpoint.port }); + const onConnect = () => { + if (settled) return; + socket.end("probe"); + }; + const onData = () => { + if (settled) return; + settled = true; + socket.off("error", onError); + socket.end(); + resume(Effect.map(Effect.void, () => undefined)); + }; + const onError = (error: Error) => { + if (settled) return; + settled = true; + socket.off("connect", onConnect); + socket.off("data", onData); + resume(Effect.fail(error)); + }; + socket.once("connect", onConnect); + socket.once("data", onData); + socket.once("error", onError); + return Effect.sync(() => { + socket.off("connect", onConnect); + socket.off("data", onData); + socket.off("error", onError); + socket.destroy(); + }); + }); -const makeIngressContext = (prefix: string) => +const makeFixture = (backendPort: number, bindHost?: SupervisorIngressOptions["bindHost"]) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-v2-" }); + const templatePath = `${root}/confirm.html`; + yield* fs.writeFileString(templatePath, "auth-template"); + const identity: StackIdentity = { + projectRoot: root, + branchContext: "ordinary-workspace", + stackName: "ingress", + }; + const stackId = yield* deriveStackId(identity); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + listeners: { + api: { enabled: true }, + functionsInspector: { enabled: true }, + }, + }, + }); + const seeded = yield* seedServiceRegistry( + compiled.definition, + { projectRoot: root, path, runtime: { kind: "container", engine: "docker" } }, + compiled.sourceConfig, + compiled.secrets, + ); + const resolved = yield* resolveSecrets( + { declarations: seeded.secretSlots }, + undefined, + "stopped", + ); + const registry = { + ...seeded.registry, + instances: seeded.registry.instances.map((instance) => ({ + ...instance, + intent: "started" as const, + })), + }; + const runtime = { kind: "container", engine: "docker" } as const; + const plan = yield* createExecutionPlan(runtime, registry); + const privateIntents = privateBindingIntentsFor(plan, { + runtime, + registry, + listeners: { api: { enabled: true } }, + }); + const base: PersistedStackState = { + format: "supabase-stack-state-v2" as const, + identity, + runtime, + preparation: compiled.definition.preparation, + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "secret:auth.settings.jwt_secret" } }, + }, + }, + listeners: {}, + registry, + ports: [], + privatePorts: privateIntents.map((intent, index) => ({ + ...intent, + port: backendPort + index + 1, + })), + secrets: resolved.persisted, + } satisfies PersistedStackState; + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(stackId, base); const context = Context.make(FileSystem.FileSystem, fs).pipe( Context.add(Path.Path, path), Context.add(Crypto.Crypto, crypto), ); - const root = yield* fs.makeTempDirectoryScoped({ prefix }); - return { context, fs, path, root }; - }); - -const persistedIngressState = ( - identity: StackIdentity, - compiled: { - readonly definition: PersistedStackState["definition"]; - readonly executionPlan: ExecutionPlan; - }, - privatePortBase: number, -): PersistedStackState => ({ - format: "supabase-stack-state-v1", - identity, - runtime: { kind: "native" }, - desiredLifecycle: "running", - definition: compiled.definition, - ports: [], - privatePorts: privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }).map((binding, index) => ({ - ...binding, - port: privatePortBase + index, - })), - secrets: {}, -}); - -const request = (port: number, path = "/rest/v1/items", method = "GET", host = "127.0.0.1") => - Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { - const client = requestHttp({ host, port, path, method }, (response: IncomingMessage) => { - const chunks: Buffer[] = []; - response.on("data", (chunk: Buffer) => chunks.push(chunk)); - response.once("end", () => - resume( - Effect.succeed({ - status: response.statusCode ?? 0, - body: Buffer.concat(chunks).toString(), - }), - ), - ); + const input = { + stackId, + state: base, + definition: compiled.definition, + secrets: resolved.persisted, + plan, + }; + const ingress = yield* makeSupervisorIngress({ + stackId, + stateRoot: root, + store, + context, + bindHost, + resolveInternalApiBindAddress: () => Effect.succeed("::1"), + resolveAuthTemplates: () => + Effect.succeed([{ id: "confirm", canonicalPath: templatePath, extension: ".html" }]), + bindPrivate: (_address, port) => Effect.succeed({ port, close: Effect.void }), + apiMaterial: () => + Effect.succeed({ + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "service", + }), }); - client.once("error", (error) => resume(Effect.fail(error))); - client.end(); - return Effect.sync(() => client.destroy()); + if (ingress.publish === undefined) + return yield* Effect.die("Ingress publication is unavailable"); + if (ingress.setTrafficAcquirer === undefined) + return yield* Effect.die("Ingress traffic admission is unavailable"); + yield* ingress.setTrafficAcquirer(() => Effect.succeed({ release: Effect.void })); + return { input, ingress, plan, publish: ingress.publish, store }; }); -describe("Supervisor ingress", () => { - it.live("does not allocate a public listener for a disabled pooler workload", () => - run( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const context = Context.make(FileSystem.FileSystem, fs).pipe( - Context.add(Path.Path, path), - Context.add(Crypto.Crypto, crypto), - ); - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-pooler-" }); - const stackIdentity = { ...identity, projectRoot: root }; - const stackId = yield* deriveStackId(stackIdentity); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { pooler: { enabled: false } }, - listeners: { pooler: { enabled: true, port: 55_329 } }, - }, - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(stackId, { - format: "supabase-stack-state-v1", - identity: stackIdentity, - runtime: { kind: "native" }, - desiredLifecycle: "running", - definition: compiled.definition, - ports: [], - privatePorts: privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }).map((binding, index) => ({ ...binding, port: 30_000 + index })), - secrets: {}, - }); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindPrivate, - }); - const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); - yield* ingress.acquire({ - stackId, - state, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }); - const acquired = yield* store.read(stackId).pipe(Effect.map((value) => value!)); - expect(acquired.ports.some(({ field }) => field === "pooler")).toBe(false); - expect(acquired.ports.some(({ field }) => field === "api")).toBe(true); - }), - ), - ); +const workloadFor = ( + plan: { readonly workloads: ReadonlyArray }, + recipeId: string, +) => { + const workload = plan.workloads.find((entry) => entry.recipeId === recipeId); + if (workload === undefined) throw new Error(`Missing workload ${recipeId}`); + return workload; +}; - it.live("closes reservation scopes after repeated failed acquire attempts", () => +describe("Supervisor ingress", () => { + it.live("arms dormant Studio and pooler listeners and wakes each backend", () => run( Effect.gen(function* () { - const { context, fs, path, root } = yield* makeIngressContext("supabase-ingress-scope-"); - const projectRoot = path.join(root, "project"); - yield* fs.makeDirectory(projectRoot); - const stackIdentity = { - ...identity, - projectRoot, - }; - const stackId = yield* deriveStackId(stackIdentity); - const databasePort = 50_000 + (Number.parseInt(stackId.slice(0, 4), 16) % 10_000); - const compiled = yield* compileStack({ - projectRoot, - runtime: { kind: "native" }, - config: { listeners: { database: { port: databasePort } } }, - }); - const store = yield* makeStackStateStore({ - stateRoot: path.join(root, "managed", "stacks"), - }); - yield* store.initialize(stackId, { - format: "supabase-stack-state-v1", - identity: stackIdentity, - runtime: { kind: "native" }, - desiredLifecycle: "running", - definition: compiled.definition, - ports: [], - privatePorts: privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }).map((binding, index) => ({ - ...binding, - port: 30_000 + index, - })), - secrets: {}, - }); - const apiCloseCount = yield* Ref.make(0); - const bindHost = ( - address: string, - port: number, - field: HostListener["field"], - ): Effect.Effect => { - if (field === "database") - return Effect.fail( - new PortUnavailableError({ - field, - port, - message: "Injected database listener failure", - }), - ); - if (field !== "api") - return Effect.fail( - new PortUnavailableError({ - field, - port, - message: "Unexpected listener bind", + const studioBackend = yield* listenBackend("studio-ready"); + const poolerBackend = yield* listenTcpBackend("pooler-ready"); + const studioAddress = studioBackend.address(); + const poolerAddress = poolerBackend.server.address(); + if ( + typeof studioAddress !== "object" || + studioAddress === null || + typeof poolerAddress !== "object" || + poolerAddress === null + ) + return yield* Effect.die("lazy backends did not expose ports"); + const boundPorts = new Map(); + const bindHost = (address: string, _port: number, field: PortField) => + bindHostListener(address, 0, field).pipe( + Effect.tap((listener: HostListener) => + Effect.sync(() => { + boundPorts.set(field, listener.port); }), - ); - const close = Ref.update(apiCloseCount, (count) => count + 1); - return Effect.gen(function* () { - yield* Effect.addFinalizer(() => close); - return { - field, - address, - port, - close, - connections: { sockets: new Set() }, - binding: { kind: "http", server: createHttpServer() }, - } satisfies HostListener; - }); - }; - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: path.join(root, "managed", "stacks"), - store, - context, - bindHost, - bindPrivate, - }); - const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); - const input = { - stackId, - state, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }; - for (let attempt = 1; attempt <= 3; attempt += 1) { - expect(Exit.isFailure(yield* ingress.acquire(input).pipe(Effect.exit))).toBe(true); - expect(yield* Ref.get(apiCloseCount)).toBe(attempt); - } - }), - ), - ); - - it.live("adopts a coordinated listener and forwards a public request", () => - run( - Effect.gen(function* () { - const { context, root } = yield* makeIngressContext("supabase-ingress-"); - const stackIdentity = { - ...identity, - projectRoot: root, - }; - const stackId = yield* deriveStackId(stackIdentity); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - listeners: { - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, - }, - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(stackId, persistedIngressState(stackIdentity, compiled, 30000)); - const listenerCloseCount = yield* Ref.make(0); - const bindHost = (address: string, port: number, field: HostListener["field"]) => - bindHostListener(address, port, field).pipe( - Effect.map((listener) => ({ - ...listener, - close: listener.close.pipe( - Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), - ), - })), + ), ); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindPrivate, - apiMaterial: () => - Effect.succeed({ - publishableKey: "sb_publishable_test", - secretKey: "sb_secret_test", - anonJwt: "anon-jwt", - serviceRoleJwt: "service-jwt", - }), - bindHost, - resolveInternalApiBindAddress: () => Effect.succeed("::1"), - }); - const input = { - stackId, - desiredLifecycle: "running" as const, - state: yield* store.read(stackId).pipe(Effect.map((value) => value!)), - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }; - const reservation = yield* ingress.acquire(input); - expect(reservation.hostListeners).toHaveLength(1); - const backend = yield* listenBackend( - createHttpServer((_request: IncomingMessage, response: ServerResponse) => { - response.statusCode = 200; - response.end("forwarded"); - }), - ); - const backendAddress = backend.address(); - if (typeof backendAddress !== "object" || backendAddress === null) - return yield* Effect.die("backend did not expose an address"); - let recoveryRequired = false; - yield* ingress.open(input, reservation, (capability) => - recoveryRequired - ? Effect.fail( - new StackLifecycleConflictError({ - message: "cleanup failed", - recovery: { operation: "stop", message: "backend cleanup failed" }, - }), - ) - : Effect.succeed({ - capability, - endpoint: { host: "127.0.0.1", port: backendAddress.port }, - }), + const fixture = yield* makeFixture(studioAddress.port, bindHost); + const studio = fixture.input.state.registry.instances.find( + (instance) => instance.service === "studio", ); - const api = reservation.assignments.api; - if (api === undefined) return yield* Effect.die("API listener was not assigned"); - const response = yield* request(api.port); - expect(response.status).toBe(200); - expect(response.body).toBe("forwarded"); - const internalResponse = yield* request(api.port, "/rest/v1/items", "GET", "::1"); - expect(internalResponse.status).toBe(200); - expect(internalResponse.body).toBe("forwarded"); - recoveryRequired = true; - const recoveryResponse = yield* request(api.port); - expect(recoveryResponse.status).toBe(503); - expect(recoveryResponse.body).toContain('"error":"STACK_RECOVERY_REQUIRED"'); - expect(recoveryResponse.body).toContain('"operation":"stop"'); - expect(recoveryResponse.body).toContain( - '"message":"Retry stack stop before activating workloads"', + const pooler = fixture.input.state.registry.instances.find( + (instance) => instance.service === "pooler", ); - const reused = yield* ingress.acquire(input); - expect(reused.fresh).toBe(false); - recoveryRequired = false; - yield* ingress.open(input, reused, (capability) => - Effect.succeed({ - capability, - endpoint: { host: "127.0.0.1", port: backendAddress.port }, + if (studio === undefined || pooler === undefined) + return yield* Effect.die("Studio or pooler instance is missing"); + const registry = { + ...fixture.input.state.registry, + instances: fixture.input.state.registry.instances.map((instance) => { + if (instance.service === "studio") + return { + ...instance, + intent: "started" as const, + config: { ...instance.config, activation: "lazy" as const }, + }; + if (instance.service === "pooler") + return { + ...instance, + intent: "started" as const, + config: { ...instance.config, activation: "lazy" as const }, + }; + return instance; }), - ); - const reusedResponse = yield* request(api.port); - expect(reusedResponse.status).toBe(200); - expect(reusedResponse.body).toBe("forwarded"); - yield* ingress.close; - expect(yield* Ref.get(listenerCloseCount)).toBe(2); - const reacquired = yield* ingress.acquire(input); - expect(reacquired.fresh).toBe(true); - const stale = yield* ingress - .open(input, reservation, (capability) => - Effect.succeed({ - capability, - endpoint: { host: "127.0.0.1", port: backendAddress.port }, - }), - ) - .pipe(Effect.exit); - expect(Exit.isFailure(stale)).toBe(true); - yield* ingress.close; - }), - ), - ); - - it.live("reuses a wildcard API listener for the internal loopback bind", () => - run( - Effect.gen(function* () { - const { context, root } = yield* makeIngressContext("supabase-ingress-wildcard-"); - const stackIdentity = { - ...identity, - projectRoot: root, }; - const stackId = yield* deriveStackId(stackIdentity); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, + const studioPort = 10_000; + const poolerPort = 10_001; + const state: PersistedStackState = { + ...fixture.input.state, + listeners: { api: { enabled: false } }, + registry, + ports: [ + { + owner: "instance", + instanceId: studio.id, + binding: "studio", + address: "127.0.0.1", + port: studioPort, + intent: "exact", }, - listeners: { - api: { address: "0.0.0.0" }, - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, + { + owner: "instance", + instanceId: pooler.id, + binding: "pooler", + address: "127.0.0.1", + port: poolerPort, + intent: "exact", }, - }, - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const persisted = persistedIngressState(stackIdentity, compiled, 30_000); - yield* store.initialize(stackId, persisted); - const binds: Array<{ readonly address: string; readonly field: HostListener["field"] }> = - []; - const bindHost = (address: string, port: number, field: HostListener["field"]) => - bindHostListener(address, port, field).pipe( - Effect.tap((listener) => - Effect.sync(() => { - binds.push({ address: listener.address, field }); - }), - ), - ); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindHost, - bindPrivate, - apiMaterial: () => - Effect.succeed({ - publishableKey: "sb_publishable_test", - secretKey: "sb_secret_test", - anonJwt: "anon-jwt", - serviceRoleJwt: "service-jwt", - }), - resolveInternalApiBindAddress: () => Effect.succeed("127.0.0.1"), - }); - const input = { - stackId, - desiredLifecycle: "running" as const, - state: persisted, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, + ], }; - const reservation = yield* ingress.acquire(input); - const backend = yield* listenBackend( - createHttpServer((_request: IncomingMessage, response: ServerResponse) => { - response.statusCode = 200; - response.end("wildcard-forwarded"); - }), - ); - const backendAddress = backend.address(); - if (typeof backendAddress !== "object" || backendAddress === null) - return yield* Effect.die("backend did not expose an address"); - yield* ingress.open(input, reservation, (capability) => - Effect.succeed({ - capability, - endpoint: { host: "127.0.0.1", port: backendAddress.port }, - }), + const plan = yield* createExecutionPlan(state.runtime, registry); + yield* fixture.store.replace(fixture.input.stackId, state); + const studioWorkload = workloadFor(plan, "studio:studio"); + const poolerWorkload = plan.workloads.find((entry) => entry.instanceId === pooler.id); + if (studioWorkload === undefined || poolerWorkload === undefined) + return yield* Effect.die("lazy workloads are missing"); + const setActivator = fixture.ingress.setInstanceActivator; + if (setActivator === undefined) return yield* Effect.die("activation is unavailable"); + yield* setActivator((instanceId) => + instanceId === studio.id + ? fixture.publish(studio.id, [ + { + workloadId: studioWorkload.id, + recipeId: studioWorkload.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: studioAddress.port }, + }, + ]) + : fixture.publish(pooler.id, [ + { + workloadId: poolerWorkload.id, + recipeId: poolerWorkload.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: poolerAddress.port }, + }, + ]), ); - const api = reservation.assignments.api; - if (api === undefined) return yield* Effect.die("API listener was not assigned"); - const response = yield* request(api.port); - expect(response.status).toBe(200); - expect(response.body).toBe("wildcard-forwarded"); - expect(binds.filter((entry) => entry.field === "api")).toHaveLength(1); - expect(binds.find((entry) => entry.field === "api")?.address).toBe("0.0.0.0"); - yield* ingress.close; + const arm = fixture.ingress.armLazyIngress; + if (arm === undefined) return yield* Effect.die("lazy ingress arming is unavailable"); + yield* arm(state, plan); + yield* arm(state, plan); + const actualStudioPort = boundPorts.get("studio"); + const actualPoolerPort = boundPorts.get("pooler"); + if (actualStudioPort === undefined || actualPoolerPort === undefined) + return yield* Effect.die("lazy listeners were not bound"); + const studioResponse = yield* request({ host: "127.0.0.1", port: actualStudioPort }, "/"); + expect(studioResponse).toEqual({ status: 200, body: "studio-ready" }); + yield* tcpRequest({ host: "127.0.0.1", port: actualPoolerPort }); + yield* Deferred.await(poolerBackend.received); + yield* fixture.ingress.close; }), ), ); - it.live("rejects incomplete persisted gateway material before opening", () => + it.live("arms the shared Functions API before a lazy workload starts", () => run( Effect.gen(function* () { - const { context, root } = yield* makeIngressContext("supabase-ingress-material-"); - const stackId = yield* deriveStackId({ - ...identity, - projectRoot: root, - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { capabilities: { rest: {} } }, - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(stackId, { - format: "supabase-stack-state-v1", - identity: { - ...identity, - projectRoot: root, - }, - runtime: { kind: "native" }, - desiredLifecycle: "running", - definition: compiled.definition, - ports: [], - privatePorts: privateBindingIntentsFor(compiled.executionPlan, { - definition: compiled.definition, - runtime: { kind: "native" }, - }).map((binding, index) => ({ - ...binding, - port: 30100 + index, - })), - secrets: {}, - }); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindPrivate, - }); - const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); - const reservation = yield* ingress.acquire({ - stackId, - state, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }); - const failed = yield* ingress - .open( - { - stackId, - state, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }, - reservation, - () => Effect.fail(new GatewayActivationError({ message: "not reached" })), - ) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - if (Exit.isFailure(failed)) { - const error = Cause.findErrorOption(failed.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackPreparationError); - expect(error.value.message).toBe("Persisted API gateway material is incomplete"); - } - } + const backend = yield* listenBackend(); + const address = backend.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("backend did not expose a port"); + const changedAddress = "::1"; + const fixture = yield* makeFixture(address.port); + const functions = workloadFor(fixture.plan, "functions:edge-runtime"); + const arm = fixture.ingress.armLazyIngress; + if (arm === undefined) return yield* Effect.die("Functions API arming is unavailable"); + const primary: RuntimeBindingPublication = { + workloadId: functions.id, + recipeId: functions.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: address.port }, + }; + const setActivator = fixture.ingress.setInstanceActivator; + if (setActivator === undefined) + return yield* Effect.die("Functions activation is unavailable"); + yield* setActivator(() => fixture.publish(functions.instanceId, [primary])); + yield* arm(fixture.input.state, fixture.plan); + yield* arm(fixture.input.state, fixture.plan); + const isWakeable = fixture.ingress.isInstanceWakeable; + if (isWakeable === undefined) + return yield* Effect.die("Functions wakeability is unavailable"); + expect(yield* isWakeable(functions.instanceId)).toBe(true); + const state = yield* fixture.store.read(fixture.input.stackId); + if (state === undefined) return yield* Effect.die("Armed state is missing"); + const api = state?.ports.find( + (entry) => entry.owner === "stack" && entry.binding === "api", + ); + if (api === undefined) return yield* Effect.die("shared API listener was not reserved"); + const response = yield* request( + { host: "127.0.0.1", port: api.port }, + "/functions/v1/items", + ); + expect(response).toEqual({ status: 200, body: "backend-ready" }); + const changedState: PersistedStackState = { + ...state, + listeners: { api: { enabled: true, address: changedAddress, port: api.port } }, + ports: state.ports.map((entry) => + entry.owner === "stack" && entry.binding === "api" + ? { ...entry, address: changedAddress, intent: "exact" as const } + : entry, + ), + }; + yield* fixture.store.replace(fixture.input.stackId, changedState); + yield* arm(changedState, fixture.plan); + const changedResponse = yield* request( + { host: changedAddress, port: api.port }, + "/functions/v1/items", + ); + expect(changedResponse).toEqual({ status: 200, body: "backend-ready" }); + const disabledState: PersistedStackState = { + ...changedState, + listeners: { api: { enabled: false } }, + }; + yield* fixture.store.replace(fixture.input.stackId, disabledState); + yield* arm(disabledState, fixture.plan); + const reopened = yield* bindHostListener(changedAddress, api.port, "api"); + yield* reopened.close; + yield* fixture.ingress.close; }), ), ); - it.live("opens non-API listeners without resolving API gateway material", () => + it.live("arms shared API routes when Functions is disabled", () => run( Effect.gen(function* () { - const { context, root } = yield* makeIngressContext("supabase-ingress-no-api-"); - const stackIdentity = { - ...identity, - projectRoot: root, + const backend = yield* listenBackend(); + const storageBackend = yield* listenBackend("storage-backend"); + const imgproxyBackend = yield* listenBackend("imgproxy-backend"); + const address = backend.address(); + const storageAddress = storageBackend.address(); + const imgproxyAddress = imgproxyBackend.address(); + if ( + typeof address !== "object" || + address === null || + typeof storageAddress !== "object" || + storageAddress === null || + typeof imgproxyAddress !== "object" || + imgproxyAddress === null + ) + return yield* Effect.die("backend did not expose a port"); + const fixture = yield* makeFixture(address.port); + const registry = { + ...fixture.input.state.registry, + instances: fixture.input.state.registry.instances.map((instance) => + instance.service === "functions" + ? { ...instance, config: { ...instance.config, enabled: false } } + : instance, + ), }; - const stackId = yield* deriveStackId(stackIdentity); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - listeners: { - api: { enabled: false }, - database: { enabled: true }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, + const state = { ...fixture.input.state, registry }; + const plan = yield* createExecutionPlan(state.runtime, registry); + yield* fixture.store.replace(fixture.input.stackId, state); + const rest = plan.workloads.find((entry) => entry.capability === "rest"); + if (rest === undefined) return yield* Effect.die("REST workload is missing"); + const storage = workloadFor(plan, "storage:storage"); + const imgproxy = workloadFor(plan, "storage:imgproxy"); + const arm = fixture.ingress.armLazyIngress; + if (arm === undefined) return yield* Effect.die("API arming is unavailable"); + const setActivator = fixture.ingress.setInstanceActivator; + if (setActivator === undefined) return yield* Effect.die("API activation is unavailable"); + yield* setActivator(() => + fixture.publish(rest.instanceId, [ + { + workloadId: rest.id, + recipeId: rest.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: address.port }, }, - }, - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const persisted = persistedIngressState(stackIdentity, compiled, 30200); - yield* store.initialize(stackId, persisted); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindPrivate, - apiMaterial: () => - Effect.fail(new StackPreparationError({ message: "API material must not resolve" })), - }); - const input = { - stackId, - desiredLifecycle: "running" as const, - state: persisted, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }; - const reservation = yield* ingress.acquire(input); - expect(reservation.assignments.api).toBeUndefined(); - expect(reservation.assignments.database?.port).toEqual(expect.any(Number)); - expect(reservation.privateAssignments).toEqual( - expect.arrayContaining([ - { workloadId: "database:database", binding: "primary", port: expect.any(Number) }, ]), ); - yield* ingress.open(input, reservation, () => - Effect.fail(new GatewayActivationError({ message: "not reached" })), + yield* arm(state, plan); + yield* fixture.publish(storage.instanceId, [ + { + workloadId: storage.id, + recipeId: storage.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: storageAddress.port }, + }, + { + workloadId: imgproxy.id, + recipeId: imgproxy.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: imgproxyAddress.port }, + }, + ]); + const isWakeable = fixture.ingress.isInstanceWakeable; + if (isWakeable === undefined) return yield* Effect.die("API wakeability is unavailable"); + expect(yield* isWakeable(rest.instanceId)).toBe(true); + const persisted = yield* fixture.store.read(fixture.input.stackId); + const api = persisted?.ports.find( + (entry) => entry.owner === "stack" && entry.binding === "api", + ); + if (api === undefined) return yield* Effect.die("shared API listener was not reserved"); + const response = yield* request({ host: "127.0.0.1", port: api.port }, "/rest/v1/items"); + expect(response).toEqual({ status: 200, body: "backend-ready" }); + const storageResponse = yield* request( + { host: "127.0.0.1", port: api.port }, + "/storage/v1/bucket", + ); + expect(storageResponse).toEqual({ status: 200, body: "storage-backend" }); + const internalResponse = yield* request({ host: "::1", port: api.port }, "/rest/v1/items"); + expect(internalResponse).toEqual({ status: 200, body: "backend-ready" }); + const templateResponse = yield* request( + { host: "127.0.0.1", port: api.port }, + "/email/confirm.html", ); - yield* ingress.close; + expect(templateResponse).toEqual({ status: 200, body: "auth-template" }); + yield* fixture.ingress.close; }), ), ); - it.live("opens postgres-only stacks without resolving API gateway material", () => + it.live("updates the shared API route set as instances are added and removed", () => run( Effect.gen(function* () { - const { context, root } = yield* makeIngressContext("supabase-ingress-pg-only-"); - const stackIdentity = { - ...identity, - projectRoot: root, + const firstBackend = yield* listenBackend("primary-backend"); + const secondBackend = yield* listenBackend("secondary-backend"); + const firstAddress = firstBackend.address(); + const secondAddress = secondBackend.address(); + if ( + typeof firstAddress !== "object" || + firstAddress === null || + typeof secondAddress !== "object" || + secondAddress === null + ) + return yield* Effect.die("backends did not expose ports"); + const fixture = yield* makeFixture(firstAddress.port); + const primary = fixture.input.state.registry.instances.find( + (instance) => instance.service === "rest", + ); + if (primary === undefined) return yield* Effect.die("default REST instance is missing"); + const secondaryId = ServiceInstanceIdSchema.make("secondary-rest"); + const secondary = { ...primary, id: secondaryId, name: "secondary-rest" }; + const registry = { + ...fixture.input.state.registry, + instances: [...fixture.input.state.registry.instances, secondary], }; - const stackId = yield* deriveStackId(stackIdentity); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: excludeStackCapabilities( - {}, - CAPABILITY_NAMES.filter((name) => name !== "database"), + const state = { ...fixture.input.state, registry }; + const plan = yield* createExecutionPlan(state.runtime, registry); + yield* fixture.store.replace(fixture.input.stackId, state); + const arm = fixture.ingress.armLazyIngress; + if (arm === undefined) return yield* Effect.die("API arming is unavailable"); + yield* arm(state, plan); + const primaryWorkload = plan.workloads.find( + (workload) => workload.instanceId === primary.id && workload.capability === "rest", + ); + const secondaryWorkload = plan.workloads.find( + (workload) => workload.instanceId === secondary.id && workload.capability === "rest", + ); + if (primaryWorkload === undefined || secondaryWorkload === undefined) + return yield* Effect.die("REST workloads are missing"); + yield* fixture.publish(primary.id, [ + { + workloadId: primaryWorkload.id, + recipeId: primaryWorkload.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: firstAddress.port }, + }, + ]); + yield* fixture.publish(secondary.id, [ + { + workloadId: secondaryWorkload.id, + recipeId: secondaryWorkload.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: secondAddress.port }, + }, + ]); + const persisted = yield* fixture.store.read(fixture.input.stackId); + const api = persisted?.ports.find( + (entry) => entry.owner === "stack" && entry.binding === "api", + ); + if (api === undefined) return yield* Effect.die("shared API listener was not reserved"); + const unpublish = fixture.ingress.unpublish; + if (unpublish === undefined) return yield* Effect.die("Ingress unpublish is unavailable"); + yield* unpublish(primary.id); + const response = yield* request({ host: "127.0.0.1", port: api.port }, "/rest/v1/items"); + expect(response).toEqual({ status: 200, body: "secondary-backend" }); + const stoppedRegistry = { + ...registry, + instances: registry.instances.map((instance) => + instance.service === "rest" ? { ...instance, intent: "stopped" as const } : instance, ), - }); - expect(compiled.definition.listeners.api.enabled).toBe(true); - const store = yield* makeStackStateStore({ stateRoot: root }); - const persisted = persistedIngressState(stackIdentity, compiled, 30300); - yield* store.initialize(stackId, persisted); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindPrivate, - }); - const input = { - stackId, - desiredLifecycle: "running" as const, - state: persisted, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, }; - const reservation = yield* ingress.acquire(input); - expect(reservation.assignments.api).toBeUndefined(); - expect(reservation.assignments.database?.port).toEqual(expect.any(Number)); - yield* ingress.open(input, reservation, () => - Effect.fail(new GatewayActivationError({ message: "not reached" })), + const armedState = yield* fixture.store.read(fixture.input.stackId); + if (armedState === undefined) return yield* Effect.die("Armed state is missing"); + const stoppedState = { ...armedState, registry: stoppedRegistry }; + const stoppedPlan = yield* createExecutionPlan(stoppedState.runtime, stoppedRegistry); + yield* fixture.store.replace(fixture.input.stackId, stoppedState); + const auth = stoppedPlan.workloads.find((workload) => workload.capability === "auth"); + if (auth === undefined) return yield* Effect.die("Auth workload is missing"); + yield* fixture.publish(auth.instanceId, [ + { + workloadId: auth.id, + recipeId: auth.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: firstAddress.port }, + }, + ]); + const stoppedResponse = yield* request( + { host: "127.0.0.1", port: api.port }, + "/rest/v1/items", ); - yield* ingress.close; - }), - ), - ); - - it.live("serves accepted Auth templates locally with live content and no activation", () => - run( - Effect.gen(function* () { - const { context, fs, path, root } = yield* makeIngressContext("supabase-ingress-template-"); - const stackIdentity = { - ...identity, - projectRoot: root, + expect(stoppedResponse.status).toBe(404); + const resumedRegistry = { + ...stoppedRegistry, + instances: stoppedRegistry.instances.map((instance) => + instance.id === secondary.id ? { ...instance, intent: "started" as const } : instance, + ), }; - const stackId = yield* deriveStackId(stackIdentity); - const templatePath = path.join(root, "templates", "confirmation.html"); - const outsideRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-ingress-outside-", - }); - const outsidePath = path.join(outsideRoot, "outside.html"); - yield* fs.makeDirectory(path.join(root, "templates"), { recursive: true }); - yield* fs.writeFileString(templatePath, "first"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - config: { - capabilities: { - rest: { enabled: false }, - auth: { - settings: { - email: { - template: { confirmation: { content_path: "templates/confirmation.html" } }, - }, - }, - }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - listeners: { - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, + const resumedState = { ...stoppedState, registry: resumedRegistry }; + const resumedPlan = yield* createExecutionPlan(resumedState.runtime, resumedRegistry); + yield* fixture.store.replace(fixture.input.stackId, resumedState); + const resumedWorkload = resumedPlan.workloads.find( + (workload) => workload.instanceId === secondary.id && workload.capability === "rest", + ); + if (resumedWorkload === undefined) + return yield* Effect.die("Resumed REST workload is missing"); + yield* fixture.publish(secondary.id, [ + { + workloadId: resumedWorkload.id, + recipeId: resumedWorkload.recipeId, + binding: "primary", + endpoint: { host: "127.0.0.1", port: secondAddress.port }, }, - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const persisted = persistedIngressState(stackIdentity, compiled, 30300); - yield* store.initialize(stackId, persisted); - const activated: string[] = []; - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindPrivate, - apiMaterial: () => - Effect.succeed({ - publishableKey: "sb_publishable_test", - secretKey: "sb_secret_test", - anonJwt: "anon-jwt", - serviceRoleJwt: "service-jwt", - }), - resolveAuthTemplates: () => - Effect.all({ root: fs.realPath(root), template: fs.realPath(templatePath) }).pipe( - Effect.mapError( - (cause) => new StackPreparationError({ message: "Template is unavailable", cause }), - ), - Effect.flatMap(({ root: canonicalRoot, template: canonicalPath }) => - !path.relative(canonicalRoot, canonicalPath).startsWith("..") && - !path.isAbsolute(path.relative(canonicalRoot, canonicalPath)) - ? Effect.succeed([ - { - id: "confirmation", - extension: ".html", - canonicalPath, - }, - ]) - : Effect.fail(new StackPreparationError({ message: "Template escaped root" })), - ), - ), - }); - const input = { - stackId, - desiredLifecycle: "running" as const, - state: persisted, - definition: compiled.definition, - secrets: {}, - plan: compiled.executionPlan, - }; - const reservation = yield* ingress.acquire(input); - yield* ingress.open(input, reservation, (capability) => { - activated.push(capability); - return Effect.fail(new GatewayActivationError({ message: "not reached" })); - }); - const api = reservation.assignments.api; - if (api === undefined) return yield* Effect.die("API listener was not assigned"); - const first = yield* request(api.port, "/email/confirmation.html"); - expect(first.status).toBe(200); - expect(first.body).toBe("first"); - expect(activated).toEqual([]); - yield* fs.writeFileString(templatePath, "second"); - const second = yield* request(api.port, "/email/confirmation.html?live=1"); - expect(second.status).toBe(200); - expect(second.body).toBe("second"); - expect((yield* request(api.port, "/email/unknown.html")).status).toBe(404); - expect((yield* request(api.port, "/email/confirmation.html", "POST")).status).toBe(404); - expect((yield* request(api.port, "/email/confirmation.html", "OPTIONS")).status).toBe(404); - expect((yield* request(api.port, "/email/../outside.html")).status).toBe(404); - expect((yield* request(api.port, "/email/%2e%2e/outside.html")).status).toBe(404); - yield* fs.remove(templatePath); - expect((yield* request(api.port, "/email/confirmation.html")).status).toBe(404); - yield* fs.writeFileString(outsidePath, "outside"); - yield* fs.symlink(outsidePath, templatePath); - expect((yield* request(api.port, "/email/confirmation.html")).status).toBe(404); - expect(activated).toEqual([]); - yield* ingress.close; + ]); + const resumedResponse = yield* request( + { host: "127.0.0.1", port: api.port }, + "/rest/v1/items", + ); + expect(resumedResponse).toEqual({ status: 200, body: "secondary-backend" }); + yield* fixture.ingress.close; }), ), ); diff --git a/packages/stack/src/supervisor/instance-concurrency.integration.test.ts b/packages/stack/src/supervisor/instance-concurrency.integration.test.ts new file mode 100644 index 0000000000..c0cf72f7b5 --- /dev/null +++ b/packages/stack/src/supervisor/instance-concurrency.integration.test.ts @@ -0,0 +1,1455 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + Crypto, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Option, + Path, + Ref, + Scope, + Stream, +} from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { deriveStackId } from "../identity/Identity.ts"; +import { compileServiceInstance, compileServiceRestart } from "../model/Compiler.ts"; +import type { SnapshotDescriptor } from "../public/Service.ts"; +import type { ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import { StackLifecycleConflictError, StackStateInvalidError } from "../public/Errors.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; +import { makeInstanceEngine } from "./InstanceEngine.ts"; +import type { SupervisorRuntime } from "./Supervisor.ts"; +import type { InstanceRuntimeInput } from "./Lifecycle.ts"; + +const makeFixture = ( + pauseAfterStartAdmission = false, + pauseFirstStop = false, + failEndpointPublication = false, + failRuntimeStop = false, + failRuntimeDestroy = false, + pauseHandoffCleanup = false, + failHandoffCleanup = false, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-instance-concurrency-" }); + const identity = { projectRoot: root, branchContext: "test", stackName: "concurrency" }; + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: path.join(root, "state") }); + yield* store.initialize(stackId, { + format: "supabase-stack-state-v2", + identity, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: {}, + registry: { initialized: true, instances: [], defaultInstanceIds: {} }, + ports: [], + privatePorts: [], + secrets: { [AUTH_JWT_SECRET_SLOT]: { policy: "managed", value: "concurrency-test-secret" } }, + }); + const context = { projectRoot: root, path, runtime: { kind: "native" } as const }; + const database = yield* compileServiceInstance( + { service: "database", name: "shadow", config: { endpoints: { sql: { enabled: false } } } }, + context, + ); + const functions = yield* compileServiceInstance( + { service: "functions", name: "functions", config: { activation: "lazy" } }, + context, + ); + const enteredStart = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + const enteredDependentStart = yield* Deferred.make(); + const releaseDependentStart = yield* Deferred.make(); + const pauseDependentStart = yield* Ref.make(false); + const enteredExport = yield* Deferred.make(); + const releaseExport = yield* Deferred.make(); + const admittedStart = yield* Deferred.make(); + const admissionPaused = yield* Ref.make(false); + const startAdmissionComplete = yield* Ref.make(!pauseAfterStartAdmission); + const pauseBatchAdmission = yield* Ref.make(false); + const batchReadArmed = yield* Ref.make(false); + const batchReadPending = yield* Ref.make(false); + const admittedBatch = yield* Deferred.make(); + const releaseBatchAdmission = yield* Deferred.make(); + const handoffCleanupPaused = yield* Ref.make(false); + const admittedHandoffCleanup = yield* Deferred.make(); + const releaseHandoffCleanup = yield* Deferred.make(); + const ready = yield* Ref.make>(new Set()); + const active = yield* Ref.make>(new Set()); + const starts = yield* Ref.make>([]); + const enteredStop = yield* Deferred.make(); + const releaseStop = yield* Deferred.make(); + const stopPaused = yield* Ref.make(false); + const runtimeStopFails = yield* Ref.make(failRuntimeStop); + const runtimeDestroyFails = yield* Ref.make(failRuntimeDestroy); + const failHandoffCleanupOnce = yield* Ref.make(failHandoffCleanup); + const snapshot = (input: InstanceRuntimeInput): SnapshotDescriptor => ({ + lineageId: "source-lineage", + initializationProfileId: null, + artifactIdentity: "postgres-test", + runtimeIdentity: "native", + dataFormat: { provider: "postgres", format: "pgdata", majorVersion: 17 }, + provenance: { sourceInstanceId: input.instance.id, exportOperationId: input.operation.id }, + }); + const runtime: Pick< + SupervisorRuntime, + "prepare" | "start" | "stop" | "destroy" | "exportSnapshot" | "restoreSnapshot" + > = { + prepare: () => Effect.succeed({ instances: [] }), + start: (input) => + Effect.gen(function* () { + yield* Ref.update(starts, (ids) => [...ids, input.instance.id]); + if (input.instance.id === database.id) { + yield* Deferred.succeed(enteredStart, undefined); + yield* Deferred.await(releaseStart); + } + if (input.instance.id !== database.id && (yield* Ref.get(pauseDependentStart))) { + yield* Deferred.succeed(enteredDependentStart, undefined); + yield* Deferred.await(releaseDependentStart); + } + yield* Ref.update(ready, (ids) => new Set(ids).add(input.instance.id)); + return []; + }), + stop: (input) => + Effect.gen(function* () { + if (yield* Ref.get(runtimeStopFails)) + return yield* new StackLifecycleConflictError({ message: "runtime cleanup failed" }); + if (pauseFirstStop && !(yield* Ref.getAndSet(stopPaused, true))) { + yield* Deferred.succeed(enteredStop, input.instance.id); + yield* Deferred.await(releaseStop); + } + yield* Ref.update( + ready, + (ids) => new Set([...ids].filter((id) => id !== input.instance.id)), + ); + }), + destroy: (input) => + Effect.gen(function* () { + if (yield* Ref.get(runtimeDestroyFails)) + return yield* new StackLifecycleConflictError({ message: "runtime destroy failed" }); + yield* Ref.update( + ready, + (ids) => new Set([...ids].filter((id) => id !== input.instance.id)), + ); + }), + exportSnapshot: (input) => + Deferred.succeed(enteredExport, undefined).pipe( + Effect.andThen(Deferred.await(releaseExport)), + Effect.as(snapshot(input)), + ), + restoreSnapshot: (input) => Effect.succeed(snapshot(input)), + }; + const engineStore: typeof store = { + ...store, + update: (id, transform) => + store.update(id, transform).pipe( + Effect.flatMap((saved) => + saved.registry.instances.some( + (instance) => + instance.id === database.id && + instance.intent === "started" && + instance.pendingOperation === null, + ) + ? Ref.getAndSet(failHandoffCleanupOnce, false).pipe( + Effect.flatMap((fail) => + fail + ? Effect.fail( + new StackStateInvalidError({ + message: "handoff journal cleanup failed", + }), + ) + : Effect.succeed(saved), + ), + ) + : Effect.succeed(saved), + ), + Effect.tap((saved) => + Effect.gen(function* () { + if ( + pauseAfterStartAdmission && + saved.registry.instances.some( + (instance) => + instance.id === database.id && instance.pendingOperation?.kind === "start", + ) && + !(yield* Ref.getAndSet(admissionPaused, true)) + ) { + yield* Deferred.succeed(admittedStart, undefined); + } + if ( + (yield* Ref.get(pauseBatchAdmission)) && + (yield* Ref.get(startAdmissionComplete)) && + !(yield* Ref.getAndSet(batchReadArmed, true)) + ) { + yield* Ref.set(batchReadPending, true); + } + if ( + pauseHandoffCleanup && + saved.registry.instances.some( + (instance) => + instance.id === database.id && + instance.intent === "started" && + instance.pendingOperation === null, + ) && + !(yield* Ref.getAndSet(handoffCleanupPaused, true)) + ) { + yield* Deferred.succeed(admittedHandoffCleanup, undefined); + yield* Deferred.await(releaseHandoffCleanup); + } + if (pauseAfterStartAdmission && (yield* Ref.get(admissionPaused))) + yield* Ref.set(startAdmissionComplete, true); + }), + ), + ), + read: (id) => + store + .read(id) + .pipe( + Effect.flatMap((state) => + Ref.getAndSet(batchReadPending, false).pipe( + Effect.flatMap((pause) => + pause + ? Deferred.succeed(admittedBatch, undefined).pipe( + Effect.andThen(Deferred.await(releaseBatchAdmission)), + Effect.as(state), + ) + : Effect.succeed(state), + ), + ), + ), + ), + }; + const engine = yield* makeInstanceEngine({ + stackId, + ownerSessionId: "concurrency-owner", + stateStore: engineStore, + runtime, + scope: yield* Scope.Scope, + context: yield* Effect.context(), + isInstanceActive: (id) => Ref.get(active).pipe(Effect.map((ids) => ids.has(id))), + ...(failEndpointPublication + ? { + publishEndpoints: () => + Effect.fail( + new StackLifecycleConflictError({ message: "endpoint publication failed" }), + ), + } + : {}), + }); + yield* engine.create(database.instance, database.secretSlots); + yield* engine.create(functions.instance, functions.secretSlots); + return { + context, + engine, + store, + stackId, + database, + functions, + ready, + active, + starts, + enteredStop, + releaseStop, + runtimeStopFails, + runtimeDestroyFails, + failHandoffCleanupOnce, + enteredStart, + releaseStart, + enteredDependentStart, + releaseDependentStart, + pauseDependentStart, + enteredExport, + releaseExport, + admittedStart, + pauseBatchAdmission, + admittedBatch, + releaseBatchAdmission, + handoffCleanupPaused, + admittedHandoffCleanup, + releaseHandoffCleanup, + }; + }); +const fixture = makeFixture(); + +describe("instance operation isolation with durable transactions", () => { + it.effect("automatically sleeps an eligible instance after its configured idle interval", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* Deferred.succeed(f.releaseStart, undefined); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "idle-rest", + config: { activation: "lazy", idleTimeoutSeconds: 5 }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* f.engine.start(rest.id); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + const observing = yield* Deferred.make(); + const slept = yield* f.engine.followStatus(rest.id).pipe( + Stream.tap((status) => + status.phase === "ready" ? Deferred.succeed(observing, undefined) : Effect.void, + ), + Stream.filter((status) => status.phase === "dormant"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(observing); + yield* TestClock.adjust("4 seconds"); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(slept); + expect(yield* f.engine.status(rest.id)).toMatchObject({ + phase: "dormant", + intent: "started", + }); + expect((yield* Ref.get(f.ready)).has(rest.id)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps active traffic awake and rearms idle retirement when the traffic ends", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* Deferred.succeed(f.releaseStart, undefined); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "active-rest", + config: { activation: "lazy", idleTimeoutSeconds: 5 }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* f.engine.start(rest.id); + const release = yield* f.engine.acquireTraffic(rest.id); + yield* TestClock.adjust("10 seconds"); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + expect((yield* Ref.get(f.ready)).has(rest.id)).toBe(true); + yield* release.release; + const observing = yield* Deferred.make(); + const slept = yield* f.engine.followStatus(rest.id).pipe( + Stream.tap((status) => + status.phase === "ready" ? Deferred.succeed(observing, undefined) : Effect.void, + ), + Stream.filter((status) => status.phase === "dormant"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(observing); + yield* TestClock.adjust("4 seconds"); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(slept); + expect(yield* f.engine.status(rest.id)).toMatchObject({ + phase: "dormant", + intent: "started", + }); + expect((yield* Ref.get(f.ready)).has(rest.id)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("waits for readiness before admitting ordinary traffic", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true, true); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "startup-control-rest", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + + const unclaimed = yield* f.engine.acquireTraffic(rest.id, "startup-control"); + yield* unclaimed.release; + + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const startup = yield* f.engine.acquireTraffic(f.database.id, "startup-control"); + const ordinary = yield* f.engine + .acquireTraffic(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + yield* startup.release; + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* Fiber.join(starting)).phase).toBe("ready"); + yield* (yield* Fiber.join(ordinary)).release; + + const ready = yield* f.engine.acquireTraffic(f.database.id, "startup-control"); + yield* ready.release; + + const stopping = yield* f.engine + .stop(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.enteredStop); + const duringRetirement = yield* f.engine + .acquireTraffic(f.database.id) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(duringRetirement.pollUnsafe()).toBeUndefined(); + const duringStop = yield* f.engine + .acquireTraffic(f.database.id, "startup-control") + .pipe(Effect.exit); + expect(Exit.isFailure(duringStop)).toBe(true); + yield* Deferred.succeed(f.releaseStop, undefined); + expect((yield* Fiber.join(stopping)).phase).toBe("stopped"); + expect(Exit.isFailure(yield* Fiber.join(duringRetirement))).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("waits through sleep before admitting traffic to a dormant started instance", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + const sleeping = yield* f.engine + .sleep(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.enteredStop); + const traffic = yield* f.engine + .acquireTraffic(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(traffic.pollUnsafe()).toBeUndefined(); + yield* Effect.yieldNow; + yield* Deferred.succeed(f.releaseStop, undefined); + expect((yield* Fiber.join(sleeping)).phase).toBe("dormant"); + const lease = yield* Fiber.join(traffic); + expect((yield* f.engine.start(f.database.id)).phase).toBe("ready"); + yield* lease.release; + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("stopAll supersedes an in-flight start", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const stopping = yield* f.engine + .stopAll([f.database.id]) + .pipe(Effect.forkChild({ startImmediately: true })); + const statuses = yield* Fiber.join(stopping); + expect(statuses[0]?.phase).toBe("stopped"); + const startExit = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(startExit)).toBe(true); + if (Exit.isFailure(startExit)) + expect(Option.getOrUndefined(Cause.findErrorOption(startExit.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* f.engine.start(f.database.id)).phase).toBe("ready"); + const traffic = yield* f.engine.acquireTraffic(f.database.id); + yield* traffic.release; + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("startAll joins an in-flight single-instance start", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const batchStarting = yield* f.engine + .startAll([f.database.id]) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* Fiber.join(starting)).phase).toBe("ready"); + expect((yield* Fiber.join(batchStarting))[0]?.phase).toBe("ready"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("shares a start owner when its first waiter is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const first = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const second = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(first); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* Fiber.join(second)).phase).toBe("ready"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("keeps a batch start owner alive when its caller is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false); + yield* Ref.set(f.pauseBatchAdmission, true); + const starting = yield* f.engine + .startAll([f.database.id]) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedBatch); + const traffic = yield* f.engine + .acquireTraffic(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(traffic.pollUnsafe()).toBeUndefined(); + const interruption = yield* Effect.forkChild(Fiber.interrupt(starting), { + startImmediately: true, + }); + yield* Deferred.succeed(f.releaseBatchAdmission, undefined); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* Fiber.join(interruption).pipe(Effect.timeout("5 seconds")); + const lease = yield* Fiber.join(traffic).pipe(Effect.timeout("5 seconds")); + yield* lease.release; + expect((yield* f.engine.status(f.database.id)).phase).toBe("ready"); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("settles traffic that observed a superseded batch start claim", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + yield* Ref.set(f.pauseBatchAdmission, true); + const stopping = yield* f.engine + .stopAll([f.database.id]) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedBatch); + const trafficObservedState = yield* Deferred.make(); + const traffic = yield* Effect.gen(function* () { + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + yield* Deferred.succeed(trafficObservedState, undefined); + return yield* f.engine.acquireTraffic(f.database.id); + }).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(trafficObservedState); + const competing = yield* f.engine.stopAll([f.database.id]).pipe(Effect.exit); + expect(Exit.isFailure(competing)).toBe(true); + expect(Exit.isFailure(yield* f.engine.stop(f.database.id).pipe(Effect.exit))).toBe(true); + yield* Deferred.succeed(f.releaseBatchAdmission, undefined); + expect((yield* Fiber.join(stopping))[0]?.phase).toBe("stopped"); + expect(Exit.isFailure(yield* Fiber.join(traffic).pipe(Effect.exit))).toBe(true); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* f.engine.start(f.database.id)).phase).toBe("ready"); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("keeps a superseded stop owner alive when its caller is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true, true, false, false, false, true); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const stopping = yield* f.engine + .stop(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedHandoffCleanup); + yield* Fiber.interrupt(stopping); + yield* Deferred.succeed(f.releaseHandoffCleanup, undefined); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* Deferred.await(f.enteredStop).pipe(Effect.timeout("5 seconds")); + const traffic = yield* f.engine + .acquireTraffic(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(traffic.pollUnsafe()).toBeUndefined(); + const settled = yield* f.engine.followStatus(f.database.id).pipe( + Stream.takeUntil((status) => status.phase === "stopped"), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(f.releaseStop, undefined); + yield* Fiber.join(settled); + expect((yield* f.engine.status(f.database.id)).phase).toBe("stopped"); + expect(Exit.isFailure(yield* Fiber.join(traffic).pipe(Effect.exit))).toBe(true); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fences a superseded stop when its journal cleanup fails", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true, false, false, false, false, false, true); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + yield* Ref.set(f.failHandoffCleanupOnce, true); + const stopped = yield* f.engine.stop(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(stopped)).toBe(true); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* f.engine.status(f.database.id)).phase).toBe("recovery"); + expect( + Exit.isFailure(yield* f.engine.acquireTraffic(f.database.id).pipe(Effect.exit)), + ).toBe(true); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("cleans up a runtime when endpoint publication fails", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + + const started = yield* f.engine.start(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(started)).toBe(true); + expect((yield* Ref.get(f.ready)).has(f.database.id)).toBe(false); + expect((yield* f.engine.status(f.database.id)).phase).toBe("failed"); + expect((yield* f.engine.status(f.functions.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("keeps the startup fence when publication cleanup fails", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, true, true); + yield* Deferred.succeed(f.releaseStart, undefined); + + const started = yield* f.engine.start(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(started)).toBe(true); + expect((yield* f.engine.status(f.database.id)).phase).toBe("recovery"); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + expect((yield* f.engine.status(f.database.id)).recovery?.operation).toBe("stop"); + yield* Ref.set(f.runtimeStopFails, false); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fences traffic after a failed stop until cleanup is retried", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + const stopped = yield* f.engine.stop(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(stopped)).toBe(true); + expect(yield* f.engine.status(f.database.id)).toMatchObject({ + intent: "stopped", + phase: "recovery", + pendingOperation: { kind: "stop" }, + recovery: { operation: "stop" }, + }); + expect( + Exit.isFailure(yield* f.engine.acquireTraffic(f.database.id).pipe(Effect.exit)), + ).toBe(true); + yield* Ref.set(f.runtimeStopFails, false); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fences traffic after a failed sleep until cleanup is retried", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + expect(Exit.isFailure(yield* f.engine.sleep(f.database.id).pipe(Effect.exit))).toBe(true); + expect(yield* f.engine.status(f.database.id)).toMatchObject({ + intent: "started", + phase: "recovery", + pendingOperation: { kind: "sleep" }, + recovery: { operation: "stop" }, + }); + expect( + Exit.isFailure(yield* f.engine.acquireTraffic(f.database.id).pipe(Effect.exit)), + ).toBe(true); + yield* Ref.set(f.runtimeStopFails, false); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fences traffic after a failed restart teardown until cleanup is retried", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + expect(Exit.isFailure(yield* f.engine.restart(f.database.id).pipe(Effect.exit))).toBe(true); + expect(yield* f.engine.status(f.database.id)).toMatchObject({ + intent: "started", + phase: "recovery", + pendingOperation: { kind: "restart" }, + recovery: { operation: "stop" }, + }); + expect( + Exit.isFailure(yield* f.engine.acquireTraffic(f.database.id).pipe(Effect.exit)), + ).toBe(true); + yield* Ref.set(f.runtimeStopFails, false); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("destroys a publication cleanup fence in the same owner", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, true, true); + yield* Deferred.succeed(f.releaseStart, undefined); + expect(Exit.isFailure(yield* f.engine.start(f.database.id).pipe(Effect.exit))).toBe(true); + yield* Ref.set(f.runtimeStopFails, false); + yield* f.engine.destroy(f.database.id); + expect((yield* f.engine.list).some(({ id }) => id === f.database.id)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retains stopped intent after a failed destroy until cleanup is retried", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, false, false, false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + expect(Exit.isFailure(yield* f.engine.destroy(f.database.id).pipe(Effect.exit))).toBe(true); + expect(yield* f.engine.status(f.database.id)).toMatchObject({ + intent: "stopped", + phase: "recovery", + pendingOperation: { kind: "destroy" }, + recovery: { operation: "destroy" }, + }); + yield* Ref.set(f.runtimeDestroyFails, false); + yield* f.engine.destroy(f.database.id); + expect((yield* f.engine.list).some(({ id }) => id === f.database.id)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("destroyAll supersedes an in-flight start", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + yield* f.engine.destroyAll([f.database.id]); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + expect((yield* f.engine.list).some(({ id }) => id === f.database.id)).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects targeted destroy for a registered stopped dependent", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "stopped-dependent", + config: {}, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + const starting = yield* f.engine + .start(f.database.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const destroyed = yield* f.engine.destroyAll([f.database.id]).pipe(Effect.exit); + expect(Exit.isFailure(destroyed)).toBe(true); + if (Exit.isFailure(destroyed)) + expect(Option.getOrUndefined(Cause.findErrorOption(destroyed.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + expect((yield* f.engine.stop(f.database.id)).phase).toBe("stopped"); + yield* Deferred.succeed(f.releaseStart, undefined); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects explicit sleep of a stopped instance without admitting it", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const before = yield* f.engine.status(f.database.id); + expect(before).toMatchObject({ intent: "stopped", phase: "stopped" }); + const slept = yield* f.engine.sleep(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(slept)).toBe(true); + expect(yield* f.engine.status(f.database.id)).toEqual(before); + expect(yield* Ref.get(f.ready)).toEqual(new Set()); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("reserves every batch sleep member before waiting for a slow selected stop", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.startAll([f.database.id, f.functions.id]); + const sleeping = yield* f.engine + .sleepAll([f.database.id, f.functions.id]) + .pipe(Effect.forkChild); + const stopping = yield* Effect.raceFirst( + Deferred.await(f.enteredStop), + Fiber.join(sleeping).pipe( + Effect.andThen( + Effect.die(new Error("Batch sleep completed without stopping a runtime")), + ), + ), + ); + const other = stopping === f.database.id ? f.functions.id : f.database.id; + const restarted = yield* f.engine.restart(other).pipe(Effect.exit); + yield* Deferred.succeed(f.releaseStop, undefined); + yield* Fiber.join(sleeping); + expect(Exit.isFailure(restarted)).toBe(true); + if (Exit.isFailure(restarted)) + expect(Option.getOrUndefined(Cause.findErrorOption(restarted.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(f.database.id)).phase).toBe("dormant"); + expect((yield* f.engine.status(f.functions.id)).phase).toBe("dormant"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "rejects stopping or restarting a database with a ready dependent outside selection", + () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "rest", + config: {}, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(rest.id); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + expect(yield* f.engine.stop(f.database.id).pipe(Effect.flip)).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect(yield* f.engine.restart(f.database.id).pipe(Effect.flip)).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(f.database.id)).phase).toBe("ready"); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + expect(yield* Ref.get(f.starts)).toEqual([f.database.id, rest.id]); + yield* f.engine.stopAll([rest.id, f.database.id]); + expect((yield* f.engine.status(f.database.id)).phase).toBe("stopped"); + expect((yield* f.engine.status(rest.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects targeted teardown before superseding a joined dependency start", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "joined-rest", + config: {}, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + const starting = yield* f.engine + .start(rest.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.admittedStart); + const stopped = yield* f.engine.stop(f.database.id).pipe(Effect.exit); + const destroyed = yield* f.engine.destroy(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(stopped)).toBe(true); + expect(Exit.isFailure(destroyed)).toBe(true); + if (Exit.isFailure(stopped)) + expect(Option.getOrUndefined(Cause.findErrorOption(stopped.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + if (Exit.isFailure(destroyed)) + expect(Option.getOrUndefined(Cause.findErrorOption(destroyed.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + expect((yield* f.engine.status(rest.id)).pendingOperation?.kind).toBe("start"); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* Fiber.join(starting)).phase).toBe("ready"); + const statuses = yield* f.engine.stopAll(); + expect(statuses.every((status) => status.phase === "stopped")).toBe(true); + expect((yield* f.engine.status(f.database.id)).pendingOperation).toBeUndefined(); + expect((yield* f.engine.status(rest.id)).pendingOperation).toBeUndefined(); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("protects a ready dependent while allowing an already dormant one", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, true); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "sleep-dependent-rest", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(rest.id); + expect((yield* f.engine.status(rest.id)).phase).toBe("ready"); + expect(yield* f.engine.sleep(f.database.id).pipe(Effect.flip)).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(f.database.id)).phase).toBe("ready"); + const sleepingRest = yield* f.engine + .sleep(rest.id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(f.enteredStop); + yield* Deferred.succeed(f.releaseStop, undefined); + yield* Fiber.join(sleepingRest); + expect((yield* f.engine.status(rest.id)).phase).toBe("dormant"); + expect((yield* f.engine.sleep(f.database.id)).phase).toBe("dormant"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects dependency teardown after a dependent setup is admitted", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "blocked-dependent-rest", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* Ref.set(f.pauseDependentStart, true); + yield* Deferred.succeed(f.releaseStart, undefined); + const starting = yield* f.engine.start(rest.id).pipe(Effect.forkChild); + yield* Deferred.await(f.enteredDependentStart); + expect(yield* f.engine.sleep(f.database.id).pipe(Effect.flip)).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(rest.id)).pendingOperation?.kind).toBe("start"); + yield* Deferred.succeed(f.releaseDependentStart, undefined); + expect((yield* Fiber.join(starting)).phase).toBe("ready"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects dependent admission after teardown claims its dependency", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, true); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "partial-lease-rest", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + const analytics = yield* compileServiceInstance( + { + service: "analytics", + name: "partial-lease-analytics", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + const studio = yield* compileServiceInstance( + { + service: "studio", + name: "partial-lease-studio", + config: { activation: "lazy" }, + dependencies: { database: f.database.id, rest: rest.id, analytics: analytics.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* f.engine.create(analytics.instance, analytics.secretSlots); + yield* f.engine.create( + { ...studio.instance, intent: "stopped" as const }, + studio.secretSlots, + ); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(rest.id); + yield* f.engine.start(analytics.id); + const stopping = yield* f.engine.stop(rest.id).pipe(Effect.forkChild); + yield* Deferred.await(f.enteredStop); + const starting = yield* f.engine.start(studio.id).pipe(Effect.forkChild); + const startExit = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(startExit)).toBe(true); + if (Exit.isFailure(startExit)) + expect(Option.getOrUndefined(Cause.findErrorOption(startExit.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(studio.id)).pendingOperation).toBeUndefined(); + yield* Deferred.succeed(f.releaseStop, undefined); + yield* Fiber.join(stopping); + expect((yield* f.engine.status(rest.id)).phase).toBe("stopped"); + yield* f.engine.sleep(analytics.id); + expect((yield* f.engine.sleep(f.database.id)).phase).toBe("dormant"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects dependent registration after teardown claims its dependency", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false, true); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + const stopping = yield* f.engine.stop(f.database.id).pipe(Effect.forkChild); + yield* Deferred.await(f.enteredStop); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "blocked-registration-rest", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + const created = yield* f.engine.create(rest.instance, rest.secretSlots).pipe(Effect.exit); + expect(Exit.isFailure(created)).toBe(true); + if (Exit.isFailure(created)) + expect(Option.getOrUndefined(Cause.findErrorOption(created.cause))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect(yield* f.engine.list).not.toContainEqual(expect.objectContaining({ id: rest.id })); + yield* Deferred.succeed(f.releaseStop, undefined); + yield* Fiber.join(stopping); + expect((yield* f.engine.status(f.database.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("replans endpoint assignments when a targeted restart changes intent", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(false); + const shadow = yield* compileServiceInstance( + { + service: "database", + name: "restart-endpoint-shadow", + config: { endpoints: { sql: { port: "auto" } } }, + }, + f.context, + ); + yield* f.engine.create(shadow.instance, shadow.secretSlots); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(shadow.id); + const before = yield* f.store.read(f.stackId); + expect( + before?.ports.some( + (assignment) => assignment.owner === "instance" && assignment.instanceId === shadow.id, + ), + ).toBe(true); + if (before === undefined) + return yield* new StackLifecycleConflictError({ message: "state was not persisted" }); + const currentShadow = before.registry.instances.find( + (instance) => instance.id === shadow.id, + ); + if (currentShadow === undefined) + return yield* new StackLifecycleConflictError({ message: "shadow was not persisted" }); + const replacement = yield* compileServiceRestart( + currentShadow, + { endpoints: { sql: { enabled: false } } }, + f.context, + ); + yield* f.engine.restart(shadow.id, { + ...replacement, + previous: { state: before, instance: currentShadow }, + }); + const after = yield* f.store.read(f.stackId); + expect( + after?.ports.some( + (assignment) => assignment.owner === "instance" && assignment.instanceId === shadow.id, + ), + ).toBe(false); + expect( + after?.registry.instances.find((instance) => instance.id === shadow.id), + ).toMatchObject({ config: { endpoints: { sql: { enabled: false } } } }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects an active batch sleep before stopping any selected instance", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.startAll([f.database.id, f.functions.id]); + yield* Ref.set(f.active, new Set([f.database.id])); + expect( + yield* f.engine.sleepAll([f.database.id, f.functions.id]).pipe(Effect.flip), + ).toBeInstanceOf(StackLifecycleConflictError); + expect((yield* f.engine.status(f.database.id)).phase).toBe("ready"); + expect((yield* f.engine.status(f.functions.id)).phase).toBe("ready"); + expect(yield* Ref.get(f.ready)).toEqual(new Set([f.database.id, f.functions.id])); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("admits lazy Functions on whole start and makes explicit selections ready", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "whole-start-rest", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + const analytics = yield* compileServiceInstance( + { + service: "analytics", + name: "whole-start-analytics", + config: { activation: "lazy" }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + const studio = yield* compileServiceInstance( + { + service: "studio", + name: "whole-start-studio", + config: { activation: "lazy", endpoints: { studio: { port: "auto" } } }, + dependencies: { database: f.database.id, rest: rest.id, analytics: analytics.id }, + }, + f.context, + ); + const pooler = yield* compileServiceInstance( + { + service: "pooler", + name: "whole-start-pooler", + config: { activation: "lazy", endpoints: { pooler: { port: "auto" } } }, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* f.engine.create(analytics.instance, analytics.secretSlots); + yield* f.engine.create(studio.instance, studio.secretSlots); + yield* f.engine.create(pooler.instance, pooler.secretSlots); + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.startAll(); + expect(yield* Ref.get(f.starts)).toEqual([f.database.id]); + const persisted = yield* f.store.read(f.stackId); + expect( + persisted?.ports.filter( + (entry) => entry.owner === "instance" && entry.instanceId === studio.id, + ), + ).toEqual([expect.objectContaining({ binding: "studio", intent: "automatic" })]); + expect( + persisted?.ports.filter( + (entry) => entry.owner === "instance" && entry.instanceId === pooler.id, + ), + ).toEqual([expect.objectContaining({ binding: "pooler", intent: "automatic" })]); + expect(yield* f.engine.status(f.functions.id)).toMatchObject({ + intent: "started", + phase: "dormant", + activation: "lazy", + }); + yield* f.engine.startAll([f.functions.id]); + expect((yield* f.engine.status(f.functions.id)).phase).toBe("ready"); + expect(yield* Ref.get(f.starts)).toEqual([f.database.id, f.functions.id]); + yield* f.engine.startAll(); + expect((yield* f.engine.status(f.functions.id)).phase).toBe("ready"); + expect(yield* Ref.get(f.starts)).toEqual([f.database.id, f.functions.id]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("completes a paused status subscriber after its instance has been destroyed", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const subscribed = yield* Deferred.make(); + const resume = yield* Deferred.make(); + const observing = yield* f.engine.followStatus(f.database.id).pipe( + Stream.tap(() => + Deferred.succeed(subscribed, undefined).pipe(Effect.andThen(Deferred.await(resume))), + ), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscribed); + yield* f.engine.destroy(f.database.id); + expect((yield* f.engine.list).some(({ id }) => id === f.database.id)).toBe(false); + yield* Deferred.succeed(resume, undefined); + const statuses = yield* Fiber.join(observing); + expect(statuses[0]?.id).toBe(f.database.id); + expect((yield* f.engine.describe({ id: f.functions.id })).id).toBe(f.functions.id); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retains a new registration's ports when another start resumes after admission", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* makeFixture(true); + yield* Deferred.succeed(f.releaseStart, undefined); + const starting = yield* f.engine.start(f.database.id).pipe(Effect.forkChild); + yield* Deferred.await(f.admittedStart); + const second = yield* compileServiceInstance( + { + service: "database", + name: "concurrent-shadow", + config: { endpoints: { sql: { port: "auto" } } }, + }, + f.context, + ); + yield* f.engine.create(second.instance, second.secretSlots); + const registered = yield* f.store.read(f.stackId); + const publicPorts = registered?.ports.filter( + (entry) => entry.owner === "instance" && entry.instanceId === second.id, + ); + const privatePorts = registered?.privatePorts.filter( + ({ instanceId }) => instanceId === second.id, + ); + expect(publicPorts).toHaveLength(1); + expect(privatePorts).toHaveLength(1); + expect((yield* Fiber.join(starting)).phase).toBe("ready"); + const settled = yield* f.store.read(f.stackId); + expect( + settled?.ports.filter( + (entry) => entry.owner === "instance" && entry.instanceId === second.id, + ), + ).toEqual(publicPorts); + expect(settled?.privatePorts.filter(({ instanceId }) => instanceId === second.id)).toEqual( + privatePorts, + ); + expect((yield* f.engine.describe({ id: second.id })).name).toBe("concurrent-shadow"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "rejects database destruction before cleanup when a stopped dependent is registered", + () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* Deferred.succeed(f.releaseStart, undefined); + yield* f.engine.start(f.database.id); + const dependent = yield* compileServiceInstance( + { + service: "rest", + name: "retained-rest", + config: {}, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(dependent.instance, dependent.secretSlots); + const before = yield* f.store.read(f.stackId); + const result = yield* f.engine.destroy(f.database.id).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect((yield* Ref.get(f.ready)).has(f.database.id)).toBe(true); + expect(yield* f.store.read(f.stackId)).toEqual(before); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("destroys disabled dependents before their registered database", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const disabled = yield* compileServiceInstance( + { service: "database", name: "disabled-database", config: { enabled: false } }, + f.context, + ); + yield* f.engine.create(disabled.instance, disabled.secretSlots); + const dependent = yield* compileServiceInstance( + { + service: "rest", + name: "disabled-rest", + config: { enabled: false }, + dependencies: { database: disabled.id }, + }, + f.context, + ); + yield* f.engine.create(dependent.instance, dependent.secretSlots); + expect((yield* f.engine.list).map(({ id }) => id)).toContain(disabled.id); + expect((yield* f.engine.describe({ id: dependent.id })).dependencies.database).toBe( + disabled.id, + ); + yield* f.engine.destroyAll(); + expect(yield* f.engine.list).toEqual([]); + const saved = yield* f.store.read(f.stackId); + expect(saved?.registry.instances).toEqual([]); + expect(saved?.ports).toEqual([]); + expect(saved?.privatePorts).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("keeps one shared dependency start when a dependent caller abandons its wait", () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + const rest = yield* compileServiceInstance( + { service: "rest", name: "rest", config: {}, dependencies: { database: f.database.id } }, + f.context, + ); + const storage = yield* compileServiceInstance( + { + service: "storage", + name: "storage", + config: {}, + dependencies: { database: f.database.id }, + }, + f.context, + ); + yield* f.engine.create(rest.instance, rest.secretSlots); + yield* f.engine.create(storage.instance, storage.secretSlots); + const abandoned = yield* f.engine.start(rest.id).pipe(Effect.forkChild); + yield* Deferred.await(f.enteredStart); + const surviving = yield* f.engine.start(storage.id).pipe(Effect.forkChild); + yield* Fiber.interrupt(abandoned); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* Fiber.join(surviving)).phase).toBe("ready"); + expect((yield* Ref.get(f.starts)).filter((id) => id === f.database.id)).toHaveLength(1); + expect((yield* Ref.get(f.ready)).has(f.database.id)).toBe(true); + expect((yield* f.engine.status(f.database.id)).phase).toBe("ready"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "finishes a Functions restart while shadow initialization outlives its first waiter", + () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* f.engine.start(f.functions.id); + const abandoned = yield* f.engine.start(f.database.id).pipe(Effect.forkChild); + yield* Deferred.await(f.enteredStart); + yield* Fiber.interrupt(abandoned); + const joined = yield* f.engine.start(f.database.id).pipe(Effect.forkChild); + expect((yield* f.engine.restart(f.functions.id)).phase).toBe("ready"); + expect((yield* Ref.get(f.ready)).has(f.functions.id)).toBe(true); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe("start"); + yield* Deferred.succeed(f.releaseStart, undefined); + expect((yield* Fiber.join(joined)).phase).toBe("ready"); + expect((yield* Ref.get(f.starts)).filter((id) => id === f.database.id)).toHaveLength(1); + const saved = yield* f.store.read(f.stackId); + expect( + saved?.registry.instances.map(({ intent, pendingOperation }) => ({ + intent, + pendingOperation, + })), + ).toEqual([ + { intent: "started", pendingOperation: null }, + { intent: "started", pendingOperation: null }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "restarts Functions while a stopped shadow exports and rejects conflicting shadow starts", + () => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* Deferred.succeed(f.releaseStart, undefined); + yield* Effect.all([f.engine.start(f.database.id), f.engine.start(f.functions.id)], { + concurrency: "unbounded", + }); + yield* f.engine.stop(f.database.id); + const exporting = yield* f.engine + .exportSnapshot(f.database.id, "/unused-test-destination") + .pipe(Effect.forkChild); + yield* Deferred.await(f.enteredExport); + expect((yield* f.engine.restart(f.functions.id)).phase).toBe("ready"); + expect(yield* f.engine.start(f.database.id).pipe(Effect.flip)).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* f.engine.status(f.database.id)).pendingOperation?.kind).toBe( + "exportSnapshot", + ); + yield* Deferred.succeed(f.releaseExport, undefined); + expect((yield* Fiber.join(exporting)).provenance.sourceInstanceId).toBe(f.database.id); + const saved = yield* f.store.read(f.stackId); + expect(saved?.registry.instances.find(({ id }) => id === f.database.id)?.intent).toBe( + "stopped", + ); + expect(saved?.registry.instances.find(({ id }) => id === f.functions.id)?.intent).toBe( + "started", + ); + expect( + saved?.registry.instances.every(({ pendingOperation }) => pendingOperation === null), + ).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/supervisor/instance-engine.integration.test.ts b/packages/stack/src/supervisor/instance-engine.integration.test.ts new file mode 100644 index 0000000000..682ffd9fbc --- /dev/null +++ b/packages/stack/src/supervisor/instance-engine.integration.test.ts @@ -0,0 +1,642 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + Context, + Crypto, + Deferred, + Exit, + Effect, + FileSystem, + Fiber, + Path, + Redacted, + Scope, + Stream, +} from "effect"; +import { makeInstanceEngine } from "./InstanceEngine.ts"; +import type { InstanceRuntimeInput } from "./Lifecycle.ts"; +import type { SupervisorRuntime } from "./Supervisor.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import type { + PersistedPendingOperation, + PersistedServiceInstance, +} from "../model/ServiceRegistry.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import { StackCleanupError, StackLifecycleConflictError } from "../public/Errors.ts"; +import type { SnapshotDescriptor } from "../public/Service.ts"; +import type { StackError } from "../public/Errors.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import type { SupervisorIngress } from "./Ingress.ts"; +import type { LogStore } from "./LogStore.ts"; + +const noOpIngress: SupervisorIngress = { close: Effect.void }; + +const noOpLogStore: LogStore = { + path: "/dev/null", + append: () => Effect.die("instance-engine test does not write logs"), + read: () => Effect.succeed([]), +}; + +const instance = ( + id: ServiceInstanceId, + name: string, + enabled = true, +): PersistedServiceInstance => ({ + id, + service: "database", + name, + intent: "stopped", + config: { + enabled, + activation: "eager", + idleTimeoutSeconds: false, + version: "17.6.1.168", + settings: { + health_timeout: "2m", + settings: { + effective_cache_size: null, + logical_decoding_work_mem: null, + maintenance_work_mem: null, + max_connections: null, + max_locks_per_transaction: null, + max_parallel_maintenance_workers: null, + max_parallel_workers: null, + max_parallel_workers_per_gather: null, + max_replication_slots: null, + max_slot_wal_keep_size: null, + max_standby_archive_delay: null, + max_standby_streaming_delay: null, + max_wal_size: null, + max_wal_senders: null, + max_worker_processes: null, + session_replication_role: null, + shared_buffers: null, + statement_timeout: null, + track_activity_query_size: null, + track_commit_timestamp: null, + wal_keep_size: null, + wal_sender_timeout: null, + work_mem: null, + }, + }, + endpoints: {}, + }, + dependencies: {}, + resources: {}, + revisions: { config: 0, intent: 0 }, + pendingOperation: null, + initialization: null, + initializationInputs: null, + data: { origin: "absent" }, +}); + +const state = ( + projectRoot: string, + first: PersistedServiceInstance, + second: PersistedServiceInstance, + secrets: PersistedStackState["secrets"] = { + "test-jwt": { policy: "managed", value: "test-jwt-secret" }, + }, +): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity: { projectRoot, branchContext: "test", stackName: "engine" }, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "test-jwt" } }, + }, + }, + listeners: {}, + registry: { + initialized: true, + instances: [first, second], + defaultInstanceIds: { database: first.id }, + }, + ports: [], + privatePorts: [], + secrets, +}); + +interface Fixture { + readonly context: Context.Context; + readonly engine: import("./InstanceEngine.ts").InstanceEngine; + readonly store: StackStateStore; + readonly stackId: string; + readonly first: PersistedServiceInstance; + readonly second: PersistedServiceInstance; + readonly read: () => Effect.Effect; +} + +interface RuntimeHooks { + readonly start?: ( + input: InstanceRuntimeInput, + ) => Effect.Effect, StackError>; + readonly stop?: (input: InstanceRuntimeInput) => Effect.Effect; + readonly destroy?: (input: InstanceRuntimeInput) => Effect.Effect; + readonly restoreSnapshot?: ( + input: InstanceRuntimeInput, + options: { readonly source: string }, + ) => Effect.Effect; + readonly recoverSnapshot?: ( + input: InstanceRuntimeInput, + operation: PersistedPendingOperation, + ) => Effect.Effect; +} + +const runtimeFor = (hooks: RuntimeHooks = {}): SupervisorRuntime => { + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("instance-engine test does not start driver workloads"), + stop: () => Effect.die("instance-engine test does not stop driver workloads"), + remove: () => Effect.die("instance-engine test does not remove driver workloads"), + cleanup: () => Effect.die("instance-engine test does not clean driver workloads"), + wipePersistentData: () => Effect.die("instance-engine test does not wipe driver workloads"), + }; + const unsupportedSnapshot = (): Effect.Effect => + Effect.fail(new StackLifecycleConflictError({ message: "snapshot is outside this test" })); + return { + driver, + preflight: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + prepareArtifacts: () => Effect.void, + start: hooks.start ?? (() => Effect.succeed([])), + stop: hooks.stop ?? (() => Effect.void), + destroy: hooks.destroy ?? (() => Effect.void), + exportSnapshot: unsupportedSnapshot, + restoreSnapshot: hooks.restoreSnapshot ?? unsupportedSnapshot, + ...(hooks.recoverSnapshot === undefined ? {} : { recoverSnapshot: hooks.recoverSnapshot }), + prefetch: () => Effect.void, + artifacts: Effect.succeed([]), + activate: () => Effect.die("instance-engine test does not activate gateways"), + ingress: noOpIngress, + logStore: noOpLogStore, + }; +}; + +const makeFixture = ( + first: PersistedServiceInstance, + second: PersistedServiceInstance, + secrets?: PersistedStackState["secrets"], + hooks?: RuntimeHooks, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-instance-engine-" }); + const projectRoot = path.join(root, "project"); + yield* fs.makeDirectory(projectRoot); + const identity = { projectRoot, branchContext: "test", stackName: "engine" } as const; + const stackId = yield* deriveStackId(identity); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const store = yield* makeStackStateStore({ stateRoot: path.join(root, "state") }); + yield* store + .initialize(stackId, state(projectRoot, first, second, secrets)) + .pipe(Effect.provideContext(context)); + const engine = yield* makeInstanceEngine({ + stackId, + ownerSessionId: "owner", + stateStore: store, + runtime: runtimeFor(hooks), + scope: yield* Scope.Scope, + context, + }); + return { + context, + engine, + store, + stackId, + first, + second, + read: () => store.read(stackId).pipe(Effect.provideContext(context)), + } satisfies Fixture; + }); + +const id = (value: string): ServiceInstanceId => ServiceInstanceIdSchema.make(value); + +describe("instance engine", () => { + it.live("starts independent instances while one runtime operation is blocked", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000001"), "primary"); + const second = instance(id("00000000-0000-4000-8000-000000000002"), "shadow"); + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + let startCalls = 0; + const f = yield* makeFixture(first, second, undefined, { + start: (input) => + Effect.gen(function* () { + startCalls += 1; + if (input.instance.id === first.id) { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(releaseFirst); + } else yield* Deferred.succeed(secondStarted, undefined); + return []; + }), + }); + const firstFiber = yield* Effect.forkChild(f.engine.start(first.id), { + startImmediately: true, + }); + yield* Deferred.await(firstStarted); + const secondFiber = yield* Effect.forkChild(f.engine.start(second.id), { + startImmediately: true, + }); + yield* Deferred.await(secondStarted); + expect((yield* Fiber.join(secondFiber)).phase).toBe("ready"); + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(firstFiber); + expect((yield* f.engine.start(second.id)).phase).toBe("ready"); + expect(startCalls).toBe(2); + yield* f.engine.destroy(first.id); + expect( + (yield* f.read())?.registry.instances.map(({ id: instanceId }) => instanceId), + ).toEqual([second.id]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("lets stop supersede and cancel an in-flight start", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000041"), "cancel-start"); + const second = instance(id("00000000-0000-4000-8000-000000000042"), "other"); + const entered = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + const never = yield* Deferred.make(); + const f = yield* makeFixture(first, second, undefined, { + start: () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(never)), + Effect.andThen(Effect.succeed([])), + ), + stop: () => Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), + }); + const starting = yield* Effect.forkChild(f.engine.start(first.id), { + startImmediately: true, + }); + yield* Deferred.await(entered); + const stopping = yield* Effect.forkChild(f.engine.stop(first.id), { + startImmediately: true, + }); + yield* Deferred.await(stopped); + expect((yield* Fiber.join(stopping)).phase).toBe("stopped"); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects starting a disabled instance before invoking its runtime", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000051"), "disabled", false); + const second = instance(id("00000000-0000-4000-8000-000000000052"), "other"); + let starts = 0; + const f = yield* makeFixture(first, second, undefined, { + start: () => + Effect.sync(() => { + starts += 1; + return []; + }), + }); + expect(Exit.isFailure(yield* f.engine.start(first.id).pipe(Effect.exit))).toBe(true); + expect(starts).toBe(0); + expect((yield* f.engine.status(first.id)).intent).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("reports mail listener bindings as TCP endpoints", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000055"), "mail-endpoints"); + const second = instance(id("00000000-0000-4000-8000-000000000056"), "other"); + const f = yield* makeFixture(first, second); + yield* f.store + .update(f.stackId, (current) => + Effect.succeed({ + ...current, + ports: [ + { + owner: "instance" as const, + instanceId: first.id, + binding: "smtp", + address: "127.0.0.1", + port: 2525, + intent: "exact" as const, + }, + { + owner: "instance" as const, + instanceId: first.id, + binding: "pop3", + address: "127.0.0.1", + port: 2110, + intent: "exact" as const, + }, + ], + }), + ) + .pipe(Effect.provideContext(f.context)); + + const descriptor = (yield* f.engine.list).find( + ({ id: instanceId }) => instanceId === first.id, + ); + expect(descriptor?.endpoints.smtp).toMatchObject({ + protocol: "tcp", + url: "tcp://127.0.0.1:2525", + }); + expect(descriptor?.endpoints.pop3).toMatchObject({ + protocol: "tcp", + url: "tcp://127.0.0.1:2110", + }); + const status = yield* f.engine.status(first.id); + expect(status.endpoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ binding: "smtp", protocol: "tcp" }), + expect.objectContaining({ binding: "pop3", protocol: "tcp" }), + ]), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retains a redacted startup failure per instance until retry succeeds", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000057"), "failed-start"); + const second = instance(id("00000000-0000-4000-8000-000000000058"), "healthy-start"); + let failFirst = true; + const f = yield* makeFixture(first, second, undefined, { + start: (input) => + input.instance.id === first.id && failFirst + ? Effect.fail( + new StackLifecycleConflictError({ + message: "startup failed with test-jwt-secret", + }), + ) + : Effect.succeed([]), + }); + + expect(Exit.isFailure(yield* f.engine.start(first.id).pipe(Effect.exit))).toBe(true); + const failed = yield* f.engine.status(first.id); + expect(failed.phase).toBe("failed"); + expect(failed.error).toMatchObject({ + tag: "StackLifecycleConflictError", + message: "startup failed with [REDACTED]", + instanceId: first.id, + }); + expect(failed.error?.operationId).toEqual(expect.any(String)); + expect((yield* f.engine.start(second.id)).phase).toBe("ready"); + + failFirst = false; + expect((yield* f.engine.start(first.id)).phase).toBe("ready"); + expect((yield* f.engine.status(first.id)).error).toBeUndefined(); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fences only the instance whose recovery cleanup failed", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000061"), "broken-recovery"); + const second = instance(id("00000000-0000-4000-8000-000000000062"), "healthy"); + const f = yield* makeFixture(first, second, undefined, { + stop: () => + Effect.fail(new StackLifecycleConflictError({ message: "cleanup unavailable" })), + }); + yield* f.store + .update(f.stackId, (current): Effect.Effect => + Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === first.id + ? { + ...entry, + intent: "started" as const, + pendingOperation: { + id: "recovery-stop", + kind: "stop" as const, + generation: 1, + ownerSessionId: "crashed-owner", + phase: "running" as const, + }, + } + : entry, + ), + }, + }), + ) + .pipe(Effect.provideContext(f.context)); + expect(Exit.isSuccess(yield* f.engine.recover.pipe(Effect.exit))).toBe(true); + expect((yield* f.engine.status(first.id)).phase).toBe("recovery"); + expect((yield* f.engine.status(first.id)).recovery?.operation).toBe("stop"); + expect((yield* f.engine.status(second.id)).phase).toBe("stopped"); + expect(Exit.isFailure(yield* f.engine.start(first.id).pipe(Effect.exit))).toBe(true); + yield* f.engine.destroy(first.id); + expect((yield* f.engine.list).some(({ id: currentId }) => currentId === first.id)).toBe( + false, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("keeps an unresolved restore fenced until destroy cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000071"), "broken-restore"); + const second = instance(id("00000000-0000-4000-8000-000000000072"), "healthy"); + const f = yield* makeFixture(first, second, undefined, { + recoverSnapshot: () => + Effect.fail(new StackLifecycleConflictError({ message: "restore manifest missing" })), + }); + yield* f.store + .update(f.stackId, (current): Effect.Effect => + Effect.succeed({ + ...current, + registry: { + ...current.registry, + instances: current.registry.instances.map((entry) => + entry.id === first.id + ? { + ...entry, + intent: "started" as const, + data: { + origin: "incomplete" as const, + operationId: "restore-operation", + }, + pendingOperation: { + id: "restore-operation", + kind: "restoreSnapshot" as const, + generation: 1, + ownerSessionId: "crashed-owner", + phase: "complete" as const, + }, + } + : entry, + ), + }, + }), + ) + .pipe(Effect.provideContext(f.context)); + expect(Exit.isSuccess(yield* f.engine.recover.pipe(Effect.exit))).toBe(true); + expect((yield* f.engine.status(first.id)).recovery?.operation).toBe("destroy"); + const stopped = yield* f.engine.stop(first.id).pipe(Effect.exit); + expect(Exit.isFailure(stopped)).toBe(true); + expect( + (yield* f.read())?.registry.instances.find((entry) => entry.id === first.id) + ?.pendingOperation, + ).toMatchObject({ kind: "restoreSnapshot", id: "restore-operation" }); + yield* f.engine.destroy(first.id); + expect((yield* f.engine.list).some(({ id: currentId }) => currentId === first.id)).toBe( + false, + ); + expect((yield* f.engine.status(second.id)).phase).toBe("stopped"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retains a restore journal when cleanup failure is combined with a defect", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000073"), "mixed-restore"); + const second = instance(id("00000000-0000-4000-8000-000000000074"), "healthy"); + const f = yield* makeFixture(first, second, undefined, { + restoreSnapshot: () => + Effect.failCause( + Cause.combine( + Cause.fail(new StackCleanupError({ message: "restore cleanup is uncertain" })), + Cause.die(new Error("restore driver crashed")), + ), + ), + }); + + const failed = yield* f.engine.restoreSnapshot(first.id, "snapshot.tar").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) { + expect(Cause.hasDies(failed.cause)).toBe(true); + expect(Cause.findErrorOption(failed.cause)).toMatchObject({ + _tag: "Some", + value: expect.any(StackCleanupError), + }); + } + const persisted = yield* f.read(); + expect( + persisted?.registry.instances.find((entry) => entry.id === first.id)?.pendingOperation, + ).toMatchObject({ kind: "restoreSnapshot", phase: "running" }); + expect((yield* f.engine.status(first.id)).phase).toBe("recovery"); + expect((yield* f.engine.status(first.id)).recovery?.operation).toBe("destroy"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fences a pure restore defect until destroy recovery", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000075"), "defect-restore"); + const second = instance(id("00000000-0000-4000-8000-000000000076"), "healthy"); + const f = yield* makeFixture(first, second, undefined, { + restoreSnapshot: () => Effect.die("restore driver crashed"), + }); + + const failed = yield* f.engine.restoreSnapshot(first.id, "snapshot.tar").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) expect(Cause.hasDies(failed.cause)).toBe(true); + expect( + (yield* f.read())?.registry.instances.find((entry) => entry.id === first.id) + ?.pendingOperation, + ).toMatchObject({ kind: "restoreSnapshot", phase: "running" }); + expect((yield* f.engine.status(first.id)).phase).toBe("recovery"); + expect((yield* f.engine.status(first.id)).recovery?.operation).toBe("destroy"); + + yield* f.engine.destroy(first.id); + expect((yield* f.engine.list).map(({ id: instanceId }) => instanceId)).toEqual([second.id]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("preserves secrets owned by existing instances when registering a new one", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000011"), "secret-primary"); + const second = instance(id("00000000-0000-4000-8000-000000000012"), "secret-shadow"); + const third = instance(id("00000000-0000-4000-8000-000000000013"), "secret-new"); + const f = yield* makeFixture(first, second, { + [`secret:${first.id}:password`]: { policy: "managed", value: "first-password" }, + [`secret:${second.id}:password`]: { policy: "managed", value: "second-password" }, + "secret:functions.env.API_KEY": { policy: "passthrough", value: "functions-key" }, + }); + yield* f.engine.create(third, [ + { + slot: `secret:${third.id}:password`, + policy: "managed", + value: Redacted.make("third-password"), + }, + ]); + const persisted = yield* f.read(); + expect(persisted?.secrets[`secret:${first.id}:password`]?.value).toBe("first-password"); + expect(persisted?.secrets[`secret:${second.id}:password`]?.value).toBe("second-password"); + expect(persisted?.secrets["secret:functions.env.API_KEY"]?.value).toBe("functions-key"); + expect(persisted?.secrets[`secret:${third.id}:password`]?.value).toBe("third-password"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("subscribes before the initial service status read", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000021"), "follow"); + const second = instance(id("00000000-0000-4000-8000-000000000022"), "other"); + const f = yield* makeFixture(first, second); + const subscribed = yield* Deferred.make(); + const observed = yield* f.engine.followStatus(first.id).pipe( + Stream.tap(() => Deferred.succeed(subscribed, undefined)), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscribed); + yield* f.engine.start(first.id); + const statuses = yield* Fiber.join(observed); + expect(statuses[0]?.phase).toBe("stopped"); + expect(statuses.some(({ phase }) => phase === "ready")).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("completes a status stream after its instance is destroyed", () => + Effect.scoped( + Effect.gen(function* () { + const first = instance(id("00000000-0000-4000-8000-000000000031"), "destroy-follow"); + const second = instance(id("00000000-0000-4000-8000-000000000032"), "other"); + const f = yield* makeFixture(first, second); + const subscribed = yield* Deferred.make(); + const observing = yield* f.engine.followStatus(first.id).pipe( + Stream.tap(() => Deferred.succeed(subscribed, undefined)), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscribed); + yield* f.engine.destroy(first.id); + expect((yield* f.engine.list).some(({ id: instanceId }) => instanceId === first.id)).toBe( + false, + ); + const statuses = yield* Fiber.join(observing); + expect(statuses[0]?.id).toBe(first.id); + expect((yield* f.engine.describe({ id: second.id })).id).toBe(second.id); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts deleted file mode 100644 index bb06ef9162..0000000000 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, FileSystem, Option, Redacted, Ref } from "effect"; -import { deriveStackId } from "../identity/Identity.ts"; -import { - StackRuntimeError, - StackCleanupError, - StackStateInvalidError, - StackMustBeStoppedError, - type StackError, -} from "../public/Errors.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; -import type { PersistedStackState } from "../state/StackState.ts"; -import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; -import { - makeLifecycleController, - type LifecycleBackend, - type LifecycleInput, - type LifecycleLaunchResult, -} from "./Lifecycle.ts"; - -const layer = NodeServices.layer; - -const identity = { - projectRoot: "/tmp/supabase-lifecycle", - branchContext: "ordinary-workspace", - stackName: "lifecycle", -} as const; - -const errorOf = (exit: Exit.Exit): E | undefined => - Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; - -interface BackendState { - readonly calls: Array; - readonly preflight: Array; - failPreflight?: boolean; - failLaunch?: boolean; - failDestroyData?: boolean; - failDestroyDataOnce?: boolean; - failCleanupOnce?: boolean; - gate?: Deferred.Deferred; - preflightStarted?: Deferred.Deferred; - waitBeforeLaunch?: Deferred.Deferred; - launchMutation?: () => Effect.Effect; - failCurrentRead?: Ref.Ref; - stopLaunchStarted?: Deferred.Deferred; - stopLaunchGate?: Deferred.Deferred; - startLaunchStarted?: Deferred.Deferred; - lastLifecycle?: PersistedStackState["desiredLifecycle"]; -} - -const backend = (state: BackendState): LifecycleBackend => ({ - preflight: (input) => - Effect.gen(function* () { - state.calls.push("preflight"); - state.preflight.push(input); - if (state.preflightStarted !== undefined) - yield* Deferred.succeed(state.preflightStarted, undefined); - if (state.failPreflight) return yield* new StackRuntimeError({ message: "preflight failed" }); - if (state.gate !== undefined) yield* Deferred.await(state.gate); - }), - launch: (input) => - Effect.gen(function* () { - state.lastLifecycle = input.state.desiredLifecycle; - state.calls.push(`launch:${input.state.desiredLifecycle}`); - if (input.state.desiredLifecycle === "running" && state.startLaunchStarted !== undefined) - yield* Deferred.succeed(state.startLaunchStarted, undefined); - if (state.waitBeforeLaunch !== undefined) yield* Deferred.await(state.waitBeforeLaunch); - if (state.launchMutation !== undefined) yield* state.launchMutation(); - if (state.failLaunch) return yield* new StackRuntimeError({ message: "launch failed" }); - return { - _tag: "started", - rollback: Effect.succeed({ _tag: "proven" }), - } satisfies LifecycleLaunchResult; - }), - cleanup: Effect.gen(function* () { - state.calls.push(`cleanup:${state.lastLifecycle ?? "invalid"}`); - if (state.failCleanupOnce) { - state.failCleanupOnce = false; - return yield* new StackCleanupError({ message: "cleanup failed" }); - } - }), - destroyData: Effect.gen(function* () { - state.calls.push(`destroy-data:${state.lastLifecycle ?? "invalid"}`); - if (state.failDestroyDataOnce) { - state.failDestroyDataOnce = false; - return yield* new StackCleanupError({ message: "destroy-data failed" }); - } - if (state.failDestroyData) - return yield* new StackCleanupError({ message: "destroy-data failed" }); - }), -}); - -const makeFixture = (runtime: StackRuntime = { kind: "native" }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-" }); - const id = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(id, { - format: "supabase-stack-state-v1", - identity, - runtime, - desiredLifecycle: "unconfigured", - ports: [], - privatePorts: [], - secrets: {}, - }); - const state: BackendState = { calls: [], preflight: [] }; - const persistedStore: StackStateStore = { - ...store, - read: (stackId) => - Effect.gen(function* () { - if (state.failCurrentRead !== undefined && (yield* Ref.get(state.failCurrentRead))) { - yield* Ref.set(state.failCurrentRead, false); - return yield* new StackStateInvalidError({ - message: "injected current-state read failure", - }); - } - return yield* store.read(stackId); - }), - }; - const controller = yield* makeLifecycleController({ - stackId: id, - runtime, - stateStore: persistedStore, - backend: backend(state), - }); - const testController = { - ...controller, - start: (options?: Parameters[0]) => - controller - .start(options) - .pipe( - Effect.flatMap((outcome) => - outcome._tag === "started" - ? Effect.succeed(outcome.state) - : Effect.failCause(outcome.cause), - ), - ), - }; - return { id, root, store: persistedStore, state, controller: testController }; - }); - -const run = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(layer)); - -describe("durable lifecycle controller", () => { - it.live("commits a complete running definition before reconciling", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const result = yield* fixture.controller.start(); - expect(result).toMatchObject({ - desiredLifecycle: "running", - definition: { preparation: "background" }, - }); - expect(fixture.state.calls).toEqual(["preflight", "launch:running"]); - expect(yield* fixture.store.read(fixture.id)).toEqual(result); - }), - ), - ); - - it.live("reuses persisted definition for an omitted start", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const first = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); - fixture.state.calls.length = 0; - const second = yield* fixture.controller.stop; - expect(second.desiredLifecycle).toBe("stopped"); - fixture.state.calls.length = 0; - const third = yield* fixture.controller.start(); - expect(third.definition).toEqual(first.definition); - expect(fixture.state.preflight.at(-1)?.definition).toEqual(first.definition); - }), - ), - ); - - it.live("is idempotent for an identical running input", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const first = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); - fixture.state.calls.length = 0; - const second = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); - expect(second).toEqual(first); - expect(fixture.state.calls).toEqual(["launch:running"]); - }), - ), - ); - - it.live("persists stopped when a fresh running session cannot launch", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start(); - fixture.state.failLaunch = true; - - const failed = yield* fixture.controller.start({ freshSession: true }).pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); - expect(fixture.state.calls.at(-1)).toBe("cleanup:running"); - }), - ), - ); - - it.live("preserves sticky ports allocated during a failed cold launch", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - fixture.state.failLaunch = true; - fixture.state.launchMutation = () => - Effect.provide( - fixture.store.read(fixture.id).pipe( - Effect.flatMap((current) => - current === undefined - ? Effect.fail(new StackStateInvalidError({ message: "state disappeared" })) - : fixture.store.replace(fixture.id, { - ...current, - ports: [{ field: "api", port: 54_321, intent: "automatic" }], - privatePorts: [{ workloadId: "rest:rest", binding: "http", port: 54_322 }], - }), - ), - Effect.asVoid, - ), - layer, - ); - - const failed = yield* fixture.controller.start().pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); - const stopped = yield* fixture.store.read(fixture.id); - expect(stopped?.desiredLifecycle).toBe("unconfigured"); - expect(stopped?.ports).toEqual([{ field: "api", port: 54_321, intent: "automatic" }]); - expect(stopped?.privatePorts).toEqual([ - { workloadId: "rest:rest", binding: "http", port: 54_322 }, - ]); - }), - ), - ); - - it.live("does not write a stale stopped state when current-state read fails", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - fixture.state.failLaunch = true; - const failCurrentRead = yield* Ref.make(false); - fixture.state.failCurrentRead = failCurrentRead; - fixture.state.launchMutation = () => Ref.set(failCurrentRead, true); - const failed = yield* fixture.controller.start().pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); - expect(fixture.state.calls).toContain("cleanup:running"); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); - }), - ), - ); - - it.live("accepts explicit materialized defaults for a running stack", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const first = yield* fixture.controller.start(); - const second = yield* fixture.controller.start({ - config: { capabilities: { rest: { enabled: true } } }, - }); - expect(second.definition).toEqual(first.definition); - }), - ), - ); - - it.live("allows pass-through secret changes after a failed cold launch", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - fixture.state.failLaunch = true; - const original = { - capabilities: { - functions: { - settings: { - functions: { hello: { env: { TOKEN: Redacted.make("one") } } }, - }, - }, - }, - }; - const failed = yield* fixture.controller.start({ config: original }).pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); - fixture.state.failLaunch = false; - const changed = { - capabilities: { - functions: { - settings: { - functions: { hello: { env: { TOKEN: Redacted.make("two") } } }, - }, - }, - }, - }; - const restarted = yield* fixture.controller.start({ config: changed }); - expect(restarted.desiredLifecycle).toBe("running"); - expect(restarted.secrets).toMatchObject({ - "secret:functions.settings.functions.hello.env.TOKEN": { value: "two" }, - }); - }), - ), - ); - - it.live("requires stop before applying pass-through secret changes", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const original = { - capabilities: { - functions: { - settings: { - functions: { hello: { env: { TOKEN: Redacted.make("one") } } }, - }, - }, - }, - }; - const changed = { - capabilities: { - functions: { - settings: { - functions: { hello: { env: { TOKEN: Redacted.make("two") } } }, - }, - }, - }, - }; - yield* fixture.controller.start({ config: original }); - const running = yield* fixture.controller.start({ config: changed }).pipe(Effect.exit); - expect(errorOf(running)).toBeInstanceOf(StackMustBeStoppedError); - yield* fixture.controller.stop; - const restarted = yield* fixture.controller.start({ config: changed }); - expect(restarted.desiredLifecycle).toBe("running"); - expect(restarted.secrets).toMatchObject({ - "secret:functions.settings.functions.hello.env.TOKEN": { value: "two" }, - }); - }), - ), - ); - - it.live("rejects a changed running input before backend mutation", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); - const before = [...fixture.state.calls]; - const exit = yield* fixture.controller - .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) - .pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackMustBeStoppedError); - expect(fixture.state.calls).toEqual(before); - }), - ), - ); - - it.live("retains stopped state and makes stop idempotent", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start(); - fixture.state.calls.length = 0; - const first = yield* fixture.controller.stop; - expect(first).toMatchObject({ - desiredLifecycle: "stopped", - definition: { preparation: "background" }, - }); - expect(fixture.state.calls).toEqual(["cleanup:running"]); - fixture.state.calls.length = 0; - const second = yield* fixture.controller.stop; - expect(second).toEqual(first); - expect(fixture.state.calls).toEqual(["cleanup:running"]); - }), - ), - ); - - it.live("retries stopped cleanup after a prior cleanup failure", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start(); - fixture.state.calls.length = 0; - fixture.state.failCleanupOnce = true; - const first = yield* fixture.controller.stop.pipe(Effect.exit); - expect(errorOf(first)).toBeInstanceOf(StackCleanupError); - const stopped = yield* fixture.store.read(fixture.id); - expect(stopped?.desiredLifecycle).toBe("stopped"); - fixture.state.calls.length = 0; - const second = yield* fixture.controller.stop; - expect(second.desiredLifecycle).toBe("stopped"); - expect(fixture.state.calls).toEqual(["cleanup:running"]); - }), - ), - ); - - it.live("leaves old state untouched when preflight fails", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const first = yield* fixture.controller.start(); - yield* fixture.controller.stop; - fixture.state.calls.length = 0; - fixture.state.failPreflight = true; - const exit = yield* fixture.controller - .start({ config: { capabilities: { rest: {} } } }) - .pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackRuntimeError); - expect(yield* fixture.store.read(fixture.id)).toMatchObject({ - desiredLifecycle: "stopped", - definition: first.definition, - }); - expect(fixture.state.calls).toEqual(["preflight"]); - }), - ), - ); - - it.live("destroys runtime data before deleting the exact identity root", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(`${fixture.root}/${fixture.id}/runtime`, { recursive: true }); - yield* fixture.controller.start(); - fixture.state.calls.length = 0; - yield* fixture.controller.destroy; - expect(fixture.state.calls).toEqual(["destroy-data:running"]); - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - expect(yield* fs.exists(`${fixture.root}/${fixture.id}`)).toBe(false); - const recreated = yield* fixture.store.initialize(fixture.id, { - format: "supabase-stack-state-v1", - identity, - runtime: { kind: "native" }, - desiredLifecycle: "unconfigured", - ports: [], - privatePorts: [], - secrets: {}, - }); - expect(recreated.desiredLifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("retains the destroying fence when data cleanup fails", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start(); - fixture.state.calls.length = 0; - fixture.state.failDestroyData = true; - const exit = yield* fixture.controller.destroy.pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackCleanupError); - expect(yield* fixture.store.read(fixture.id)).toMatchObject({ - desiredLifecycle: "destroying", - }); - expect(fixture.state.calls).toEqual(["destroy-data:running"]); - fixture.state.failDestroyData = false; - yield* fixture.controller.destroy; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("destroys exact runtime remnants from an unconfigured state", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.destroy; - expect(fixture.state.calls).toEqual(["destroy-data:invalid"]); - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("retries destructive cleanup after a transient failure", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - fixture.state.failDestroyDataOnce = true; - const first = yield* fixture.controller.destroy.pipe(Effect.exit); - expect(errorOf(first)).toBeInstanceOf(StackCleanupError); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("destroying"); - yield* fixture.controller.destroy; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("fails closed when state is missing", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.store.cleanup(fixture.id); - const exit = yield* fixture.controller.start().pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackStateInvalidError); - }), - ), - ); -}); diff --git a/packages/stack/src/supervisor/owner-retirement.integration.test.ts b/packages/stack/src/supervisor/owner-retirement.integration.test.ts new file mode 100644 index 0000000000..910e3d69cb --- /dev/null +++ b/packages/stack/src/supervisor/owner-retirement.integration.test.ts @@ -0,0 +1,482 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Context, + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Option, + Path, + Ref, +} from "effect"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import { compileServiceInstance } from "../model/Compiler.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; +import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; +import { StackLifecycleConflictError } from "../public/Errors.ts"; +import type { SupervisorRuntime, Supervisor } from "./Supervisor.ts"; +import { makeSupervisor } from "./Supervisor.ts"; +import type { SupervisorIngress } from "./Ingress.ts"; +import type { LogStore } from "./LogStore.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const ingress: SupervisorIngress = { + close: Effect.void, +}; + +const logStore: LogStore = { + path: "/dev/null", + append: () => Effect.die("owner-retirement test does not write logs"), + read: () => Effect.succeed([]), +}; + +const stateFor = ( + projectRoot: string, + instance: PersistedStackState["registry"]["instances"][number], +): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity: { projectRoot, branchContext: "test", stackName: "owner-retirement" }, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "test-jwt" } }, + }, + }, + listeners: {}, + registry: { + initialized: true, + instances: [instance], + defaultInstanceIds: { database: instance.id }, + }, + ports: [], + privatePorts: [], + secrets: { "test-jwt": { policy: "managed", value: "test-jwt" } }, +}); + +interface Fixture { + readonly stackId: string; + readonly ownerSessionId: string; + readonly endpoint: { readonly kind: "unix"; readonly path: string }; + readonly supervisor: Supervisor; + readonly stateStore: StackStateStore; + readonly instanceId: ServiceInstanceId; + readonly destroyFailure: Ref.Ref; + readonly destroyCalls: Ref.Ref; + readonly cleanupCalls: Ref.Ref; + readonly startEntered: Deferred.Deferred; + readonly startRelease: Deferred.Deferred; + readonly startCalls: Ref.Ref; + readonly shutdownRequested: Deferred.Deferred; + readonly prefaceAcquired: Deferred.Deferred; + readonly prefaceReleased: Deferred.Deferred; + readonly requestEntered: Deferred.Deferred; + readonly requestRelease: Deferred.Deferred; + readonly holdRequest: Ref.Ref; +} + +const withFixture = (f: (fixture: Fixture) => Effect.Effect) => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-owner-retirement-", + }); + const projectRoot = path.join(root, "project"); + yield* fs.makeDirectory(projectRoot); + const identity = { + projectRoot, + branchContext: "test", + stackName: "owner-retirement", + } as const; + const stackId = yield* deriveStackId(identity); + const instanceId = ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const compiled = yield* compileServiceInstance( + { service: "database", config: {} }, + { projectRoot, path, runtime: { kind: "native" }, instanceId }, + ).pipe(Effect.provideContext(context)); + const stateStore = yield* makeStackStateStore({ stateRoot: path.join(root, "state") }); + yield* stateStore + .initialize(stackId, stateFor(projectRoot, compiled.instance)) + .pipe(Effect.provideContext(context)); + const destroyFailure = yield* Ref.make(false); + const destroyCalls = yield* Ref.make(0); + const cleanupCalls = yield* Ref.make(0); + const startEntered = yield* Deferred.make(); + const startRelease = yield* Deferred.make(); + const startCalls = yield* Ref.make(0); + const shutdownRequested = yield* Deferred.make(); + const prefaceAcquired = yield* Deferred.make(); + const prefaceReleased = yield* Deferred.make(); + const requestEntered = yield* Deferred.make(); + const requestRelease = yield* Deferred.make(); + const holdRequest = yield* Ref.make(false); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("owner-retirement test does not start workloads"), + stop: () => Effect.die("owner-retirement test does not stop workloads"), + remove: () => Effect.die("owner-retirement test does not remove workloads"), + cleanup: () => Ref.update(cleanupCalls, (calls) => calls + 1), + wipePersistentData: () => Effect.die("owner-retirement test does not wipe workloads"), + }; + const runtime: SupervisorRuntime = { + driver, + preflight: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + prepareArtifacts: () => Effect.void, + start: () => + Ref.update(startCalls, (calls) => calls + 1).pipe( + Effect.andThen(Deferred.succeed(startEntered, undefined)), + Effect.andThen(Deferred.await(startRelease)), + Effect.andThen(Effect.succeed([] as ReadonlyArray)), + ), + stop: () => Effect.void, + destroy: () => + Ref.update(destroyCalls, (calls) => calls + 1).pipe( + Effect.andThen( + Ref.get(destroyFailure).pipe( + Effect.flatMap((fail) => + fail + ? Effect.fail( + new StackLifecycleConflictError({ + stackId, + message: "test destroy is busy", + }), + ) + : Effect.void, + ), + ), + ), + ), + exportSnapshot: () => + Effect.fail( + new StackLifecycleConflictError({ message: "snapshot is outside this test" }), + ), + restoreSnapshot: () => + Effect.fail( + new StackLifecycleConflictError({ message: "snapshot is outside this test" }), + ), + prefetch: () => Effect.void, + artifacts: Effect.succeed([]), + activate: () => Effect.die("owner-retirement test does not activate gateways"), + ingress, + logStore, + }; + const supervisor = yield* makeSupervisor({ + stackId, + ownerSessionId: "owner-session", + stateStore, + context, + runtime, + }).pipe(Effect.provideContext(context)); + const endpoint = { kind: "unix" as const, path: path.join(root, "control", "owner.sock") }; + const onShutdownReady = Deferred.succeed(shutdownRequested, undefined).pipe( + Effect.andThen(supervisor.shutdownIfIdle), + ); + const rpcHandlers: StackRpcHandlers = { + ...supervisor.rpcHandlers, + servicesList: (payload, options) => + Rpc.wrap({})( + Effect.gen(function* () { + if (yield* Ref.get(holdRequest)) { + yield* Deferred.succeed(requestEntered, undefined); + yield* Deferred.await(requestRelease); + } + const result = Rpc.unwrap(supervisor.rpcHandlers.servicesList(payload, options)); + return yield* Effect.isEffect(result) ? result : Effect.succeed(result); + }), + ), + }; + yield* startControlServer({ + stackId, + ownerSessionId: "owner-session", + endpoint, + rpcRelease: STACK_RPC_RELEASE, + maintenanceHandlers: supervisor.maintenanceHandlers, + onShutdownReady, + onRpcPreface: () => + supervisor.acquireRpcPreface.pipe( + Effect.tap(() => Deferred.succeed(prefaceAcquired, undefined)), + Effect.map((lease) => ({ + release: lease.release.pipe( + Effect.andThen(Deferred.succeed(prefaceReleased, undefined)), + ), + })), + ), + rpcHandlers, + }); + return yield* f({ + stackId, + ownerSessionId: "owner-session", + endpoint, + supervisor, + stateStore, + instanceId, + destroyFailure, + destroyCalls, + cleanupCalls, + startEntered, + startRelease, + startCalls, + shutdownRequested, + prefaceAcquired, + prefaceReleased, + requestEntered, + requestRelease, + holdRequest, + }); + }), + ); + +describe("production owner admission and retirement", { timeout: 30_000 }, () => { + it.live("flushes metadata before an idle owner retires", () => + withFixture(({ endpoint, stackId, ownerSessionId, supervisor, shutdownRequested }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const probe = yield* client.probe; + expect(probe).toMatchObject({ ok: true, stackId, ownerSessionId }); + expect(Option.isNone(yield* Deferred.poll(shutdownRequested))).toBe(true); + const listed = yield* Effect.scoped( + client.rpc.pipe(Effect.flatMap((rpc) => rpc.servicesList())), + ); + expect(listed).toHaveLength(1); + yield* Deferred.await(shutdownRequested).pipe(Effect.timeout("5 seconds")); + yield* supervisor.shutdown; + }), + ), + ); + + it.live("keeps an admitted preface until the actual RPC response", () => + withFixture( + ({ + endpoint, + stackId, + ownerSessionId, + supervisor, + prefaceAcquired, + prefaceReleased, + requestEntered, + requestRelease, + holdRequest, + shutdownRequested, + }) => + Effect.scoped( + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const rpc = yield* client.rpc; + yield* Deferred.await(prefaceAcquired).pipe(Effect.timeout("5 seconds")); + yield* Ref.set(holdRequest, true); + const request = yield* Effect.forkChild(rpc.servicesList(), { + startImmediately: true, + }); + yield* Deferred.await(requestEntered).pipe(Effect.timeout("5 seconds")); + yield* supervisor.shutdownIfIdle; + expect(Option.isNone(yield* Deferred.poll(shutdownRequested))).toBe(true); + expect(Option.isNone(yield* Deferred.poll(prefaceReleased))).toBe(true); + yield* Deferred.succeed(requestRelease, undefined); + const listed = yield* Fiber.join(request); + expect(listed).toHaveLength(1); + yield* Deferred.await(prefaceReleased).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(shutdownRequested).pipe(Effect.timeout("5 seconds")); + }), + ), + ), + ); + + it.live("does not replay a request after retirement", () => + withFixture(({ endpoint, stackId, ownerSessionId, supervisor, instanceId, prefaceAcquired }) => + Effect.scoped( + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const rpc = yield* client.rpc; + yield* Deferred.await(prefaceAcquired).pipe(Effect.timeout("5 seconds")); + yield* supervisor.destroy; + const request = yield* Effect.exit(rpc.serviceDestroy({ id: instanceId })); + expect(Exit.isFailure(request)).toBe(true); + }), + ), + ), + ); + + it.live("releases an unused RPC preface when its client disconnects", () => + withFixture(({ endpoint, stackId, ownerSessionId, prefaceAcquired, prefaceReleased }) => + Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + yield* client.rpc; + yield* Deferred.await(prefaceAcquired).pipe(Effect.timeout("5 seconds")); + }), + ); + yield* Deferred.await(prefaceReleased).pipe(Effect.timeout("5 seconds")); + }), + ), + ); + + it.live("protects an admitted request when whole-stack retirement races it", () => + withFixture( + ({ + endpoint, + stackId, + ownerSessionId, + supervisor, + instanceId, + startEntered, + startRelease, + startCalls, + destroyCalls, + shutdownRequested, + }) => + Effect.gen(function* () { + const startFiber = yield* Effect.forkChild( + Effect.scoped( + makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }).rpc.pipe(Effect.flatMap((rpc) => rpc.serviceStart({ id: instanceId }))), + ), + { startImmediately: true }, + ); + yield* Deferred.await(startEntered).pipe(Effect.timeout("5 seconds")); + expect(Option.isNone(yield* Deferred.poll(shutdownRequested))).toBe(true); + const destroyResult = yield* Effect.scoped( + makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }).rpc.pipe(Effect.flatMap((rpc) => Effect.exit(rpc.destroy({})))), + ); + expect(Exit.isFailure(destroyResult)).toBe(true); + expect(yield* Ref.get(destroyCalls)).toBe(0); + yield* Deferred.succeed(startRelease, undefined); + const started = yield* Fiber.join(startFiber); + expect(started.phase).toBe("ready"); + expect(yield* Ref.get(startCalls)).toBe(1); + expect(yield* Ref.get(destroyCalls)).toBe(0); + expect((yield* supervisor.status).lifecycle).toBe("running"); + }), + ), + ); + + it.live("keeps RPC usable after a failed whole-stack destroy", () => + withFixture( + ({ + endpoint, + stackId, + ownerSessionId, + instanceId, + destroyFailure, + destroyCalls, + startEntered, + startRelease, + startCalls, + }) => + Effect.scoped( + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const startFiber = yield* Effect.forkChild( + Effect.scoped( + client.rpc.pipe(Effect.flatMap((rpc) => rpc.serviceStart({ id: instanceId }))), + ), + { startImmediately: true }, + ); + yield* Deferred.await(startEntered).pipe(Effect.timeout("5 seconds")); + yield* Deferred.succeed(startRelease, undefined); + expect((yield* Fiber.join(startFiber)).phase).toBe("ready"); + expect(yield* Ref.get(startCalls)).toBe(1); + yield* Ref.set(destroyFailure, true); + const failed = yield* Effect.exit( + client.rpc.pipe(Effect.flatMap((rpc) => rpc.destroy({}))), + ); + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* Ref.get(destroyCalls)).toBe(1); + const listed = yield* client.rpc.pipe(Effect.flatMap((rpc) => rpc.servicesList())); + expect(listed).toHaveLength(1); + }), + ), + ), + ); + + it.live("flushes a fresh owner's whole destroy before signaling shutdown", () => + withFixture( + ({ + endpoint, + stackId, + ownerSessionId, + supervisor, + shutdownRequested, + destroyCalls, + cleanupCalls, + }) => + Effect.scoped( + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const rpc = yield* client.rpc; + yield* rpc.destroy({}); + expect(yield* Ref.get(destroyCalls)).toBe(1); + expect(yield* Ref.get(cleanupCalls)).toBe(1); + yield* Deferred.await(shutdownRequested).pipe(Effect.timeout("5 seconds")); + yield* supervisor.shutdown; + }), + ), + ), + ); + + it.live("does not clean stack runtime resources for selected destroy", () => + withFixture(({ endpoint, stackId, ownerSessionId, instanceId, cleanupCalls }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + yield* client.rpc.pipe(Effect.flatMap((rpc) => rpc.destroy({ services: [instanceId] }))); + expect(yield* Ref.get(cleanupCalls)).toBe(0); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/restart-admission.integration.test.ts b/packages/stack/src/supervisor/restart-admission.integration.test.ts new file mode 100644 index 0000000000..ec0cac12a4 --- /dev/null +++ b/packages/stack/src/supervisor/restart-admission.integration.test.ts @@ -0,0 +1,451 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Context, + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Path, + Redacted, + Ref, + Stream, +} from "effect"; +import { deriveStackId } from "../identity/Identity.ts"; +import { compileServiceInstance, compileServiceRestart } from "../model/Compiler.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import { StackLifecycleConflictError, type StackError } from "../public/Errors.ts"; +import type { ServiceRestartPayload } from "../public/Service.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; +import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; +import { STACK_RPC_RELEASE, type StackRpcClient } from "../control/StackRpc.ts"; +import type { InstanceRuntimeInput } from "./Lifecycle.ts"; +import { makeSupervisor, type Supervisor, type SupervisorRuntime } from "./Supervisor.ts"; +import type { InstanceRestartCandidate } from "./InstanceEngine.ts"; +import type { SupervisorIngress } from "./Ingress.ts"; +import type { LogStore } from "./LogStore.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; + +const ingress: SupervisorIngress = { + close: Effect.void, +}; + +const logStore: LogStore = { + path: "/dev/null", + append: () => Effect.die("restart admission test does not write logs"), + read: () => Effect.succeed([]), +}; + +interface RuntimeTrace { + readonly events: Array; + readonly starts: Array; + readonly stops: Array; +} + +interface Fixture { + readonly supervisor: Supervisor; + readonly stateStore: StackStateStore; + readonly context: Context.Context; + readonly stackId: string; + readonly endpoint: { readonly kind: "unix"; readonly path: string }; + readonly database: ServiceInstanceId; + readonly rest: ServiceInstanceId; + readonly functions: ServiceInstanceId; + readonly trace: RuntimeTrace; + readonly databaseStartEntered: Deferred.Deferred; + readonly releaseDatabaseStart: Deferred.Deferred; + readonly pauseDatabaseStart: Ref.Ref; + readonly firstStopEntered: Deferred.Deferred; + readonly releaseFirstStop: Deferred.Deferred; + readonly read: () => Effect.Effect; +} + +interface FixtureOptions { + readonly pauseDatabaseStart?: boolean; + readonly pauseFirstStop?: boolean; +} + +const withFixture = ( + options: FixtureOptions, + use: (fixture: Fixture) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-restart-admission-" }); + const projectRoot = path.join(root, "project"); + yield* fs.makeDirectory(projectRoot); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const identity = { + projectRoot, + branchContext: "test", + stackName: "restart-admission", + } as const; + const stackId = yield* deriveStackId(identity); + const databaseId = ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"); + const restId = ServiceInstanceIdSchema.make("22222222-2222-4222-8222-222222222222"); + const functionsId = ServiceInstanceIdSchema.make("33333333-3333-4333-8333-333333333333"); + const database = yield* compileServiceInstance( + { + service: "database", + name: "primary", + config: { password: Redacted.make("old-password"), settings: {} }, + }, + { projectRoot, path, runtime: { kind: "native" }, instanceId: databaseId }, + ).pipe(Effect.provideContext(context)); + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "rest", + config: { settings: {} }, + dependencies: { database: databaseId }, + }, + { projectRoot, path, runtime: { kind: "native" }, instanceId: restId }, + ).pipe(Effect.provideContext(context)); + const functions = yield* compileServiceInstance( + { service: "functions", name: "functions", config: { settings: {} } }, + { projectRoot, path, runtime: { kind: "native" }, instanceId: functionsId }, + ).pipe(Effect.provideContext(context)); + const state: PersistedStackState = { + format: "supabase-stack-state-v2", + identity, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "test-jwt" } }, + }, + }, + listeners: {}, + registry: { + initialized: true, + instances: [database.instance, rest.instance, functions.instance], + defaultInstanceIds: { + database: databaseId, + rest: restId, + functions: functionsId, + }, + }, + ports: [], + privatePorts: [], + secrets: { + "test-jwt": { policy: "managed", value: "test-jwt" }, + [`secret:${databaseId}:password`]: { policy: "managed", value: "old-password" }, + }, + }; + const stateStore = yield* makeStackStateStore({ stateRoot: path.join(root, "state") }); + yield* stateStore.initialize(stackId, state).pipe(Effect.provideContext(context)); + const trace: RuntimeTrace = { events: [], starts: [], stops: [] }; + const pauseDatabaseStart = yield* Ref.make(options.pauseDatabaseStart ?? false); + const databaseStartEntered = yield* Deferred.make(); + const releaseDatabaseStart = yield* Deferred.make(); + const firstStopEntered = yield* Deferred.make(); + const releaseFirstStop = yield* Deferred.make(); + let firstStop = true; + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("restart admission test does not start driver workloads"), + stop: () => Effect.die("restart admission test does not stop driver workloads"), + remove: () => Effect.die("restart admission test does not remove driver workloads"), + cleanup: () => Effect.die("restart admission test does not clean driver workloads"), + wipePersistentData: () => + Effect.die("restart admission test does not wipe driver workloads"), + }; + const runtime: SupervisorRuntime = { + driver, + preflight: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + prepareArtifacts: () => Effect.void, + start: (input) => + Effect.gen(function* () { + trace.starts.push(input); + trace.events.push(`start:${input.instance.service}`); + if (input.instance.id === databaseId && (yield* Ref.get(pauseDatabaseStart))) { + yield* Deferred.succeed(databaseStartEntered, undefined); + yield* Deferred.await(releaseDatabaseStart); + } + return [] as ReadonlyArray; + }), + stop: (input) => + Effect.gen(function* () { + trace.stops.push(input); + trace.events.push(`stop:${input.instance.service}`); + if (options.pauseFirstStop && firstStop) { + firstStop = false; + yield* Deferred.succeed(firstStopEntered, undefined); + yield* Deferred.await(releaseFirstStop); + } + }), + destroy: () => Effect.void, + exportSnapshot: () => + Effect.fail( + new StackLifecycleConflictError({ message: "snapshot is outside this test" }), + ), + restoreSnapshot: () => + Effect.fail( + new StackLifecycleConflictError({ message: "snapshot is outside this test" }), + ), + prefetch: () => Effect.void, + artifacts: Effect.succeed([]), + activate: () => Effect.die("restart admission test does not activate gateways"), + ingress, + logStore, + }; + const supervisor = yield* makeSupervisor({ + stackId, + ownerSessionId: "restart-admission-owner", + stateStore, + context, + runtime, + }).pipe(Effect.provideContext(context)); + const endpoint = { kind: "unix" as const, path: path.join(root, "control", "owner.sock") }; + yield* startControlServer({ + stackId, + ownerSessionId: "restart-admission-owner", + endpoint, + rpcRelease: STACK_RPC_RELEASE, + maintenanceHandlers: supervisor.maintenanceHandlers, + rpcHandlers: supervisor.rpcHandlers, + }); + return yield* use({ + supervisor, + stateStore, + context, + stackId, + endpoint, + database: databaseId, + rest: restId, + functions: functionsId, + trace, + databaseStartEntered, + releaseDatabaseStart, + pauseDatabaseStart, + firstStopEntered, + releaseFirstStop, + read: () => stateStore.read(stackId).pipe(Effect.provideContext(context)), + }); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +const databaseUpdate = (id: ServiceInstanceId, password: string): ServiceRestartPayload => ({ + id, + service: "database", + config: { password: Redacted.make(password), settings: {} }, +}); + +const invalidSelectedUpdate = (id: ServiceInstanceId): ServiceRestartPayload => ({ + id, + service: "functions", + config: { settings: {} }, +}); + +const withRpc = ( + fixture: Pick, + use: (rpc: StackRpcClient) => Effect.Effect, +) => + Effect.scoped( + makeControlClient(fixture.endpoint, { + stackId: fixture.stackId, + ownerSessionId: "restart-admission-owner", + rpcRelease: STACK_RPC_RELEASE, + }).rpc.pipe(Effect.flatMap(use)), + ); + +describe("restart admission", { timeout: 30_000 }, () => { + it.live("rejects an invalid later update without mutating the admitted selection", () => + withFixture({}, ({ endpoint, stackId, read, database, functions, trace }) => + Effect.gen(function* () { + const before = yield* read(); + const failed = yield* Effect.exit( + withRpc({ endpoint, stackId }, (rpc) => + rpc.restart({ + services: [database, functions], + updates: [databaseUpdate(database, "new-password"), invalidSelectedUpdate(database)], + }), + ), + ); + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* read()).toEqual(before); + expect(trace.events).toEqual([]); + }), + ), + ); + + it.live("passes the old state to stop and the new secret to start", () => + withFixture({}, ({ endpoint, stackId, read, database, trace }) => + Effect.gen(function* () { + const result = yield* withRpc({ endpoint, stackId }, (rpc) => + rpc.serviceRestart(databaseUpdate(database, "new-password")), + ); + expect(result.phase).toBe("ready"); + const stopped = trace.stops.find((input) => input.instance.id === database); + const started = trace.starts.find((input) => input.instance.id === database); + expect(stopped?.state.secrets[`secret:${database}:password`]?.value).toBe("old-password"); + expect(started?.state.secrets[`secret:${database}:password`]?.value).toBe("new-password"); + const after = yield* read(); + const persisted = after?.registry.instances.find((instance) => instance.id === database); + expect(persisted?.revisions.config).toBe(1); + expect(after?.secrets[`secret:${database}:password`]?.value).toBe("new-password"); + }), + ), + ); + + it.live("keeps dependency order while an independent Functions restart completes", () => + withFixture( + {}, + ({ + supervisor, + endpoint, + stackId, + database, + rest, + functions, + trace, + databaseStartEntered, + releaseDatabaseStart, + pauseDatabaseStart, + }) => + Effect.gen(function* () { + yield* supervisor.start({ services: [database, rest, functions] }); + trace.events.length = 0; + trace.starts.length = 0; + trace.stops.length = 0; + yield* Ref.set(pauseDatabaseStart, true); + const observingFunctions = yield* Deferred.make(); + const functionsReady = yield* supervisor.instances.followStatus(functions).pipe( + Stream.tap(() => Deferred.succeed(observingFunctions, undefined)), + Stream.filter( + (status) => + status.phase === "ready" && + status.pendingOperation === undefined && + trace.starts.some((input) => input.instance.id === functions), + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(observingFunctions); + const restart = yield* Effect.forkChild( + withRpc({ endpoint, stackId }, (rpc) => + rpc.restart({ + services: [database, rest, functions], + updates: [databaseUpdate(database, "old-password")], + }), + ), + { startImmediately: true }, + ); + yield* Deferred.await(databaseStartEntered).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(functionsReady).pipe(Effect.timeout("5 seconds")); + expect(trace.events).toContain("stop:rest"); + expect(trace.events.indexOf("stop:rest")).toBeLessThan( + trace.events.indexOf("stop:database"), + ); + expect(trace.events).toContain("start:functions"); + expect(trace.events).not.toContain("start:rest"); + yield* Deferred.succeed(releaseDatabaseStart, undefined); + yield* Fiber.join(restart); + expect(trace.events.indexOf("start:database")).toBeLessThan( + trace.events.indexOf("start:rest"), + ); + }).pipe(Effect.ensuring(Deferred.succeed(releaseDatabaseStart, undefined))), + ), + ); + + it.live("does not strand later members when the restart caller is interrupted", () => + withFixture( + { pauseFirstStop: true }, + ({ + supervisor, + context, + database, + functions, + trace, + firstStopEntered, + releaseFirstStop, + read, + }) => + Effect.gen(function* () { + const before = yield* read(); + if (before === undefined) throw new Error("restart fixture state is missing"); + const currentDatabase = before.registry.instances.find( + (instance) => instance.id === database, + ); + const currentFunctions = before.registry.instances.find( + (instance) => instance.id === functions, + ); + if (currentDatabase === undefined || currentFunctions === undefined) + throw new Error("restart fixture instances are missing"); + const nextDatabase = yield* compileServiceRestart( + currentDatabase, + { password: Redacted.make("old-password"), settings: {} }, + { + projectRoot: before.identity.projectRoot, + path: yield* Path.Path, + runtime: before.runtime, + }, + ).pipe(Effect.provideContext(context)); + const nextFunctions = yield* compileServiceRestart( + currentFunctions, + { settings: {} }, + { + projectRoot: before.identity.projectRoot, + path: yield* Path.Path, + runtime: before.runtime, + }, + ).pipe(Effect.provideContext(context)); + const candidates: ReadonlyArray = [ + { + instance: nextDatabase.instance, + secretSlots: nextDatabase.secretSlots, + previous: { state: before, instance: currentDatabase }, + }, + { + instance: nextFunctions.instance, + secretSlots: nextFunctions.secretSlots, + previous: { state: before, instance: currentFunctions }, + }, + ]; + const settled = yield* supervisor.followStatus.pipe( + Stream.filter((status) => + status.instances + .filter(({ id }) => id === database || id === functions) + .every( + ({ phase, pendingOperation }) => + phase === "ready" && pendingOperation === undefined, + ), + ), + Stream.runHead, + Effect.forkChild, + ); + const restart = yield* Effect.forkChild(supervisor.instances.restartAll(candidates), { + startImmediately: true, + }); + yield* Deferred.await(firstStopEntered).pipe(Effect.timeout("5 seconds")); + const _interruption = yield* Effect.forkChild(Fiber.interrupt(restart), { + startImmediately: true, + }); + yield* Deferred.succeed(releaseFirstStop, undefined); + yield* Fiber.join(settled).pipe(Effect.timeout("5 seconds")); + const state = yield* read(); + expect( + state?.registry.instances + .filter((instance) => instance.id === database || instance.id === functions) + .map((instance) => instance.pendingOperation), + ).toEqual([null, null]); + expect(trace.starts.map((input) => input.instance.id)).toEqual( + expect.arrayContaining([database, functions]), + ); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/service-credentials.integration.test.ts b/packages/stack/src/supervisor/service-credentials.integration.test.ts new file mode 100644 index 0000000000..e96a542393 --- /dev/null +++ b/packages/stack/src/supervisor/service-credentials.integration.test.ts @@ -0,0 +1,196 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Crypto, Effect, FileSystem, Path, Redacted } from "effect"; +import { compileServiceInstance } from "../model/Compiler.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { + AUTH_ANON_KEY_SLOT, + AUTH_PUBLISHABLE_KEY_SLOT, + AUTH_SECRET_KEY_SLOT, + AUTH_SERVICE_ROLE_KEY_SLOT, +} from "../state/SecretStore.ts"; +import { projectServiceCredentials, projectStackCredentials } from "./ServiceCredentials.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const stateFor = ( + projectRoot: string, + instances: PersistedStackState["registry"]["instances"], + defaults: PersistedStackState["registry"]["defaultInstanceIds"], + options: { + readonly api?: boolean; + readonly ports?: PersistedStackState["ports"]; + readonly secrets?: PersistedStackState["secrets"]; + } = {}, +): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity: { projectRoot, branchContext: "test", stackName: "credentials" }, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "test-jwt" } }, + }, + }, + listeners: + options.api === true ? { api: { enabled: true, address: "127.0.0.1", port: 54321 } } : {}, + registry: { initialized: true, instances, defaultInstanceIds: defaults }, + ports: options.ports ?? [], + privatePorts: [], + secrets: { + "test-jwt": { policy: "managed", value: "jwt-secret" }, + ...options.secrets, + }, +}); + +describe("service credentials projections", { timeout: 30_000 }, () => { + it.live("projects shared API credentials for a Functions-only stack", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-credentials-" }); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const functions = yield* compileServiceInstance( + { service: "functions", config: { activation: "lazy" } }, + { projectRoot: root, path, runtime: { kind: "native" } }, + ).pipe(Effect.provideContext(context)); + const state = stateFor( + root, + [functions.instance], + { functions: functions.id }, + { + api: true, + secrets: { + [AUTH_PUBLISHABLE_KEY_SLOT]: { policy: "managed", value: "publishable" }, + [AUTH_SECRET_KEY_SLOT]: { policy: "managed", value: "secret" }, + [AUTH_ANON_KEY_SLOT]: { policy: "managed", value: "anon" }, + [AUTH_SERVICE_ROLE_KEY_SLOT]: { policy: "managed", value: "service-role" }, + }, + }, + ); + expect(yield* projectServiceCredentials(state, functions.instance)).toEqual({ + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "service-role", + }); + const stack = yield* projectStackCredentials(state); + expect(stack.database).toBeUndefined(); + expect(stack.api?.publishableKey).toBe("publishable"); + expect(stack.api?.anonJwt).toBe("anon"); + expect(stack.api === undefined ? undefined : Redacted.value(stack.api.secretKey)).toBe( + "secret", + ); + expect(stack.api === undefined ? undefined : Redacted.value(stack.api.serviceRoleJwt)).toBe( + "service-role", + ); + }), + ), + ); + + it.live("projects planned database and optional storage credentials", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-credentials-" }); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const database = yield* compileServiceInstance( + { service: "database", config: {} }, + { projectRoot: root, path, runtime: { kind: "native" } }, + ).pipe(Effect.provideContext(context)); + if (database.instance.service !== "database") throw new Error("Expected database instance"); + const storage = yield* compileServiceInstance( + { service: "storage", config: {}, dependencies: { database: database.id } }, + { projectRoot: root, path, runtime: { kind: "native" } }, + ).pipe(Effect.provideContext(context)); + if (storage.instance.service !== "storage") throw new Error("Expected storage instance"); + const databasePassword = database.instance.config.passwordSecretRef; + const storageSecret = storage.instance.config.settings.s3_protocol?.secret_access_key; + if (databasePassword === undefined || storageSecret === null || storageSecret === undefined) + return; + const state = stateFor( + root, + [database.instance, storage.instance], + { database: database.id, storage: storage.id }, + { + api: true, + ports: [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 54321, + intent: "exact", + }, + { + owner: "instance", + instanceId: database.id, + binding: "sql", + address: "127.0.0.1", + port: 54322, + intent: "exact", + }, + ], + secrets: { + [databasePassword]: { policy: "managed", value: "db password" }, + [storageSecret.slot]: { policy: "managed", value: "storage secret" }, + }, + }, + ); + const credentials = yield* projectStackCredentials(state); + expect(credentials.database).toEqual({ + url: Redacted.make("postgresql://postgres:db%20password@127.0.0.1:54322/postgres"), + password: Redacted.make("db password"), + }); + expect(credentials.storage).toEqual({ + endpoint: "http://127.0.0.1:54321/storage/v1/s3", + region: "local", + accessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + secretAccessKey: Redacted.make("storage secret"), + }); + }), + ), + ); + + it.live("returns no credentials for disabled or incomplete projections", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-credentials-" }); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const functions = yield* compileServiceInstance( + { service: "functions", config: { enabled: false } }, + { projectRoot: root, path, runtime: { kind: "native" } }, + ).pipe(Effect.provideContext(context)); + const state = stateFor( + root, + [functions.instance], + { functions: functions.id }, + { api: true }, + ); + expect(yield* projectServiceCredentials(state, functions.instance)).toEqual({ + kind: "none", + }); + expect(yield* projectStackCredentials(state)).toEqual({}); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/session-launcher.integration.test.ts b/packages/stack/src/supervisor/session-launcher.integration.test.ts deleted file mode 100644 index 2101fdd913..0000000000 --- a/packages/stack/src/supervisor/session-launcher.integration.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Fiber, Option } from "effect"; -import type { CapabilityName } from "../public/Capability.ts"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import { StackIdSchema } from "../public/StackId.ts"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { RuntimeDriverError, type RuntimeDriver } from "../runtime/RuntimeDriver.ts"; -import { makeNativeRuntime } from "../runtime/NativeRuntime.ts"; -import { makeSessionLauncher } from "./SessionLauncher.ts"; - -const stackId = StackIdSchema.make("b".repeat(64)); - -const workload = (id: string, dependencies: ReadonlyArray = []): PlannedWorkload => ({ - id, - capability: "database", - dependencies, - readiness: {}, - artifacts: { - native: { kind: "native", release: "test" }, - container: { kind: "container", image: "test/image" }, - }, - selected: { kind: "native", release: "test" }, -}); - -const plan = (workloads: ReadonlyArray): ExecutionPlan => { - const activation = { - database: "eager", - rest: "lazy", - auth: "lazy", - realtime: "lazy", - storage: "lazy", - functions: "lazy", - studio: "lazy", - mail: "lazy", - analytics: "lazy", - pooler: "lazy", - } satisfies { [Name in CapabilityName]: "eager" | "lazy" }; - const dependencies = { - database: [], - rest: [], - auth: [], - realtime: [], - storage: [], - functions: [], - studio: [], - mail: [], - analytics: [], - pooler: [], - } satisfies { [Name in CapabilityName]: ReadonlyArray }; - return { - runtime: { kind: "native" }, - activation, - startOrder: CAPABILITY_NAMES, - dependencies, - routes: [], - workloads, - }; -}; - -const ready = (workload: PlannedWorkload) => ({ - stackId, - workloadId: workload.id, - state: "ready" as const, -}); - -const withPlatform = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -describe("session launcher", () => { - it.live("starts independent workloads in parallel before their dependants", () => - Effect.gen(function* () { - const databaseEntered = yield* Deferred.make(); - const releaseDatabase = yield* Deferred.make(); - const mailEntered = yield* Deferred.make(); - const calls: string[] = []; - const database = workload("database:database"); - const mail = workload("mail:mail"); - const rest = workload("rest:rest", [database.id]); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (_key, current) => - Effect.gen(function* () { - calls.push(`start:${current.id}`); - if (current.id === database.id) { - yield* Deferred.succeed(databaseEntered, undefined); - yield* Deferred.await(releaseDatabase); - } - if (current.id === mail.id) yield* Deferred.succeed(mailEntered, undefined); - return ready(current); - }), - stop: (key) => Effect.sync(() => calls.push(`stop:${key.workloadId}`)), - remove: (key) => Effect.sync(() => calls.push(`remove:${key.workloadId}`)), - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const launcher = yield* makeSessionLauncher({ stackId, driver }); - const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail, rest])), { - startImmediately: true, - }); - - yield* Deferred.await(databaseEntered); - yield* Deferred.await(mailEntered); - expect(calls).toContain("start:database:database"); - expect(calls).toContain("start:mail:mail"); - expect(calls).not.toContain("start:rest:rest"); - - yield* Deferred.succeed(releaseDatabase, undefined); - yield* Fiber.join(launching); - expect(calls).toContain("start:rest:rest"); - }), - ); - - it.live("starts a dependant when its own prerequisite completes", () => - Effect.gen(function* () { - const databaseEntered = yield* Deferred.make(); - const mailEntered = yield* Deferred.make(); - const releaseMail = yield* Deferred.make(); - const restEntered = yield* Deferred.make(); - const database = workload("database:database"); - const mail = workload("mail:mail"); - const rest = workload("rest:rest", [database.id]); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (_key, current) => - Effect.gen(function* () { - if (current.id === database.id) yield* Deferred.succeed(databaseEntered, undefined); - if (current.id === mail.id) { - yield* Deferred.succeed(mailEntered, undefined); - yield* Deferred.await(releaseMail); - } - if (current.id === rest.id) yield* Deferred.succeed(restEntered, undefined); - return ready(current); - }), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const launcher = yield* makeSessionLauncher({ stackId, driver }); - const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail, rest])), { - startImmediately: true, - }); - yield* Deferred.await(databaseEntered); - yield* Deferred.await(mailEntered); - yield* Deferred.await(restEntered); - yield* Deferred.succeed(releaseMail, undefined); - yield* Fiber.join(launching); - }), - ); - - it.live("interrupts and removes an in-flight native workload when a sibling fails", () => - withPlatform( - Effect.gen(function* () { - const databaseEntered = yield* Deferred.make(); - const databaseInterrupted = yield* Deferred.make(); - const database = workload("database:database"); - const mail = workload("mail:mail"); - const driver = yield* makeNativeRuntime({ - resolveProcess: (key) => { - if (key.workloadId === database.id) - return Effect.gen(function* () { - yield* Deferred.succeed(databaseEntered, undefined); - return yield* Effect.never; - }).pipe(Effect.ensuring(Deferred.succeed(databaseInterrupted, undefined))); - return Deferred.await(databaseEntered).pipe( - Effect.andThen( - Effect.fail( - new RuntimeDriverError({ - message: "mail failed", - stackId, - workloadId: key.workloadId, - }), - ), - ), - ); - }, - waitForReadiness: () => Effect.never, - }); - const launcher = yield* makeSessionLauncher({ stackId, driver }); - const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail])), { - startImmediately: true, - }); - yield* Deferred.await(databaseEntered); - const result = yield* Fiber.join(launching); - - expect(result._tag).toBe("failed"); - yield* Deferred.await(databaseInterrupted); - expect(yield* driver.observe(stackId)).toEqual([]); - }), - ), - ); - - it.live("fails with a typed error when no pending workload can become ready", () => - Effect.gen(function* () { - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: () => Effect.die("unreachable"), - stop: () => Effect.die("unreachable"), - remove: () => Effect.die("unreachable"), - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const launcher = yield* makeSessionLauncher({ stackId, driver }); - const result = yield* launcher.launch( - plan([workload("cycle:a", ["cycle:b"]), workload("cycle:b", ["cycle:a"])]), - ); - expect(result._tag).toBe("failed"); - const error = - result._tag === "failed" - ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) - : undefined; - expect(error).toBeInstanceOf(RuntimeDriverError); - }), - ); - - it.live("retains a partially started workload when rollback removal fails", () => - Effect.gen(function* () { - const owned = new Set(); - let failRemove = true; - const database = workload("database:database"); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (_key, current) => - Effect.gen(function* () { - owned.add(current.id); - return yield* new RuntimeDriverError({ - message: "database start failed after acquiring resource", - stackId, - workloadId: current.id, - }); - }), - stop: () => Effect.void, - remove: (key) => - Effect.gen(function* () { - if (key.workloadId === database.id && failRemove) { - failRemove = false; - return yield* new RuntimeDriverError({ - message: "database remove failed", - stackId, - workloadId: key.workloadId, - }); - } - owned.delete(key.workloadId); - }), - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const launcher = yield* makeSessionLauncher({ stackId, driver }); - const result = yield* launcher.launch(plan([database])); - expect(result._tag).toBe("failed"); - if (result._tag === "failed") expect(result.cleanup._tag).toBe("unproven"); - expect(owned).toEqual(new Set([database.id])); - yield* launcher.stop; - expect(owned).toEqual(new Set()); - }), - ); - - it.live("interrupts an owner without stranding dependent completion or cleanup ownership", () => - Effect.gen(function* () { - const databaseEntered = yield* Deferred.make(); - const owned = new Set(); - const database = workload("database:database"); - const rest = workload("rest:rest", [database.id]); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (_key, current) => - current.id === database.id - ? Deferred.succeed(databaseEntered, undefined).pipe( - Effect.andThen(Effect.sync(() => owned.add(current.id))), - Effect.andThen(Effect.never), - ) - : Effect.die("dependent must await database"), - stop: () => Effect.void, - remove: (key) => Effect.sync(() => void owned.delete(key.workloadId)), - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const launcher = yield* makeSessionLauncher({ stackId, driver }); - const launching = yield* Effect.forkChild(launcher.launch(plan([database, rest])), { - startImmediately: true, - }); - yield* Deferred.await(databaseEntered); - yield* Fiber.interrupt(launching).pipe(Effect.timeout("5 seconds")); - expect(owned).toEqual(new Set()); - }), - ); -}); diff --git a/packages/stack/src/supervisor/startup-ingress.integration.test.ts b/packages/stack/src/supervisor/startup-ingress.integration.test.ts deleted file mode 100644 index e492273861..0000000000 --- a/packages/stack/src/supervisor/startup-ingress.integration.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { - Context, - Crypto, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Option, - Path, - Ref, -} from "effect"; -import { - request as requestHttp, - createServer, - type ClientRequest, - type IncomingMessage, - type ServerResponse, - // oxlint-disable-next-line effecttsgo/node-builtin-import -- fixture needs ClientRequest.end's sent callback and a mutable ClientRequest for cancellation; Effect HttpClient exposes neither. -} from "node:http"; -import { deriveStackId } from "../identity/Identity.ts"; -import type { StackError } from "../public/Errors.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { bindHostListener, type HostListener } from "./HostListener.ts"; -import { makeSupervisorIngress } from "./Ingress.ts"; -import { makeSupervisor, type SupervisorRuntime } from "./Supervisor.ts"; -import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; -import type { StackLogEntry } from "../public/Logs.ts"; - -const withPlatform = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -const response = ( - port: number, - sent: Deferred.Deferred, - finished: Deferred.Deferred, - requestRef: { value?: ClientRequest }, -) => - Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { - const client = requestHttp({ host: "127.0.0.1", port, path: "/rest/v1/items" }, (incoming) => { - const chunks: Buffer[] = []; - incoming.on("data", (chunk: Buffer) => chunks.push(chunk)); - incoming.once("end", () => - resume( - Effect.succeed({ - status: incoming.statusCode ?? 0, - body: Buffer.concat(chunks).toString(), - }).pipe(Effect.tap(() => Deferred.succeed(finished, undefined))), - ), - ); - }); - requestRef.value = client; - client.once("error", (error) => resume(Effect.fail(error))); - client.end(() => Deferred.doneUnsafe(sent, Effect.void)); - return Effect.sync(() => client.destroy()); - }); - -const backend = Effect.acquireRelease( - Effect.callback, Error>((resume) => { - const server = createServer((_request: IncomingMessage, result: ServerResponse) => { - result.statusCode = 200; - result.end("backend-ready"); - }); - server.once("error", (error) => resume(Effect.fail(error))); - server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); - return Effect.sync(() => { - if (server.listening) server.close(); - }); - }), - (server) => - Effect.callback((resume) => { - if (!server.listening) return resume(Effect.void); - server.close(() => resume(Effect.void)); - }), -); - -const makeStartupFixture = () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-startup-ingress-" }); - const identity = { - projectRoot: root, - branchContext: "ordinary-workspace", - stackName: "startup-ingress", - } as const; - const stackId = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.initialize(stackId, { - format: "supabase-stack-state-v1", - identity, - runtime: { kind: "native" }, - desiredLifecycle: "unconfigured", - ports: [], - privatePorts: [], - secrets: {}, - }); - const context = Context.make(FileSystem.FileSystem, fs).pipe( - Context.add(Path.Path, path), - Context.add(Crypto.Crypto, crypto), - ); - const listenerBound = yield* Deferred.make(); - const startEntered = yield* Deferred.make(); - const releaseStart = yield* Deferred.make(); - const activationCalls = yield* Ref.make(0); - const service = yield* backend; - const address = service.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("backend did not expose an address"); - - const bindHost = (host: string, port: number, field: import("../public/Status.ts").PortField) => - bindHostListener(host, port, field).pipe( - Effect.tap((listener) => - field === "api" ? Deferred.succeed(listenerBound, listener) : Effect.void, - ), - ); - const ingress = yield* makeSupervisorIngress({ - stackId, - stateRoot: root, - store, - context, - bindHost, - apiMaterial: () => - Effect.succeed({ - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "service", - }), - }); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (key, _workload: PlannedWorkload) => - Effect.gen(function* () { - if (key.workloadId === "database:database") { - yield* Deferred.succeed(startEntered, undefined); - yield* Deferred.await(releaseStart); - } - return { ...key, state: "ready" as const }; - }), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - wipePersistentData: () => Effect.void, - }; - const entry: StackLogEntry = { - cursor: { opaque: "v1_1" }, - timestamp: "2026-01-01T00:00:00.000Z", - source: "supervisor", - stream: "internal", - message: "startup", - }; - const runtime: SupervisorRuntime = { - driver, - preflight: () => Effect.void, - prepare: () => Effect.void, - prefetch: () => Effect.void, - artifacts: Effect.succeed([]), - activate: () => - Ref.update(activationCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.succeed({ host: "127.0.0.1", port: address.port })), - ), - ingress, - logStore: { - path: "memory://startup-ingress", - append: () => Effect.succeed(entry), - read: () => Effect.succeed([entry]), - }, - }; - const supervisor = yield* makeSupervisor({ - stackId, - ownerSessionId: "startup-ingress-test", - stateStore: store, - context, - runtime, - }); - const start = supervisor - .start({ - config: { - listeners: { - api: { enabled: true }, - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, - }, - }) - .pipe(Effect.tapCause((cause) => Deferred.failCause(listenerBound, cause))); - return { listenerBound, startEntered, releaseStart, activationCalls, supervisor, start }; - }); - -describe("startup ingress", () => { - it.live("holds requests during startup and forwards them after the service is ready", () => - withPlatform( - Effect.gen(function* () { - const fixture = yield* makeStartupFixture(); - const starting = yield* Effect.forkChild(fixture.start); - const listener = yield* Deferred.await(fixture.listenerBound); - if (listener.binding.kind !== "http") return yield* Effect.die("API listener is not HTTP"); - - const requestsAccepted = yield* Deferred.make(); - let acceptedCount = 0; - const onRequest = () => { - acceptedCount += 1; - if (acceptedCount === 2) Deferred.doneUnsafe(requestsAccepted, Effect.void); - }; - listener.binding.server.on("request", onRequest); - const firstSent = yield* Deferred.make(); - const secondSent = yield* Deferred.make(); - const secondFinished = yield* Deferred.make(); - const firstRequest: { value?: ClientRequest } = {}; - const first = yield* Effect.forkChild( - response(listener.port, firstSent, yield* Deferred.make(), firstRequest), - ); - yield* Deferred.await(firstSent); - const second = yield* Effect.forkChild( - response(listener.port, secondSent, secondFinished, {}), - ); - yield* Deferred.await(secondSent); - yield* Deferred.await(requestsAccepted); - yield* Deferred.await(fixture.startEntered); - expect(Option.isNone(yield* Deferred.poll(secondFinished))).toBe(true); - firstRequest.value?.destroy(); - yield* Deferred.succeed(fixture.releaseStart, undefined); - - expect(yield* Fiber.join(second)).toEqual({ status: 200, body: "backend-ready" }); - expect((yield* Fiber.join(starting)).lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.activationCalls)).toBe(1); - expect(Exit.isFailure(yield* Fiber.join(first).pipe(Effect.exit))).toBe(true); - listener.binding.server.off("request", onRequest); - yield* fixture.supervisor.destroy; - }), - ), - ); -}); diff --git a/packages/stack/src/supervisor/status-projection.integration.test.ts b/packages/stack/src/supervisor/status-projection.integration.test.ts new file mode 100644 index 0000000000..788b908a7f --- /dev/null +++ b/packages/stack/src/supervisor/status-projection.integration.test.ts @@ -0,0 +1,129 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Crypto, Effect, FileSystem, Path, Schema } from "effect"; +import { compileServiceInstance } from "../model/Compiler.ts"; +import { StackStatusSchema } from "../public/Status.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { ServiceInstanceIdSchema } from "../public/ServiceInstanceId.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { statusForPersistedState } from "./StatusProjection.ts"; + +const stackId = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", +); + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("persisted status projection", { timeout: 30_000 }, () => { + it.live("omits a destroyed default and preserves the live default instance id", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-status-projection-" }); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const database = yield* compileServiceInstance( + { service: "database", config: {} }, + { + projectRoot: root, + path, + runtime: { kind: "native" }, + }, + ).pipe(Effect.provideContext(context)); + const dynamicId = ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"); + const state = ( + instances: PersistedStackState["registry"]["instances"], + defaults: PersistedStackState["registry"]["defaultInstanceIds"], + includePorts = true, + ): PersistedStackState => ({ + format: "supabase-stack-state-v2", + identity: { projectRoot: root, branchContext: "test", stackName: "status" }, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: "test-jwt" } }, + }, + }, + listeners: { api: { enabled: true, address: "127.0.0.1" } }, + registry: { initialized: true, instances, defaultInstanceIds: defaults }, + ports: includePorts + ? [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 54321, + intent: "automatic", + }, + ...( + [ + ["sql", 55432], + ["pooler", 65432], + ["studio", 3000], + ["mailUi", 4000], + ["smtp", 2525], + ["pop3", 3110], + ["inspector", 8081], + ] as const + ).map(([binding, port]) => ({ + owner: "instance" as const, + instanceId: database.id, + binding, + address: "127.0.0.1", + port, + intent: "automatic" as const, + })), + { + owner: "instance", + instanceId: dynamicId, + binding: "sql", + address: "127.0.0.1", + port: 55433, + intent: "automatic", + }, + ] + : [], + privatePorts: [], + secrets: { "test-jwt": { policy: "managed", value: "test" } }, + }); + + const live = yield* statusForPersistedState( + stackId, + state([{ ...database.instance, id: dynamicId }, database.instance], { + database: database.id, + }), + ); + const decodedLive = yield* Schema.decodeEffect(StackStatusSchema)(live); + expect(decodedLive.capabilities).toEqual([ + expect.objectContaining({ name: "database", id: database.id }), + ]); + expect(decodedLive.endpoints).toEqual({ + api: expect.objectContaining({ protocol: "http", port: 54321 }), + database: expect.objectContaining({ protocol: "tcp", port: 55432 }), + pooler: expect.objectContaining({ protocol: "tcp", port: 65432 }), + studio: expect.objectContaining({ protocol: "http", port: 3000 }), + mailUi: expect.objectContaining({ protocol: "http", port: 4000 }), + smtp: expect.objectContaining({ protocol: "tcp", port: 2525 }), + pop3: expect.objectContaining({ protocol: "tcp", port: 3110 }), + functionsInspector: expect.objectContaining({ protocol: "http", port: 8081 }), + }); + expect(decodedLive.endpoints.database?.port).toBe(55432); + expect(decodedLive.instances.find(({ id }) => id === dynamicId)?.endpoints).toEqual([ + expect.objectContaining({ binding: "sql", port: 55433 }), + ]); + + const destroyed = yield* statusForPersistedState(stackId, state([], {}, false)); + const decodedDestroyed = yield* Schema.decodeEffect(StackStatusSchema)(destroyed); + expect(decodedDestroyed.capabilities).toEqual([]); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts deleted file mode 100644 index 60cbc1bb27..0000000000 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ /dev/null @@ -1,4680 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Option, - Path, - Queue, - Redacted, - Ref, - Scheduler, - Scope, -} from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { Headers } from "effect/unstable/http"; -import { Rpc } from "effect/unstable/rpc"; -import { RequestId } from "effect/unstable/rpc/RpcMessage"; -import type { LogQuery, StackLogEntry } from "../public/Logs.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; -import type { ArtifactPreparationStatus, StackStatus } from "../public/Status.ts"; -import { - GatewayActivationError, - InvalidLogCursorError, - PortUnavailableError, - StackLifecycleConflictError, - StackNotRunningError, - StackRuntimeError, - StackCleanupError, - StackStateInvalidError, - StackMustBeStoppedError, - StackVersionUnsupportedError, - ArtifactIntegrityError, - ContainerEngineError, -} from "../public/Errors.ts"; -import { - RuntimeDriverError, - type RuntimeDriver, - type ObservedWorkload, -} from "../runtime/RuntimeDriver.ts"; -import type { EffectStackCredentials } from "../public/Credentials.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { deriveStackId } from "../identity/Identity.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { resolveStackPaths } from "../state/Paths.ts"; -import { StackRpcGroup, type StackRpcError } from "../control/StackRpc.ts"; -import { makeSupervisor, type Supervisor, type SupervisorRuntime } from "./Supervisor.ts"; -import type { SupervisorIngress } from "./Ingress.ts"; -import type { GatewayActivity } from "../gateway/ActivityTracker.ts"; - -const identity = { - projectRoot: "/tmp/supabase-supervisor", - branchContext: "ordinary-workspace", - stackName: "supervisor", -} as const; - -const errorOf = (exit: Exit.Exit): E | undefined => - Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; - -const invokeCredentials = ( - supervisor: Supervisor, -): Effect.Effect => - Effect.gen(function* () { - const handler = yield* StackRpcGroup.accessHandler("credentials").pipe( - Effect.provide( - StackRpcGroup.toLayerHandler("credentials", supervisor.rpcHandlers.credentials), - ), - ); - const value = yield* handler(undefined, { - client: new Rpc.ServerClient(1), - requestId: RequestId(1), - headers: Headers.empty, - }); - if (Deferred.isDeferred(value)) - return yield* Deferred.await(value); - return value; - }); - -type ReadGate = { - readonly started: Deferred.Deferred; - readonly gate: Deferred.Deferred; - readonly beforeRead?: boolean; -}; - -const makeFixture = ( - fixtureOptions: { - readonly ingress?: SupervisorIngress; - readonly timeline?: Ref.Ref>; - readonly logRecords?: Ref.Ref>; - readonly logWritten?: Deferred.Deferred; - readonly logWrittenFor?: string; - readonly logWrittenAdditional?: Deferred.Deferred; - readonly logWrittenAdditionalFor?: string; - readonly logQueue?: Queue.Queue; - readonly runtime?: StackRuntime; - readonly supervisorScope?: Scope.Scope; - readonly startGate?: Deferred.Deferred; - readonly startStarted?: Deferred.Deferred; - readonly startWorkload?: string; - readonly startFinished?: Deferred.Deferred; - readonly activationGate?: Deferred.Deferred; - readonly activationGateAfterFirst?: Deferred.Deferred; - readonly activationStarted?: Deferred.Deferred; - readonly activationStartedAfterFirst?: Deferred.Deferred; - readonly activationCalls?: Ref.Ref; - readonly activationFailFirst?: Ref.Ref; - readonly startFailures?: Ref.Ref; - readonly startFailureWorkload?: string; - readonly preflightFailFirst?: Ref.Ref; - readonly preflightCalls?: Ref.Ref; - readonly preflightGate?: Deferred.Deferred; - readonly preflightStarted?: Deferred.Deferred; - readonly stopGate?: Deferred.Deferred; - readonly stopStarted?: Deferred.Deferred; - readonly workloadStopFailFirst?: Ref.Ref; - readonly workloadStopGate?: Deferred.Deferred; - readonly workloadStopStarted?: Deferred.Deferred; - readonly workloadRemoveFailFirst?: Ref.Ref; - readonly workloadRemoveFailWorkload?: string; - readonly workloadRemoveDieFirst?: Ref.Ref; - readonly stopFailFirst?: Ref.Ref; - readonly destroyGate?: Deferred.Deferred; - readonly destroyStarted?: Deferred.Deferred; - readonly destroyPreFenceFail?: Ref.Ref; - readonly observeFailure?: Ref.Ref; - readonly stoppedReplaceFail?: Ref.Ref; - readonly startQueue?: Queue.Queue; - readonly readGateQueue?: Ref.Ref>; - readonly readCalls?: Ref.Ref; - readonly prefetchStarted?: Deferred.Deferred; - readonly prefetchFinished?: Deferred.Deferred; - readonly prefetchGate?: Deferred.Deferred; - readonly prefetchInterrupted?: Deferred.Deferred; - readonly prefetchCalls?: Ref.Ref; - readonly prepareStarted?: Deferred.Deferred; - readonly prepareActivationStarted?: Deferred.Deferred; - readonly prepareGate?: Deferred.Deferred; - readonly prepareGateEnabledRef?: Ref.Ref; - readonly prepareFailure?: boolean; - readonly prepareFailureRef?: Ref.Ref; - readonly artifactStatuses?: Ref.Ref>; - } = {}, -) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-supervisor-" }); - const id = yield* deriveStackId(identity); - const baseStore = yield* makeStackStateStore({ stateRoot: root }); - const destroyPreFenceFail = fixtureOptions.destroyPreFenceFail; - const stoppedReplaceFail = fixtureOptions.stoppedReplaceFail; - const persistedStore = - destroyPreFenceFail === undefined && stoppedReplaceFail === undefined - ? baseStore - : { - ...baseStore, - replace: (stackId: string, state: Parameters[1]) => - Effect.gen(function* () { - if (state.desiredLifecycle === "destroying" && destroyPreFenceFail !== undefined) { - const fail = yield* Ref.get(destroyPreFenceFail); - if (fail) { - yield* Ref.set(destroyPreFenceFail, false); - return yield* new StackStateInvalidError({ - message: "injected destroy fence failure", - }); - } - } - if (state.desiredLifecycle === "stopped" && stoppedReplaceFail !== undefined) { - const fail = yield* Ref.get(stoppedReplaceFail); - if (fail) { - yield* Ref.set(stoppedReplaceFail, false); - return yield* new StackStateInvalidError({ - message: "injected stopped-state persistence failure", - }); - } - } - return yield* baseStore.replace(stackId, state); - }), - }; - const runtimeStore = - fixtureOptions.readGateQueue !== undefined - ? { - ...persistedStore, - read: (stackId: string) => - Effect.gen(function* () { - const gate = yield* Ref.modify(fixtureOptions.readGateQueue!, (gates) => [ - gates[0], - gates.slice(1), - ]); - if (gate !== undefined) { - if (gate.beforeRead === true) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.gate); - } - } - const state = yield* persistedStore.read(stackId); - if (gate !== undefined && gate.beforeRead !== true) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.gate); - } - return state; - }), - } - : persistedStore; - const store = - fixtureOptions.readCalls === undefined - ? runtimeStore - : { - ...runtimeStore, - read: (stackId: string) => - Ref.update(fixtureOptions.readCalls!, (count) => count + 1).pipe( - Effect.andThen(runtimeStore.read(stackId)), - ), - }; - yield* store.initialize(id, { - format: "supabase-stack-state-v1", - identity, - runtime: fixtureOptions.runtime ?? { kind: "native" }, - desiredLifecycle: "unconfigured", - ports: [], - privatePorts: [], - secrets: {}, - }); - const resources = yield* Ref.make>([]); - const calls = yield* Ref.make>([]); - const logOptions = yield* Ref.make>([]); - const entry: StackLogEntry = { - cursor: { opaque: "v1_1" }, - timestamp: "2026-01-01T00:00:00.000Z", - source: "auth", - stream: "internal", - message: "hello", - }; - const finalEntry: StackLogEntry = { - ...entry, - cursor: { opaque: "v1_2" }, - timestamp: "2026-01-01T00:00:01.000Z", - message: "stopped", - }; - const logEntries = yield* Ref.make>([entry]); - const failDestroy = yield* Ref.make(false); - let gateStopCleanup = false; - const driver: RuntimeDriver = { - observe: () => - Effect.gen(function* () { - if ( - fixtureOptions.observeFailure !== undefined && - (yield* Ref.get(fixtureOptions.observeFailure)) - ) - return yield* new RuntimeDriverError({ - cause: new ContainerEngineError({ - engine: "docker", - message: "injected observe failure", - }), - message: "injected observe failure", - }); - return yield* Ref.get(resources); - }), - start: (key, workload: PlannedWorkload) => - Effect.gen(function* () { - gateStopCleanup = true; - if (fixtureOptions.timeline !== undefined) - yield* Ref.update(fixtureOptions.timeline, (current) => [ - ...current, - `start:${workload.id}`, - ]); - yield* Ref.update(calls, (current) => [...current, `start:${workload.id}`]); - if ( - fixtureOptions.startStarted !== undefined && - (fixtureOptions.startWorkload === undefined || - fixtureOptions.startWorkload === workload.id) - ) - yield* Deferred.succeed(fixtureOptions.startStarted, undefined); - if ( - fixtureOptions.startGate !== undefined && - (fixtureOptions.startWorkload === undefined || - fixtureOptions.startWorkload === workload.id) - ) - yield* Deferred.await(fixtureOptions.startGate); - if ( - fixtureOptions.startFailures !== undefined && - key.workloadId === (fixtureOptions.startFailureWorkload ?? "functions:edge-runtime") - ) { - const remaining = yield* Ref.get(fixtureOptions.startFailures); - if (remaining > 0) { - yield* Ref.set(fixtureOptions.startFailures, remaining - 1); - if (fixtureOptions.stopFailFirst !== undefined) - yield* Ref.set(fixtureOptions.stopFailFirst, true); - const failed = { ...key, state: "failed" as const, error: "injected start failure" }; - yield* Ref.update(resources, (current) => [ - ...current.filter((entry) => entry.workloadId !== key.workloadId), - failed, - ]); - return yield* new RuntimeDriverError({ - message: "injected start failure", - stackId: key.stackId, - workloadId: key.workloadId, - }); - } - } - const ready = { ...key, state: "ready" as const }; - yield* Ref.update(resources, (current) => [ - ...current.filter((entry) => entry.workloadId !== key.workloadId), - ready, - ]); - if ( - fixtureOptions.startFinished !== undefined && - workload.id === (fixtureOptions.startWorkload ?? "rest:rest") - ) - yield* Deferred.succeed(fixtureOptions.startFinished, undefined); - if (fixtureOptions.startQueue !== undefined) - yield* Queue.offer(fixtureOptions.startQueue, workload.id); - return ready; - }), - stop: (key) => - Effect.gen(function* () { - if (fixtureOptions.workloadStopStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.workloadStopStarted, undefined); - if (fixtureOptions.workloadStopGate !== undefined) - yield* Deferred.await(fixtureOptions.workloadStopGate); - if (fixtureOptions.workloadStopFailFirst !== undefined) { - const fail = yield* Ref.get(fixtureOptions.workloadStopFailFirst); - if (fail) { - yield* Ref.set(fixtureOptions.workloadStopFailFirst, false); - return yield* new RuntimeDriverError({ - message: "injected workload stop failure", - stackId: key.stackId, - workloadId: key.workloadId, - }); - } - } - if (fixtureOptions.timeline !== undefined) - yield* Ref.update(fixtureOptions.timeline, (current) => [ - ...current, - `stop:${key.workloadId}`, - ]); - yield* Ref.update(resources, (current) => - current.map((entry) => - entry.workloadId === key.workloadId ? { ...entry, state: "stopped" as const } : entry, - ), - ); - }), - remove: (key) => - Effect.gen(function* () { - if ( - fixtureOptions.workloadRemoveFailFirst !== undefined && - (fixtureOptions.workloadRemoveFailWorkload === undefined || - fixtureOptions.workloadRemoveFailWorkload === key.workloadId) - ) { - const fail = yield* Ref.get(fixtureOptions.workloadRemoveFailFirst); - if (fail) { - yield* Ref.set(fixtureOptions.workloadRemoveFailFirst, false); - return yield* new RuntimeDriverError({ - message: "injected workload remove failure", - stackId: key.stackId, - workloadId: key.workloadId, - }); - } - } - if (fixtureOptions.workloadRemoveDieFirst !== undefined) { - const fail = yield* Ref.get(fixtureOptions.workloadRemoveDieFirst); - if (fail) { - yield* Ref.set(fixtureOptions.workloadRemoveDieFirst, false); - return yield* Effect.die("injected workload remove defect"); - } - } - yield* Ref.update(resources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ); - }), - cleanup: ({ destroy }) => - Effect.gen(function* () { - if (destroy && (yield* Ref.get(failDestroy))) - return yield* new RuntimeDriverError({ message: "destroy failed" }); - if (!destroy && fixtureOptions.stopFailFirst !== undefined) { - const fail = yield* Ref.get(fixtureOptions.stopFailFirst); - if (fail) { - yield* Ref.set(fixtureOptions.stopFailFirst, false); - return yield* new RuntimeDriverError({ message: "injected stop cleanup failure" }); - } - } - if (fixtureOptions.timeline !== undefined) - yield* Ref.update(fixtureOptions.timeline, (current) => [ - ...current, - `cleanup:${destroy ? "destroy" : "stop"}`, - ]); - yield* Ref.update(calls, (current) => [ - ...current, - `cleanup:${destroy ? "destroy" : "stop"}`, - ]); - if (destroy && fixtureOptions.destroyStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.destroyStarted, undefined); - if (destroy && fixtureOptions.destroyGate !== undefined) - yield* Deferred.await(fixtureOptions.destroyGate); - if (!destroy && gateStopCleanup && fixtureOptions.stopStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.stopStarted, undefined); - if (!destroy && gateStopCleanup && fixtureOptions.stopGate !== undefined) - yield* Deferred.await(fixtureOptions.stopGate); - yield* Ref.set(resources, []); - if (!destroy && gateStopCleanup) - yield* Ref.update(logEntries, (current) => [...current, finalEntry]); - }), - wipePersistentData: () => Effect.void, - }; - const runtime: SupervisorRuntime = { - driver, - preflight: (_input) => - Effect.gen(function* () { - if (fixtureOptions.preflightCalls !== undefined) - yield* Ref.update(fixtureOptions.preflightCalls, (count) => count + 1); - if (fixtureOptions.preflightStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.preflightStarted, undefined); - if (fixtureOptions.preflightGate !== undefined) - yield* Deferred.await(fixtureOptions.preflightGate); - if (fixtureOptions.preflightFailFirst !== undefined) { - const fail = yield* Ref.get(fixtureOptions.preflightFailFirst); - if (fail) { - yield* Ref.set(fixtureOptions.preflightFailFirst, false); - return yield* new StackRuntimeError({ message: "injected preflight failure" }); - } - } - }), - prepare: () => - fixtureOptions.prepareStarted === undefined && fixtureOptions.prepareGate === undefined - ? Effect.void - : Effect.gen(function* () { - if (fixtureOptions.prepareStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.prepareStarted, undefined); - if ( - fixtureOptions.prepareActivationStarted !== undefined && - fixtureOptions.prepareGateEnabledRef !== undefined && - (yield* Ref.get(fixtureOptions.prepareGateEnabledRef)) - ) - yield* Deferred.succeed(fixtureOptions.prepareActivationStarted, undefined); - if ( - fixtureOptions.prepareGate !== undefined && - (fixtureOptions.prepareGateEnabledRef === undefined || - (yield* Ref.get(fixtureOptions.prepareGateEnabledRef))) - ) - yield* Deferred.await(fixtureOptions.prepareGate); - if ( - fixtureOptions.prepareFailure === true || - (fixtureOptions.prepareFailureRef !== undefined && - (yield* Ref.get(fixtureOptions.prepareFailureRef))) - ) - return yield* new StackRuntimeError({ message: "injected preparation failure" }); - }), - prefetch: (state) => { - if (state.definition?.preparation === "on-demand") return Effect.void; - return Effect.gen(function* () { - if (fixtureOptions.prefetchCalls !== undefined) - yield* Ref.update(fixtureOptions.prefetchCalls, (count) => count + 1); - if (fixtureOptions.artifactStatuses !== undefined) - yield* Ref.set(fixtureOptions.artifactStatuses, [ - { workloadId: "rest:rest", capability: "rest", state: "downloading" }, - ]); - if (fixtureOptions.prefetchStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.prefetchStarted, undefined); - if (fixtureOptions.prefetchGate !== undefined) - yield* Deferred.await(fixtureOptions.prefetchGate); - if (fixtureOptions.artifactStatuses !== undefined) - yield* Ref.set(fixtureOptions.artifactStatuses, [ - { workloadId: "rest:rest", capability: "rest", state: "ready" }, - ]); - if (fixtureOptions.prefetchFinished !== undefined) - yield* Deferred.succeed(fixtureOptions.prefetchFinished, undefined); - }).pipe( - Effect.onInterrupt(() => - Effect.gen(function* () { - if (fixtureOptions.artifactStatuses !== undefined) - yield* Ref.set(fixtureOptions.artifactStatuses, []); - if (fixtureOptions.prefetchInterrupted !== undefined) - yield* Deferred.succeed(fixtureOptions.prefetchInterrupted, undefined); - }), - ), - ); - }, - artifacts: - fixtureOptions.artifactStatuses === undefined - ? Effect.succeed([]) - : Ref.get(fixtureOptions.artifactStatuses), - activate: () => - Effect.gen(function* () { - const callNumber = - fixtureOptions.activationCalls === undefined - ? undefined - : yield* Ref.updateAndGet(fixtureOptions.activationCalls, (count) => count + 1); - if (fixtureOptions.activationStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.activationStarted, undefined); - if (fixtureOptions.activationGateAfterFirst !== undefined && callNumber !== 1) { - if (fixtureOptions.activationStartedAfterFirst !== undefined) - yield* Deferred.succeed(fixtureOptions.activationStartedAfterFirst, undefined); - yield* Deferred.await(fixtureOptions.activationGateAfterFirst); - } else if (fixtureOptions.activationGate !== undefined) - yield* Deferred.await(fixtureOptions.activationGate); - if (fixtureOptions.activationFailFirst !== undefined) { - const fail = yield* Ref.get(fixtureOptions.activationFailFirst); - if (fail) { - yield* Ref.set(fixtureOptions.activationFailFirst, false); - return yield* new GatewayActivationError({ message: "injected activation failure" }); - } - } - return { host: "127.0.0.1", port: 9999 }; - }), - ingress: fixtureOptions.ingress ?? { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: () => Effect.void, - close: Effect.void, - }, - logStore: { - path: "memory://logs", - append: (record) => - (fixtureOptions.logRecords === undefined - ? Effect.void - : Ref.update(fixtureOptions.logRecords, (current) => [...current, record.message]) - ).pipe( - Effect.andThen( - fixtureOptions.logQueue === undefined - ? Effect.void - : Queue.offer(fixtureOptions.logQueue, record.message), - ), - Effect.andThen( - fixtureOptions.logWritten === undefined || - (fixtureOptions.logWrittenFor !== undefined && - !record.message.includes(fixtureOptions.logWrittenFor)) - ? Effect.void - : Deferred.succeed(fixtureOptions.logWritten, undefined), - ), - Effect.andThen( - fixtureOptions.logWrittenAdditional === undefined || - (fixtureOptions.logWrittenAdditionalFor !== undefined && - !record.message.includes(fixtureOptions.logWrittenAdditionalFor)) - ? Effect.void - : Deferred.succeed(fixtureOptions.logWrittenAdditional, undefined), - ), - Effect.andThen( - Effect.succeed({ - ...entry, - source: record.source, - stream: record.stream, - message: record.message, - }), - ), - ), - read: (options) => - options?.cursor?.opaque === "not-a-cursor" - ? Effect.fail(new InvalidLogCursorError({ message: "Log cursor is invalid" })) - : Ref.update(logOptions, (current) => [...current, options]).pipe( - Effect.andThen(Ref.get(logEntries)), - ), - }, - }; - const context = yield* Effect.context< - FileSystem.FileSystem | Path.Path | import("effect").Crypto.Crypto - >(); - const supervisorEffect = makeSupervisor({ - stackId: id, - ownerSessionId: "owner-session", - stateStore: store, - context, - runtime, - }); - const supervisor = - fixtureOptions.supervisorScope === undefined - ? yield* supervisorEffect - : yield* supervisorEffect.pipe( - Effect.provideService(Scope.Scope, fixtureOptions.supervisorScope), - ); - yield* Ref.set(calls, []); - return { - supervisor, - calls, - logOptions, - failDestroy, - context, - store, - id, - runtime, - resources, - root, - }; - }); - -const run = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -const makeCredentialsFixture = ({ authEnabled = true }: { readonly authEnabled?: boolean } = {}) => - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: {}, auth: { enabled: authEnabled } } }, - }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - if (running.definition === undefined) - return yield* new StackStateInvalidError({ message: "running definition is missing" }); - const state = { - ...running, - ports: [ - { field: "api", port: 55433, intent: "exact" as const }, - { field: "database", port: 55432, intent: "exact" as const }, - ] as const, - }; - yield* fixture.store.replace(fixture.id, state).pipe(Effect.provideContext(fixture.context)); - const baseSecrets = { - ...state.secrets, - "secret:auth.settings.publishable_key": { - policy: "managed" as const, - value: "publishable", - }, - "secret:auth.settings.secret_key": { policy: "managed" as const, value: "secret" }, - "secret:auth.settings.anon_key": { policy: "managed" as const, value: "anon" }, - "secret:auth.settings.service_role_key": { policy: "managed" as const, value: "service" }, - }; - return { fixture, state, definition: running.definition, baseSecrets }; - }); - -describe("Supervisor composition", () => { - it.live("rejects new work after owner shutdown begins", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.shutdownIfIdle; - - const start = yield* fixture.supervisor.start().pipe(Effect.exit); - expect(errorOf(start)).toBeInstanceOf(StackLifecycleConflictError); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("does not admit a lifecycle while idle shutdown makes its final decision", () => - run( - Effect.gen(function* () { - const readGateQueue = yield* Ref.make>([]); - const readStarted = yield* Deferred.make(); - const readGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - readGateQueue, - }); - yield* Ref.set(readGateQueue, [{ started: readStarted, gate: readGate, beforeRead: true }]); - const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdownIfIdle); - yield* Deferred.await(readStarted); - const start = yield* Effect.forkChild(fixture.supervisor.start({ config: {} })); - yield* Deferred.succeed(readGate, undefined); - yield* Fiber.join(shutdown); - expect(errorOf(yield* Fiber.join(start).pipe(Effect.exit))).toBeInstanceOf( - StackLifecycleConflictError, - ); - }), - ), - ); - - it.live("starts through the composed lifecycle and reports observed readiness", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - const status = yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - expect(status.lifecycle).toBe("running"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); - }), - ), - ); - - it.live("publishes starting until an eager workload reaches readiness", () => - run( - Effect.gen(function* () { - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ startStarted, startGate }); - const starting = yield* Effect.forkChild( - fixture.supervisor.start({ config: { capabilities: { rest: { activation: "eager" } } } }), - { startImmediately: true }, - ); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - expect((yield* fixture.supervisor.status).lifecycle).toBe("starting"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("starting"); - yield* Deferred.succeed(startGate, undefined); - const ready = yield* Fiber.join(starting); - expect(ready.lifecycle).toBe("running"); - expect(ready.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - }), - ), - ); - - it.live("retires lazy traffic after its lease ends and reactivates on demand", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const timeline = yield* Ref.make>([]); - const activationStarted = yield* Deferred.make(); - const logWritten = yield* Deferred.make(); - const fixture = yield* makeFixture({ - timeline, - activationStarted, - logWritten, - logWrittenFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy", idleTimeoutSeconds: 1 }, - auth: { activation: "lazy" }, - }, - }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - const release = yield* Deferred.make(); - const request = yield* Effect.forkChild( - tracker.track( - "rest", - fixture.supervisor.activate("rest").pipe(Effect.andThen(Deferred.await(release))), - ), - ); - yield* Deferred.await(activationStarted); - yield* TestClock.adjust("1 second"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(request); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(logWritten); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - expect(yield* Ref.get(timeline)).toContain("stop:rest:rest"); - yield* fixture.supervisor.activate("rest"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("keeps a lazy dependency pinned until its dependent retires", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const timeline = yield* Ref.make>([]); - const activationStarted = yield* Deferred.make(); - const logWritten = yield* Deferred.make(); - const restLogWritten = yield* Deferred.make(); - const fixture = yield* makeFixture({ - timeline, - activationStarted, - logWritten, - logWrittenFor: "Stopped studio after inactivity", - logWrittenAdditional: restLogWritten, - logWrittenAdditionalFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy", idleTimeoutSeconds: 1 }, - studio: { activation: "lazy", idleTimeoutSeconds: 1 }, - }, - }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - const release = yield* Deferred.make(); - const request = yield* Effect.forkChild( - tracker.track( - "studio", - fixture.supervisor.activate("studio").pipe(Effect.andThen(Deferred.await(release))), - ), - ); - yield* Deferred.await(activationStarted); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(request); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(logWritten); - const afterStudio = yield* fixture.supervisor.status; - expect(afterStudio.capabilities.find(({ name }) => name === "studio")?.state).toBe( - "dormant", - ); - expect(afterStudio.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(restLogWritten); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - expect(yield* Ref.get(timeline)).toEqual( - expect.arrayContaining(["stop:studio:pgmeta", "stop:rest:rest"]), - ); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("re-arms a dependency idle timer after a pinned dependent retires", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const timeline = yield* Ref.make>([]); - const restReady = yield* Deferred.make(); - const studioReady = yield* Deferred.make(); - const restStopped = yield* Deferred.make(); - const studioStopped = yield* Deferred.make(); - const fixture = yield* makeFixture({ - timeline, - logWritten: studioStopped, - logWrittenFor: "Stopped studio after inactivity", - logWrittenAdditional: restStopped, - logWrittenAdditionalFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy", idleTimeoutSeconds: 1 }, - studio: { activation: "lazy", idleTimeoutSeconds: 1 }, - }, - }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - - const releaseRest = yield* Deferred.make(); - const restRequest = yield* Effect.forkChild( - tracker.track( - "rest", - fixture.supervisor.activate("rest").pipe( - Effect.tap(() => Deferred.succeed(restReady, undefined)), - Effect.andThen(Deferred.await(releaseRest)), - ), - ), - ); - yield* TestClock.withLive(Deferred.await(restReady).pipe(Effect.timeout("5 seconds"))); - yield* Deferred.succeed(releaseRest, undefined); - yield* TestClock.withLive(Fiber.join(restRequest).pipe(Effect.timeout("5 seconds"))); - - const releaseStudio = yield* Deferred.make(); - const studioRequest = yield* Effect.forkChild( - tracker.track( - "studio", - fixture.supervisor.activate("studio").pipe( - Effect.tap(() => Deferred.succeed(studioReady, undefined)), - Effect.andThen(Deferred.await(releaseStudio)), - ), - ), - ); - yield* TestClock.withLive(Deferred.await(studioReady).pipe(Effect.timeout("5 seconds"))); - yield* TestClock.adjust("1 second"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - yield* Deferred.succeed(releaseStudio, undefined); - yield* TestClock.withLive(Fiber.join(studioRequest).pipe(Effect.timeout("5 seconds"))); - yield* TestClock.adjust("1 second"); - yield* TestClock.withLive(Deferred.await(studioStopped).pipe(Effect.timeout("5 seconds"))); - yield* TestClock.adjust("1 second"); - yield* TestClock.withLive(Deferred.await(restStopped).pipe(Effect.timeout("5 seconds"))); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - expect(yield* Ref.get(timeline)).toEqual( - expect.arrayContaining(["stop:studio:pgmeta", "stop:rest:rest"]), - ); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("retires an unresolved dependency after its cancelled lookup waiter finishes", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const lookupStarted = yield* Deferred.make(); - const lookupStartedAfterFirst = yield* Deferred.make(); - const lookupGate = yield* Deferred.make(); - const activationCalls = yield* Ref.make(0); - const studioStopped = yield* Deferred.make(); - const restStopped = yield* Deferred.make(); - const fixture = yield* makeFixture({ - activationGateAfterFirst: lookupGate, - activationStarted: lookupStarted, - activationStartedAfterFirst: lookupStartedAfterFirst, - activationCalls, - logWritten: studioStopped, - logWrittenFor: "Stopped studio after inactivity", - logWrittenAdditional: restStopped, - logWrittenAdditionalFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy", idleTimeoutSeconds: 1 }, - studio: { activation: "lazy", idleTimeoutSeconds: 1 }, - }, - }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - - yield* tracker.track("studio", fixture.supervisor.activate("studio")); - yield* TestClock.adjust("1 second"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "studio") - ?.state, - ).toBe("dormant"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - - const cancelled = yield* Effect.forkChild( - tracker.track("rest", fixture.supervisor.activate("rest")), - ); - yield* TestClock.withLive( - Deferred.await(lookupStartedAfterFirst).pipe(Effect.timeout("5 seconds")), - ); - yield* Fiber.interrupt(cancelled); - const owner = yield* Effect.forkChild(fixture.supervisor.activate("rest")); - yield* Deferred.succeed(lookupGate, undefined); - yield* TestClock.withLive(Fiber.join(owner).pipe(Effect.timeout("5 seconds"))); - yield* TestClock.adjust("1 second"); - yield* TestClock.withLive(Deferred.await(restStopped).pipe(Effect.timeout("5 seconds"))); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("joins concurrent activation while a lazy dependency is starting", () => - run( - Effect.gen(function* () { - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - startStarted, - startGate, - startWorkload: "rest:rest", - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy" }, - studio: { activation: "lazy" }, - }, - }, - }); - const studio = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds")); - const rest = yield* Effect.forkChild(fixture.supervisor.activate("rest"), { - startImmediately: true, - }); - yield* Deferred.succeed(startGate, undefined); - yield* Fiber.join(rest).pipe(Effect.timeout("5 seconds")); - yield* Fiber.join(studio).pipe(Effect.timeout("5 seconds")); - const status = yield* fixture.supervisor.status; - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - expect(status.capabilities.find(({ name }) => name === "studio")?.state).toBe("ready"); - }), - ), - ); - - it.live( - "propagates a recovered owner read failure instead of fabricating an empty running stack", - () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - const failRead = yield* Ref.make(false); - const stateStore = { - ...fixture.store, - read: (stackId: string) => - Effect.gen(function* () { - if (yield* Ref.getAndSet(failRead, false)) - return yield* new StackStateInvalidError({ - message: "transient state read failure", - }); - return yield* fixture.store.read(stackId); - }), - }; - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore, - context: fixture.context, - runtime: fixture.runtime, - }); - yield* Ref.set(failRead, true); - const failed = yield* successor.start().pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* successor.status).lifecycle).toBe("stopping"); - expect(Exit.isFailure(yield* successor.start().pipe(Effect.exit))).toBe(true); - expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); - const restarted = yield* successor.start().pipe(Effect.exit); - expect(Exit.isSuccess(restarted)).toBe(true); - if (Exit.isSuccess(restarted)) expect(restarted.value.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "database:database", state: "ready" }), - ); - }), - ), - ); - - it.live("retries lazy activation after a preclaim state read failure", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const failRead = yield* Ref.make(false); - const stateStore = { - ...fixture.store, - read: (stackId: string) => - Effect.gen(function* () { - if (yield* Ref.getAndSet(failRead, false)) - return yield* new StackStateInvalidError({ - message: "transient activation state read failure", - }); - return yield* fixture.store.read(stackId); - }), - }; - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore, - context: fixture.context, - runtime: fixture.runtime, - }); - yield* successor.start(); - const before = yield* Ref.get(fixture.resources); - yield* Ref.set(failRead, true); - const failed = yield* successor - .activate("functions") - .pipe(Effect.timeout("5 seconds"), Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(errorOf(failed)).toBeInstanceOf(StackStateInvalidError); - expect(errorOf(failed)?.message).toBe("transient activation state read failure"); - expect(yield* Ref.get(fixture.resources)).toEqual(before); - const retry = yield* successor.activate("functions").pipe(Effect.timeout("5 seconds")); - expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - expect( - (yield* successor.status).capabilities.find(({ name }) => name === "functions")?.state, - ).toBe("ready"); - }), - ), - ); - - it.live("shares the original workload start failure with concurrent dependency callers", () => - run( - Effect.gen(function* () { - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const startFailures = yield* Ref.make(1); - const fixture = yield* makeFixture({ - startStarted, - startGate, - startWorkload: "rest:rest", - startFailures, - startFailureWorkload: "rest:rest", - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy" }, - studio: { activation: "lazy" }, - }, - }, - }); - const studio = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - const rest = yield* Effect.forkChild(fixture.supervisor.activate("rest"), { - startImmediately: true, - }); - yield* Deferred.succeed(startGate, undefined); - const studioExit = yield* Fiber.join(studio).pipe(Effect.exit); - const restExit = yield* Fiber.join(rest).pipe(Effect.exit); - expect(Exit.isFailure(studioExit)).toBe(true); - expect(Exit.isFailure(restExit)).toBe(true); - expect(errorOf(studioExit)?._tag).toBe(errorOf(restExit)?._tag); - expect(errorOf(studioExit)?.message).toBe(errorOf(restExit)?.message); - }), - ), - ); - - it.live("fences a lazy launch when root rollback removal is unproven", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(1); - const workloadRemoveFailFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ - startFailures, - startFailureWorkload: "studio:pgmeta", - workloadRemoveFailFirst, - workloadRemoveFailWorkload: "studio:pgmeta", - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy" }, - studio: { activation: "lazy" }, - }, - }, - }); - const activation = yield* fixture.supervisor.activate("studio").pipe(Effect.exit); - expect(Exit.isFailure(activation)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("failed"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "studio") - ?.state, - ).toBe("failed"); - expect(Exit.isFailure(yield* fixture.supervisor.activate("rest").pipe(Effect.exit))).toBe( - true, - ); - expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); - }), - ), - ); - - it.live("rejects activation of a disabled capability without changing its state", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { - capabilities: { rest: { enabled: false }, studio: { enabled: false } }, - }, - }); - const activation = yield* fixture.supervisor.activate("rest").pipe(Effect.exit); - expect(Exit.isFailure(activation)).toBe(true); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("disabled"); - }), - ), - ); - - it.live("re-arms an idle timer after a failed root activation releases execution", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const prepareGateEnabledRef = yield* Ref.make(false); - const prepareActivationStarted = yield* Deferred.make(); - const prepareGate = yield* Deferred.make(); - const prepareFailureRef = yield* Ref.make(false); - const logWritten = yield* Deferred.make(); - const fixture = yield* makeFixture({ - prepareGateEnabledRef, - prepareActivationStarted, - prepareGate, - prepareFailureRef, - logWritten, - logWrittenFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy", idleTimeoutSeconds: 1 }, - auth: { activation: "lazy" }, - studio: { activation: "lazy", idleTimeoutSeconds: 1 }, - }, - }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* Ref.set(prepareGateEnabledRef, true); - const auth = yield* Effect.forkChild( - tracker.track("auth", fixture.supervisor.activate("auth")), - { startImmediately: true }, - ); - yield* Deferred.await(prepareActivationStarted); - yield* TestClock.adjust("1 second"); - const studio = yield* Effect.forkChild( - tracker.track("studio", fixture.supervisor.activate("studio")), - { startImmediately: true }, - ); - const beforeRelease = yield* fixture.supervisor.status; - expect(beforeRelease.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - expect(beforeRelease.capabilities.find(({ name }) => name === "studio")?.state).toBe( - "starting", - ); - yield* Ref.set(prepareFailureRef, true); - yield* Deferred.succeed(prepareGate, undefined); - const authResult = yield* Fiber.join(auth).pipe(Effect.exit); - expect(Exit.isFailure(authResult)).toBe(true); - expect(errorOf(authResult)).toBeInstanceOf(StackRuntimeError); - const studioResult = yield* Fiber.join(studio).pipe(Effect.exit); - expect(Exit.isFailure(studioResult)).toBe(true); - expect(errorOf(studioResult)).toBeInstanceOf(StackRuntimeError); - const afterFailure = yield* fixture.supervisor.status; - expect(afterFailure.lifecycle).toBe("running"); - expect(afterFailure.capabilities.find(({ name }) => name === "studio")?.state).toBe( - "dormant", - ); - yield* TestClock.adjust("1 second"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - yield* Deferred.await(logWritten); - expect(yield* Ref.get(fixture.resources)).toEqual( - expect.not.arrayContaining([expect.objectContaining({ workloadId: "rest:rest" })]), - ); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("accepts very small idle timeout values", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const logWritten = yield* Deferred.make(); - const fixture = yield* makeFixture({ - logWritten, - logWrittenFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1e-7 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 millis"); - yield* Deferred.await(logWritten); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("rearms a zero-rounded idle timeout after its first retirement", () => - run( - Effect.gen(function* () { - const logs = yield* Queue.unbounded(); - const fixture = yield* makeFixture({ - logQueue: logs, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1e-12 } } }, - }); - yield* fixture.supervisor.activate("rest"); - yield* TestClock.adjust("1 millis"); - expect(yield* Queue.take(logs)).toContain("Stopped rest after inactivity"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - - yield* fixture.supervisor.activate("rest"); - yield* TestClock.adjust("1 millis"); - expect(yield* Queue.take(logs)).toContain("Stopped rest after inactivity"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("resets the idle deadline when a second request arrives", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const activationStarted = yield* Deferred.make(); - const logWritten = yield* Deferred.make(); - const fixture = yield* makeFixture({ - activationStarted, - logWritten, - logWrittenFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - const first = yield* Effect.forkChild( - tracker.track("rest", fixture.supervisor.activate("rest")), - ); - yield* Deferred.await(activationStarted); - yield* Fiber.join(first); - yield* TestClock.adjust("500 millis"); - yield* tracker.track("rest", Effect.void); - yield* TestClock.adjust("500 millis"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(logWritten); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("queues traffic arriving during idle cleanup for a fresh activation", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const activationCalls = yield* Ref.make(0); - const logQueue = yield* Queue.unbounded(); - const stopStarted = yield* Deferred.make(); - const stopGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - activationCalls, - logQueue, - workloadStopStarted: stopStarted, - workloadStopGate: stopGate, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(stopStarted); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "auth") - ?.state, - ).toBe("dormant"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("stopping"); - - const requestStarted = yield* Deferred.make(); - const cancelled = yield* Effect.forkChild( - Deferred.succeed(requestStarted, undefined).pipe( - Effect.andThen(tracker.track("rest", fixture.supervisor.activate("rest"))), - ), - ); - yield* Deferred.await(requestStarted); - expect(yield* Ref.get(activationCalls)).toBe(1); - yield* Fiber.interrupt(cancelled); - - const survivorReady = yield* Deferred.make(); - const survivorRelease = yield* Deferred.make(); - const request = yield* Effect.forkChild( - tracker.track( - "rest", - fixture.supervisor - .activate("rest") - .pipe( - Effect.andThen(Deferred.succeed(survivorReady, undefined)), - Effect.andThen(Deferred.await(survivorRelease)), - ), - ), - ); - - yield* Deferred.succeed(stopGate, undefined); - expect(yield* Queue.take(logQueue)).toContain("Stopped rest after inactivity"); - yield* Deferred.await(survivorReady); - expect(yield* Ref.get(activationCalls)).toBe(2); - yield* TestClock.adjust("1 second"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - yield* Deferred.succeed(survivorRelease, undefined); - yield* Fiber.join(request); - yield* TestClock.adjust("1 second"); - expect(yield* Queue.take(logQueue)).toContain("Stopped rest after inactivity"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("rejects an idempotent start admitted during idle retirement", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const stopStarted = yield* Deferred.make(); - const stopGate = yield* Deferred.make(); - const workloadRemoveFailFirst = yield* Ref.make(true); - const config = { - capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } }, - } as const; - const fixture = yield* makeFixture({ - workloadStopStarted: stopStarted, - workloadStopGate: stopGate, - workloadRemoveFailFirst, - workloadRemoveFailWorkload: "rest:rest", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ config }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(stopStarted); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("stopping"); - - const admitted = yield* Effect.forkChild(fixture.supervisor.start({ config }), { - startImmediately: true, - }); - yield* Effect.yieldNow; - const rejected = yield* fixture.supervisor.start({ config }).pipe(Effect.exit); - expect(errorOf(rejected)).toBeInstanceOf(StackLifecycleConflictError); - - yield* Deferred.succeed(stopGate, undefined); - const failed = yield* Fiber.join(admitted).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("failed"); - expect((yield* Ref.get(fixture.calls)).filter((call) => call.startsWith("start:"))).toEqual( - ["start:database:database", "start:rest:rest"], - ); - expect( - errorOf(yield* fixture.supervisor.start({ config }).pipe(Effect.exit)), - ).toBeInstanceOf(StackLifecycleConflictError); - - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("keeps a ready capability alive while an overlapping request lease remains", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const firstAcquired = yield* Deferred.make(); - const firstRelease = yield* Deferred.make(); - const secondAcquired = yield* Deferred.make(); - const secondRelease = yield* Deferred.make(); - const logQueue = yield* Queue.unbounded(); - const fixture = yield* makeFixture({ - logQueue, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - const first = yield* Effect.forkChild( - tracker.track( - "rest", - fixture.supervisor - .activate("rest") - .pipe( - Effect.andThen(Deferred.succeed(firstAcquired, undefined)), - Effect.andThen(Deferred.await(firstRelease)), - ), - ), - ); - yield* Deferred.await(firstAcquired); - const second = yield* Effect.forkChild( - tracker.track( - "rest", - Deferred.succeed(secondAcquired, undefined).pipe( - Effect.andThen(Deferred.await(secondRelease)), - ), - ), - ); - yield* Deferred.await(secondAcquired); - yield* Deferred.succeed(firstRelease, undefined); - yield* Fiber.join(first); - yield* TestClock.adjust("2 seconds"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - yield* Deferred.succeed(secondRelease, undefined); - yield* Fiber.join(second); - yield* TestClock.adjust("1 second"); - expect(yield* Queue.take(logQueue)).toContain("Stopped rest after inactivity"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("settles activation waiters when the owner scope is interrupted during retirement", () => - run( - Effect.gen(function* () { - const ownerScope = yield* Scope.make(); - const stopStarted = yield* Deferred.make(); - const stopGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - workloadStopStarted: stopStarted, - workloadStopGate: stopGate, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - yield* fixture.supervisor.activate("rest"); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(stopStarted); - - const waiter = yield* Effect.forkChild(fixture.supervisor.activate("rest"), { - startImmediately: true, - }); - yield* TestClock.withLive( - Scope.close(ownerScope, Exit.void).pipe(Effect.timeout("5 seconds")), - ); - const settled = yield* TestClock.withLive( - Fiber.await(waiter).pipe(Effect.timeout("5 seconds")), - ); - expect(Exit.isFailure(settled)).toBe(true); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("settles overlapping dependency activation when launch owner scope closes", () => - run( - Effect.gen(function* () { - const ownerScope = yield* Scope.make(); - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - startStarted, - startGate, - startWorkload: "rest:rest", - supervisorScope: ownerScope, - }); - const config = { - capabilities: { - rest: { activation: "lazy" as const }, - studio: { activation: "lazy" as const }, - functions: { activation: "lazy" as const }, - }, - }; - yield* fixture.supervisor.start({ config }); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "database:database", state: "ready" }), - ); - const studio = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(startStarted); - const rest = yield* Effect.forkChild(fixture.supervisor.activate("rest"), { - startImmediately: true, - }); - const functions = yield* Effect.forkChild(fixture.supervisor.activate("functions"), { - startImmediately: true, - }); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("starting"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") - ?.state, - ).toBe("starting"); - - yield* Scope.close(ownerScope, Exit.void).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("owner scope did not close"), - }), - ); - const studioExit = yield* Fiber.await(studio).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("Studio activation remained pending after owner scope close"), - }), - ); - const restExit = yield* Fiber.await(rest).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("REST activation remained pending after owner scope close"), - }), - ); - expect(Exit.isFailure(studioExit)).toBe(true); - if (Exit.isFailure(studioExit)) expect(Cause.hasInterrupts(studioExit.cause)).toBe(true); - expect(Exit.isFailure(restExit)).toBe(true); - if (Exit.isFailure(restExit)) expect(Cause.hasInterrupts(restExit.cause)).toBe(true); - const functionsExit = yield* Fiber.await(functions).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => - Effect.die("Functions activation remained pending after owner scope close"), - }), - ); - expect(Exit.isFailure(functionsExit)).toBe(true); - if (Exit.isFailure(functionsExit)) - expect(Cause.hasInterrupts(functionsExit.cause)).toBe(true); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopping"); - expect(status.recovery?.operation).toBe("stop"); - expect(status.capabilities.find(({ name }) => name === "studio")?.state).toBe("failed"); - expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("dormant"); - }), - ), - ); - - it.live("rejects lifecycle admission after the owner scope closes", () => - run( - Effect.gen(function* () { - const ownerScope = yield* Scope.make(); - const fixture = yield* makeFixture({ supervisorScope: ownerScope }); - const config = { capabilities: { rest: { activation: "lazy" as const } } }; - yield* fixture.supervisor.start({ config }); - yield* Scope.close(ownerScope, Exit.void); - const activation = yield* Effect.exit(fixture.supervisor.activate("rest")); - expect(errorOf(activation)).toBeInstanceOf(StackLifecycleConflictError); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - const retry = yield* Effect.exit(fixture.supervisor.start({ config })); - expect(errorOf(retry)).toBeInstanceOf(StackLifecycleConflictError); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopping"); - expect(status.recovery).toEqual({ - operation: "stop", - message: "Stack owner scope is closed", - }); - }), - ), - ); - - it.live("settles endpoint activation when launch owner scope closes", () => - run( - Effect.gen(function* () { - const ownerScope = yield* Scope.make(); - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const activationStarted = yield* Deferred.make(); - const activationGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - startStarted, - startGate, - startWorkload: "rest:rest", - activationStarted, - activationGate, - supervisorScope: ownerScope, - }); - const config = { - capabilities: { - rest: { activation: "lazy" as const }, - studio: { activation: "lazy" as const }, - }, - }; - yield* fixture.supervisor.start({ config }); - const studio = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(startStarted); - const rest = yield* Effect.forkChild(fixture.supervisor.activate("rest"), { - startImmediately: true, - }); - yield* Deferred.succeed(startGate, undefined); - yield* Deferred.await(activationStarted); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "rest:rest", state: "ready" }), - ); - - yield* Scope.close(ownerScope, Exit.void).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("owner scope did not close"), - }), - ); - const studioExit = yield* Fiber.await(studio).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("Studio activation remained pending after owner scope close"), - }), - ); - expect(Exit.isFailure(studioExit)).toBe(true); - if (Exit.isFailure(studioExit)) expect(Cause.hasInterrupts(studioExit.cause)).toBe(true); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "rest:rest", state: "ready" }), - ); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopping"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); - const restExit = yield* Fiber.await(rest).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => - Effect.die("REST activation remained pending after endpoint owner scope close"), - }), - ); - expect(Exit.isFailure(restExit)).toBe(true); - }), - ), - ); - - it.live("settles lazy activation interrupted while waiting for admission", () => - run( - Effect.gen(function* () { - const ownerScope = yield* Scope.make(); - const readGateQueue = yield* Ref.make>([]); - const activationReadStarted = yield* Deferred.make(); - const activationReadGate = yield* Deferred.make(); - const shutdownReadStarted = yield* Deferred.make(); - const shutdownReadGate = yield* Deferred.make(); - const activationStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - readGateQueue, - supervisorScope: ownerScope, - activationStarted, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy" } } }, - }); - yield* Ref.set(readGateQueue, [ - { started: activationReadStarted, gate: activationReadGate }, - ]); - - const activation = yield* Effect.forkChild( - fixture.supervisor - .activate("rest") - .pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true)), - { startImmediately: true }, - ); - yield* Deferred.await(activationReadStarted).pipe( - Effect.timeout("5 seconds"), - Effect.orDie, - ); - yield* Ref.update(readGateQueue, (gates) => [ - ...gates, - { started: shutdownReadStarted, gate: shutdownReadGate }, - ]); - const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdownIfIdle, { - startImmediately: true, - }); - yield* Deferred.await(shutdownReadStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - - // Deferred resumes synchronously to the masked wait; wake-up is queued. - // Only FiberSet has a live finalizer, so scope close installs interruption first. - yield* Deferred.succeed(activationReadGate, undefined); - const closing = yield* Effect.forkChild( - Scope.close(ownerScope, Exit.void).pipe( - Effect.provideService(Scheduler.PreventSchedulerYield, true), - ), - { startImmediately: true }, - ); - yield* Deferred.succeed(shutdownReadGate, undefined); - - yield* Fiber.join(closing).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("owner scope did not close"), - }), - ); - const activationExit = yield* Fiber.await(activation).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("activation remained pending after owner scope close"), - }), - ); - yield* Fiber.join(shutdown); - expect(Exit.isFailure(activationExit)).toBe(true); - if (Exit.isFailure(activationExit)) - expect(Cause.hasInterrupts(activationExit.cause)).toBe(true); - expect(yield* Deferred.isDone(activationStarted)).toBe(false); - - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("running"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); - - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - yield* successor.start(); - const retry = yield* successor.activate("rest"); - expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - }), - ), - ); - - it.live("keeps unrelated ready traffic flowing during idle cleanup", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const stopStarted = yield* Deferred.make(); - const stopGate = yield* Deferred.make(); - const authCompleted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - workloadStopStarted: stopStarted, - workloadStopGate: stopGate, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - const body = Effect.gen(function* () { - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy", idleTimeoutSeconds: 1 }, - auth: { activation: "eager" }, - }, - }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("auth", fixture.supervisor.activate("auth")); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(stopStarted); - - const authRequest = yield* Effect.forkChild( - tracker.track( - "auth", - fixture.supervisor - .activate("auth") - .pipe(Effect.andThen(Deferred.succeed(authCompleted, undefined))), - ), - { startImmediately: true }, - ); - yield* Deferred.await(authCompleted); - yield* Deferred.succeed(stopGate, undefined); - yield* Fiber.join(authRequest); - }); - yield* body.pipe(Effect.ensuring(Deferred.succeed(stopGate, undefined))); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("rejects queued demand when explicit stop wins idle retirement", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const stopStarted = yield* Deferred.make(); - const stopGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - workloadStopStarted: stopStarted, - workloadStopGate: stopGate, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(stopStarted); - const request = yield* Effect.forkChild( - tracker.track("rest", fixture.supervisor.activate("rest")), - { - startImmediately: true, - }, - ); - const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop, { - startImmediately: true, - }); - yield* Deferred.succeed(stopGate, undefined); - expect((yield* Fiber.join(stopping)).ok).toBe(true); - const requestResult = yield* Fiber.join(request).pipe(Effect.exit); - expect(Exit.isFailure(requestResult)).toBe(true); - expect(yield* Ref.get(fixture.resources)).toEqual([]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("records the original cause when idle cleanup fails", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const logRecords = yield* Ref.make>([]); - const logWritten = yield* Deferred.make(); - const workloadStopFailFirst = yield* Ref.make(true); - const workloadStopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - logRecords, - logWritten, - workloadStopFailFirst, - workloadStopStarted, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(workloadStopStarted).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("workload stop did not start"), - }), - ); - yield* Deferred.await(logWritten); - const messages = yield* Ref.get(logRecords); - expect(messages).toEqual( - expect.arrayContaining([ - expect.stringContaining("Failed to stop rest after inactivity"), - expect.stringContaining("injected workload stop failure"), - ]), - ); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopping"); - expect(status.recovery).toEqual({ - operation: "stop", - message: expect.stringContaining("injected workload stop failure"), - }); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("fences the session after an idle cleanup defect", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const logRecords = yield* Ref.make>([]); - const logWritten = yield* Deferred.make(); - const workloadRemoveDieFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ - logRecords, - logWritten, - workloadRemoveDieFirst, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(logWritten); - - const messages = yield* Ref.get(logRecords); - expect(messages).toEqual( - expect.arrayContaining([ - expect.stringContaining("Failed to stop rest after inactivity"), - expect.stringContaining("injected workload remove defect"), - ]), - ); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - const activation = yield* fixture.supervisor.activate("rest").pipe(Effect.exit); - expect(errorOf(activation)).toBeInstanceOf(StackLifecycleConflictError); - - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const restartedTracker = yield* Ref.get(activity); - if (restartedTracker === undefined) - return yield* Effect.die("restarted gateway activity was not installed"); - yield* restartedTracker.track("rest", fixture.supervisor.activate("rest")); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("cancels idle timers when a stack session stops", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const firstTracker = yield* Ref.get(activity); - if (firstTracker === undefined) - return yield* Effect.die("first gateway activity was not installed"); - yield* firstTracker.track("rest", fixture.supervisor.activate("rest")); - yield* fixture.supervisor.maintenanceHandlers.stop; - - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 10 } } }, - }); - const secondTracker = yield* Ref.get(activity); - if (secondTracker === undefined) - return yield* Effect.die("second gateway activity was not installed"); - yield* secondTracker.track("rest", fixture.supervisor.activate("rest")); - yield* TestClock.adjust("1 second"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("ready"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("restores idle deadlines after a rejected running start", () => - run( - Effect.gen(function* () { - const activity = yield* Ref.make(undefined); - const logWritten = yield* Deferred.make(); - const fixture = yield* makeFixture({ - logWritten, - logWrittenFor: "Stopped rest after inactivity", - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: false, - ownershipToken: Symbol(), - }), - open: (_input, _reservation, _activate, tracker) => - tracker === undefined ? Effect.void : Ref.set(activity, tracker), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy", idleTimeoutSeconds: 1 } } }, - }); - const tracker = yield* Ref.get(activity); - if (tracker === undefined) return yield* Effect.die("gateway activity was not installed"); - yield* tracker.track("rest", fixture.supervisor.activate("rest")); - const rejected = yield* fixture.supervisor - .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) - .pipe(Effect.exit); - expect(errorOf(rejected)).toBeInstanceOf(StackMustBeStoppedError); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(logWritten); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("dormant"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.live("persists unconfigured after a cold startup ingress failure", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Effect.fail( - new PortUnavailableError({ - field: "api", - port: 54_321, - message: "injected ingress failure", - }), - ), - open: () => Effect.void, - close: Effect.void, - }, - }); - expect( - Exit.isFailure(yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit)), - ).toBe(true); - const status = yield* fixture.supervisor.status; - expect(status.desiredLifecycle).toBe("unconfigured"); - expect(status.lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("persists stopped after a restart ingress failure", () => - run( - Effect.gen(function* () { - const acquireCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Ref.updateAndGet(acquireCalls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count !== 2 - ? Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }) - : Effect.fail( - new PortUnavailableError({ - field: "api", - port: 54_321, - message: "injected start ingress failure", - }), - ), - ), - ), - open: () => Effect.void, - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ config: {} }); - yield* fixture.supervisor.maintenanceHandlers.stop; - - expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); - const status = yield* fixture.supervisor.status; - expect(status.desiredLifecycle).toBe("stopped"); - expect(status.lifecycle).toBe("stopped"); - expect(errorOf(yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit))?.tag).toBe( - "StackNotRunningError", - ); - yield* fixture.supervisor.shutdownIfIdle; - const retry = yield* fixture.supervisor.start().pipe(Effect.exit); - expect(errorOf(retry)).toBeInstanceOf(StackLifecycleConflictError); - expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); - }), - ), - ); - - it.live("exits after a stopped-owner restart ingress failure", () => - run( - Effect.gen(function* () { - const acquireCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Ref.updateAndGet(acquireCalls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count !== 2 - ? Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }) - : Effect.fail( - new PortUnavailableError({ - field: "api", - port: 54_321, - message: "injected stopped-owner ingress failure", - }), - ), - ), - ), - open: () => Effect.void, - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ config: {} }); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - - expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - yield* fixture.supervisor.shutdownIfIdle; - expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( - StackLifecycleConflictError, - ); - }), - ), - ); - - it.live("starts only database by default and keeps other capabilities dormant", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const status = yield* fixture.supervisor.start({ config: {} }); - expect(status.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); - expect(status.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); - for (const name of [ - "rest", - "auth", - "realtime", - "storage", - "functions", - "studio", - "mail", - "analytics", - "pooler", - ] as const) - expect(status.capabilities.find((capability) => capability.name === name)?.state).toBe( - "dormant", - ); - }), - ), - ); - - it.live("returns database readiness while background preparation remains observable", () => - run( - Effect.gen(function* () { - const prefetchStarted = yield* Deferred.make(); - const prefetchGate = yield* Deferred.make(); - const prefetchInterrupted = yield* Deferred.make(); - const prefetchCalls = yield* Ref.make(0); - const artifactStatuses = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - prefetchStarted, - prefetchGate, - prefetchInterrupted, - prefetchCalls, - artifactStatuses, - }); - const startReturned = yield* Deferred.make(); - yield* Effect.forkChild( - fixture.supervisor - .start({ config: {} }) - .pipe( - Effect.tap((status) => Deferred.succeed(startReturned, status).pipe(Effect.asVoid)), - ), - { startImmediately: true }, - ); - const started = yield* Deferred.await(startReturned).pipe( - Effect.timeout("5 seconds"), - Effect.orDie, - ); - expect(started.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); - expect(started.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); - yield* Deferred.await(prefetchStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - const during = yield* fixture.supervisor.status; - expect(during.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); - expect(during.artifacts).toEqual([ - { workloadId: "rest:rest", capability: "rest", state: "downloading" }, - ]); - - const repeated = yield* fixture.supervisor.start(); - expect(repeated.artifacts).toEqual(during.artifacts); - expect(yield* Ref.get(prefetchCalls)).toBe(1); - - const stopped = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(stopped.ok).toBe(true); - yield* Deferred.await(prefetchInterrupted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - }), - ), - ); - - it.live("launches workloads while selected preparation is still in flight", () => - run( - Effect.gen(function* () { - const prepareStarted = yield* Deferred.make(); - const prepareGate = yield* Deferred.make(); - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - prepareStarted, - prepareGate, - startStarted, - startGate, - }); - const starting = yield* Effect.forkChild(fixture.supervisor.start({ config: {} }), { - startImmediately: true, - }); - yield* Deferred.await(prepareStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.succeed(startGate, undefined); - yield* Deferred.succeed(prepareGate, undefined); - const status = yield* Fiber.join(starting); - expect(status.lifecycle).toBe("running"); - }), - ), - ); - - it.live("cleans launched workloads when preparation fails after launch completes", () => - run( - Effect.gen(function* () { - const prepareStarted = yield* Deferred.make(); - const prepareGate = yield* Deferred.make(); - const startFinished = yield* Deferred.make(); - const fixture = yield* makeFixture({ - prepareStarted, - prepareGate, - prepareFailure: true, - startFinished, - }); - const starting = yield* Effect.forkChild( - fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }), - { startImmediately: true }, - ); - yield* Deferred.await(prepareStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.await(startFinished).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.succeed(prepareGate, undefined); - const result = yield* Fiber.join(starting).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("cancels a blocked launcher when preparation fails first", () => - run( - Effect.gen(function* () { - const prepareFailureRef = yield* Ref.make(false); - const prepareGateEnabledRef = yield* Ref.make(false); - const prepareStarted = yield* Deferred.make(); - const prepareActivationStarted = yield* Deferred.make(); - const prepareGate = yield* Deferred.make(); - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - prepareStarted, - prepareActivationStarted, - prepareGate, - prepareGateEnabledRef, - prepareFailureRef, - startStarted, - startGate, - startWorkload: "studio:studio", - }); - const starting = yield* Effect.forkChild( - fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "eager" }, - studio: { activation: "lazy" }, - }, - }, - }), - { startImmediately: true }, - ); - yield* Fiber.join(starting).pipe(Effect.timeout("5 seconds")); - yield* Ref.set(prepareFailureRef, true); - yield* Ref.set(prepareGateEnabledRef, true); - const activation = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(prepareActivationStarted).pipe( - Effect.timeout("5 seconds"), - Effect.orDie, - ); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.succeed(prepareGate, undefined); - const result = yield* Fiber.join(activation).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - const statusAfterFailure = yield* fixture.supervisor.status; - expect(statusAfterFailure.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.resources)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ workloadId: "database:database", state: "ready" }), - expect.objectContaining({ workloadId: "rest:rest", state: "ready" }), - ]), - ); - yield* Deferred.succeed(startGate, undefined); - yield* Ref.set(prepareFailureRef, false); - expect((yield* fixture.supervisor.activate("studio")).endpoint).toEqual({ - host: "127.0.0.1", - port: 9999, - }); - }), - ), - ); - - it.live("retains preparation and cleanup causes after a launched workload", () => - run( - Effect.gen(function* () { - const prepareFailureRef = yield* Ref.make(false); - const prepareGateEnabledRef = yield* Ref.make(false); - const prepareActivationStarted = yield* Deferred.make(); - const prepareGate = yield* Deferred.make(); - const startStarted = yield* Deferred.make(); - const startFinished = yield* Deferred.make(); - const workloadRemoveFailFirst = yield* Ref.make(true); - const ingressCloseFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ - prepareActivationStarted, - prepareGate, - prepareGateEnabledRef, - prepareFailureRef, - startStarted, - startWorkload: "studio:studio", - startFinished, - workloadRemoveFailFirst, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }), - open: () => Effect.void, - close: Effect.gen(function* () { - if (yield* Ref.get(ingressCloseFailFirst)) { - yield* Ref.set(ingressCloseFailFirst, false); - return yield* new StackCleanupError({ message: "injected ingress close failure" }); - } - }), - }, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "eager" }, - studio: { activation: "lazy" }, - }, - }, - }); - yield* Ref.set(ingressCloseFailFirst, true); - yield* Ref.set(prepareFailureRef, true); - yield* Ref.set(prepareGateEnabledRef, true); - const activation = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(prepareActivationStarted).pipe( - Effect.timeout("5 seconds"), - Effect.orDie, - ); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.await(startFinished).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.succeed(prepareGate, undefined); - - const failed = yield* Fiber.join(activation).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - if (Exit.isFailure(failed)) { - const message = Cause.pretty(failed.cause); - expect(message).toContain("injected preparation failure"); - expect(message).toContain("injected workload remove failure"); - expect(message).toContain("injected ingress close failure"); - } - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("keeps accepted start work alive when its caller is interrupted", () => - run( - Effect.gen(function* () { - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const prefetchStarted = yield* Deferred.make(); - const prefetchGate = yield* Deferred.make(); - const prefetchFinished = yield* Deferred.make(); - const artifactStatuses = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - startStarted, - startGate, - prefetchStarted, - prefetchGate, - prefetchFinished, - artifactStatuses, - }); - const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config: {} }), { - startImmediately: true, - }); - yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Fiber.interrupt(waiter); - const interrupted = yield* Fiber.join(waiter).pipe(Effect.exit); - expect(Exit.isFailure(interrupted)).toBe(true); - yield* Deferred.succeed(startGate, undefined); - yield* Deferred.await(prefetchStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - const during = yield* fixture.supervisor.status; - expect(during.lifecycle).toBe("running"); - expect(during.artifacts).toEqual([ - { workloadId: "rest:rest", capability: "rest", state: "downloading" }, - ]); - yield* Deferred.succeed(prefetchGate, undefined); - yield* Deferred.await(prefetchFinished).pipe(Effect.timeout("5 seconds"), Effect.orDie); - expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); - yield* fixture.supervisor.maintenanceHandlers.stop; - }), - ), - ); - - it.live("interrupts background preparation before destroying the stack", () => - run( - Effect.gen(function* () { - const prefetchStarted = yield* Deferred.make(); - const prefetchGate = yield* Deferred.make(); - const prefetchInterrupted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - prefetchStarted, - prefetchGate, - prefetchInterrupted, - }); - yield* fixture.supervisor.start({ config: {} }); - yield* Deferred.await(prefetchStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - const destroying = yield* Effect.forkChild(fixture.supervisor.destroy, { - startImmediately: true, - }); - yield* Deferred.await(prefetchInterrupted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Fiber.join(destroying); - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("skips background preparation for on-demand and failed starts", () => - run( - Effect.gen(function* () { - const onDemandStarted = yield* Deferred.make(); - const onDemandCalls = yield* Ref.make(0); - const onDemand = yield* makeFixture({ - prefetchStarted: onDemandStarted, - prefetchCalls: onDemandCalls, - }); - yield* onDemand.supervisor.start({ config: { preparation: "on-demand" } }); - expect(Option.isNone(yield* Deferred.poll(onDemandStarted))).toBe(true); - expect(yield* Ref.get(onDemandCalls)).toBe(0); - - const failedStarted = yield* Deferred.make(); - const failedCalls = yield* Ref.make(0); - const startFailures = yield* Ref.make(1); - const failed = yield* makeFixture({ - prefetchStarted: failedStarted, - prefetchCalls: failedCalls, - startFailures, - }); - const result = yield* failed.supervisor - .start({ config: { capabilities: { functions: { activation: "eager" } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect(Option.isNone(yield* Deferred.poll(failedStarted))).toBe(true); - expect(yield* Ref.get(failedCalls)).toBe(0); - }), - ), - ); - - it.live("reports starting while relaunching a stopped owner", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - - const blocked = yield* Ref.make(false); - const launchStarted = yield* Deferred.make(); - const launchGate = yield* Deferred.make(); - const baseDriver = fixture.runtime.driver; - const driver: RuntimeDriver = { - ...baseDriver, - start: (key, workload) => - Effect.gen(function* () { - if (yield* Ref.get(blocked)) { - yield* Deferred.succeed(launchStarted, undefined); - yield* Deferred.await(launchGate); - } - return yield* baseDriver.start(key, workload); - }), - }; - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "stopped-relaunch-successor", - stateStore: fixture.store, - context: fixture.context, - runtime: { ...fixture.runtime, driver }, - }); - yield* successor.start({ config: {} }); - expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); - yield* Ref.set(blocked, true); - - const starting = yield* Effect.forkChild(successor.start(), { startImmediately: true }); - yield* Deferred.await(launchStarted); - expect((yield* successor.status).lifecycle).toBe("starting"); - expect((yield* successor.logs()).running).toBe(true); - yield* Deferred.succeed(launchGate, undefined); - expect((yield* Fiber.join(starting)).lifecycle).toBe("running"); - yield* successor.maintenanceHandlers.stop; - yield* successor.shutdownIfIdle; - yield* fixture.supervisor.shutdownIfIdle; - }), - ), - ); - - it.live("cleans stale runtime resources before the first start of a new supervisor", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(fixture.calls, []); - yield* Ref.set(fixture.resources, [ - { - stackId: fixture.id, - workloadId: "database:database", - state: "ready", - }, - ]); - - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - yield* successor.start(); - expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); - - yield* Ref.set(fixture.calls, []); - yield* successor.start(); - expect(yield* Ref.get(fixture.calls)).toEqual([]); - }), - ), - ); - - it.live("skips preflight for a live session and preflights a new owner", () => - run( - Effect.gen(function* () { - const preflightCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ preflightCalls }); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(preflightCalls, 0); - - yield* fixture.supervisor.start(); - expect(yield* Ref.get(preflightCalls)).toBe(0); - yield* fixture.supervisor.maintenanceHandlers.stop; - yield* fixture.supervisor.start(); - expect(yield* Ref.get(preflightCalls)).toBe(1); - - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - yield* Ref.set(preflightCalls, 0); - yield* successor.start(); - expect(yield* Ref.get(preflightCalls)).toBe(1); - }), - ), - ); - - it.live("keeps the owner for retryable first-start cleanup", () => - run( - Effect.gen(function* () { - const stopFailFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ stopFailFirst }); - const failed = yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect(yield* Ref.get(fixture.calls)).toEqual([]); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("keeps the owner when eager launch cleanup is unproven", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(1); - const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ startFailures, stopFailFirst }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { functions: { activation: "eager" } } } }) - .pipe(Effect.exit); - - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); - - const retry = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(retry.ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("shuts down after a proven eager launch failure", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(1); - const fixture = yield* makeFixture({ startFailures }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { functions: { activation: "eager" } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); - const stopped = yield* fixture.supervisor.status; - expect(stopped.lifecycle).toBe("unconfigured"); - expect(stopped.capabilities.find(({ name }) => name === "database")?.state).toBe("stopped"); - yield* fixture.supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdown; - }), - ), - ); - - it.live("persists unconfigured after a wipe-then-relaunch reset failure", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(0); - const fixture = yield* makeFixture({ - startFailures, - startFailureWorkload: "database:database", - }); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(startFailures, 1); - const failed = yield* fixture.supervisor.resetDatabase.pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("publishes stopped after launch cleanup retry succeeds", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(1); - const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ startFailures, stopFailFirst }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { functions: { activation: "eager" } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - const stopped = yield* fixture.supervisor.status; - expect(stopped.lifecycle).toBe("unconfigured"); - expect(stopped.capabilities.some(({ state }) => state === "failed")).toBe(false); - expect((yield* fixture.supervisor.start()).lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.resources)).not.toEqual([]); - }), - ), - ); - - it.live("persists stopped after a proven fresh-session preflight failure", () => - run( - Effect.gen(function* () { - const preflightFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ preflightFailFirst }); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(preflightFailFirst, true); - - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - const failed = yield* successor.start().pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); - expect((yield* successor.status).lifecycle).toBe("stopped"); - yield* successor.shutdownIfIdle; - yield* successor.shutdown; - - const retryOwner = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "retry-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - expect((yield* retryOwner.start()).lifecycle).toBe("running"); - }), - ), - ); - - it.live("persists stopped after a proven fresh-session materialization failure", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(fixture.calls, []); - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "materialization-successor", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - const failed = yield* successor - .start({ config: { capabilities: { database: { version: "99" } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(errorOf(failed)).toBeInstanceOf(StackVersionUnsupportedError); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); - expect((yield* successor.status).lifecycle).toBe("stopped"); - expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop"]); - yield* successor.shutdownIfIdle; - yield* successor.shutdown; - const retryOwner = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "materialization-retry", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - expect((yield* retryOwner.start()).lifecycle).toBe("running"); - }), - ), - ); - - it.live("persists stopped after a proven fresh-session changed-input rejection", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "changed-input-successor", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - const failed = yield* successor - .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(errorOf(failed)).toBeInstanceOf(StackMustBeStoppedError); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); - expect((yield* successor.status).lifecycle).toBe("stopped"); - yield* successor.shutdownIfIdle; - yield* successor.shutdown; - }), - ), - ); - - it.live("keeps stopping when fresh-ingress cleanup fails, then recovers on explicit stop", () => - run( - Effect.gen(function* () { - const closeFailures = yield* Ref.make(2); - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }), - open: () => Effect.void, - close: Effect.gen(function* () { - const remaining = yield* Ref.get(closeFailures); - if (remaining > 0) { - yield* Ref.set(closeFailures, remaining - 1); - return yield* new StackCleanupError({ message: "injected close failure" }); - } - }), - }, - }); - const failed = yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(false); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - }), - ), - ); - - it.live("keeps stopping when persisting the stopped fence fails", () => - run( - Effect.gen(function* () { - const stoppedReplaceFail = yield* Ref.make(true); - const fixture = yield* makeFixture({ stoppedReplaceFail }); - yield* fixture.supervisor.start({ config: {} }); - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "stopped-fence-successor", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - const failed = yield* successor.maintenanceHandlers.stop; - expect(failed.ok).toBe(false); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); - expect((yield* successor.status).lifecycle).toBe("stopping"); - expect(errorOf(yield* successor.activate("functions").pipe(Effect.exit))).toBeInstanceOf( - StackLifecycleConflictError, - ); - expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* successor.status).lifecycle).toBe("stopped"); - yield* successor.shutdownIfIdle; - yield* fixture.supervisor.shutdownIfIdle; - }), - ), - ); - - it.live("retries first-create after a proven cold-start cleanup", () => - run( - Effect.gen(function* () { - const stoppedReplaceFail = yield* Ref.make(true); - const acquireCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - stoppedReplaceFail, - ingress: { - acquire: () => - Ref.updateAndGet(acquireCalls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count === 1 - ? Effect.fail( - new PortUnavailableError({ - field: "api", - port: 54_321, - message: "injected cold start failure", - }), - ) - : Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }), - ), - ), - open: () => Effect.void, - close: Effect.void, - }, - }); - expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - expect((yield* fixture.supervisor.start()).lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.resources)).not.toEqual([]); - }), - ), - ); - - it.live("clears failed capability status after successful cleanup retry", () => - run( - Effect.gen(function* () { - const removeFailure = yield* Ref.make(true); - const fixture = yield* makeFixture({ workloadRemoveFailFirst: removeFailure }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - const failed = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(failed.ok).toBe(false); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.some(({ state }) => state === "failed")).toBe(false); - expect((yield* fixture.supervisor.start()).lifecycle).toBe("running"); - }), - ), - ); - - it.live("returns persisted database, API, and storage credentials while running", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined || running.definition === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - yield* fixture.store - .replace(fixture.id, { - ...running, - ports: [ - { field: "api", port: 55433, intent: "exact" }, - { field: "database", port: 55432, intent: "exact" }, - ] as const, - }) - .pipe(Effect.provideContext(fixture.context)); - const credentials = yield* invokeCredentials(fixture.supervisor); - expect(credentials.database.url).toEqual(expect.anything()); - expect(Redacted.value(credentials.database.url)).toMatch( - /^postgresql:\/\/postgres:.+@127\.0\.0\.1:\d+\/postgres$/, - ); - expect(Redacted.value(credentials.database.password)).toEqual(expect.any(String)); - if (credentials.api === undefined) - return yield* new StackStateInvalidError({ message: "API credentials are missing" }); - expect(credentials.api.publishableKey).toEqual(expect.any(String)); - expect(Redacted.value(credentials.api.secretKey)).toEqual(expect.any(String)); - expect(credentials.api.anonJwt).toEqual(expect.any(String)); - expect(Redacted.value(credentials.api.serviceRoleJwt)).toEqual(expect.any(String)); - expect(credentials.storage).toEqual( - expect.objectContaining({ - region: "local", - accessKeyId: "625729a08b95bf1b7ff351a663f3a23c", - }), - ); - if (credentials.storage === undefined) - return yield* new StackStateInvalidError({ message: "storage credentials are missing" }); - const persistedStorageSecret = - running.secrets["secret:storage.settings.s3_protocol.secret_access_key"]?.value; - expect(persistedStorageSecret).toEqual(expect.any(String)); - expect(Redacted.value(credentials.storage.secretAccessKey)).toBe(persistedStorageSecret); - }), - ), - ); - - it.live("URL-encodes persisted database credentials and brackets IPv6 listeners", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined || running.definition === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - const definition = { - ...running.definition, - listeners: { - ...running.definition.listeners, - database: { ...running.definition.listeners.database, address: "2001:db8::1" }, - }, - }; - const state = { - ...running, - definition, - ports: [ - { field: "api", port: 55433, intent: "exact" as const }, - { field: "database", port: 55432, intent: "exact" as const }, - ] as const, - secrets: { - ...running.secrets, - "secret:database.internal.password": { - policy: "managed" as const, - value: "p@ss:word", - }, - }, - }; - yield* fixture.store - .replace(fixture.id, state) - .pipe(Effect.provideContext(fixture.context)); - const credentials = yield* invokeCredentials(fixture.supervisor); - expect(Redacted.value(credentials.database.url)).toBe( - "postgresql://postgres:p%40ss%3Aword@[2001:db8::1]:55432/postgres", - ); - }), - ), - ); - - it.live("omits storage credentials when Storage is disabled", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: {}, storage: { enabled: false } } }, - }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - const state = { - ...running, - ports: [ - { field: "api", port: 55433, intent: "exact" as const }, - { field: "database", port: 55432, intent: "exact" as const }, - ] as const, - }; - yield* fixture.store - .replace(fixture.id, state) - .pipe(Effect.provideContext(fixture.context)); - const credentials = yield* invokeCredentials(fixture.supervisor); - expect(credentials.storage).toBeUndefined(); - }), - ), - ); - - it.live( - "returns database credentials when Auth is disabled and fails closed for missing secrets", - () => - run( - Effect.gen(function* () { - const { fixture } = yield* makeCredentialsFixture({ authEnabled: false }); - const authDisabled = yield* invokeCredentials(fixture.supervisor); - expect(authDisabled.database.url).toEqual(expect.anything()); - expect(authDisabled.api).toBeUndefined(); - }), - ), - ); - - it.live("fails closed when an enabled Auth secret slot is absent", () => - run( - Effect.gen(function* () { - const { fixture, state, baseSecrets } = yield* makeCredentialsFixture(); - const missingSecret = { - ...state, - secrets: Object.fromEntries( - Object.entries(baseSecrets).filter( - ([slot]) => slot !== "secret:auth.settings.publishable_key", - ), - ), - }; - yield* fixture.store - .replace(fixture.id, missingSecret) - .pipe(Effect.provideContext(fixture.context)); - const failed = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(failed)).toMatchObject({ tag: "StackSecretMismatchError" }); - }), - ), - ); - - it.live("fails closed when a required Storage secret slot is absent", () => - run( - Effect.gen(function* () { - const { fixture, state, baseSecrets } = yield* makeCredentialsFixture(); - const missingSecret = { - ...state, - secrets: Object.fromEntries( - Object.entries(baseSecrets).filter( - ([slot]) => slot !== "secret:storage.settings.s3_protocol.secret_access_key", - ), - ), - }; - yield* fixture.store - .replace(fixture.id, missingSecret) - .pipe(Effect.provideContext(fixture.context)); - const failed = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(failed)).toMatchObject({ tag: "StackSecretMismatchError" }); - }), - ), - ); - - it.live("fails closed when the API listener is absent", () => - run( - Effect.gen(function* () { - const { fixture, state, baseSecrets } = yield* makeCredentialsFixture(); - const missingApiListener = { - ...state, - secrets: baseSecrets, - ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, - }; - yield* fixture.store - .replace(fixture.id, missingApiListener) - .pipe(Effect.provideContext(fixture.context)); - const failed = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(failed)).toMatchObject({ tag: "InvalidStackConfigError" }); - }), - ), - ); - - it.live("fails closed when the database listener is disabled", () => - run( - Effect.gen(function* () { - const { fixture, state, definition, baseSecrets } = yield* makeCredentialsFixture(); - const disabledDatabase = { - ...state, - secrets: baseSecrets, - definition: { - ...definition, - listeners: { - ...definition.listeners, - database: { ...definition.listeners.database, enabled: false }, - }, - }, - }; - yield* fixture.store - .replace(fixture.id, disabledDatabase) - .pipe(Effect.provideContext(fixture.context)); - const failed = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(failed)).toMatchObject({ tag: "InvalidStackConfigError" }); - }), - ), - ); - - it.live("acknowledges stop only after runtime cleanup", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const response = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(response.ok).toBe(true); - expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); - }), - ), - ); - - it.live("reports stopping while stop cleanup is still in progress", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: {}, auth: { activation: "lazy" } } }, - }); - - const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.await(stopStarted); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "auth") - ?.state, - ).toBe("dormant"); - const duringStop = yield* fixture.supervisor.logs(); - expect(duringStop.running).toBe(true); - expect(duringStop.entries).toHaveLength(1); - - yield* Deferred.succeed(stopGate, undefined); - expect((yield* Fiber.join(stop)).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "auth") - ?.state, - ).toBe("stopped"); - const afterStop = yield* fixture.supervisor.logs(); - expect(afterStop.running).toBe(false); - expect(afterStop.entries).toHaveLength(2); - expect(afterStop.entries.filter(({ message }) => message === "stopped")).toHaveLength(1); - }), - ), - ); - - it.live("settles stop when the owner scope closes during workload cleanup", () => - run( - Effect.gen(function* () { - const ownerScope = yield* Scope.make(); - const workloadStopStarted = yield* Deferred.make(); - const workloadStopGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - supervisorScope: ownerScope, - workloadStopStarted, - workloadStopGate, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop, { - startImmediately: true, - }); - yield* Deferred.await(workloadStopStarted); - yield* Scope.close(ownerScope, Exit.void).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("owner scope did not close"), - }), - ); - const result = yield* Fiber.await(stopping).pipe( - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => Effect.die("stop remained pending after owner scope close"), - }), - ); - expect(Exit.isFailure(result)).toBe(true); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopping"); - expect(status.recovery).toMatchObject({ - operation: "stop", - message: expect.any(String), - }); - expect(status.recovery?.message.length).toBeGreaterThan(0); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); - }), - ), - ); - - it.live("preserves stop cleanup diagnostics through maintenance responses", () => - run( - Effect.gen(function* () { - const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ stopFailFirst }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(stopFailFirst, true); - - const response = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(response).toEqual({ - ok: false, - error: { - tag: "operation-failed", - message: "injected stop cleanup failure", - stackErrorTag: "StackCleanupError", - }, - }); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - }), - ), - ); - - it.live("attempts runtime cleanup when session workload stop fails", () => - run( - Effect.gen(function* () { - const workloadStopFailFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ workloadStopFailFirst }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - yield* Ref.set(fixture.calls, []); - - const response = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(response).toEqual({ - ok: false, - error: { - tag: "operation-failed", - message: "Session cleanup is unresolved for rest:rest: injected workload stop failure", - stackErrorTag: "StackCleanupError", - }, - }); - expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - }), - ), - ); - - it.live("stops the launched session in reverse dependency order", () => - run( - Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const fixture = yield* makeFixture({ timeline }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - yield* Ref.set(timeline, []); - - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect(yield* Ref.get(timeline)).toEqual([ - "stop:rest:rest", - "stop:database:database", - "cleanup:stop", - ]); - }), - ), - ); - - it.live("keeps stopping state while an explicit stop is active", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - - const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.await(stopStarted); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - yield* Deferred.succeed(stopGate, undefined); - yield* Fiber.join(stopping); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - yield* fixture.supervisor.shutdownIfIdle; - const restart = yield* fixture.supervisor.start().pipe(Effect.exit); - expect(errorOf(restart)).toBeInstanceOf(StackLifecycleConflictError); - }), - ), - ); - - it.live("reports destroying while persistent data cleanup is in progress", () => - run( - Effect.gen(function* () { - const destroyGate = yield* Deferred.make(); - const destroyStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ destroyGate, destroyStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - - const destroy = yield* Effect.forkChild(fixture.supervisor.destroy); - yield* Deferred.await(destroyStarted); - expect((yield* fixture.supervisor.status).lifecycle).toBe("destroying"); - - yield* Deferred.succeed(destroyGate, undefined); - yield* Fiber.join(destroy); - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("fences activation when the destroy durable pre-fence fails", () => - run( - Effect.gen(function* () { - const destroyPreFenceFail = yield* Ref.make(true); - const fixture = yield* makeFixture({ destroyPreFenceFail }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "lazy" } } }, - }); - expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); - const destroyed = yield* fixture.supervisor.destroy.pipe(Effect.exit); - expect(Exit.isFailure(destroyed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("destroying"); - const resourcesBefore = yield* Ref.get(fixture.resources); - const activation = yield* fixture.supervisor.activate("rest").pipe(Effect.exit); - expect(errorOf(activation)).toBeInstanceOf(StackLifecycleConflictError); - const readyActivation = yield* fixture.supervisor.activate("database").pipe(Effect.exit); - expect(errorOf(readyActivation)).toBeInstanceOf(StackLifecycleConflictError); - expect(yield* Ref.get(fixture.resources)).toEqual(resourcesBefore); - expect(yield* Ref.get(fixture.calls)).not.toContain("start:rest:rest"); - yield* fixture.supervisor.destroy; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("requires destroy retry after persistent cleanup fails", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(fixture.failDestroy, true); - - const failed = yield* fixture.supervisor.destroy.pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("destroying"); - expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( - StackLifecycleConflictError, - ); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(false); - - yield* Ref.set(fixture.failDestroy, false); - yield* fixture.supervisor.destroy; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("propagates typed workload observation failures while running", () => - run( - Effect.gen(function* () { - const observeFailure = yield* Ref.make(false); - const fixture = yield* makeFixture({ observeFailure }); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(observeFailure, true); - const status = yield* fixture.supervisor.status.pipe(Effect.exit); - expect(Exit.isFailure(status)).toBe(true); - expect(errorOf(status)).toBeInstanceOf(ContainerEngineError); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); - }), - ), - ); - - it.live("preserves stopping recovery status when observation also fails", () => - run( - Effect.gen(function* () { - const observeFailure = yield* Ref.make(false); - const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ observeFailure, stopFailFirst }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - yield* Ref.set(stopFailFirst, true); - const failedStop = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(failedStop.ok).toBe(false); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - - yield* Ref.set(observeFailure, true); - const status = yield* fixture.supervisor.status.pipe(Effect.exit); - expect(Exit.isSuccess(status)).toBe(true); - if (Exit.isSuccess(status)) { - expect(status.value.lifecycle).toBe("stopping"); - const rest = status.value.capabilities.find(({ name }) => name === "rest"); - expect(rest?.state).toBe("failed"); - expect(rest?.error).toContain("injected stop cleanup failure"); - } - - yield* Ref.set(observeFailure, false); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - }), - ), - ); - - it.live("projects an observed empty workload as stopped", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(fixture.resources, []); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("running"); - expect(status.capabilities.find(({ name }) => name === "database")?.state).toBe("stopped"); - }), - ), - ); - - it.live("destroys observed remnants from an unconfigured supervisor", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* Ref.set(fixture.resources, [ - { stackId: fixture.id, workloadId: "database:database", state: "ready" }, - ]); - yield* fixture.supervisor.destroy; - expect(yield* Ref.get(fixture.resources)).toEqual([]); - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); - - it.live("shuts down after a start rejects missing state following destroy", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: {} }); - yield* fixture.supervisor.destroy; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - - const failed = yield* fixture.supervisor.start().pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackStateInvalidError); - yield* fixture.supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdown.pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.die("destroyed Supervisor did not shut down"), - }), - ); - }), - ), - ); - - it.live("removes a runtime remnant when state disappears before supervisor initialization", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const fs = yield* FileSystem.FileSystem; - const paths = yield* resolveStackPaths({ stateRoot: fixture.root, stackId: fixture.id }); - yield* fixture.store.cleanup(fixture.id); - yield* fs.makeDirectory(paths.runtime, { recursive: true }); - - const failed = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "missing-state-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }).pipe(Effect.exit); - - expect(errorOf(failed)).toBeInstanceOf(StackStateInvalidError); - expect(yield* fs.exists(paths.runtime)).toBe(false); - }), - ), - ); - - it.live("passes log query through the Supervisor log batch", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const options: LogQuery = { capabilities: ["auth"] }; - expect((yield* fixture.supervisor.logs(options)).entries).toHaveLength(1); - expect(yield* Ref.get(fixture.logOptions)).toEqual([undefined]); - }), - ), - ); - - it.live("returns filtered log batches with a running marker", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const batch = yield* fixture.supervisor.logs({ capabilities: ["auth"], tail: 20 }); - expect(batch.entries.every((entry) => entry.source === "auth")).toBe(true); - expect(batch.running).toBe(false); - }), - ), - ); - - it.live("keeps running state and explicit stop guidance for changed start input", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) - .pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackMustBeStoppedError); - expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); - }), - ), - ); - - it.live("keeps the owner when stop cannot prove its cleanup", () => - run( - Effect.gen(function* () { - const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ stopFailFirst }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(stopFailFirst, true); - - const stopped = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(stopped.ok).toBe(false); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); - - const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdown, { - startImmediately: true, - }); - yield* fixture.supervisor.shutdownIfIdle; - expect(shutdown.pollUnsafe()).toBeUndefined(); - - const stoppedAgain = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(stoppedAgain.ok).toBe(true); - yield* fixture.supervisor.shutdownIfIdle; - yield* Fiber.join(shutdown); - expect(yield* Ref.get(fixture.resources)).toEqual([]); - }), - ), - ); - - it.live("requires explicit stop cleanup before starting again", () => - run( - Effect.gen(function* () { - const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ stopFailFirst }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(stopFailFirst, true); - const failedStop = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(failedStop.ok).toBe(false); - - expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( - StackLifecycleConflictError, - ); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - yield* Ref.set(fixture.calls, []); - const started = yield* fixture.supervisor.start(); - - expect(started.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); - }), - ), - ); - - it.live("keeps lazy capabilities dormant until explicit activation", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const status = yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { activation: "lazy" }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("dormant"); - expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); - const activation = yield* fixture.supervisor.activate("functions"); - expect(activation.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") - ?.state, - ).toBe("ready"); - }), - ), - ); - - it.live("preserves lazy artifact preparation failures during activation", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const artifactFailure = new ArtifactIntegrityError({ - message: "functions artifact checksum mismatch", - }); - const baseDriver = fixture.runtime.driver; - const driver: RuntimeDriver = { - ...baseDriver, - start: (key, workload) => - key.workloadId === "functions:edge-runtime" - ? Effect.fail( - new RuntimeDriverError({ - message: artifactFailure.message, - stackId: key.stackId, - workloadId: key.workloadId, - cause: artifactFailure, - }), - ) - : baseDriver.start(key, workload), - }; - const supervisor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "artifact-failure-supervisor", - stateStore: fixture.store, - context: fixture.context, - runtime: { ...fixture.runtime, driver }, - }); - yield* supervisor.start({ - config: { - capabilities: { - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { activation: "lazy" }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - const result = yield* supervisor.activate("functions").pipe(Effect.exit); - expect(errorOf(result)).toBeInstanceOf(ArtifactIntegrityError); - yield* supervisor.maintenanceHandlers.stop; - yield* supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdownIfIdle; - }), - ), - ); - - it.live("keeps accepted start work alive when its waiter is interrupted", () => - run( - Effect.gen(function* () { - const startGate = yield* Deferred.make(); - const startStarted = yield* Deferred.make(); - const startFinished = yield* Deferred.make(); - const fixture = yield* makeFixture({ startGate, startStarted, startFinished }); - const config = { - capabilities: { rest: { activation: "eager" } }, - } satisfies import("../public/Config.ts").StackConfig; - const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config })); - yield* Deferred.await(startStarted); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(startGate, undefined); - yield* Deferred.await(startFinished); - // shutdownIfIdle acquires the lifecycle admission permit, so it waits until the accepted - // owner operation has released admission after publishing its running phase. - yield* fixture.supervisor.shutdownIfIdle; - - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("running"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - const starts = (yield* Ref.get(fixture.calls)).filter((call) => call.startsWith("start:")); - expect(starts).toContain("start:database:database"); - expect(starts).toContain("start:rest:rest"); - }), - ), - ); - - it.live("shuts down after an interrupted pre-commit start later fails", () => - run( - Effect.gen(function* () { - const preflightFailFirst = yield* Ref.make(true); - const preflightGate = yield* Deferred.make(); - const preflightStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - preflightFailFirst, - preflightGate, - preflightStarted, - }); - const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config: {} })); - yield* Deferred.await(preflightStarted); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(preflightGate, undefined); - yield* Effect.yieldNow; - yield* fixture.supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdown.pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.die("idle Supervisor did not shut down"), - }), - ); - }), - ), - ); - - it.live("rejects concurrent starts even with identical input", () => - run( - Effect.gen(function* () { - const preflightFailFirst = yield* Ref.make(true); - const preflightGate = yield* Deferred.make(); - const preflightStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - preflightFailFirst, - preflightGate, - preflightStarted, - }); - const firstConfig = { capabilities: {} }; - const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); - yield* Deferred.await(preflightStarted); - const second = yield* fixture.supervisor.start({ config: firstConfig }).pipe(Effect.exit); - expect(Exit.isFailure(second)).toBe(true); - expect(errorOf(second)).toBeInstanceOf(StackLifecycleConflictError); - yield* Deferred.succeed(preflightGate, undefined); - const firstExit = yield* Fiber.join(first).pipe(Effect.exit); - expect(Exit.isFailure(firstExit)).toBe(true); - expect(yield* Ref.get(preflightFailFirst)).toBe(false); - }), - ), - ); - - it.live("does not join concurrent starts with distinct secret values", () => - run( - Effect.gen(function* () { - const preflightFailFirst = yield* Ref.make(true); - const preflightGate = yield* Deferred.make(); - const preflightStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ - preflightFailFirst, - preflightGate, - preflightStarted, - }); - const firstConfig = { - security: { - jwt: { signing: { kind: "symmetric" as const, secret: Redacted.make("a") } }, - }, - }; - const distinctConfig = { - security: { - jwt: { signing: { kind: "symmetric" as const, secret: Redacted.make("b") } }, - }, - }; - const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); - yield* Deferred.await(preflightStarted); - const secondExit = yield* fixture.supervisor - .start({ config: distinctConfig }) - .pipe(Effect.exit); - expect(Exit.isFailure(secondExit)).toBe(true); - expect(errorOf(secondExit)).toBeInstanceOf(StackLifecycleConflictError); - yield* Deferred.succeed(preflightGate, undefined); - const firstExit = yield* Fiber.join(first).pipe(Effect.exit); - expect(Exit.isFailure(firstExit)).toBe(true); - }), - ), - ); - - it.live("single-flights lazy activation and retains its endpoint", () => - run( - Effect.gen(function* () { - const gate = yield* Deferred.make(); - const started = yield* Deferred.make(); - const activationCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - activationGate: gate, - activationStarted: started, - activationCalls, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { activation: "lazy" }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - const first = yield* Effect.forkChild(fixture.supervisor.activate("functions")); - yield* Deferred.await(started); - const second = yield* Effect.forkChild(fixture.supervisor.activate("functions")); - yield* Deferred.succeed(gate, undefined); - const [left, right] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); - expect(left).toEqual(right); - expect(yield* Ref.get(activationCalls)).toBe(1); - expect(yield* fixture.supervisor.activate("functions")).toEqual(left); - expect(yield* Ref.get(activationCalls)).toBe(1); - }), - ), - ); - - it.live("keeps a ready capability while concurrent endpoint lookup is shared", () => - run( - Effect.gen(function* () { - const gate = yield* Deferred.make(); - const activationStarted = yield* Deferred.make(); - const activationCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - activationGate: gate, - activationStarted, - activationCalls, - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { activation: "eager" }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") - ?.state, - ).toBe("ready"); - const first = yield* Effect.forkChild(fixture.supervisor.activate("functions")); - yield* Deferred.await(activationStarted); - const second = yield* Effect.forkChild(fixture.supervisor.activate("functions")); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") - ?.state, - ).toBe("ready"); - yield* Deferred.succeed(gate, undefined); - yield* Fiber.join(first); - yield* Fiber.join(second); - expect(yield* Ref.get(activationCalls)).toBe(1); - }), - ), - ); - - it.live("reuses a ready lazy activation without rereading durable state", () => - run( - Effect.gen(function* () { - const readCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ readCalls }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - functions: { activation: "lazy" }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - yield* Ref.set(readCalls, 0); - - yield* fixture.supervisor.activate("functions"); - const afterFirst = yield* Ref.get(readCalls); - yield* fixture.supervisor.activate("functions"); - - expect(yield* Ref.get(readCalls)).toBe(afterFirst); - }), - ), - ); - - it.live("keeps activated lazy workloads ready across an idempotent start", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const config = { - capabilities: { - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { activation: "lazy" as const }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }; - yield* fixture.supervisor.start({ config }); - yield* fixture.supervisor.activate("functions"); - yield* Ref.set(fixture.calls, []); - - const status = yield* fixture.supervisor.start({ config }); - - expect(yield* Ref.get(fixture.calls)).toEqual([]); - expect(status.lifecycle).toBe("running"); - expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("ready"); - }), - ), - ); - - it.live("keeps a dependency ready when its overlapping endpoint activation fails", () => - run( - Effect.gen(function* () { - const startStarted = yield* Deferred.make(); - const startGate = yield* Deferred.make(); - const activationCalls = yield* Ref.make(0); - const activationStartedAfterFirst = yield* Deferred.make(); - const activationGateAfterFirst = yield* Deferred.make(); - const activationFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ - startStarted, - startGate, - startWorkload: "rest:rest", - activationCalls, - activationStartedAfterFirst, - activationGateAfterFirst, - activationFailFirst, - }); - const config = { - capabilities: { - rest: { activation: "lazy" as const }, - studio: { activation: "lazy" as const }, - }, - }; - yield* fixture.supervisor.start({ config }); - const studio = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(startStarted); - const rest = yield* Effect.forkChild(fixture.supervisor.activate("rest"), { - startImmediately: true, - }); - yield* Deferred.succeed(startGate, undefined); - yield* Deferred.await(activationStartedAfterFirst); - expect(yield* Ref.get(activationCalls)).toBe(2); - yield* Ref.set(activationFailFirst, true); - yield* Deferred.succeed(activationGateAfterFirst, undefined); - - const studioResult = yield* Fiber.join(studio).pipe(Effect.exit); - const restResult = yield* Fiber.join(rest).pipe(Effect.exit); - expect(Exit.isSuccess(studioResult)).toBe(true); - expect(errorOf(restResult)).toBeInstanceOf(GatewayActivationError); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "rest:rest", state: "ready" }), - ); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "studio:pgmeta", state: "ready" }), - ); - const status = yield* fixture.supervisor.status; - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - expect(status.capabilities.find(({ name }) => name === "studio")?.state).toBe("ready"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live( - "reports a post-ready workload failure without restarting it or stopping unrelated services", - () => - run( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const fixture = yield* makeFixture({ startQueue: starts }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: { activation: "eager" } } }, - }); - yield* Queue.take(starts); - yield* Queue.take(starts); - yield* Ref.set(fixture.calls, []); - yield* Ref.update(fixture.resources, (current) => - current.map((entry) => - entry.workloadId === "database:database" - ? { ...entry, state: "failed" as const, error: "crashed" } - : entry, - ), - ); - const status = yield* fixture.supervisor.status; - expect(status.capabilities.find(({ name }) => name === "database")?.state).toBe("failed"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); - expect(yield* Ref.get(fixture.calls)).toEqual([]); - }), - ), - ); - - it.live("allows a failed activation to retry", () => - run( - Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(true); - const activationCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ activationFailFirst, activationCalls }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - const retry = yield* fixture.supervisor.activate("functions"); - expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - expect(yield* Ref.get(activationCalls)).toBe(2); - expect( - (yield* Ref.get(fixture.calls)).filter((call) => call === "start:functions:edge-runtime"), - ).toHaveLength(2); - }), - ), - ); - - it.live("reports a failed lazy activation as dormant until it is retried", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(1); - const fixture = yield* makeFixture({ startFailures }); - yield* fixture.supervisor.start({ config: { capabilities: { functions: {} } } }); - - const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - - expect(Exit.isFailure(failed)).toBe(true); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") - ?.state, - ).toBe("dormant"); - const retry = yield* fixture.supervisor.activate("functions"); - expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") - ?.state, - ).toBe("ready"); - expect( - (yield* Ref.get(fixture.calls)).filter((call) => call === "start:functions:edge-runtime"), - ).toHaveLength(2); - }), - ), - ); - - it.live("rolls back a lazy launch when ingress opening fails", () => - run( - Effect.gen(function* () { - const openCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }), - open: () => - Ref.updateAndGet(openCalls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count === 2 - ? Effect.fail(new GatewayActivationError({ message: "injected open failure" })) - : Effect.void, - ), - ), - close: Effect.void, - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(yield* Ref.get(fixture.resources)).toEqual([ - expect.objectContaining({ workloadId: "database:database", state: "ready" }), - ]); - yield* fixture.supervisor.activate("functions"); - expect( - (yield* Ref.get(fixture.calls)).filter((call) => call === "start:functions:edge-runtime"), - ).toHaveLength(2); - }), - ), - ); - - it.live("fences fresh ingress when open failure cleanup is not proven", () => - run( - Effect.gen(function* () { - const openCalls = yield* Ref.make(0); - const closeFailures = yield* Ref.make(0); - const fixture = yield* makeFixture({ - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }), - open: () => - Ref.updateAndGet(openCalls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count === 2 - ? Effect.fail(new GatewayActivationError({ message: "injected open failure" })) - : Effect.void, - ), - ), - close: Effect.gen(function* () { - const remaining = yield* Ref.get(closeFailures); - if (remaining > 0) { - yield* Ref.set(closeFailures, remaining - 1); - return yield* new StackCleanupError({ message: "injected close failure" }); - } - }), - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - yield* Ref.set(closeFailures, 1); - expect( - Exit.isFailure(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), - ).toBe(true); - expect(yield* Ref.get(fixture.resources)).toEqual([ - expect.objectContaining({ workloadId: "database:database", state: "ready" }), - ]); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("rolls back fresh ingress after activation failure and retries", () => - run( - Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(true); - const openCalls = yield* Ref.make(0); - const closeCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ - activationFailFirst, - ingress: { - acquire: () => - Effect.succeed({ - assignments: {}, - privateAssignments: [], - hostListeners: [], - fresh: true, - ownershipToken: Symbol(), - }), - open: () => Ref.update(openCalls, (count) => count + 1), - close: Ref.update(closeCalls, (count) => count + 1), - }, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - expect( - Exit.isFailure(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), - ).toBe(true); - expect(yield* Ref.get(closeCalls)).toBe(2); - expect(yield* Ref.get(fixture.resources)).toEqual([ - expect.objectContaining({ workloadId: "database:database", state: "ready" }), - ]); - const retry = yield* fixture.supervisor.activate("functions"); - expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - expect(yield* Ref.get(openCalls)).toBe(3); - }), - ), - ); - - it.live("fences the owner when lazy rollback cannot be proven", () => - run( - Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(true); - const workloadStopFailFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ activationFailFirst, workloadStopFailFirst }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect( - errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), - ).toBeInstanceOf(StackLifecycleConflictError); - expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( - StackLifecycleConflictError, - ); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - yield* fixture.supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdown; - const successor = yield* makeSupervisor({ - stackId: fixture.id, - ownerSessionId: "successor-session", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - yield* Ref.set(fixture.calls, []); - const restarted = yield* successor.start().pipe(Effect.exit); - expect(Exit.isSuccess(restarted)).toBe(true); - if (Exit.isSuccess(restarted)) expect(restarted.value.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); - expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); - }), - ), - ); - - it.live("fences a lazy activation when endpoint rollback removal fails", () => - run( - Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(true); - const workloadRemoveFailFirst = yield* Ref.make(true); - const activationStarted = yield* Deferred.make(); - const activationGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - activationFailFirst, - activationStarted, - activationGate, - workloadRemoveFailFirst, - workloadRemoveFailWorkload: "rest:rest", - }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: { activation: "lazy" }, - studio: { activation: "lazy" }, - }, - }, - }); - const activation = yield* Effect.forkChild(fixture.supervisor.activate("studio"), { - startImmediately: true, - }); - yield* Deferred.await(activationStarted); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "rest:rest", state: "ready" }), - ); - yield* Deferred.succeed(activationGate, undefined); - const failed = yield* Fiber.join(activation).pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(GatewayActivationError); - expect(Exit.isFailure(failed)).toBe(true); - expect(yield* Ref.get(fixture.resources)).toContainEqual( - expect.objectContaining({ workloadId: "rest:rest", state: "stopped" }), - ); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect( - (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "rest") - ?.state, - ).toBe("failed"); - expect( - errorOf(yield* fixture.supervisor.activate("rest").pipe(Effect.exit)), - ).toBeInstanceOf(StackLifecycleConflictError); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("fences the owner when lazy rollback remove fails", () => - run( - Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(true); - const workloadRemoveFailFirst = yield* Ref.make(true); - const fixture = yield* makeFixture({ activationFailFirst, workloadRemoveFailFirst }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("validates lifecycle before cached lazy activation results", () => - run( - Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(false); - const workloadRemoveFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ activationFailFirst, workloadRemoveFailFirst }); - yield* fixture.supervisor.start({ - config: { - capabilities: { - functions: { activation: "lazy" }, - auth: { activation: "lazy" }, - }, - }, - }); - yield* fixture.supervisor.activate("functions"); - yield* Ref.set(activationFailFirst, true); - yield* Ref.set(workloadRemoveFailFirst, true); - expect(Exit.isFailure(yield* fixture.supervisor.activate("auth").pipe(Effect.exit))).toBe( - true, - ); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect( - errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), - ).toBeInstanceOf(StackLifecycleConflictError); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect( - errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), - ).toBeInstanceOf(StackNotRunningError); - }), - ), - ); - - it.live("rejects activation while an explicit lifecycle transition is active", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ - config: { capabilities: { rest: {}, functions: { activation: "lazy" } } }, - }); - - const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.await(stopStarted); - const activation = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - expect(errorOf(activation)).toBeInstanceOf(StackLifecycleConflictError); - - yield* Deferred.succeed(stopGate, undefined); - yield* Fiber.join(stopping); - }), - ), - ); - - it.live("fences activation against a concurrent stop", () => - run( - Effect.gen(function* () { - const activationGate = yield* Deferred.make(); - const activationStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ activationGate, activationStarted }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const activation = yield* Effect.forkChild(fixture.supervisor.activate("functions")); - yield* Deferred.await(activationStarted); - const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.succeed(activationGate, undefined); - yield* Fiber.join(activation); - yield* Fiber.join(stop); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("stopped"); - }), - ), - ); - - it.live("stops cleanly when stop is queued during activation", () => - run( - Effect.gen(function* () { - const activationStarted = yield* Deferred.make(); - const activationGate = yield* Deferred.make(); - const fixture = yield* makeFixture({ - activationStarted, - activationGate, - }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const activation = yield* Effect.forkChild(fixture.supervisor.activate("functions"), { - startImmediately: true, - }); - yield* Deferred.await(activationStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); - const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop, { - startImmediately: true, - }); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - - yield* Deferred.succeed(activationGate, undefined); - expect(Exit.isSuccess(yield* Fiber.join(activation).pipe(Effect.exit))).toBe(true); - expect((yield* Fiber.join(stopping)).ok).toBe(true); - - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("stopped"); - }), - ), - ); - - it.live("completes accepted stop after its waiter is interrupted", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const waiter = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.await(stopStarted); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(stopGate, undefined); - yield* Effect.yieldNow; - yield* fixture.supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdown; - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); - }), - ), - ); - - it.live("completes accepted destroy after its waiter is interrupted", () => - run( - Effect.gen(function* () { - const destroyGate = yield* Deferred.make(); - const destroyStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ destroyGate, destroyStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const waiter = yield* Effect.forkChild(fixture.supervisor.destroy); - yield* Deferred.await(destroyStarted); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(destroyGate, undefined); - yield* Effect.yieldNow; - yield* fixture.supervisor.shutdownIfIdle; - yield* fixture.supervisor.shutdown; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); -}); diff --git a/packages/stack/src/supervisor/whole-restart-policy.integration.test.ts b/packages/stack/src/supervisor/whole-restart-policy.integration.test.ts new file mode 100644 index 0000000000..1542ebe64e --- /dev/null +++ b/packages/stack/src/supervisor/whole-restart-policy.integration.test.ts @@ -0,0 +1,393 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Crypto, Effect, Exit, FileSystem, Path, Redacted } from "effect"; +import { compileServiceInstance } from "../model/Compiler.ts"; +import type { PersistedServiceInstance } from "../model/ServiceRegistry.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; +import { STACK_RPC_RELEASE, type StackRpcClient } from "../control/StackRpc.ts"; +import { StackLifecycleConflictError, type StackError } from "../public/Errors.ts"; +import type { RuntimeBindingPublication } from "../runtime/RuntimeBinding.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; +import type { InstanceRuntimeInput } from "./Lifecycle.ts"; +import { makeSupervisor, type Supervisor, type SupervisorRuntime } from "./Supervisor.ts"; +import type { SupervisorIngress } from "./Ingress.ts"; +import type { LogStore } from "./LogStore.ts"; +import { ServiceInstanceIdSchema, type ServiceInstanceId } from "../public/ServiceInstanceId.ts"; + +const ingress: SupervisorIngress = { + close: Effect.void, +}; +const logStore: LogStore = { + path: "/dev/null", + append: () => Effect.die("whole restart test does not write logs"), + read: () => Effect.succeed([]), +}; + +interface Fixture { + readonly endpoint: { readonly kind: "unix"; readonly path: string }; + readonly stackId: string; + readonly stateStore: StackStateStore; + readonly supervisor: Supervisor; + readonly database: ServiceInstanceId; + readonly rest: ServiceInstanceId; + readonly functions: ServiceInstanceId; + readonly dynamic: ServiceInstanceId; + readonly starts: Array; + readonly stops: Array; + readonly read: () => Effect.Effect; +} + +const withRpc = ( + fixture: Pick, + use: (rpc: StackRpcClient) => Effect.Effect, +) => + Effect.scoped( + makeControlClient(fixture.endpoint, { + stackId: fixture.stackId, + ownerSessionId: "whole-restart-owner", + rpcRelease: STACK_RPC_RELEASE, + }).rpc.pipe(Effect.flatMap(use)), + ); + +const withFixture = (use: (fixture: Fixture) => Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-whole-restart-" }); + const projectRoot = path.join(root, "project"); + yield* fs.makeDirectory(projectRoot); + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const identity = { projectRoot, branchContext: "test", stackName: "whole-restart" } as const; + const stackId = yield* deriveStackId(identity); + const database = yield* compileServiceInstance( + { + service: "database", + name: "primary", + config: { + password: Redacted.make("old"), + settings: {}, + endpoints: { sql: { address: "127.0.0.1", port: 25_124 } }, + }, + }, + { + projectRoot, + path, + runtime: { kind: "native" }, + instanceId: ServiceInstanceIdSchema.make("11111111-1111-4111-8111-111111111111"), + }, + ).pipe(Effect.provideContext(context)); + if (database.instance.service !== "database") + return yield* new StackLifecycleConflictError({ message: "database fixture is invalid" }); + const databaseInstance = database.instance; + const rest = yield* compileServiceInstance( + { + service: "rest", + name: "rest", + config: {}, + dependencies: { database: databaseInstance.id }, + }, + { + projectRoot, + path, + runtime: { kind: "native" }, + instanceId: ServiceInstanceIdSchema.make("22222222-2222-4222-8222-222222222222"), + }, + ).pipe(Effect.provideContext(context)); + if (rest.instance.service !== "rest") + return yield* new StackLifecycleConflictError({ message: "rest fixture is invalid" }); + const restInstance = rest.instance; + const functions = yield* compileServiceInstance( + { service: "functions", name: "functions", config: {} }, + { + projectRoot, + path, + runtime: { kind: "native" }, + instanceId: ServiceInstanceIdSchema.make("33333333-3333-4333-8333-333333333333"), + }, + ).pipe(Effect.provideContext(context)); + if (functions.instance.service !== "functions") + return yield* new StackLifecycleConflictError({ message: "functions fixture is invalid" }); + const functionsInstance = functions.instance; + const dynamic = yield* compileServiceInstance( + { + service: "rest", + name: "dynamic", + config: {}, + dependencies: { database: databaseInstance.id }, + }, + { + projectRoot, + path, + runtime: { kind: "native" }, + instanceId: ServiceInstanceIdSchema.make("44444444-4444-4444-8444-444444444444"), + }, + ).pipe(Effect.provideContext(context)); + if (dynamic.instance.service !== "rest") + return yield* new StackLifecycleConflictError({ message: "dynamic fixture is invalid" }); + const dynamicInstance = dynamic.instance; + const started = (instance: PersistedServiceInstance): PersistedServiceInstance => ({ + ...instance, + intent: "started", + }); + const lazy = (instance: typeof restInstance): typeof restInstance => ({ + ...instance, + config: { ...instance.config, activation: "lazy" }, + }); + const state: PersistedStackState = { + format: "supabase-stack-state-v2", + identity, + runtime: { kind: "native" }, + preparation: "on-demand", + security: { + jwt: { + issuer: null, + expirySeconds: 3600, + signing: { kind: "symmetric", secret: { slot: AUTH_JWT_SECRET_SLOT } }, + }, + }, + listeners: { api: { enabled: true, address: "127.0.0.1" } }, + registry: { + initialized: true, + instances: [ + started({ + ...databaseInstance, + initializationInputs: { profileId: "test-profile", catalog: {} }, + }), + lazy(restInstance), + functionsInstance, + started(dynamicInstance), + ], + defaultInstanceIds: { + database: databaseInstance.id, + rest: restInstance.id, + functions: functionsInstance.id, + }, + }, + ports: [ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 25_123, + intent: "automatic", + }, + ], + privatePorts: [], + secrets: { + [AUTH_JWT_SECRET_SLOT]: { policy: "managed", value: "old-jwt" }, + [`secret:${databaseInstance.id}:password`]: { policy: "managed", value: "old" }, + }, + }; + const stateStore = yield* makeStackStateStore({ stateRoot: path.join(root, "state") }); + yield* stateStore.initialize(stackId, state).pipe(Effect.provideContext(context)); + const starts: Array = []; + const stops: Array = []; + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("whole restart test does not start driver workloads"), + stop: () => Effect.die("whole restart test does not stop driver workloads"), + remove: () => Effect.die("whole restart test does not remove driver workloads"), + cleanup: () => Effect.die("whole restart test does not clean driver workloads"), + wipePersistentData: () => Effect.die("whole restart test does not wipe driver workloads"), + }; + const runtime: SupervisorRuntime = { + driver, + preflight: () => Effect.void, + prepare: () => Effect.succeed({ instances: [] }), + prepareArtifacts: () => Effect.void, + start: (input) => + Effect.sync(() => { + starts.push(input); + return [] as ReadonlyArray; + }), + stop: (input) => + Effect.sync(() => { + stops.push(input); + }), + destroy: () => Effect.void, + exportSnapshot: () => + Effect.fail(new StackLifecycleConflictError({ message: "outside test" })), + restoreSnapshot: () => + Effect.fail(new StackLifecycleConflictError({ message: "outside test" })), + prefetch: () => Effect.void, + artifacts: Effect.succeed([]), + activate: () => Effect.die("whole restart test does not activate gateways"), + ingress, + logStore, + }; + const supervisor = yield* makeSupervisor({ + stackId, + ownerSessionId: "whole-restart-owner", + stateStore, + context, + runtime, + }).pipe(Effect.provideContext(context)); + const endpoint = { kind: "unix" as const, path: path.join(root, "control", "owner.sock") }; + yield* startControlServer({ + stackId, + ownerSessionId: "whole-restart-owner", + endpoint, + rpcRelease: STACK_RPC_RELEASE, + maintenanceHandlers: supervisor.maintenanceHandlers, + rpcHandlers: supervisor.rpcHandlers, + }); + return yield* use({ + endpoint, + stackId, + stateStore, + supervisor, + database: databaseInstance.id, + rest: restInstance.id, + functions: functionsInstance.id, + dynamic: dynamicInstance.id, + starts, + stops, + read: () => stateStore.read(stackId).pipe(Effect.provideContext(context)), + }); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +const validConfig = { + preparation: "on-demand" as const, + listeners: { api: { address: "127.0.0.1", port: 25_123 } }, + security: { + jwt: { + expirySeconds: 7_200, + signing: { kind: "symmetric" as const, secret: Redacted.make("new-jwt") }, + }, + }, + capabilities: { + rest: { activation: "lazy" as const }, + functions: { enabled: false as const }, + }, +}; + +describe("whole stack restart policy", { timeout: 30_000 }, () => { + it.live("rejects an invalid whole configuration atomically", () => + withFixture(({ endpoint, stackId, read, starts, stops }) => + Effect.gen(function* () { + const before = yield* read(); + const result = yield* withRpc({ endpoint, stackId }, (rpc) => + rpc.restart({ config: { capabilities: { database: { version: "not-a-release" } } } }), + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* read()).toEqual(before); + expect(starts).toHaveLength(0); + expect(stops).toHaveLength(0); + }), + ), + ); + + it.live("applies a valid whole config while retaining dynamic and initialized state", () => + withFixture(({ endpoint, stackId, read, database, rest, functions, dynamic, starts, stops }) => + Effect.gen(function* () { + const before = yield* read(); + if (before === undefined) + return yield* new StackLifecycleConflictError({ message: "state missing" }); + const databaseBefore = before.registry.instances.find( + (instance) => instance.id === database, + ); + if (databaseBefore === undefined) + return yield* new StackLifecycleConflictError({ message: "database missing" }); + yield* withRpc({ endpoint, stackId }, (rpc) => rpc.serviceDestroy({ id: functions })); + starts.length = 0; + stops.length = 0; + const result = yield* withRpc({ endpoint, stackId }, (rpc) => + rpc.restart({ config: validConfig }), + ); + expect(result.instances.map((instance) => instance.id)).toEqual( + expect.arrayContaining([database, rest, dynamic]), + ); + const after = yield* read(); + expect(after?.registry.instances.map((instance) => instance.id)).toEqual( + expect.arrayContaining([database, rest, dynamic]), + ); + expect(after?.registry.instances).toHaveLength(3); + expect( + after?.registry.instances.find((instance) => instance.id === database) + ?.initializationInputs, + ).toEqual(databaseBefore.initializationInputs); + expect(after?.security.jwt.expirySeconds).toBe(7_200); + expect(after?.listeners.api).toEqual({ enabled: true, address: "127.0.0.1", port: 25_123 }); + expect(after?.secrets[AUTH_JWT_SECRET_SLOT]?.value).toBe("new-jwt"); + expect(stops.map((input) => input.instance.id)).toEqual( + expect.arrayContaining([database, rest]), + ); + expect(starts.map((input) => input.instance.id)).toEqual( + expect.arrayContaining([database]), + ); + expect(starts.map((input) => input.instance.id)).not.toContain(rest); + expect(starts.map((input) => input.instance.id)).not.toContain(functions); + expect(starts.map((input) => input.instance.id)).not.toContain(dynamic); + }), + ), + ); + + it.live("keeps disabled defaults stopped and lazy defaults dormant", () => + withFixture(({ endpoint, stackId, read, database, rest, functions, dynamic, starts }) => + Effect.gen(function* () { + yield* withRpc({ endpoint, stackId }, (rpc) => rpc.restart({ config: validConfig })); + const startedIds = starts.map((input) => input.instance.id); + expect(startedIds).toContain(database); + expect(startedIds).not.toContain(rest); + expect(startedIds).not.toContain(functions); + expect(startedIds).not.toContain(dynamic); + expect( + (yield* read())?.registry.instances.find((instance) => instance.id === functions)?.intent, + ).toBe("stopped"); + }), + ), + ); + + it.live("retains saved endpoint intent when whole restart omits listeners", () => + withFixture(({ endpoint, stackId, read, database }) => + Effect.gen(function* () { + const before = yield* read(); + const saved = before?.registry.instances.find((instance) => instance.id === database); + if (saved === undefined) + return yield* new StackLifecycleConflictError({ message: "database missing" }); + yield* withRpc({ endpoint, stackId }, (rpc) => rpc.restart({ config: {} })); + const after = yield* read(); + const restarted = after?.registry.instances.find((instance) => instance.id === database); + expect(restarted?.config.endpoints).toEqual(saved.config.endpoints); + expect(after?.listeners.api).toEqual({ enabled: true, address: "127.0.0.1" }); + expect(after?.ports).toEqual( + expect.arrayContaining([ + { + owner: "stack", + binding: "api", + address: "127.0.0.1", + port: 25_123, + intent: "automatic", + }, + ]), + ); + }), + ), + ); + + it.live("removes the saved API listener when whole restart disables it", () => + withFixture(({ endpoint, stackId, read }) => + Effect.gen(function* () { + const result = yield* withRpc({ endpoint, stackId }, (rpc) => + rpc.restart({ config: { listeners: { api: { enabled: false } } } }), + ); + expect(result.endpoints.api).toBeUndefined(); + const after = yield* read(); + expect(after?.listeners.api).toEqual({ enabled: false }); + expect(after?.ports).toEqual( + expect.not.arrayContaining([expect.objectContaining({ owner: "stack", binding: "api" })]), + ); + }), + ), + ); +}); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 8bcd5c7b95..44e99b2a34 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,2 +1,22 @@ -export { createTestStack } from "./public/Testing.ts"; -export type { CreateTestStackOptions, TestStack } from "./public/Testing.ts"; +export { + createTestStack, + TestStackOperationError, + TestStackReadinessError, +} from "./public/Testing.ts"; +export type { CreateTestStackOptions, TestStack, TestStackError } from "./public/Testing.ts"; +export type { + AnyServiceDescriptor, + AnyEffectCreateServiceOptions, + AnyEffectServiceInstance, + EffectCreateServiceOptions, + EffectServiceCollection, + EffectServiceConfig, + EffectServiceInstance, + PrepareResult, + ServiceCredentials, + ServiceDescriptor, + ServiceKind, + ServiceRef, + SnapshotDescriptor, +} from "./public/Service.ts"; +export type { ServiceInstanceId } from "./public/ServiceInstanceId.ts"; diff --git a/packages/stack/tests/helpers/instance-api.ts b/packages/stack/tests/helpers/instance-api.ts new file mode 100644 index 0000000000..7684bb768d --- /dev/null +++ b/packages/stack/tests/helpers/instance-api.ts @@ -0,0 +1,24 @@ +import { NodeServices } from "@effect/platform-node"; +import { Effect, Path } from "effect"; +import { makePromiseApi } from "../../src/public/PromiseStack.ts"; +import { defaultRuntimeEnvironment } from "../../src/supervisor/Launcher.ts"; + +export const isolatedInstanceApi = (projectRoot: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = yield* defaultRuntimeEnvironment; + const api = makePromiseApi(NodeServices.layer, { + ...environment, + stateRoot: path.join(projectRoot, ".stack-state"), + artifactCacheRoot: + environment.artifactCacheRoot ?? path.join(environment.stateRoot, "artifacts"), + }); + return { + ...api, + createStack: (options: Parameters[0]) => + api.createStack({ + ...options, + name: `${options.name ?? "test"}-${path.basename(projectRoot)}`, + }), + }; + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e036f77f7d..31971e9183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -636,6 +636,9 @@ importers: jose: specifier: ^6.2.10 version: 6.2.10 + tar-stream: + specifier: 3.2.0 + version: 3.2.0 devDependencies: '@effect/vitest': specifier: 'catalog:' @@ -646,6 +649,9 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.4.0 + '@types/tar-stream': + specifier: 3.1.4 + version: 3.1.4 '@types/ws': specifier: 'catalog:' version: 8.18.1 @@ -3047,6 +3053,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -8813,6 +8822,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 26.4.1 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} diff --git a/turbo.json b/turbo.json index 3487f7723d..a1984a08bf 100644 --- a/turbo.json +++ b/turbo.json @@ -60,7 +60,8 @@ "$TURBO_DEFAULT$", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock", - "$TURBO_ROOT$/packages/api/**" + "$TURBO_ROOT$/packages/api/**", + "$TURBO_ROOT$/packages/stack/**" ], "outputs": ["dist/**"] },