diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 3e3406ed00..6bf20f630e 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -72,8 +72,9 @@ and previously persisted sticky automatic ports remain hard failures and are never silently moved. Every managed document records one concrete runtime selection. Native and -container runtimes never mix. Omission defaults to Docker; callers may -explicitly select Docker or Podman. There is no probing or auto-detection; +container runtimes never mix. An omitted runtime selects native; when a +container runtime is selected, an omitted engine defaults to Docker. Callers +may explicitly select Docker or Podman. There is no probing or auto-detection; Podman is supported only on local Linux hosts. Persisted state records the resolved exact engine. Capability releases and workload artifacts are persisted as exact version pins (including their diff --git a/packages/stack/README.md b/packages/stack/README.md index e981b9ad07..4c7e95d832 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -41,4 +41,6 @@ non-PostgreSQL capability lazy. `followLogs(...)` provides filterable live entri stateless client-polled cursor. Database reset is intentionally outside the current API. Applying migrations, declarative schemas, -and seeds remains the caller's responsibility. +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. diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 51fda92418..56f0a0bb05 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -459,9 +459,8 @@ const releaseFor = ( const enabledSettings = ( name: CapabilityName, - capabilities: Readonly>, + raw: unknown, ): { enabled: boolean; activation: "eager" | "lazy"; settings: unknown; raw: unknown } => { - const raw = capabilities[name]; if (name === "database") return { enabled: true, activation: "eager", settings: extract(raw, "settings") ?? {}, raw }; if (raw === undefined || raw === null) { @@ -505,7 +504,7 @@ const materializeCapability = ( InvalidStackConfigError | StackVersionUnsupportedError, Path.Path > => { - const selected = enabledSettings(module.name, { [module.name]: raw }); + const selected = enabledSettings(module.name, raw); const mergedInput = merge(module.defaultSettings, selected.settings); const materialized = module.materialize?.(mergedInput, projectRoot) ?? mergedInput; const normalized = normalizeFunctions diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index 6733ae5b3c..8135caa3c2 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -1,22 +1,10 @@ -import { Data, Effect, Redacted, Schema } from "effect"; +import { Data, Effect, Redacted } from "effect"; /** A small runtime-neutral SQL boundary used by the internal database bootstrap. */ export type DatabaseSqlValue = string | number | boolean | null; -interface DatabaseRow { - readonly [column: string]: unknown; -} - /** Login roles provisioned by the managed database template. */ -type DatabaseBootstrapRole = - | "postgres" - | "authenticator" - | "pgbouncer" - | "supabase_auth_admin" - | "supabase_storage_admin" - | "supabase_replication_admin" - | "supabase_read_only_user"; -const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ +const DATABASE_BOOTSTRAP_ROLES = [ "postgres", "authenticator", "pgbouncer", @@ -24,7 +12,8 @@ const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ "supabase_storage_admin", "supabase_replication_admin", "supabase_read_only_user", -]; +] as const; +type DatabaseBootstrapRole = (typeof DATABASE_BOOTSTRAP_ROLES)[number]; export type DatabaseBootstrapSetting = | { @@ -36,28 +25,17 @@ export type DatabaseBootstrapSetting = readonly value: number; }; -/** Values come from resolved managed secret slots and never become SQL text. */ -export interface DatabaseBootstrapCredentials { - readonly roles?: Readonly>>>; -} - -interface DatabaseBootstrapSettings { +export interface DatabaseBootstrapOptions { + /** One managed password shared by the closed login roles. */ + readonly databasePassword: Redacted.Redacted; + /** Managed JWT material applied to the database settings on each invocation. */ readonly jwtSecret: Redacted.Redacted; readonly jwtExpiry: number; } -export interface DatabaseBootstrapOptions { - /** Ordered plan resolved for the pinned database release. */ - readonly revisions: ReadonlyArray; - readonly credentials?: DatabaseBootstrapCredentials; - /** Configuration values are reconciled on every invocation, like role passwords. */ - readonly settings?: DatabaseBootstrapSettings; -} - export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapError")<{ readonly message: string; readonly statement?: string; - readonly revision?: string; /** Whether retrying the database operation may succeed once the server settles. */ readonly retryable?: boolean; readonly cause?: unknown; @@ -80,10 +58,6 @@ export interface DatabaseTransaction { readonly setDatabaseSetting: ( setting: DatabaseBootstrapSetting, ) => Effect.Effect; - readonly query: ( - statement: string, - parameters?: ReadonlyArray, - ) => Effect.Effect, DatabaseBootstrapError>; } export interface DatabaseSession { @@ -97,157 +71,69 @@ export interface DatabaseSession { ) => Effect.Effect; } -interface DatabaseBootstrapRevision { - readonly id: string; - readonly statement: string; -} - -const TRACKING_SCHEMA = "supabase_internal"; -const TRACKING_TABLE = `${TRACKING_SCHEMA}.bootstrap_revisions`; - -const TRACKING_SCHEMA_STATEMENT = `CREATE SCHEMA IF NOT EXISTS ${TRACKING_SCHEMA};`; -const TRACKING_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} ( - revision text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - );`; -const APPLIED_REVISIONS_STATEMENT = ` - SELECT revision FROM ${TRACKING_TABLE} ORDER BY revision; -`; -const RECORD_REVISION_STATEMENT = ` - INSERT INTO ${TRACKING_TABLE} (revision) VALUES ($1) - ON CONFLICT (revision) DO NOTHING; -`; +const REALTIME_SCHEMA_STATEMENT = + "CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;"; const ADVISORY_LOCK_STATEMENT = `SELECT pg_advisory_xact_lock(hashtext('supabase_internal.bootstrap'));`; -const RevisionRowSchema = Schema.Struct({ revision: Schema.String }); - -const statementError = (error: DatabaseBootstrapError, statement: string, revision?: string) => +const statementError = (error: DatabaseBootstrapError, statement: string) => new DatabaseBootstrapError({ message: error.message, statement, - ...(revision === undefined ? {} : { revision }), ...(error.retryable === undefined ? {} : { retryable: error.retryable }), ...(error.cause === undefined ? {} : { cause: error.cause }), }); -/** Runs all unapplied internal revisions, recording each only after it succeeds. */ +/** Reconciles the runtime-owned schema, roles, and settings in one transaction. */ export const runDatabaseBootstrap = ( session: DatabaseSession, options: DatabaseBootstrapOptions, ): Effect.Effect => - Effect.gen(function* () { - const ids = new Set(); - for (const revision of options.revisions) { - if (revision.id.trim().length === 0 || ids.has(revision.id)) - return yield* new DatabaseBootstrapError({ - message: "Database bootstrap revision ids must be non-empty and unique", - revision: revision.id, - }); - ids.add(revision.id); - } - yield* session.transaction((transaction) => - Effect.gen(function* () { - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - yield* transaction - .execute(TRACKING_SCHEMA_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, TRACKING_SCHEMA_STATEMENT))); - yield* transaction - .execute(TRACKING_TABLE_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, TRACKING_TABLE_STATEMENT))); - }), - ); - for (const revision of options.revisions) { - yield* session.transaction((transaction) => - Effect.gen(function* () { - // Re-check under a transaction-scoped advisory lock. This prevents - // two owners from both applying a non-idempotent revision after - // observing the same pre-lock snapshot. - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - const rows = yield* transaction - .query(APPLIED_REVISIONS_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, APPLIED_REVISIONS_STATEMENT))); - const applied = new Set(); - for (const row of rows) { - const decoded = yield* Schema.decodeUnknownEffect(RevisionRowSchema)(row).pipe( - Effect.mapError( - (cause) => - new DatabaseBootstrapError({ - message: `Bootstrap tracking row is malformed: ${String(cause)}`, - statement: APPLIED_REVISIONS_STATEMENT, - }), - ), - ); - applied.add(decoded.revision); - } - if (applied.has(revision.id)) return; - yield* transaction - .execute(revision.statement) - .pipe( - Effect.mapError((error) => statementError(error, revision.statement, revision.id)), - ); - yield* transaction - .execute(RECORD_REVISION_STATEMENT, [revision.id]) - .pipe( - Effect.mapError((error) => - statementError(error, RECORD_REVISION_STATEMENT, revision.id), - ), - ); - }), - ); - } - if (options.credentials?.roles !== undefined || options.settings !== undefined) { - yield* session.transaction((transaction) => - Effect.gen(function* () { - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - if (options.credentials?.roles !== undefined) { - for (const role of DATABASE_BOOTSTRAP_ROLES) { - const password = options.credentials.roles[role]; - if (password === undefined) continue; - yield* transaction.setRolePassword(role, password).pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: `Unable to configure internal database role ${role}`, - }), - ), - ); - } - } - if (options.settings !== undefined) { - yield* transaction - .setDatabaseSetting({ - name: "app.settings.jwt_secret", - value: options.settings.jwtSecret, - }) - .pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: "Unable to configure database JWT secret", - }), - ), - ); - yield* transaction - .setDatabaseSetting({ - name: "app.settings.jwt_exp", - value: options.settings.jwtExpiry, - }) - .pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: "Unable to configure database JWT expiry", - }), - ), - ); - } - }), - ); - } - }); + session.transaction((transaction) => + Effect.gen(function* () { + yield* transaction + .execute(ADVISORY_LOCK_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + yield* transaction + .execute(REALTIME_SCHEMA_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, REALTIME_SCHEMA_STATEMENT))); + for (const role of DATABASE_BOOTSTRAP_ROLES) { + yield* transaction.setRolePassword(role, options.databasePassword).pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: `Unable to configure internal database role ${role}`, + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), + }), + ), + ); + } + yield* transaction + .setDatabaseSetting({ + name: "app.settings.jwt_secret", + value: options.jwtSecret, + }) + .pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: "Unable to configure database JWT secret", + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), + }), + ), + ); + yield* transaction + .setDatabaseSetting({ + name: "app.settings.jwt_exp", + value: options.jwtExpiry, + }) + .pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: "Unable to configure database JWT expiry", + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), + }), + ), + ); + }), + ); diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 0d8904f7ba..f46535da69 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -18,7 +18,6 @@ export interface NativeWorkloadArtifact { readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; readonly nativeProcess?: NativeWorkloadProcess; - readonly containerAlias: string; } /** Artifact-root-relative process metadata for native Node workloads. */ @@ -29,7 +28,6 @@ export interface NativeWorkloadProcess { } export interface WorkloadCatalogEntry { - readonly workloadId: string; readonly service: string; readonly defaultVersion: string; /** Supported exact versions mapped to their matching container image. */ @@ -43,7 +41,6 @@ export interface WorkloadCatalogEntry { } const native = ( - workloadId: string, service: string, defaultVersion: string, containerImage: string, @@ -55,7 +52,6 @@ const native = ( readonly nativeProcess?: NativeWorkloadProcess; } = {}, ): WorkloadCatalogEntry => ({ - workloadId, service, defaultVersion, releases: { @@ -71,7 +67,6 @@ const native = ( /** The single authoritative private workload identity table. */ const workloadCatalog = { "database:database": native( - "database:database", "postgres", "17.6.1.168", "ghcr.io/supabase/cli/postgres:17.6.1.168", @@ -93,7 +88,6 @@ const workloadCatalog = { }, ), "rest:rest": native( - "rest:rest", "postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", @@ -101,16 +95,10 @@ const workloadCatalog = { ["bin/postgrest"], { containerAlias: "supabase-rest" }, ), - "auth:auth": native( - "auth:auth", - "auth", - "v2.196.0", - "ghcr.io/supabase/cli/auth:v2.196.0", + "auth:auth": native("auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth", [ "bin/auth", - ["bin/auth"], - ), + ]), "realtime:realtime": native( - "realtime:realtime", "realtime", "v2.134.5", "ghcr.io/supabase/cli/realtime:v2.134.5", @@ -118,7 +106,6 @@ const workloadCatalog = { ["bin/migrate", "bin/realtime", "bin/server"], ), "storage:storage": native( - "storage:storage", "storage", "v1.73.0", "ghcr.io/supabase/cli/storage:v1.73.0", @@ -133,14 +120,12 @@ const workloadCatalog = { }, ), "storage:imgproxy": native( - "storage:imgproxy", "imgproxy", "v3.8.0", "ghcr.io/supabase/cli/imgproxy:v3.8.0", "bin/imgproxy", ), "functions:edge-runtime": native( - "functions:edge-runtime", "edge-runtime", "v1.76.2", "ghcr.io/supabase/cli/edge-runtime:v1.76.2", @@ -149,7 +134,6 @@ const workloadCatalog = { { containerAlias: "supabase-functions" }, ), "studio:studio": native( - "studio:studio", "studio", "2026.09.04-sha-5a67366", "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366", @@ -164,7 +148,6 @@ const workloadCatalog = { }, ), "studio:pgmeta": native( - "studio:pgmeta", "pgmeta", "v0.99.0", "ghcr.io/supabase/cli/pgmeta:v0.99.0", @@ -179,7 +162,6 @@ const workloadCatalog = { }, ), "mail:mail": native( - "mail:mail", "mailpit", "v1.30.2", "ghcr.io/supabase/cli/mailpit:v1.30.2", @@ -188,7 +170,6 @@ const workloadCatalog = { { containerAlias: "supabase-mail" }, ), "analytics:analytics": native( - "analytics:analytics", "analytics", "v1.50.9", "ghcr.io/supabase/cli/analytics:v1.50.9", @@ -196,7 +177,6 @@ const workloadCatalog = { ["bin/logflare"], ), "analytics:vector": native( - "analytics:vector", "vector", "0.53.0", "ghcr.io/supabase/cli/vector:0.53.0", @@ -204,7 +184,6 @@ const workloadCatalog = { ["bin/vector", "share/doc/vector/config/vector.yaml"], ), "pooler:pooler": native( - "pooler:pooler", "pooler", "v2.9.12", "ghcr.io/supabase/cli/pooler:v2.9.12", @@ -277,7 +256,6 @@ const artifactFor = ( requiredRuntimePaths: entry.requiredRuntimePaths, executablePath: entry.executablePath, ...(entry.nativeProcess === undefined ? {} : { nativeProcess: entry.nativeProcess }), - containerAlias: entry.containerAlias, }; }; diff --git a/packages/stack/src/model/database-bootstrap.integration.test.ts b/packages/stack/src/model/database-bootstrap.integration.test.ts index 4e5b9aa4fa..221ea0f237 100644 --- a/packages/stack/src/model/database-bootstrap.integration.test.ts +++ b/packages/stack/src/model/database-bootstrap.integration.test.ts @@ -2,201 +2,192 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Redacted, Semaphore } from "effect"; import { DatabaseBootstrapError, - type DatabaseBootstrapSetting, - type DatabaseBootstrapCredentials, type DatabaseSession, runDatabaseBootstrap, } from "./DatabaseBootstrap.ts"; -const revisions = [ - { id: "roles", statement: "CREATE ROLE anon" }, - { id: "extensions", statement: "CREATE EXTENSION pgcrypto" }, - { id: "schemas", statement: "CREATE SCHEMA extensions" }, +const ROLE_NAMES = [ + "postgres", + "authenticator", + "pgbouncer", + "supabase_auth_admin", + "supabase_storage_admin", + "supabase_replication_admin", + "supabase_read_only_user", ] as const; -const makeSession = ( - options: { readonly failRevision?: string; readonly failPassword?: boolean } = {}, -) => +type SettingValue = string | number; +type BootstrapState = { + readonly schemas: Set; + readonly schemaOwners: Map; + readonly passwords: Map; + readonly settings: Map; +}; + +const makeSession = (options: { readonly failOnce?: "role" | "setting" } = {}) => Effect.gen(function* () { const lock = yield* Semaphore.make(1); - let failed = false; - const applied: string[] = []; + const state: BootstrapState = { + schemas: new Set(), + schemaOwners: new Map(), + passwords: new Map(), + settings: new Map(), + }; const operations: string[] = []; - const successfulRevisions: string[] = []; - const passwords: Array<[string, string]> = []; - const settings: Array<[string, string | number]> = []; + let transactions = 0; + let failed = false; + const session: DatabaseSession = { - execute: (statement) => - Effect.sync(() => { - operations.push(statement.trim().split("\n")[0] ?? ""); - }), + execute: () => Effect.void, transaction: (use) => lock.withPermit( Effect.gen(function* () { - const pending: string[] = []; - const tx = { - execute: ( - statement: string, - parameters?: ReadonlyArray, - ) => + transactions += 1; + const pending: BootstrapState = { + schemas: new Set(state.schemas), + schemaOwners: new Map(state.schemaOwners), + passwords: new Map(state.passwords), + settings: new Map(state.settings), + }; + const shouldFail = (kind: typeof options.failOnce) => { + if (options.failOnce !== kind || failed) return false; + failed = true; + return true; + }; + const transaction = { + execute: (statement: string) => Effect.gen(function* () { - operations.push(statement.trim().split("\n")[0] ?? ""); - const id = parameters?.[0]; - if (typeof id === "string" && statement.includes("INSERT")) pending.push(id); - const revision = revisions.find((entry) => - statement.includes(entry.statement.trim().split("\n")[0] ?? ""), - ); - if (revision !== undefined && revision.id === options.failRevision && !failed) { - failed = true; + yield* Effect.sync(() => operations.push(statement)); + if (statement.includes("CREATE SCHEMA IF NOT EXISTS _realtime;")) + yield* Effect.sync(() => pending.schemas.add("_realtime")); + if (statement.includes("ALTER SCHEMA _realtime OWNER TO postgres;")) + yield* Effect.sync(() => pending.schemaOwners.set("_realtime", "postgres")); + }), + setRolePassword: (role: string, password: Redacted.Redacted) => + Effect.gen(function* () { + yield* Effect.sync(() => operations.push(`ALTER ROLE ${role} PASSWORD`)); + if (shouldFail("role")) return yield* new DatabaseBootstrapError({ - message: `failed ${revision.id}`, - revision: revision.id, + message: "role failed secret-password", }); - } - if (revision !== undefined) successfulRevisions.push(revision.id); + yield* Effect.sync(() => pending.passwords.set(role, Redacted.value(password))); }), - setRolePassword: ( - role: - | "postgres" - | "authenticator" - | "pgbouncer" - | "supabase_auth_admin" - | "supabase_storage_admin" - | "supabase_replication_admin" - | "supabase_read_only_user", - password: Redacted.Redacted, - ) => - options.failPassword - ? Effect.fail( - new DatabaseBootstrapError({ message: "password rejected secret-password" }), - ) - : Effect.sync(() => passwords.push([role, Redacted.value(password)])), - setDatabaseSetting: (setting: DatabaseBootstrapSetting) => - Effect.sync(() => { - settings.push([ - setting.name, - setting.name === "app.settings.jwt_secret" - ? Redacted.value(setting.value) - : setting.value, - ]); + setDatabaseSetting: (setting: { + readonly name: string; + readonly value: Redacted.Redacted | number; + }) => + Effect.gen(function* () { + yield* Effect.sync(() => + operations.push(`ALTER DATABASE postgres SET ${setting.name}`), + ); + if (shouldFail("setting")) + return yield* new DatabaseBootstrapError({ + message: "setting failed secret-jwt", + }); + yield* Effect.sync(() => + pending.settings.set( + setting.name, + typeof setting.value === "number" + ? setting.value + : Redacted.value(setting.value), + ), + ); }), - query: () => Effect.succeed(applied.map((revision) => ({ revision }))), }; - yield* use(tx); - applied.push(...pending); + yield* use(transaction); + state.schemas.clear(); + pending.schemas.forEach((schema) => state.schemas.add(schema)); + state.schemaOwners.clear(); + pending.schemaOwners.forEach((owner, schema) => state.schemaOwners.set(schema, owner)); + state.passwords.clear(); + pending.passwords.forEach((password, role) => state.passwords.set(role, password)); + state.settings.clear(); + pending.settings.forEach((value, name) => state.settings.set(name, value)); }), ), }; - return { session, applied, operations, passwords, settings, successfulRevisions }; + return { + session, + state, + operations, + get transactions() { + return transactions; + }, + }; }); +const options = (password: string, jwtSecret: string, jwtExpiry = 3600) => ({ + databasePassword: Redacted.make(password), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry, +}); + describe("database bootstrap", () => { - it.live("applies ordered revisions once and keeps credentials outside SQL", () => + it.live("reconciles the schema, login roles, and settings on every invocation", () => Effect.gen(function* () { - const state = yield* makeSession(); - const credentials: DatabaseBootstrapCredentials = { - roles: { postgres: Redacted.make("secret-password") }, - }; - yield* runDatabaseBootstrap(state.session, { - revisions, - credentials, - settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, - }); - yield* runDatabaseBootstrap(state.session, { - revisions, - credentials, - settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, - }); - expect(state.applied).toEqual(revisions.map(({ id }) => id)); - // Credential reconciliation is intentionally a separate idempotent - // phase. It runs on each invocation so a changed managed password is - // applied even when every schema revision is already recorded. - expect(state.passwords).toEqual([ - ["postgres", "secret-password"], - ["postgres", "secret-password"], - ]); - expect(state.settings).toEqual([ - ["app.settings.jwt_secret", "secret-jwt"], - ["app.settings.jwt_exp", 3600], - ["app.settings.jwt_secret", "secret-jwt"], - ["app.settings.jwt_exp", 3600], - ]); - expect(state.operations.join(" ")).not.toContain("secret-password"); + const runtime = yield* makeSession(); + yield* runDatabaseBootstrap(runtime.session, options("password-a", "jwt-a")); + runtime.state.schemaOwners.set("_realtime", "other"); + yield* runDatabaseBootstrap(runtime.session, options("password-b", "jwt-b", 7200)); + + expect(runtime.state.schemas).toEqual(new Set(["_realtime"])); + expect(runtime.state.schemaOwners).toEqual(new Map([["_realtime", "postgres"]])); + expect([...runtime.state.passwords.entries()]).toEqual( + ROLE_NAMES.map((role) => [role, "password-b"]), + ); + expect(runtime.state.settings).toEqual( + new Map([ + ["app.settings.jwt_secret", "jwt-b"], + ["app.settings.jwt_exp", 7200], + ]), + ); + expect(runtime.transactions).toBe(2); + expect(runtime.operations[0]).toContain("pg_advisory_xact_lock"); + expect(runtime.operations.join(" ")).not.toContain("password-a"); + expect(runtime.operations.join(" ")).not.toContain("password-b"); + expect(runtime.operations.join(" ")).not.toContain("jwt-a"); + expect(runtime.operations.join(" ")).not.toContain("jwt-b"); }), ); - it.live("does not record a failed revision and retries it later", () => + it.live("rolls back every bootstrap change and can retry after a transaction failure", () => Effect.gen(function* () { - const state = yield* makeSession({ failRevision: "schemas" }); - const first = yield* runDatabaseBootstrap(state.session, { - revisions, - }).pipe(Effect.exit); + const runtime = yield* makeSession({ failOnce: "role" }); + const first = yield* runDatabaseBootstrap( + runtime.session, + options("secret-password", "secret-jwt"), + ).pipe(Effect.exit); expect(Exit.isFailure(first)).toBe(true); - expect(state.applied).toEqual(["roles", "extensions"]); - yield* runDatabaseBootstrap(state.session, { revisions }); - expect(state.applied).toEqual(revisions.map(({ id }) => id)); - expect(state.successfulRevisions.filter((entry) => entry === "schemas")).toHaveLength(1); + if (Exit.isFailure(first)) expect(Cause.pretty(first.cause)).not.toContain("secret-password"); + expect(runtime.state.schemas).toEqual(new Set()); + expect(runtime.state.schemaOwners).toEqual(new Map()); + expect(runtime.state.passwords).toEqual(new Map()); + expect(runtime.state.settings).toEqual(new Map()); + + yield* runDatabaseBootstrap(runtime.session, options("secret-password", "secret-jwt")); + expect(runtime.state.schemas).toEqual(new Set(["_realtime"])); + expect(runtime.state.schemaOwners).toEqual(new Map([["_realtime", "postgres"]])); + expect(runtime.state.passwords.size).toBe(ROLE_NAMES.length); + expect(runtime.state.settings.get("app.settings.jwt_secret")).toBe("secret-jwt"); }), ); - it.live("keeps managed passwords out of SQL and bootstrap errors", () => + it.live("maps setting failures without exposing JWT material", () => Effect.gen(function* () { - const state = yield* makeSession({ failPassword: true }); - const result = yield* runDatabaseBootstrap(state.session, { - revisions: [], - credentials: { roles: { postgres: Redacted.make("secret-password") } }, - }).pipe(Effect.exit); + const runtime = yield* makeSession({ failOnce: "setting" }); + const result = yield* runDatabaseBootstrap( + runtime.session, + options("secret-password", "secret-jwt"), + ).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) + if (Exit.isFailure(result)) { expect(Cause.pretty(result.cause)).not.toContain("secret-password"); - expect(state.operations.join(" ")).not.toContain("secret-password"); - }), - ); - - it.live("applies the managed password to every login role", () => - Effect.gen(function* () { - const state = yield* makeSession(); - const password = Redacted.make("secret-password"); - yield* runDatabaseBootstrap(state.session, { - revisions: [], - credentials: { - roles: { - postgres: password, - authenticator: password, - pgbouncer: password, - supabase_auth_admin: password, - supabase_storage_admin: password, - supabase_replication_admin: password, - supabase_read_only_user: password, - }, - }, - settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, - }); - expect(state.passwords).toEqual([ - ["postgres", "secret-password"], - ["authenticator", "secret-password"], - ["pgbouncer", "secret-password"], - ["supabase_auth_admin", "secret-password"], - ["supabase_storage_admin", "secret-password"], - ["supabase_replication_admin", "secret-password"], - ["supabase_read_only_user", "secret-password"], - ]); - expect(state.operations.join(" ")).not.toContain("secret-password"); - }), - ); - - it.live("serializes concurrent callers before applying a revision", () => - Effect.gen(function* () { - const state = yield* makeSession(); - yield* Effect.all( - [ - runDatabaseBootstrap(state.session, { revisions }), - runDatabaseBootstrap(state.session, { revisions }), - ], - { concurrency: "unbounded", discard: true }, - ); - expect(state.applied).toEqual(revisions.map(({ id }) => id)); - expect(state.operations.filter((entry) => entry === "CREATE ROLE anon")).toHaveLength(1); + expect(Cause.pretty(result.cause)).not.toContain("secret-jwt"); + } + expect(runtime.state.schemas).toEqual(new Set()); + expect(runtime.state.schemaOwners).toEqual(new Map()); + expect(runtime.state.passwords).toEqual(new Map()); + expect(runtime.state.settings).toEqual(new Map()); }), ); }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 05a8fecfea..88492cf486 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Stream } from "effect"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; import type { @@ -74,6 +74,7 @@ const containerEngine = ( waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, removeContainer: () => Effect.void, + streamLogs: () => Stream.empty, }); describe("runtime artifact preparation", () => { diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 9a753d4ed6..48d481b5f5 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -27,7 +27,6 @@ const artifact: NativeWorkloadArtifact = { checksumUrl: "https://example.test/SHA256SUMS", requiredRuntimePaths: ["bin/demo"], executablePath: "bin/demo", - containerAlias: "supabase-demo", }; const request: ArtifactRequest = { key: "demo/v1", diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 40f0fc5e0d..13202a6344 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -426,7 +426,6 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, launch = false, - cleanupFreshOwner = false, ): Effect.Effect => { const rpcCall: Effect.Effect = resolveClient( launch, @@ -434,9 +433,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff Effect.flatMap(({ client, resolution }) => Effect.scoped(client.rpc.pipe(Effect.flatMap(call))).pipe( Effect.onExit((result) => - cleanupFreshOwner && resolution.launched - ? cleanupLaunchedOwner(resolution, result) - : Effect.void, + launch ? cleanupLaunchedOwner(resolution, result) : Effect.void, ), ), ), @@ -564,7 +561,6 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff : rpc.start({ config: startOptions.config }), startError, true, - true, ).pipe( Effect.tapError(() => options.readPersistedState.pipe( diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index d7c41021c2..9c38c70795 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -1003,6 +1003,7 @@ describe("Effect stack lifecycle handoff", () => { }); return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); }, + stream: () => Stream.empty, }; const engine = makeDockerEngine({ runner, platform: { os: "linux" } }); const stack = yield* createStack({ @@ -1238,6 +1239,7 @@ describe("Effect stack lifecycle handoff", () => { ); return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); }, + stream: () => Stream.empty, }; const engine = makeDockerEngine({ runner, diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index bfcc2b8c67..860ae5a21f 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -14,7 +14,6 @@ export interface ContainerPlatform { } export interface ContainerHostRoute { readonly host: string; - readonly gateway?: string; /** Host address where the in-process gateway also binds for rootful Linux containers. */ readonly bindAddress?: string; } @@ -72,7 +71,7 @@ export const CONTAINER_LABEL_KEYS = { }; /** Shapes byte-identical label argv for Docker and Podman. */ -export const containerLabels = (value: ContainerLabels): ReadonlyArray => { +const containerLabels = (value: ContainerLabels): ReadonlyArray => { const pairs: ReadonlyArray = value.role === "network" ? [ @@ -137,7 +136,6 @@ export interface ContainerContainerSpec { readonly mounts: ReadonlyArray; readonly volumeMounts: ReadonlyArray; readonly publications: ReadonlyArray; - readonly hostRoute?: ContainerHostRoute; readonly role: "workload"; /** Optional image entrypoint override used by service-owned init processes. */ readonly entrypoint?: string; @@ -170,6 +168,90 @@ export type ContainerCommand = | { readonly operation: "wait-container"; readonly id: string } | { readonly operation: "stop-container"; readonly id: string } | { readonly operation: "remove-container"; readonly id: string }; + +type CommonContainerCommand = Extract< + ContainerCommand, + | { readonly operation: "create-network" } + | { readonly operation: "remove-network" } + | { readonly operation: "create-volume" } + | { readonly operation: "remove-volume" } + | { readonly operation: "create-container" } + | { readonly operation: "copy-container" } + | { readonly operation: "start-container" } + | { readonly operation: "wait-container" } + | { readonly operation: "stop-container" } + | { readonly operation: "remove-container" } +>; + +export const serializeCommonContainerCommand = ( + command: CommonContainerCommand, +): ContainerProcessRequest => { + switch (command.operation) { + case "create-network": + return { + args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; + case "remove-network": + return { args: ["network", "rm", command.id] }; + case "create-volume": + return { + args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; + case "remove-volume": + return { args: ["volume", "rm", command.id] }; + case "create-container": { + const bindMounts = command.spec.mounts.flatMap((mount) => [ + "--mount", + `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ + "--mount", + `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const publications = command.spec.publications.flatMap((port) => [ + "--publish", + `${port.address}:${port.hostPort}:${port.containerPort}`, + ]); + const environment = + command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; + const networkAliases = + command.spec.networkAliases === undefined + ? [] + : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); + const entrypoint = + command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; + return { + args: [ + "create", + "--name", + command.spec.name, + "--network", + command.spec.network, + ...networkAliases, + ...containerLabels(command.spec.labels), + ...bindMounts, + ...volumeMounts, + ...publications, + ...environment, + ...entrypoint, + command.spec.image, + ...(command.spec.command ?? []), + ], + }; + } + case "copy-container": + return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; + case "start-container": + return { args: ["start", command.id] }; + case "wait-container": + return { args: ["wait", command.id] }; + case "stop-container": + return { args: ["stop", command.id] }; + case "remove-container": + return { args: ["rm", "--force", command.id] }; + } +}; + export interface ContainerCommandResult { readonly stdout: string; readonly stderr: string; @@ -197,7 +279,7 @@ export interface ContainerCommandRunner { request: ContainerProcessRequest, ) => Effect.Effect; /** Follows one exact process's stdout/stderr until it exits. */ - readonly stream?: ( + readonly stream: ( request: ContainerProcessRequest, ) => Stream.Stream; } @@ -405,17 +487,12 @@ export interface ContainerEngineCodecs { readonly decodeWait: ( result: ContainerCommandResult, ) => Effect.Effect; - readonly serializeLogs: ( - id: string, - options: ContainerLogOptions | undefined, - ) => ContainerProcessRequest; } export const makeContainerEngineCodecs = (options: { readonly engineName: "Docker" | "Podman"; readonly scalarFormat: "json" | "raw"; readonly serialize: ContainerEngineCodecs["serialize"]; - readonly serializeLogs: ContainerEngineCodecs["serializeLogs"]; }): ContainerEngineCodecs => { const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => new ContainerEngineProtocolError({ @@ -579,7 +656,6 @@ export const makeContainerEngineCodecs = (options: { }; return { serialize: options.serialize, - serializeLogs: options.serializeLogs, decodeProbe: (result) => scalar("probe", result.stdout).pipe(Effect.asVoid), decodeImage: (result) => Effect.forEach(lines(result.stdout), (line) => scalar("inspect-image", line)).pipe( @@ -635,7 +711,7 @@ export interface ContainerEngine { readonly stopContainer: (id: string) => Effect.Effect; readonly removeContainer: (id: string) => Effect.Effect; /** Follows one exact container and emits complete stdout/stderr lines. */ - readonly streamLogs?: ( + readonly streamLogs: ( id: string, options?: ContainerLogOptions, ) => Stream.Stream; @@ -662,13 +738,6 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai logOptions?: ContainerLogOptions, ): Stream.Stream => { const source = options.runner.stream; - if (source === undefined) - return Stream.fail( - new ContainerEngineProtocolError({ - operation: "logs", - message: "Container engine log streaming is unavailable", - }), - ); interface LineState { readonly stdout: { readonly decoder: TextDecoder; remainder: string }; readonly stderr: { readonly decoder: TextDecoder; remainder: string }; @@ -696,7 +765,15 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai accumulator.remainder = ""; return [{ stream: streamName, message }]; }); - return source(options.codecs.serializeLogs(id, logOptions)).pipe( + return source({ + args: [ + "logs", + "--follow", + "--tail", + logOptions?.tail === undefined ? "all" : String(logOptions.tail), + id, + ], + }).pipe( Stream.mapAccum(stateFor, (state, chunk) => [state, split(state, chunk)] as const, { onHalt: flush, }), @@ -711,13 +788,7 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai message: "Docker remote daemon has no verified host route", }), ) - : Effect.succeed( - options.platform.os === "linux" && - options.platform.desktop !== true && - options.platform.rootless !== true - ? { host: "host.docker.internal", gateway: "host-gateway" } - : { host: "host.docker.internal" }, - ) + : Effect.succeed({ host: "host.docker.internal" }) : options.platform.remote === true || options.platform.os !== "linux" ? Effect.fail( new ContainerRoutingUnsupportedError({ diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 9f47fdfaf9..68e3238c67 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -17,7 +17,6 @@ import type { StackId } from "../public/StackId.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; import { type ContainerContainerSpec, - type ContainerHostRoute, type ContainerEngine, type ContainerEngineFailure, type ContainerLabels, @@ -78,7 +77,6 @@ export interface ContainerWorkloadResolution { /** Path to an owned env file; secret bytes are kept out of engine argv. */ readonly envFile?: string; readonly networkAliases?: ReadonlyArray; - readonly hostRoute?: ContainerHostRoute; /** Private host-loopback publications used by the in-process gateway. */ readonly publications?: ReadonlyArray; /** Service-owned one-shot processes run before a newly-created main container. */ @@ -346,12 +344,6 @@ export const makeContainerRuntime = ( ): Effect.Effect => { const logStore = options.logStore; if (logStore === undefined) return Effect.void; - if (options.engine.streamLogs === undefined) - return reportFailure( - resource, - new Error("Container log streaming is unavailable"), - `Container log stream failed for ${resource.key.workloadId}`, - ); const stream = options.engine.streamLogs(resource.container, { tail }); const consume = stream.pipe( Stream.runForEach((line) => @@ -466,9 +458,6 @@ export const makeContainerRuntime = ( ...(context.resolution.envFile === undefined ? {} : { envFile: context.resolution.envFile }), - ...(context.resolution.hostRoute === undefined - ? {} - : { hostRoute: context.resolution.hostRoute }), }; let logFiber: Fiber.Fiber | undefined; const acquire = withEngine(key, options.engine.createContainer(specification)); @@ -476,7 +465,7 @@ export const makeContainerRuntime = ( const use = (container: ContainerResource): Effect.Effect => Effect.gen(function* () { yield* withEngine(key, options.engine.startContainer(container.id)); - if (logStore !== undefined && options.engine.streamLogs !== undefined) { + if (logStore !== undefined) { const consume = options.engine.streamLogs(container.id, { tail: "all" }).pipe( Stream.runForEach((line) => logStore @@ -751,7 +740,6 @@ export const makeContainerRuntime = ( ...(resolution.networkAliases === undefined ? {} : { networkAliases: resolution.networkAliases }), - ...(resolution.hostRoute === undefined ? {} : { hostRoute: resolution.hostRoute }), ...(resolution.entrypoint === undefined ? {} : { entrypoint: resolution.entrypoint }), ...(resolution.command === undefined ? {} : { command: resolution.command }), } satisfies ContainerContainerSpec), diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts index 73d4794d2e..fc279b33d1 100644 --- a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -4,16 +4,6 @@ import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { AUTH_JWT_SECRET_SLOT, DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; -/** - * The only SQL revision owned by the runtime bootstrap. The slim Postgres - * artifact owns its bundled init scripts and migrations; this revision only - * reconciles the schema ownership that used to be applied by the CLI. - */ -export const DATABASE_BOOTSTRAP_REVISION = { - id: "database-realtime-schema-owner", - statement: "CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;", -} as const; - const missingMaterial = (message: string) => new StackPreparationError({ message }); const secretValue = (state: PersistedStackState, slot: string): string | undefined => { @@ -25,8 +15,8 @@ const secretValue = (state: PersistedStackState, slot: string): string | undefin * Builds the initial database bootstrap from fully materialized state. * * This helper intentionally does not read artifact SQL files or perform - * caller-driven reset/migration/seed work. It returns only the closed role - * credentials, database settings, and one idempotent `_realtime` revision. + * caller-driven reset/migration/seed work. It returns only the managed + * material required by the fixed runtime bootstrap reconciliation. */ export const databaseBootstrapPlan = ( state: PersistedStackState, @@ -54,23 +44,9 @@ export const databaseBootstrapPlan = ( ) return yield* missingMaterial("Auth JWT expiry must be a finite positive integer"); - const password = Redacted.make(databasePassword); return { - revisions: [DATABASE_BOOTSTRAP_REVISION], - credentials: { - roles: { - postgres: password, - authenticator: password, - pgbouncer: password, - supabase_auth_admin: password, - supabase_storage_admin: password, - supabase_replication_admin: password, - supabase_read_only_user: password, - }, - }, - settings: { - jwtSecret: Redacted.make(jwtSecret), - jwtExpiry, - }, + databasePassword: Redacted.make(databasePassword), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry, } satisfies DatabaseBootstrapOptions; }); diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index e8ddc3932e..f5842b8ae4 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -2,11 +2,10 @@ import { makeContainerEngineCodecs, makeContainerEngineCore, CONTAINER_LABEL_KEYS, - containerLabels, + serializeCommonContainerCommand, type ContainerCommand, type ContainerEngine, type ContainerEngineOptions, - type ContainerLogOptions, type ContainerProcessRequest, } from "./ContainerEngine.ts"; @@ -83,89 +82,11 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc volumeFormat, ], }; - case "create-network": - return { - args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-network": - return { args: ["network", "rm", command.id] }; - case "create-volume": - return { - args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-volume": - return { args: ["volume", "rm", command.id] }; - case "create-container": { - const bindMounts = command.spec.mounts.flatMap((mount) => [ - "--mount", - `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ - "--mount", - `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const publications = command.spec.publications.flatMap((port) => [ - "--publish", - `${port.address}:${port.hostPort}:${port.containerPort}`, - ]); - const hostRoute = - command.spec.hostRoute?.gateway === undefined - ? [] - : ["--add-host", `${command.spec.hostRoute.host}:${command.spec.hostRoute.gateway}`]; - const environment = - command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; - const networkAliases = - command.spec.networkAliases === undefined - ? [] - : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); - const entrypoint = - command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; - return { - args: [ - "create", - "--name", - command.spec.name, - "--network", - command.spec.network, - ...networkAliases, - ...containerLabels(command.spec.labels), - ...bindMounts, - ...volumeMounts, - ...publications, - ...hostRoute, - ...environment, - ...entrypoint, - command.spec.image, - ...(command.spec.command ?? []), - ], - }; - } - case "copy-container": - return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; - case "start-container": - return { args: ["start", command.id] }; - case "wait-container": - return { args: ["wait", command.id] }; - case "stop-container": - return { args: ["stop", command.id] }; - case "remove-container": - return { args: ["rm", "--force", command.id] }; + default: + return serializeCommonContainerCommand(command); } }; -const serializeDockerLogs = ( - id: string, - options: ContainerLogOptions | undefined, -): ContainerProcessRequest => ({ - args: [ - "logs", - "--follow", - "--tail", - options?.tail === undefined ? "all" : String(options.tail), - id, - ], -}); - export const makeDockerEngine = ( options: Omit, ): ContainerEngine => @@ -176,6 +97,5 @@ export const makeDockerEngine = ( engineName: "Docker", scalarFormat: "json", serialize: serializeDockerCommand, - serializeLogs: serializeDockerLogs, }), }); diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index fcdf9e97d5..55e03591cd 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -2,11 +2,10 @@ import { makeContainerEngineCodecs, makeContainerEngineCore, CONTAINER_LABEL_KEYS, - containerLabels, + serializeCommonContainerCommand, type ContainerCommand, type ContainerEngine, type ContainerEngineOptions, - type ContainerLogOptions, type ContainerProcessRequest, } from "./ContainerEngine.ts"; @@ -84,84 +83,11 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc volumeFormat, ], }; - case "create-network": - return { - args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-network": - return { args: ["network", "rm", command.id] }; - case "create-volume": - return { - args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-volume": - return { args: ["volume", "rm", command.id] }; - case "create-container": { - const bindMounts = command.spec.mounts.flatMap((mount) => [ - "--mount", - `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ - "--mount", - `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const publications = command.spec.publications.flatMap((port) => [ - "--publish", - `${port.address}:${port.hostPort}:${port.containerPort}`, - ]); - const environment = - command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; - const networkAliases = - command.spec.networkAliases === undefined - ? [] - : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); - const entrypoint = - command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; - return { - args: [ - "create", - "--name", - command.spec.name, - "--network", - command.spec.network, - ...networkAliases, - ...containerLabels(command.spec.labels), - ...bindMounts, - ...volumeMounts, - ...publications, - ...environment, - ...entrypoint, - command.spec.image, - ...(command.spec.command ?? []), - ], - }; - } - case "copy-container": - return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; - case "start-container": - return { args: ["start", command.id] }; - case "wait-container": - return { args: ["wait", command.id] }; - case "stop-container": - return { args: ["stop", command.id] }; - case "remove-container": - return { args: ["rm", "--force", command.id] }; + default: + return serializeCommonContainerCommand(command); } }; -const serializePodmanLogs = ( - id: string, - options: ContainerLogOptions | undefined, -): ContainerProcessRequest => ({ - args: [ - "logs", - "--follow", - "--tail", - options?.tail === undefined ? "all" : String(options.tail), - id, - ], -}); - export const makePodmanEngine = ( options: Omit, ): ContainerEngine => @@ -172,6 +98,5 @@ export const makePodmanEngine = ( engineName: "Podman", scalarFormat: "raw", serialize: serializePodmanCommand, - serializeLogs: serializePodmanLogs, }), }); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 5d69f2b055..79f173c68a 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -10,7 +10,6 @@ import { runDatabaseBootstrap, } from "../model/DatabaseBootstrap.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; @@ -94,7 +93,6 @@ export const makeDatabaseSessionFromSqlClient = ( const transaction: DatabaseTransaction = { execute: executeWith, - query: queryWith, setRolePassword: (role, password) => generated("SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text) AS statement", [ role, @@ -190,17 +188,12 @@ export const bootstrapDatabaseAt = ( return yield* new StackPreparationError({ message: "A persisted database private port is required for bootstrap", }); - const password = state.secrets[DATABASE_INTERNAL_PASSWORD_SLOT]?.value; - if (typeof password !== "string" || password.length === 0) - return yield* new StackPreparationError({ - message: "Managed database password is unavailable for bootstrap", - }); yield* Effect.scoped( Effect.gen(function* () { const session = yield* makePostgresDatabaseSession({ host: "127.0.0.1", port, - password: Redacted.make(password), + password: plan.databasePassword, }); yield* ensureInternalDatabase( session, @@ -208,7 +201,7 @@ export const bootstrapDatabaseAt = ( host: "127.0.0.1", port, database: INTERNAL_DATABASE, - password: Redacted.make(password), + password: plan.databasePassword, }), ); yield* runDatabaseBootstrap(session, plan); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 3584df6db6..1c993629e8 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -181,7 +181,6 @@ export interface ContainerWorkloadResolution { readonly hostPort: number; readonly containerPort: number; }>; - readonly hostRoute?: ContainerHostRoute; readonly bootstrap?: Readonly<{ readonly source: string; readonly destination: string }>; } @@ -1495,7 +1494,6 @@ export const containerResolutionFor = ( }, } : {}), - ...(inputs.hostRoute === undefined ? {} : { hostRoute: inputs.hostRoute }), }; }; diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index db7b743269..5ce695f172 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -41,9 +41,11 @@ import { makeSupervisor, type SupervisorRuntime } from "../supervisor/Supervisor import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import { deriveStackId } from "../identity/Identity.ts"; -const makeControlledCommandRunner = (options: ContainerCommandRunner): ContainerCommandRunner => ({ +const makeControlledCommandRunner = ( + options: Pick & Partial>, +): ContainerCommandRunner => ({ run: options.run, - ...(options.stream === undefined ? {} : { stream: options.stream }), + stream: options.stream ?? (() => Stream.empty), }); const stackId = StackIdSchema.make("a".repeat(64)); @@ -196,6 +198,7 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { state.calls.push(`remove:${resourceId}`); state.resources = state.resources.filter((resource) => resource.id !== resourceId); }), + streamLogs: () => Stream.empty, }; }; @@ -284,12 +287,14 @@ describe("container runtime", () => { executable: process.execPath, baseArgs: ["-e", "process.stdout.write('follower-line\\n'); setInterval(() => {}, 1000)"], }); - const follower = yield* runner.stream!({ - args: ["logs", "--follow", "--tail", "0", "container-id"], - }).pipe( - Stream.runForEach(() => Deferred.succeed(firstChunk, undefined)), - Effect.forkChild({ startImmediately: true }), - ); + const follower = yield* runner + .stream({ + args: ["logs", "--follow", "--tail", "0", "container-id"], + }) + .pipe( + Stream.runForEach(() => Deferred.succeed(firstChunk, undefined)), + Effect.forkChild({ startImmediately: true }), + ); yield* Deferred.await(firstChunk); yield* Fiber.interrupt(follower); }).pipe(Effect.provide(NodeServices.layer)), @@ -394,7 +399,7 @@ describe("container runtime", () => { runner, platform: { os: "linux", desktop: false }, }); - expect((yield* linux.preflight).gateway).toBe("host-gateway"); + expect((yield* linux.preflight).host).toBe("host.docker.internal"); const podman = makePodmanEngine({ runner, platform: { os: "linux", rootless: true }, @@ -520,9 +525,7 @@ describe("container runtime", () => { resolveWorkload: () => Effect.sync(() => { resolutions += 1; - return routeReady - ? { hostRoute: { host: "172.18.0.1", bindAddress: "172.18.0.1" } } - : {}; + return { command: [routeReady ? "updated" : "initial"] }; }), onNetworkReady: () => Effect.sync(() => { @@ -533,10 +536,7 @@ describe("container runtime", () => { const ready = yield* runtime.start(key, workload()); expect(ready.state).toBe("ready"); expect(resolutions).toBe(2); - expect(state.createdSpecs.at(-1)?.hostRoute).toEqual({ - host: "172.18.0.1", - bindAddress: "172.18.0.1", - }); + expect(state.createdSpecs.at(-1)?.command).toEqual(["updated"]); yield* runtime.cleanup({ stackId, destroy: true }); }), ); @@ -1775,7 +1775,7 @@ describe("container runtime", () => { }), ); - it.live("keeps Docker and Podman command codecs independent and closed", () => + it.live("preserves Docker and Podman command behavior", () => Effect.gen(function* () { const workload = { name: "backend", @@ -1787,20 +1787,19 @@ describe("container runtime", () => { role: "workload" as const, }, network: "private", - mounts: [], - volumeMounts: [], + mounts: [{ source: "/tmp/backend", target: "/app/backend", readOnly: true }], + volumeMounts: [{ volume: "backend-data", target: "/var/lib/backend", readOnly: false }], publications: [{ address: "127.0.0.1" as const, hostPort: 54321, containerPort: 8000 }], envFile: "/tmp/supabase-owned.env", networkAliases: ["supabase-database"], command: ["serve", "--port", "8000"], - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, role: "workload" as const, }; const docker = serializeDockerCommand({ operation: "create-container", spec: workload }); - expect(docker.args).toContain("--add-host"); - expect(docker.args).toContain("host.docker.internal:host-gateway"); expect(docker.args).toContain("--publish"); expect(docker.args).toContain("127.0.0.1:54321:8000"); + expect(docker.args).toContain("type=bind,src=/tmp/backend,dst=/app/backend,ro"); + expect(docker.args).toContain("type=volume,src=backend-data,dst=/var/lib/backend"); expect(docker.args).toContain("--network-alias"); expect(docker.args).toContain("supabase-database"); expect(docker.args).toContain("--env-file"); @@ -1813,13 +1812,14 @@ describe("container runtime", () => { }); expect(podmanCreate.args).toContain("--env-file"); expect(podmanCreate.args).toContain("127.0.0.1:54321:8000"); + expect(podmanCreate.args).toContain("type=bind,src=/tmp/backend,dst=/app/backend,ro"); + expect(podmanCreate.args).toContain("type=volume,src=backend-data,dst=/var/lib/backend"); expect(podmanCreate.args).toContain("--network-alias"); expect(podmanCreate.args).toContain("/tmp/supabase-owned.env"); expect(podmanCreate.args.join(" ")).not.toContain("value"); expect(podmanCreate.args.slice(-3)).toEqual(["serve", "--port", "8000"]); const podman = serializePodmanCommand({ operation: "inspect-networks", stackId }); expect(podman.args.join(" ")).toContain("{{index .Labels"); - expect(podman.args.join(" ")).not.toContain("host-gateway"); expect( serializeDockerCommand({ operation: "copy-container", @@ -1910,8 +1910,8 @@ describe("container runtime", () => { runner, platform: { os: "linux", rootless: true }, }); - const dockerLogs = yield* Stream.runCollect(docker.streamLogs!("container-id", { tail: 0 })); - const podmanLogs = yield* Stream.runCollect(podman.streamLogs!("podman-id")); + const dockerLogs = yield* Stream.runCollect(docker.streamLogs("container-id", { tail: 0 })); + const podmanLogs = yield* Stream.runCollect(podman.streamLogs("podman-id")); expect(dockerLogs).toEqual([ { stream: "stdout", message: "first" }, { stream: "stderr", message: "error" }, 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 0a885e3ba2..59bc895034 100644 --- a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts +++ b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts @@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { compileStack } from "../model/Compiler.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; -import { DATABASE_BOOTSTRAP_REVISION, databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; +import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; const stackId = "a".repeat(64); @@ -43,20 +43,13 @@ const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("database bootstrap catalog", () => { - it.live("creates one revision and reconciles all closed roles and settings", () => + it.live("returns the managed database material required for reconciliation", () => Effect.gen(function* () { const state = yield* compileDefinition; const plan = yield* databaseBootstrapPlan(state); - expect(plan.revisions).toEqual([DATABASE_BOOTSTRAP_REVISION]); - expect(Object.keys(plan.credentials?.roles ?? {})).toHaveLength(7); - for (const password of Object.values(plan.credentials?.roles ?? {})) - expect(password === undefined ? undefined : Redacted.value(password)).toBe( - "database-secret", - ); - expect(plan.settings?.jwtExpiry).toBe(3600); - expect( - plan.settings === undefined ? undefined : Redacted.value(plan.settings.jwtSecret), - ).toBe("jwt-secret"); + expect(Redacted.value(plan.databasePassword)).toBe("database-secret"); + expect(Redacted.value(plan.jwtSecret)).toBe("jwt-secret"); + expect(plan.jwtExpiry).toBe(3600); }), ); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index d41b04ed02..9df922656b 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -625,7 +625,7 @@ describe("workload runtime catalog", () => { ); expect( runtimeSpecFor(functions)?.env(configured, functions, 9000, "container", { - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + hostRoute: { host: "host.docker.internal" }, }), ).toMatchObject({ SUPABASE_URL: "http://host.docker.internal:54321" }); expect(rest?.env(configured, planned("rest:rest"), 3000, "native")).toMatchObject({ @@ -786,7 +786,7 @@ describe("workload runtime catalog", () => { { address: "127.0.0.1", hostPort: 30017, containerPort: 4000 }, ]); const resolution = containerResolutionFor(configured, functions, { - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + hostRoute: { host: "host.docker.internal" }, }); expect(resolution?.command.join(" ")).toContain( `--main-service=${FUNCTIONS_BOOTSTRAP_CONTAINER_PATH}`, @@ -808,10 +808,6 @@ describe("workload runtime catalog", () => { Object.keys(resolution?.env ?? {}).some((key) => key.startsWith("FUNCTIONS_FUNCTIONS_")), ).toBe(false); expect(resolution?.env.EDGE_RUNTIME_PORT).toBe("9000"); - expect(resolution?.hostRoute).toEqual({ - host: "host.docker.internal", - gateway: "host-gateway", - }); const bootstrapResolution = containerResolutionFor(configured, functions, { functions: { bootstrapPath: "/tmp/functions/4/main.ts" }, }); @@ -831,7 +827,7 @@ describe("workload runtime catalog", () => { const studio = runtimeSpecFor(planned("studio:studio")); expect( studio?.env(configured, planned("studio:studio"), 3000, "container", { - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + hostRoute: { host: "host.docker.internal" }, }), ).toMatchObject({ SUPABASE_URL: "http://host.docker.internal:54321", diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index 9545c0fff7..fa53eb5e3e 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -279,14 +279,6 @@ export const readOwnerMetadata = ( const paths = yield* resolveStackPaths({ stateRoot, stackId: validId }).pipe( Effect.mapError((error) => stateError(String(error))), ); - const exists = yield* fs - .exists(paths.controlMetadata) - .pipe( - Effect.mapError((error) => - stateError(`Unable to inspect owner metadata: ${error.message}`), - ), - ); - if (!exists) return undefined; const raw = yield* fs.readFileString(paths.controlMetadata).pipe( Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 3ef4eb74cb..d553f477bf 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -758,13 +758,21 @@ export const makeSupervisor = ( 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 ( - definition === undefined || databaseListener === undefined || !databaseListener.enabled || databaseAssignment === undefined ) - return yield* Effect.fail(credentialsUnavailable); + return yield* Effect.fail( + rpcError( + "InvalidStackConfigError", + "Stack credentials require an enabled database listener and assigned database port", + ), + ); const auth = definition.capabilities.auth; if (!auth.enabled) @@ -811,7 +819,12 @@ export const makeSupervisor = ( const apiListener = definition.listeners.api; const apiAssignment = state.ports.find(({ field }) => field === "api"); if (apiListener === undefined || !apiListener.enabled || apiAssignment === undefined) - return yield* Effect.fail(credentialsUnavailable); + 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 ( diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 7bcedb9bc6..ea2506a8eb 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -154,6 +154,7 @@ const fakeContainerEngine = (kind: "docker" | "podman", calls: string[]): Contai waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, removeContainer: () => Effect.void, + streamLogs: () => Stream.empty, }); describe("managed stack handles", { timeout: 30_000 }, () => { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 9970fbd443..992d87df6e 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1124,7 +1124,25 @@ describe("Supervisor composition", () => { .pipe(Effect.provideContext(fixture.context)); const listenerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(listenerExit)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), + expect.objectContaining({ tag: "InvalidStackConfigError" }), + ); + + const disabledDatabase = { + ...complete, + definition: { + ...complete.definition, + listeners: { + ...complete.definition.listeners, + database: { ...complete.definition.listeners.database, enabled: false }, + }, + }, + }; + yield* fixture.store + .replace(fixture.id, disabledDatabase) + .pipe(Effect.provideContext(fixture.context)); + const disabledDatabaseExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); + expect(errorOf(disabledDatabaseExit)).toEqual( + expect.objectContaining({ tag: "InvalidStackConfigError" }), ); }), ),