diff --git a/packages/stack/README.md b/packages/stack/README.md index 6f412c8944..461a1fd130 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -214,6 +214,10 @@ start, restart, activation, and reload operations; a call-specific option overri deadline fails with `STACK_READINESS_TIMEOUT` and disposes the local runtime, so the handle cannot be used to relaunch processes afterward. +When no readiness policy is configured, request-triggered lazy activation expands the three-minute +default to cover the target service's complete transitive startup budget. An explicitly configured +finite or infinite stack policy remains authoritative. + ### Status ```typescript diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index a6ba09c9d2..d6e5a9a3b3 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -56,6 +56,10 @@ reload, and explicit readiness waits. A finite deadline fails with `StackReadine the same scoped cleanup used by disposal. Promise and remote Adapters pass `ReadyOptions` through to that Implementation instead of layering a second timeout rule around it. +Request-triggered lazy activation expands the package-default deadline when a service's transitive +startup budget is longer than three minutes. Explicit finite and infinite stack policies are never +expanded. + The current zero-config stack enables PostgreSQL, PostgREST, Auth, and Edge Runtime. Realtime, Storage, imgproxy, Mailpit, Postgres Meta, Studio, Analytics, Vector, and Supavisor are enabled only when their corresponding configuration object is present. In `native` mode, Edge Runtime is also @@ -93,7 +97,9 @@ runtime. `StackBuilder.build(config, prepared)` is the explicit owner of cross-service topology. Individual factories under `src/services/` own executable arguments, environment, mounts, health checks, and -per-process cleanup. The builder owns which definitions exist and how they depend on one another. +per-process cleanup. Docker factories also own their host-network and port-mapping arguments. The +builder owns which definitions exist and how they depend on one another, including the choice +between `postgres-init (completed)` and `postgres (healthy)` for every database consumer. | Public service | Automatic runtime support | Principal dependency or role | | -------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | @@ -213,8 +219,10 @@ binding. Supervised Docker services have a wider window because the supervisor s Every Docker definition has ordinary in-process cleanup and supervisor-owned orphan cleanup. `StackBuilder` also returns exact Docker container names for the definitions it constructed. The -coordinator persists these targets for managed daemons and uses them as a force-removal safety net -after graceful stop. Auto-created PostgreSQL, Storage, and runtime paths are also removed. +local Implementation captures these targets before persistence or orchestrator setup, persists +them for managed daemons, and uses them as a force-removal safety net after graceful stop. Launch, +exact cleanup, and candidate cleanup all derive container identity through the same naming +function. Auto-created PostgreSQL, Storage, and runtime paths are also removed. Cleanup is intentionally defensive: @@ -223,7 +231,8 @@ Cleanup is intentionally defensive: external resources; 3. stack disposal force-removes exact known Docker containers; 4. managed `stop` can use persisted cleanup metadata after daemon death; -5. startup failure has candidate cleanup derived from the requested configuration. +5. a failure before the exact build plan exists has candidate cleanup derived from enabled catalog + services; a partial startup failure disposes the exact build-produced plan. These paths overlap by design and must remain idempotent. diff --git a/packages/stack/src/CleanupTargets.ts b/packages/stack/src/CleanupTargets.ts index 47cb77a788..6de3b51cd9 100644 --- a/packages/stack/src/CleanupTargets.ts +++ b/packages/stack/src/CleanupTargets.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import type { ServiceName } from "./ServiceName.ts"; export interface CleanupTargets { readonly dockerContainerNames: ReadonlyArray; @@ -7,3 +8,6 @@ export interface CleanupTargets { export const CleanupTargetsSchema = Schema.Struct({ dockerContainerNames: Schema.Array(Schema.String), }); + +export const dockerContainerName = (service: ServiceName, apiPort: number): string => + `supabase-${service}-${apiPort}`; diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 4900fcba8c..58bb409c78 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -19,6 +19,7 @@ export class DaemonServer extends Context.Service< DaemonServer, { readonly address: HttpServer.Address; + readonly beginShutdown: Effect.Effect; readonly awaitShutdown: Effect.Effect; } >()("stack/DaemonServer") { @@ -366,6 +367,7 @@ export class DaemonServer extends Context.Service< return { address: server.address, + beginShutdown, awaitShutdown: Deferred.await(shutdownDeferred), }; }), diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 363a7ac48a..81f89e6b58 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -23,6 +23,7 @@ import { configureFunctionsRuntime, type FunctionsConfig } from "./functions.ts" import { detectPlatform, dockerHostAddress } from "./Platform.ts"; import type { PortLease } from "./PortAllocator.ts"; import { + activationReadinessPolicy, activationTargetsForService, eagerServices, lifecycleTargetsForService, @@ -58,11 +59,19 @@ type LifecyclePhase = type StackService = typeof Stack.Service; +/** Private signal used by the Promise adapter to close its enclosing managed runtime. */ +export class LocalStackLifecycle extends Context.Service< + LocalStackLifecycle, + { + readonly awaitDisposed: Effect.Effect; + readonly isDisposed: Effect.Effect; + } +>()("stack/LocalStackLifecycle") {} + interface RuntimeState { readonly orchestrator: Orchestrator["Service"]; readonly graph: ResolvedGraph; readonly serviceProjection: StackServiceProjectionCatalog; - readonly cleanupTargets: CleanupTargets; } const initialPublicStates = (config: ResolvedStackConfig): ReadonlyArray => @@ -147,7 +156,7 @@ export const localStackLayer = ( config: ResolvedStackConfig, portLease: PortLease, ): Layer.Layer< - Stack | StackServiceActivator, + Stack | StackServiceActivator | LocalStackLifecycle, StackBuildError, | StackBuilder | StackPreparation @@ -170,6 +179,7 @@ export const localStackLayer = ( const enabledServices = enabledServicesForConfig(config); const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); const phaseRef = yield* Ref.make("idle"); + const disposedSignal = yield* Deferred.make(); const lifecycleLock = Semaphore.makeUnsafe(1); const projectionLock = Semaphore.makeUnsafe(1); @@ -223,6 +233,7 @@ export const localStackLayer = ( let prepareDeferred: Deferred.Deferred | undefined; let runtimeState: RuntimeState | undefined; let runtimeDeferred: Deferred.Deferred | undefined; + let exactCleanupTargets: CleanupTargets | undefined; const ensurePrepared = Effect.suspend(() => { if (preparedArtifacts !== undefined) { @@ -337,6 +348,7 @@ export const localStackLayer = ( config, prepared, ); + exactCleanupTargets = cleanupTargets; yield* metadataPersistence.persistCleanupTargets(cleanupTargets).pipe( Effect.mapError( @@ -366,7 +378,6 @@ export const localStackLayer = ( orchestrator, graph, serviceProjection, - cleanupTargets, } satisfies RuntimeState; }).pipe( Effect.tap((value) => @@ -596,14 +607,17 @@ export const localStackLayer = ( yield* cleanupLocalStackResources({ stop: () => runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), - cleanupTargets: runtimeState?.cleanupTargets ?? { dockerContainerNames: [] }, + cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, config, }).pipe( Effect.ensuring(portLease.releaseAll), Effect.ensuring(Ref.set(phaseRef, "disposed")), ); }).pipe(withLifecycleLock); - }).pipe(Effect.uninterruptible); + }).pipe( + Effect.ensuring(Deferred.succeed(disposedSignal, undefined).pipe(Effect.asVoid)), + Effect.uninterruptible, + ); const withReadinessPolicy = ( effect: Effect.Effect, @@ -639,7 +653,6 @@ export const localStackLayer = ( disposeOnce().pipe(Effect.andThen(Effect.fail(error))), ), ); - yield* Effect.addFinalizer(disposeOnce); const activateService = (name: ServiceName) => @@ -654,7 +667,13 @@ export const localStackLayer = ( return; } if (existing !== undefined) { - yield* waitForTargets(existing).pipe((effect) => withReadinessPolicy(effect, name)); + yield* waitForTargets(existing).pipe((effect) => + withReadinessPolicy( + effect, + name, + activationReadinessPolicy(service, config.readiness, config.readinessSource), + ), + ); return; } const started = yield* Effect.gen(function* () { @@ -663,17 +682,25 @@ export const localStackLayer = ( if (concurrentlyStarted !== undefined) return concurrentlyStarted; return yield* beginStartTargets(service, new Set()); }).pipe(withLifecycleLock); - yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + yield* waitForTargets(started).pipe((effect) => + withReadinessPolicy( + effect, + name, + activationReadinessPolicy(service, config.readiness, config.readinessSource), + ), + ); }).pipe(cleanupOnReadinessFailure); const stack = { getInfo: () => Effect.succeed(info), - start: () => - Effect.gen(function* () { + start: () => { + let serviceStartupBegan = false; + return Effect.gen(function* () { yield* requireMutable("start"); yield* Ref.set(phaseRef, "starting"); const runtime = yield* ensureRuntime; yield* configureFunctions(config); + serviceStartupBegan = true; if (config.startupMode === "lazy") { const readiness: Array> = []; @@ -718,8 +745,9 @@ export const localStackLayer = ( }).pipe( Effect.onError(() => Ref.set(phaseRef, "stopped")), withLifecycleLock, - cleanupOnReadinessFailure, - ), + Effect.onError(() => (serviceStartupBegan ? disposeOnce() : Effect.void)), + ); + }, stop: () => Effect.gen(function* () { if (disposed) { @@ -889,6 +917,10 @@ export const localStackLayer = ( return Context.make(Stack, stack).pipe( Context.add(StackServiceActivator, { activate: activateService }), + Context.add(LocalStackLifecycle, { + awaitDisposed: Deferred.await(disposedSignal), + isDisposed: Effect.sync(() => disposed), + }), ); }), ); diff --git a/packages/stack/src/ServiceActivation.ts b/packages/stack/src/ServiceActivation.ts index 27f055eae2..f61281abf4 100644 --- a/packages/stack/src/ServiceActivation.ts +++ b/packages/stack/src/ServiceActivation.ts @@ -5,6 +5,7 @@ import { StackBuildError, StackNotRunningError, StackReadinessError } from "./er import { stackServiceStartupBudgetSeconds } from "./services/health-budgets.ts"; import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; +import type { ReadinessPolicy } from "./StackConfig.ts"; export const eagerServices = (enabled: ReadonlyArray): ReadonlyArray => enabled.filter((service) => serviceMetadata(service).activation.startup === "eager"); @@ -46,6 +47,19 @@ export const activationTimeoutSecondsForService = (service: ServiceName): number ); }; +/** + * The package default expands for services whose transitive startup budget is + * longer than three minutes. An explicit stack policy remains authoritative. + */ +export const activationReadinessPolicy = ( + service: ServiceName, + stackPolicy: ReadinessPolicy, + source: "default" | "configured", +): ReadinessPolicy => + source === "default" + ? { mode: "finite", timeoutMs: activationTimeoutSecondsForService(service) * 1000 } + : stackPolicy; + /** Services exclusively owned by a public service for stop/restart operations. */ export const lifecycleTargetsForService = ( enabledServices: ReadonlyArray, diff --git a/packages/stack/src/ServiceActivation.unit.test.ts b/packages/stack/src/ServiceActivation.unit.test.ts index 7b8a09de96..0f28d4d6f2 100644 --- a/packages/stack/src/ServiceActivation.unit.test.ts +++ b/packages/stack/src/ServiceActivation.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + activationReadinessPolicy, activationTimeoutSecondsForService, activationTargetsForService, eagerServices, @@ -50,6 +51,18 @@ describe("service activation", () => { expect(activationTimeoutSecondsForService("studio")).toBe(825); }); + it("expands only the package-default activation deadline", () => { + expect( + activationReadinessPolicy("analytics", { mode: "finite", timeoutMs: 180_000 }, "default"), + ).toEqual({ mode: "finite", timeoutMs: 554_000 }); + expect( + activationReadinessPolicy("analytics", { mode: "finite", timeoutMs: 10_000 }, "configured"), + ).toEqual({ mode: "finite", timeoutMs: 10_000 }); + expect(activationReadinessPolicy("analytics", { mode: "infinite" }, "configured")).toEqual({ + mode: "infinite", + }); + }); + it("does not assign shared public dependencies to their consumers", () => { expect(lifecycleTargetsForService(SERVICE_NAMES, "storage")).toEqual(["storage", "imgproxy"]); expect(lifecycleTargetsForService(SERVICE_NAMES, "analytics")).toEqual(["analytics", "vector"]); diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index c52918dedb..baff8e3260 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; +import { StackBuildError } from "./errors.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; import type { AllocatedPorts, PortField, PortLease } from "./PortAllocator.ts"; import { StackServiceActivator } from "./ServiceActivation.ts"; @@ -46,6 +48,7 @@ const defaultConfig: ResolvedStackConfig = { mode: "native", startupMode: "eager", readiness: DEFAULT_STACK_READINESS_POLICY, + readinessSource: "default", jwtSecret: testJwtSecret, ports: defaultPorts, apiPort: 54321, @@ -343,6 +346,7 @@ describe("Stack", () => { postgrest: false, auth: false, readiness: { mode: "finite", timeoutMs: 250 }, + readinessSource: "configured", } satisfies ResolvedStackConfig; const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( @@ -438,6 +442,109 @@ describe("Stack", () => { }).pipe(Effect.provide(providedLayer)); }); + it.live("can retry start after a build failure before services start", () => { + let buildAttempts = 0; + const graph = Effect.runSync( + buildGraph([{ name: "postgres", command: "true", restart: "no" }]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.suspend(() => { + buildAttempts += 1; + return buildAttempts === 1 + ? Effect.fail(new StackBuildError({ detail: "transient build failure" })) + : Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" }], + ]), + }); + }), + }); + const resolver = mockBinaryResolver(); + const spawner = mockChildProcessSpawner(); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(spawner.layer), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + + expect(Exit.isFailure(yield* stack.start().pipe(Effect.exit))).toBe(true); + yield* stack.start(); + + expect(buildAttempts).toBe(2); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("a partial startup failure disposes resources from services already started", () => { + let cleaned = false; + const spawner = mockChildProcessSpawner({ + beforeSpawn: (record) => + record.command === "fail" ? Effect.die("simulated spawn failure") : Effect.void, + }); + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "no", + cleanup: Effect.sync(() => { + cleaned = true; + }), + }, + { + name: "postgrest", + command: "fail", + dependencies: [{ service: "postgres", condition: "started" }], + restart: "no", + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" }], + ["postgrest", { visibility: "public" }], + ]), + }), + }); + const resolver = mockBinaryResolver(); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer( + { + ...defaultConfig, + readiness: { mode: "finite", timeoutMs: 1_000 }, + readinessSource: "configured", + }, + noopPortLease(defaultConfig.ports), + ).pipe( + Layer.provide(builderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(spawner.layer), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + const exit = yield* stack.start().pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(cleaned).toBe(true); + expect(spawner.killed).toContain("SIGTERM"); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + it.live("lazy startup starts direct services without starting HTTP backends", () => { const { layer, spawner } = setupLayer({ ...defaultConfig, startupMode: "lazy" }); @@ -677,6 +784,7 @@ describe("Stack", () => { ...defaultConfig, startupMode: "lazy", readiness: { mode: "finite", timeoutMs: 500 }, + readinessSource: "configured", } satisfies ResolvedStackConfig; const lease: PortLease = { ...noopPortLease(config.ports), @@ -735,6 +843,7 @@ describe("Stack", () => { ...defaultConfig, startupMode: "lazy", readiness: { mode: "infinite" }, + readinessSource: "configured", } satisfies ResolvedStackConfig; const lease: PortLease = { ...noopPortLease(config.ports), diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 9cc12215d9..924aaecab7 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -1,16 +1,11 @@ import { buildGraph } from "@supabase/process-compose"; import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; import { Effect, Layer, Context } from "effect"; -import type { CleanupTargets } from "./CleanupTargets.ts"; +import { dockerContainerName, type CleanupTargets } from "./CleanupTargets.ts"; import { StackBuildError } from "./errors.ts"; import { generateJwks } from "./JwtGenerator.ts"; -import { - detectPlatform, - dockerHostAddress, - dockerNetworkArgs, - dockerPortMapArgs, -} from "./Platform.ts"; -import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./services/analytics.ts"; +import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { makeEdgeRuntimeServiceDocker, @@ -19,7 +14,7 @@ import { import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; import { makeMailpitServiceDocker } from "./services/mailpit.ts"; import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; -import { makePoolerServiceDocker, poolerContainerPorts } from "./services/pooler.ts"; +import { makePoolerServiceDocker } from "./services/pooler.ts"; import { makePostgresInitService } from "./services/postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; @@ -90,8 +85,6 @@ const publicServiceProjection = ( return serviceProjection; }; -const dockerContainerName = (service: string, apiPort: number) => `supabase-${service}-${apiPort}`; - const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => config.autoManagedPaths.some( (managedPath) => @@ -271,16 +264,18 @@ export class StackBuilder extends Context.Service< port: config.dbPort, dockerAccessible: needsDockerAccess, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], }) : makePostgresServiceDocker({ image: postgresResolution.image, dataDir: config.postgres.dataDir, port: config.dbPort, - networkArgs: dockerNetworkArgs(platform.os, [config.dbPort]), + platformOs: platform.os, jwtSecret: config.jwtSecret, jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, apiPort: config.apiPort, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], })), enabled: true, }, @@ -292,6 +287,7 @@ export class StackBuilder extends Context.Service< postgresDir: postgresResolution.path, dbPort: config.dbPort, autoExposeNewTables: config.postgres.autoExposeNewTables, + dependencies: [{ service: "postgres", condition: "healthy" }], }), dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, enabled: true, @@ -309,6 +305,7 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, + dependencies: postgresDeps, }) : makePostgrestServiceDocker({ image: postgrestResolution.image, @@ -320,17 +317,10 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, - networkArgs: dockerNetworkArgs(platform.os, [ - config.postgrest.port, - config.postgrest.adminPort, - ]), + platformOs: platform.os, apiPort: config.apiPort, + dependencies: postgresDeps, })), - ...(hasPostgresInit - ? {} - : { - dependencies: [{ service: "postgres", condition: "healthy" as const }], - }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); @@ -366,7 +356,7 @@ export class StackBuilder extends Context.Service< smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, - networkArgs: dockerNetworkArgs(platform.os, [config.auth.port]), + platformOs: platform.os, apiPort: config.apiPort, dependencies: postgresDeps, })), @@ -396,7 +386,7 @@ export class StackBuilder extends Context.Service< inspectorPort: config.edgeRuntime.inspectorPort, policy: config.edgeRuntime.policy, env: config.edgeRuntime.env, - networkArgs: dockerNetworkArgs(platform.os, [config.edgeRuntime.port]), + platformOs: platform.os, dependencies: postgresDeps, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -413,11 +403,8 @@ export class StackBuilder extends Context.Service< webPort: config.mailpit.port, smtpPort: config.mailpit.smtpPort, pop3Port: config.mailpit.pop3Port, - networkArgs: dockerNetworkArgs(platform.os, [ - config.mailpit.port, - config.mailpit.smtpPort, - config.mailpit.pop3Port, - ]), + platformOs: platform.os, + dependencies: [], }), enabled: true, }); @@ -438,7 +425,7 @@ export class StackBuilder extends Context.Service< encryptionKey: config.realtime.encryptionKey, secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, - networkArgs: dockerNetworkArgs(platform.os, [config.realtime.port]), + platformOs: platform.os, dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -465,7 +452,7 @@ export class StackBuilder extends Context.Service< imgproxyUrl: config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", s3ProtocolEnabled: config.storage.s3ProtocolEnabled, - networkArgs: dockerNetworkArgs(platform.os, [config.storage.port]), + platformOs: platform.os, dependencies: postgresDeps, cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), }), @@ -483,7 +470,7 @@ export class StackBuilder extends Context.Service< port: config.imgproxy.port, apiPort: config.apiPort, dataDir: storageConfig === false ? "" : storageConfig.dataDir, - networkArgs: dockerNetworkArgs(platform.os, [config.imgproxy.port]), + platformOs: platform.os, dependencies: [{ service: "storage", condition: "healthy" }], }), dependencyTimeoutSeconds: storageDependencyTimeoutSeconds, @@ -500,7 +487,7 @@ export class StackBuilder extends Context.Service< port: config.pgmeta.port, dbHost: serviceHost, dbPort: config.dbPort, - networkArgs: dockerNetworkArgs(platform.os, [config.pgmeta.port]), + platformOs: platform.os, dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -510,25 +497,16 @@ export class StackBuilder extends Context.Service< if (config.analytics !== false) { const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); - const analyticsRuntimeNetwork = analyticsDockerRuntimeNetwork( - platform.os, - config.analytics.port, - serviceHost, - ); defs.push({ ...makeAnalyticsServiceDocker({ image: analyticsImage, apiPort: config.apiPort, hostPort: config.analytics.port, - listenPort: analyticsRuntimeNetwork.listenPort, - nodeHost: analyticsRuntimeNetwork.nodeHost, + platformOs: platform.os, dbHost: serviceHost, dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, - networkArgs: dockerPortMapArgs(platform.os, [ - { host: config.analytics.port, container: 4000 }, - ]), dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -546,7 +524,7 @@ export class StackBuilder extends Context.Service< serviceHost, analyticsPort: analyticsConfig === false ? 0 : analyticsConfig.port, analyticsApiKey: analyticsConfig === false ? "api-key" : analyticsConfig.apiKey, - networkArgs: dockerNetworkArgs(platform.os, []), + platformOs: platform.os, dependencies: [{ service: "analytics", condition: "healthy" }], }), dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, @@ -561,6 +539,8 @@ export class StackBuilder extends Context.Service< image: poolerImage, apiPort: config.apiPort, hostAdminPort: config.pooler.apiPort, + hostPort: config.pooler.port, + platformOs: platform.os, dbHost: serviceHost, dbPort: config.dbPort, poolMode: config.pooler.mode, @@ -570,19 +550,6 @@ export class StackBuilder extends Context.Service< tenantId: config.pooler.tenantId, encryptionKey: config.pooler.encryptionKey, secretKeyBase: config.pooler.secretKeyBase, - networkArgs: dockerPortMapArgs(platform.os, [ - { - host: config.pooler.apiPort, - container: poolerContainerPorts.admin, - }, - { - host: config.pooler.port, - container: - config.pooler.mode === "session" - ? poolerContainerPorts.session - : poolerContainerPorts.transaction, - }, - ]), dependencies: postgresDeps, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -611,7 +578,7 @@ export class StackBuilder extends Context.Service< analyticsUrl: config.analytics !== false ? `http://${serviceHost}:${config.analytics.port}` : "", analyticsApiKey: config.analytics !== false ? config.analytics.apiKey : "api-key", - networkArgs: dockerNetworkArgs(platform.os, [config.studio.port]), + platformOs: platform.os, dependencies: config.analytics === false ? [{ service: "pgmeta", condition: "healthy" }] @@ -625,9 +592,9 @@ export class StackBuilder extends Context.Service< }); } - const dockerContainerNames = defs - .filter((def) => def.command === "docker") - .map((def) => dockerContainerName(def.name, config.apiPort)); + const dockerContainerNames = SERVICE_NAMES.filter((service) => + defs.some((def) => def.name === service && def.command === "docker"), + ).map((service) => dockerContainerName(service, config.apiPort)); const graph = yield* buildGraph(defs).pipe( Effect.mapError( diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 3344b5f2e9..16c1fb17d2 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -3,6 +3,7 @@ import { Deferred, Effect, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { candidateCleanupTargets } from "./cleanup.ts"; import { StackBuilder } from "./StackBuilder.ts"; import type { BuildResult } from "./StackBuilder.ts"; import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; @@ -48,6 +49,7 @@ const baseConfig: ResolvedStackConfig = { mode: "auto", startupMode: "eager", readiness: DEFAULT_STACK_READINESS_POLICY, + readinessSource: "default", jwtSecret: testJwtSecret, ports: basePorts, apiPort: 3000, @@ -197,6 +199,11 @@ describe("StackBuilder", () => { expect(graph.startOrder.length).toBe(4); expect(cleanupTargets.dockerContainerNames).toEqual([]); + expect(candidateCleanupTargets(baseConfig).dockerContainerNames).toEqual([ + `supabase-postgres-${baseConfig.apiPort}`, + `supabase-postgrest-${baseConfig.apiPort}`, + `supabase-auth-${baseConfig.apiPort}`, + ]); const names = graph.startOrder.map((s) => s.name); expect(names).toContain("postgres"); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 77b23e0bec..2eb2e83cd1 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -27,7 +27,7 @@ export const ReadyOptionsSchema = Schema.Union([ export const inheritReadyOptions: ReadyOptions = { mode: "inherit" }; -/** Standalone stacks wait at most three minutes unless a caller or launch Adapter chooses otherwise. */ +/** Default readiness deadline; lazy activation expands it for longer transitive startup budgets. */ export const DEFAULT_STACK_READINESS_POLICY: ReadinessPolicy = { mode: "finite", timeoutMs: 180_000, @@ -281,6 +281,8 @@ export interface ResolvedStackConfig { readonly mode: StackMode; readonly startupMode: StackStartupMode; readonly readiness: ReadinessPolicy; + /** Whether readiness came from the package default or an explicit stack policy. */ + readonly readinessSource: "default" | "configured"; readonly jwtSecret: string; readonly ports: AllocatedPorts; readonly apiPort: number; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 88cd888abf..03d3bcac16 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -507,6 +507,7 @@ export async function resolveConfig( mode: resolvedMode, startupMode: config.startupMode ?? "eager", readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), + readinessSource: config.readiness === undefined ? "default" : "configured", jwtSecret, ports, apiPort: ports.apiPort, diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts index 7e43ecb6c6..419c405384 100644 --- a/packages/stack/src/cleanup.ts +++ b/packages/stack/src/cleanup.ts @@ -1,9 +1,17 @@ import { execFileSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { Duration, Effect } from "effect"; -import type { CleanupTargets } from "./CleanupTargets.ts"; +import { dockerContainerName, type CleanupTargets } from "./CleanupTargets.ts"; +import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; import type { ResolvedStackConfig } from "./StackConfig.ts"; +export const candidateCleanupTargets = (config: ResolvedStackConfig): CleanupTargets => ({ + dockerContainerNames: SERVICE_NAMES.filter((service) => { + const serviceConfig = config[serviceMetadata(service).configKey]; + return service === "postgres" || serviceConfig !== false; + }).map((service) => dockerContainerName(service, config.apiPort)), +}); + /** * Force-remove Docker containers by name. Best-effort safety net — * silently ignores containers that don't exist or are already removed. diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 6d06b9666f..658f26bace 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -3,12 +3,12 @@ import { Context, Effect, type Layer, ManagedRuntime, Stream } from "effect"; import { FileSystem, Path } from "effect"; import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; -import type { CleanupTargets } from "./CleanupTargets.ts"; import { ApiProxy } from "./ApiProxy.ts"; +import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; import { toStackError } from "./errors.ts"; import type { FunctionsConfig } from "./functions.ts"; import { daemonLayer, foregroundLayer, type DaemonStartError } from "./layers.ts"; +import { LocalStackLifecycle } from "./LocalStack.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; import { Stack } from "./Stack.ts"; @@ -34,16 +34,17 @@ export interface PlatformFactoryOptions { export type PlatformFactory = (options: PlatformFactoryOptions) => PlatformLayer; -/** @internal Converts foreground operation failures and closes the runtime after terminal timeouts. */ +/** @internal Converts operation failures and closes a terminal foreground runtime. */ export async function runForegroundOperation( operation: Promise, + isDisposed: () => Promise, dispose: () => Promise, ): Promise { try { return await operation; } catch (error: unknown) { const stackError = toStackError(error); - if (stackError.code === "STACK_READINESS_TIMEOUT") { + if (await isDisposed()) { await dispose(); } throw stackError; @@ -98,24 +99,6 @@ export const projectDaemonLayer = (opts: { opts.daemonEntryPoint, ); -function possibleCleanupTargetsForConfig(config: ResolvedStackConfig): CleanupTargets { - const dockerContainerNames = [`supabase-postgres-${config.apiPort}`]; - if (config.postgrest !== false) dockerContainerNames.push(`supabase-postgrest-${config.apiPort}`); - if (config.auth !== false) dockerContainerNames.push(`supabase-auth-${config.apiPort}`); - if (config.edgeRuntime !== false) - dockerContainerNames.push(`supabase-edge-runtime-${config.apiPort}`); - if (config.realtime !== false) dockerContainerNames.push(`supabase-realtime-${config.apiPort}`); - if (config.storage !== false) dockerContainerNames.push(`supabase-storage-${config.apiPort}`); - if (config.imgproxy !== false) dockerContainerNames.push(`supabase-imgproxy-${config.apiPort}`); - if (config.mailpit !== false) dockerContainerNames.push(`supabase-mailpit-${config.apiPort}`); - if (config.pgmeta !== false) dockerContainerNames.push(`supabase-pgmeta-${config.apiPort}`); - if (config.studio !== false) dockerContainerNames.push(`supabase-studio-${config.apiPort}`); - if (config.analytics !== false) dockerContainerNames.push(`supabase-analytics-${config.apiPort}`); - if (config.vector !== false) dockerContainerNames.push(`supabase-vector-${config.apiPort}`); - if (config.pooler !== false) dockerContainerNames.push(`supabase-pooler-${config.apiPort}`); - return { dockerContainerNames }; -} - export async function createStack( config: StackConfig | undefined, platformFactory: PlatformFactory, @@ -157,6 +140,7 @@ export async function createStack( const services = await runtime.context(); const localStack = Context.get(services, Stack); const apiProxy = Context.get(services, ApiProxy); + const lifecycle = Context.get(services, LocalStackLifecycle); const info = await runtime.runPromise(localStack.getInfo()); let disposal: Promise | undefined; @@ -165,12 +149,17 @@ export async function createStack( return disposal; }; const run = (effect: Effect.Effect) => - runForegroundOperation(runtime.runPromise(effect), gracefulDispose); - - // A terminal lazy-activation timeout disposes LocalStack. Close the - // foreground runtime as well so the public API port cannot outlive it. + runForegroundOperation( + runtime.runPromise(effect), + () => runtime.runPromise(lifecycle.isDisposed), + gracefulDispose, + ); + + // The HTTP module has no response-flushed hook. Give the proxy's final + // 503 response a brief opportunity to leave the socket before closing + // the runtime after terminal lazy activation. void runtime - .runPromise(apiProxy.awaitTerminalFailure) + .runPromise(apiProxy.awaitTerminalFailure.pipe(Effect.andThen(Effect.sleep("25 millis")))) .then(gracefulDispose) .catch(() => {}); @@ -205,7 +194,7 @@ export async function createStack( } } catch (error: unknown) { await Effect.runPromise(portLease.releaseAll); - dockerForceRemove(possibleCleanupTargetsForConfig(resolved).dockerContainerNames); + dockerForceRemove(candidateCleanupTargets(resolved).dockerContainerNames); cleanupAutoManagedPaths(resolved); throw toStackError(error); } diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 90516c1884..6200b53310 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { candidateCleanupTargets } from "./cleanup.ts"; +import { dockerContainerName } from "./CleanupTargets.ts"; import { runForegroundOperation, type StackHandle } from "./createStack.ts"; import { StackReadinessError } from "./errors.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; @@ -79,20 +81,43 @@ describe("foreground operation lifecycle", () => { ); await expect( - runForegroundOperation(operation, async () => { - disposeCount += 1; - }), + runForegroundOperation( + operation, + async () => true, + async () => { + disposeCount += 1; + }, + ), ).rejects.toMatchObject({ code: "STACK_READINESS_TIMEOUT" }); expect(disposeCount).toBe(1); }); + it("disposes the foreground runtime after another terminal start failure", async () => { + let disposeCount = 0; + + await expect( + runForegroundOperation( + Promise.reject(new Error("service startup failed")), + async () => true, + async () => { + disposeCount += 1; + }, + ), + ).rejects.toMatchObject({ code: "UNKNOWN" }); + expect(disposeCount).toBe(1); + }); + it("keeps the foreground runtime open after a non-terminal operation failure", async () => { let disposeCount = 0; await expect( - runForegroundOperation(Promise.reject(new Error("failed")), async () => { - disposeCount += 1; - }), + runForegroundOperation( + Promise.reject(new Error("failed")), + async () => false, + async () => { + disposeCount += 1; + }, + ), ).rejects.toMatchObject({ code: "UNKNOWN" }); expect(disposeCount).toBe(0); }); @@ -278,6 +303,32 @@ describe("resolveConfig edge runtime defaults", () => { }); }); +describe("candidateCleanupTargets", () => { + it("derives fallback Docker identities from enabled catalog services", async () => { + const config = await resolveConfig({ + mode: "docker", + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }); + + expect(candidateCleanupTargets(config)).toEqual({ + dockerContainerNames: [ + dockerContainerName("postgres", config.apiPort), + dockerContainerName("postgrest", config.apiPort), + ], + }); + }); +}); + describe("resolveConfig startup mode", () => { it("keeps eager startup as the package default", async () => { const config = await resolveConfig(); @@ -294,10 +345,12 @@ describe("resolveConfig readiness policy", () => { it("uses a finite package default", async () => { const config = await resolveConfig(); expect(config.readiness).toEqual({ mode: "finite", timeoutMs: 180_000 }); + expect(config.readinessSource).toBe("default"); }); it("preserves an explicit infinite policy", async () => { const config = await resolveConfig({ readiness: { mode: "infinite" } }); expect(config.readiness).toEqual({ mode: "infinite" }); + expect(config.readinessSource).toBe("configured"); }); }); diff --git a/packages/stack/src/daemon.ts b/packages/stack/src/daemon.ts index de7671d72e..a3e0c61464 100644 --- a/packages/stack/src/daemon.ts +++ b/packages/stack/src/daemon.ts @@ -7,6 +7,7 @@ import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; import { runningServiceVersionsForConfig } from "./StackMetadata.ts"; import { foregroundDaemonLayer } from "./layers.ts"; +import { LocalStackLifecycle } from "./LocalStack.ts"; import { Stack } from "./Stack.ts"; import { resolveDaemonConfig, type DaemonConfigInput } from "./StackConfigResolver.ts"; import { StateManager, type StackState } from "./StateManager.ts"; @@ -47,7 +48,9 @@ export async function runDaemon( const msg = await waitForMessage(); const { socketPath } = msg; - let appRuntime: ManagedRuntime.ManagedRuntime | undefined; + let appRuntime: + | ManagedRuntime.ManagedRuntime + | undefined; let daemonRuntime: ManagedRuntime.ManagedRuntime | undefined; let portLease: PortLease | undefined; @@ -79,7 +82,7 @@ export async function runDaemon( // Build the stack (services are started later via POST /start) const localStack = await localAppRuntime.runPromise(Stack); - const apiProxy = await localAppRuntime.runPromise(ApiProxy); + const localStackLifecycle = await localAppRuntime.runPromise(LocalStackLifecycle); const info = await localAppRuntime.runPromise(localStack.getInfo()); const localStateManager = await localAppRuntime.runPromise(StateManager); @@ -89,8 +92,9 @@ export async function runDaemon( Layer.provide(daemonServerFactory(socketPath)), ); - daemonRuntime = ManagedRuntime.make(daemonLayer); - await daemonRuntime.runPromise(DaemonServer); + const localDaemonRuntime = ManagedRuntime.make(daemonLayer); + daemonRuntime = localDaemonRuntime; + await localDaemonRuntime.runPromise(DaemonServer); // Claim live state before acknowledging startup to the parent. const state: StackState = { @@ -117,14 +121,19 @@ export async function runDaemon( process.send!(response); process.disconnect?.(); - const daemon = await daemonRuntime.runPromise(DaemonServer); - // A terminal activation deadline has already disposed the stack's scoped processes and port - // leases. Treat that as a whole-runtime failure: keeping the management or proxy servers alive - // would expose partially disposed state. This path deliberately does not drain unrelated - // in-flight proxy requests; callers should reconnect after starting a fresh daemon. + const daemon = await localDaemonRuntime.runPromise(DaemonServer); + // Any terminal stack disposal is a whole-runtime failure: keeping the management or proxy + // servers alive would expose disposed state. Route it through the server's delayed shutdown + // signal so the request that caused disposal can flush its typed response first. + const shutdownAfterLocalStackDisposal = localAppRuntime + .runPromise(localStackLifecycle.awaitDisposed) + .then(async () => { + await localDaemonRuntime.runPromise(daemon.beginShutdown); + await localDaemonRuntime.runPromise(daemon.awaitShutdown); + }); await Promise.race([ - daemonRuntime.runPromise(daemon.awaitShutdown), - localAppRuntime.runPromise(apiProxy.awaitTerminalFailure), + localDaemonRuntime.runPromise(daemon.awaitShutdown), + shutdownAfterLocalStackDisposal, waitForSignal(), ]); await shutdownDaemon({ appRuntime, daemonRuntime }); @@ -170,7 +179,10 @@ function waitForSignal(): Promise<"SIGINT" | "SIGTERM"> { } async function shutdownDaemon(opts: { - readonly appRuntime?: ManagedRuntime.ManagedRuntime; + readonly appRuntime?: ManagedRuntime.ManagedRuntime< + Stack | StateManager | ApiProxy | LocalStackLifecycle, + never + >; readonly daemonRuntime?: ManagedRuntime.ManagedRuntime; }): Promise { await opts.daemonRuntime?.dispose().catch(() => {}); diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index c0afe22dcb..be97567638 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -12,7 +12,7 @@ import { DaemonMessageSchema } from "./DaemonProtocol.ts"; import type { PortLease } from "./PortAllocator.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack } from "./Stack.ts"; -import { localStackLayer } from "./LocalStack.ts"; +import { LocalStackLifecycle, localStackLayer } from "./LocalStack.ts"; import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; import { StackPreparation } from "./StackPreparation.ts"; import { @@ -45,7 +45,7 @@ export const foregroundLayer = ( config: ResolvedStackConfig, platformFactory: PlatformFactory, portLease: PortLease, -): Layer.Layer => { +): Layer.Layer => { const platform = platformFactory({ apiPort: config.apiPort, releaseApiPort: portLease.release(["apiPort"]), @@ -102,7 +102,7 @@ export const foregroundDaemonLayer = ( config: ResolvedDaemonConfig, platformFactory: PlatformFactory, portLease: PortLease, -): Layer.Layer => { +): Layer.Layer => { const platform = platformFactory({ apiPort: config.apiPort, releaseApiPort: portLease.release(["apiPort"]), diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index e1ff634ed1..d34c86a519 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerPortMapArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -6,13 +7,11 @@ interface DockerAnalyticsOptions { readonly image: string; readonly apiPort: number; readonly hostPort: number; - readonly listenPort: number; - readonly nodeHost: string; + readonly platformOs: string; readonly dbHost: string; readonly dbPort: number; readonly apiKey: string; readonly backend: "postgres" | "bigquery"; - readonly networkArgs: ReadonlyArray; readonly dependencies: ReadonlyArray; } @@ -39,9 +38,10 @@ const analyticsHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ }); export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): ServiceDef => { + const runtimeNetwork = analyticsDockerRuntimeNetwork(opts.platformOs, opts.hostPort, opts.dbHost); const env: Record = { - PORT: String(opts.listenPort), - PHX_HTTP_PORT: String(opts.listenPort), + PORT: String(runtimeNetwork.listenPort), + PHX_HTTP_PORT: String(runtimeNetwork.listenPort), DB_DATABASE: "_supabase", DB_HOSTNAME: opts.dbHost, DB_PORT: String(opts.dbPort), @@ -53,7 +53,7 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic LOGFLARE_SUPABASE_MODE: "true", LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.apiKey, LOGFLARE_LOG_LEVEL: "warn", - LOGFLARE_NODE_HOST: opts.nodeHost, + LOGFLARE_NODE_HOST: runtimeNetwork.nodeHost, LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", RELEASE_COOKIE: "cookie", }; @@ -69,9 +69,11 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic return dockerRunService({ name: "analytics", - containerName: `supabase-analytics-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerPortMapArgs(opts.platformOs, [ + { host: opts.hostPort, container: ANALYTICS_CONTAINER_PORT }, + ]), entrypoint: "sh", cmd: [ "-c", @@ -84,7 +86,7 @@ EOF `, ], env, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: analyticsHealthCheck(opts.hostPort), }); }; diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index 355b8265c6..6e604695b6 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -1,6 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; interface AuthServiceOptions { readonly dbPort: number; @@ -13,10 +14,7 @@ interface AuthServiceOptions { readonly smtpPort?: number; readonly smtpAdminEmail?: string; readonly smtpSenderName?: string; - readonly dependencies: ReadonlyArray<{ - readonly service: string; - readonly condition: "healthy" | "completed"; - }>; + readonly dependencies: ReadonlyArray; } interface NativeAuthOptions extends AuthServiceOptions { @@ -26,7 +24,7 @@ interface NativeAuthOptions extends AuthServiceOptions { interface DockerAuthOptions extends AuthServiceOptions { readonly image: string; readonly dbHost: string; - readonly networkArgs: readonly string[]; + readonly platformOs: string; readonly apiPort: number; } @@ -82,17 +80,13 @@ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { const env = authEnv(opts, opts.dbHost); - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); - const containerName = `supabase-auth-${opts.apiPort}`; - - return { + return dockerRunService({ name: "auth", - command: "docker", - args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], + apiPort: opts.apiPort, + image: opts.image, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.authPort]), + env, dependencies: opts.dependencies, healthCheck: authHealthCheck(opts.authPort), - cleanup: dockerServiceCleanup(containerName), - supervision: { orphanCleanup: dockerServiceOrphanCleanup(containerName) }, - restart: "unless-stopped", - }; + }); }; diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 66f6d52b9d..bd3a35c66d 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -22,7 +23,7 @@ interface NativeEdgeRuntimeOptions extends EdgeRuntimeOptions { interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions { readonly image: string; readonly apiPort: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; } const bootstrapFileName = "index.ts"; @@ -85,9 +86,9 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se return dockerRunService({ name: "edge-runtime", - containerName: `supabase-edge-runtime-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [ `${bootstrapDir}:${bootstrapMountDir}:ro`, ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), @@ -98,7 +99,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, }, cmd: [...edgeRuntimeArgs(opts, bootstrapMountDir)], - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: edgeRuntimeHealthCheck(opts.port), }); }; diff --git a/packages/stack/src/services/imgproxy.ts b/packages/stack/src/services/imgproxy.ts index f38699f88e..a1526da08e 100644 --- a/packages/stack/src/services/imgproxy.ts +++ b/packages/stack/src/services/imgproxy.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -7,7 +8,7 @@ interface DockerImgproxyOptions { readonly port: number; readonly apiPort: number; readonly dataDir: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -21,9 +22,9 @@ const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => dockerRunService({ name: "imgproxy", - containerName: `supabase-imgproxy-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:${IMGPROXY_STORAGE_DIR}`], env: { IMGPROXY_BIND: `:${opts.port}`, @@ -36,6 +37,6 @@ export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceD IMGPROXY_PRESETS: "default=width:3000/height:8192", IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: imgproxyHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index e60f41a14e..edc8673e6a 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -1,5 +1,6 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerRunService, hostHttpHealthCheck } from "./service-utils.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; +import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; interface DockerMailpitOptions { @@ -8,7 +9,8 @@ interface DockerMailpitOptions { readonly webPort: number; readonly smtpPort: number; readonly pop3Port: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; + readonly dependencies: ReadonlyArray; } const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => @@ -19,9 +21,10 @@ const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef => dockerRunService({ name: "mailpit", - containerName: `supabase-mailpit-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.webPort, opts.smtpPort, opts.pop3Port]), + dependencies: opts.dependencies, env: { MP_UI_BIND_ADDR: `0.0.0.0:${opts.webPort}`, MP_SMTP_BIND_ADDR: `0.0.0.0:${opts.smtpPort}`, diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts index 67a258c6f2..26a9e26d7a 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -8,7 +9,7 @@ interface DockerPgmetaOptions { readonly port: number; readonly dbHost: string; readonly dbPort: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -26,9 +27,9 @@ const pgmetaHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef => dockerRunService({ name: "pgmeta", - containerName: `supabase-pgmeta-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), env: { PG_META_PORT: String(opts.port), PG_META_DB_HOST: opts.dbHost, @@ -37,6 +38,6 @@ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef = PG_META_DB_PORT: String(opts.dbPort), PG_META_DB_PASSWORD: "postgres", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: pgmetaHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts index 7d81e3edda..0091689aa5 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerPortMapArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -8,6 +9,7 @@ interface DockerPoolerOptions { readonly image: string; readonly apiPort: number; readonly hostAdminPort: number; + readonly hostPort: number; readonly dbHost: string; readonly dbPort: number; readonly poolMode: PoolMode; @@ -17,7 +19,7 @@ interface DockerPoolerOptions { readonly tenantId: string; readonly encryptionKey: string; readonly secretKeyBase: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -68,9 +70,18 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = (() => { return dockerRunService({ name: "pooler", - containerName: `supabase-pooler-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerPortMapArgs(opts.platformOs, [ + { host: opts.hostAdminPort, container: poolerContainerPorts.admin }, + { + host: opts.hostPort, + container: + opts.poolMode === "session" + ? poolerContainerPorts.session + : poolerContainerPorts.transaction, + }, + ]), env: { PORT: String(poolerContainerPorts.admin), PROXY_PORT_SESSION: String(poolerContainerPorts.session), @@ -91,7 +102,7 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = "-c", `/app/bin/migrate && /app/bin/supavisor eval '${tenantScript(opts)}' && /app/bin/server`, ], - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: poolerHealthCheck(opts.hostAdminPort), }); })(); diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 694951230d..63917352b1 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import type { ServiceDependency } from "./service-utils.ts"; interface PostgresInitOptions { readonly postgresDir: string; @@ -8,6 +9,7 @@ interface PostgresInitOptions { * Data API privileges on the `public` schema so newly-created entities require explicit GRANTs. */ readonly autoExposeNewTables: boolean; + readonly dependencies: ReadonlyArray; } /** @@ -136,7 +138,7 @@ END LD_LIBRARY_PATH: pgLibDir, PGPASSWORD: "postgres", }, - dependencies: [{ service: "postgres", condition: "healthy" }], + dependencies: opts.dependencies, supervision: {}, restart: "no", }; diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index 6968f989f8..296cc5d5bb 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -1,16 +1,20 @@ import { mkdirSync, writeFileSync } from "node:fs"; import type { ServiceDef } from "@supabase/process-compose"; -import { - dockerServiceCleanup, - dockerServiceOrphanCleanup, - removePathOnOrphanCleanup, -} from "./docker-cleanup.ts"; +import { dockerContainerName } from "../CleanupTargets.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; +import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import { + dockerExecHealthCheck, + dockerRunService, + type ServiceDependency, +} from "./service-utils.ts"; interface PostgresServiceOptions { readonly dataDir: string; readonly port: number; readonly cleanupDataDirOnExit?: boolean; + readonly dependencies: ReadonlyArray; } interface NativePostgresOptions extends PostgresServiceOptions { @@ -21,7 +25,7 @@ interface NativePostgresOptions extends PostgresServiceOptions { interface DockerPostgresOptions extends PostgresServiceOptions { readonly image: string; - readonly networkArgs: readonly string[]; + readonly platformOs: string; readonly jwtSecret: string; readonly jwtExpiry: number; readonly apiPort: number; @@ -101,14 +105,10 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ * queries with "unexpected EOF". We use `docker exec` to run pg_isready * inside the container, which verifies postgres is accepting commands. */ -const postgresDockerHealthCheck = (containerName: string, port: number) => ({ - probe: { - _tag: "Exec" as const, - command: "docker", - args: ["exec", containerName, "pg_isready", "-p", String(port), "-U", "postgres"], - }, - ...stackHealthBudgets.postgresDocker, -}); +const postgresDockerHealthCheck = (containerName: string, port: number) => + dockerExecHealthCheck(containerName, "pg_isready", ["-p", String(port), "-U", "postgres"], { + ...stackHealthBudgets.postgresDocker, + }); export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; @@ -146,6 +146,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => `hba_file=${customHbaPath}`, ], env: postgresEnv(opts), + dependencies: opts.dependencies, healthCheck: postgresHealthCheck(opts.binPath, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { @@ -163,6 +164,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => command: "bash", args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], env: postgresEnv(opts), + dependencies: opts.dependencies, healthCheck: postgresHealthCheck(opts.binPath, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { orphanCleanup: orphanCleanup(opts) }, @@ -172,33 +174,19 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { const env = postgresDockerEnv(opts); - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); - const containerName = `supabase-postgres-${opts.apiPort}`; - const dockerArgs = [ - "run", - "--rm", - "--name", - containerName, - ...opts.networkArgs, - "-v", - `${opts.dataDir}:/var/lib/postgresql/data`, - ...envArgs, - "--entrypoint", - "sh", - opts.image, - "-c", - dockerPostgresEntrypoint(opts.port), - ]; - return { + const containerName = dockerContainerName("postgres", opts.apiPort); + return dockerRunService({ name: "postgres", - command: "docker", - args: dockerArgs, + apiPort: opts.apiPort, + image: opts.image, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), + volumes: [`${opts.dataDir}:/var/lib/postgresql/data`], + env, + entrypoint: "sh", + cmd: ["-c", dockerPostgresEntrypoint(opts.port)], + dependencies: opts.dependencies, healthCheck: postgresDockerHealthCheck(containerName, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - cleanup: dockerServiceCleanup(containerName), - supervision: { - orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...orphanCleanup(opts)], - }, - restart: "unless-stopped", - }; + orphanCleanup: orphanCleanup(opts), + }); }; diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts index 0562277927..c8c7959de3 100644 --- a/packages/stack/src/services/postgrest.ts +++ b/packages/stack/src/services/postgrest.ts @@ -1,6 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; interface PostgrestServiceOptions { readonly dbPort: number; @@ -9,6 +10,7 @@ interface PostgrestServiceOptions { readonly extraSearchPath: ReadonlyArray; readonly maxRows: number; readonly jwtSecret: string; + readonly dependencies: ReadonlyArray; } interface NativePostgrestOptions extends PostgrestServiceOptions { @@ -18,7 +20,7 @@ interface NativePostgrestOptions extends PostgrestServiceOptions { interface DockerPostgrestOptions extends PostgrestServiceOptions { readonly image: string; readonly dbHost: string; - readonly networkArgs: readonly string[]; + readonly platformOs: string; readonly adminPort: number; readonly apiPort: number; } @@ -47,13 +49,11 @@ const postgrestHealthCheck = (port: number) => ({ ...stackHealthBudgets.postgrest, }); -const postgrestDependencies = [{ service: "postgres-init", condition: "completed" as const }]; - export const makePostgrestService = (opts: NativePostgrestOptions): ServiceDef => ({ name: "postgrest", command: `${opts.binPath}/postgrest`, env: postgrestEnv(opts), - dependencies: postgrestDependencies, + dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), supervision: {}, restart: "unless-stopped", @@ -64,17 +64,13 @@ export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): Servic ...postgrestEnv(opts, opts.dbHost), PGRST_ADMIN_SERVER_PORT: String(opts.adminPort), }; - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); - const containerName = `supabase-postgrest-${opts.apiPort}`; - - return { + return dockerRunService({ name: "postgrest", - command: "docker", - args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], - dependencies: postgrestDependencies, + apiPort: opts.apiPort, + image: opts.image, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port, opts.adminPort]), + env, + dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), - cleanup: dockerServiceCleanup(containerName), - supervision: { orphanCleanup: dockerServiceOrphanCleanup(containerName) }, - restart: "unless-stopped", - }; + }); }; diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index a8bd42eacd..68910a19e4 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -14,7 +15,7 @@ interface DockerRealtimeOptions { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -38,9 +39,9 @@ const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["health export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceDef => dockerRunService({ name: "realtime", - containerName: `supabase-realtime-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), env: { PORT: String(opts.port), DB_HOST: opts.dbHost, @@ -62,6 +63,6 @@ export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceD RUN_JANITOR: "true", MAX_HEADER_LENGTH: String(opts.maxHeaderLength), }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: realtimeHealthCheck(opts.port, opts.tenantId), }); diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts index 4259ed02f7..91a326eaea 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -1,4 +1,6 @@ import type { ExternalCleanupAction, ServiceDef } from "@supabase/process-compose"; +import { dockerContainerName } from "../CleanupTargets.ts"; +import type { ServiceName } from "../ServiceName.ts"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; export interface ServiceDependency { @@ -7,8 +9,8 @@ export interface ServiceDependency { } interface DockerRunServiceOptions { - readonly name: string; - readonly containerName: string; + readonly name: ServiceName; + readonly apiPort: number; readonly image: string; readonly networkArgs?: ReadonlyArray; readonly env?: Record; @@ -16,7 +18,7 @@ interface DockerRunServiceOptions { readonly cmd?: ReadonlyArray; readonly entrypoint?: string; readonly volumes?: ReadonlyArray; - readonly dependsOn?: ReadonlyArray; + readonly dependencies: ReadonlyArray; readonly healthCheck?: ServiceDef["healthCheck"]; readonly restart?: ServiceDef["restart"]; readonly shutdown?: ServiceDef["shutdown"]; @@ -56,11 +58,12 @@ export const dockerExecHealthCheck = ( }); export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { + const containerName = dockerContainerName(opts.name, opts.apiPort); const dockerArgs = [ "run", "--rm", "--name", - opts.containerName, + containerName, ...(opts.networkArgs ?? []), ...(opts.volumes ?? []).flatMap((volume) => ["-v", volume]), ...(opts.entrypoint === undefined ? [] : ["--entrypoint", opts.entrypoint]), @@ -74,15 +77,12 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { name: opts.name, command: "docker", args: dockerArgs, - dependencies: opts.dependsOn, + dependencies: opts.dependencies, healthCheck: opts.healthCheck, shutdown: opts.shutdown, - cleanup: dockerServiceCleanup(opts.containerName), + cleanup: dockerServiceCleanup(containerName), supervision: { - orphanCleanup: [ - ...dockerServiceOrphanCleanup(opts.containerName), - ...(opts.orphanCleanup ?? []), - ], + orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...(opts.orphanCleanup ?? [])], }, restart: opts.restart ?? "unless-stopped", }; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 7c4d50c1a1..22f8730c4c 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -7,14 +7,20 @@ import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; +import { makePgmetaServiceDocker } from "./pgmeta.ts"; import { makePostgresInitService, REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL, } from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; -import { makePostgrestService } from "./postgrest.ts"; +import { makePostgrestService, makePostgrestServiceDocker } from "./postgrest.ts"; +import { makeRealtimeServiceDocker } from "./realtime.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; -import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET } from "./storage.ts"; +import { + LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + makeStorageServiceDocker, +} from "./storage.ts"; import { makeStudioServiceDocker } from "./studio.ts"; import { makeVectorServiceDocker } from "./vector.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; @@ -26,14 +32,13 @@ const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-a const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; const AUTH_BIN_PATH = `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`; const EDGE_RUNTIME_BIN_PATH = `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`; -const LINUX_HOST_GATEWAY_ARGS = ["--add-host", "host.docker.internal:host-gateway"]; - describe("makePostgresService", () => { it("creates a postgres ServiceDef with correct defaults", () => { const def = makePostgresService({ binPath: POSTGRES_BIN_PATH, dataDir: "/tmp/supabase/data", port: DB_PORT, + dependencies: [], }); expect(def.name).toBe("postgres"); @@ -61,7 +66,7 @@ describe("makePostgresService", () => { LD_LIBRARY_PATH: `${POSTGRES_BIN_PATH}/lib`, }, }); - expect(def.dependencies).toBeUndefined(); + expect(def.dependencies).toEqual([]); expect(def.restart).toBe("unless-stopped"); expect(def.supervision).toBeDefined(); }); @@ -101,7 +106,7 @@ describe("makeStudioServiceDocker", () => { analyticsBackend: "postgres", analyticsUrl: "http://host.docker.internal:54327", analyticsApiKey: "test-api-key", - networkArgs: ["-p", "54323:54323"], + platformOs: "darwin", dependencies: [{ service: "pgmeta", condition: "healthy" }], }); @@ -125,6 +130,7 @@ describe("makePostgresService (dockerAccessible)", () => { port: DB_PORT, dockerAccessible: true, cleanupDataDirOnExit: true, + dependencies: [], }); const customHbaPath = `${path.join(tempDir, "data")}_pg_hba_docker.conf`; @@ -166,10 +172,11 @@ describe("makePostgresServiceDocker", () => { image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", `${DB_PORT}:${DB_PORT}`], + platformOs: "linux", jwtSecret: "test-jwt-secret-with-at-least-32-characters", jwtExpiry: 3600, apiPort: API_PORT, + dependencies: [], }); expect(def.name).toBe("postgres"); @@ -197,7 +204,7 @@ describe("makePostgresServiceDocker", () => { "postgres", ], }); - expect(def.dependencies).toBeUndefined(); + expect(def.dependencies).toEqual([]); expect(def.restart).toBe("unless-stopped"); expect(def.supervision).toEqual({ orphanCleanup: [ @@ -216,10 +223,11 @@ describe("makePostgresServiceDocker", () => { image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", `${DB_PORT}:${DB_PORT}`], + platformOs: "linux", jwtSecret: "test-jwt-secret-with-at-least-32-characters", jwtExpiry: 3600, apiPort: API_PORT, + dependencies: [], }); const script = def.args?.[def.args.length - 1] as string; @@ -243,6 +251,7 @@ describe("makePostgrestService", () => { extraSearchPath: ["public", "extensions"], maxRows: 1000, jwtSecret: JWT_SECRET, + dependencies: [{ service: "postgres-init", condition: "completed" }], }); expect(def.name).toBe("postgrest"); @@ -263,6 +272,38 @@ describe("makePostgrestService", () => { }); expect(def.supervision).toBeDefined(); }); + + it("creates a docker definition with caller-supplied topology and derived identity", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makePostgrestServiceDocker({ + image: dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest), + apiPort: API_PORT, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + port: 54323, + adminPort: 54324, + schemas: ["public", "storage"], + extraSearchPath: ["public", "extensions"], + maxRows: 1000, + jwtSecret: JWT_SECRET, + platformOs: "linux", + dependencies, + }); + + expect(def.command).toBe("docker"); + expect(def.args).toContain(`supabase-postgrest-${API_PORT}`); + expect(def.args).toContain("host.docker.internal:host-gateway"); + expect(def.args).toContain("54323:54323"); + expect(def.args).toContain("54324:54324"); + expect(def.args).toContain("PGRST_ADMIN_SERVER_PORT=54324"); + expect(def.dependencies).toEqual(dependencies); + expect(def.supervision?.orphanCleanup).toContainEqual({ + _tag: "RunCommand", + executable: "docker", + args: ["rm", "-f", `supabase-postgrest-${API_PORT}`], + timeoutMs: 5_000, + }); + }); }); describe("makeAuthServiceNative", () => { @@ -306,7 +347,7 @@ describe("makeAuthServiceDocker", () => { jwtExpiry: 3600, externalUrl: `http://127.0.0.1:${API_PORT}`, dbHost: "127.0.0.1", - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "9999:9999"], + platformOs: "linux", apiPort: API_PORT, dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -345,7 +386,7 @@ describe("makeEdgeRuntimeServiceDocker", () => { inspectorPort: 54341, policy: "per_worker", env: { SUPABASE_INTERNAL_DEBUG: "true" }, - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54340:54340", "-p", "54341:54341"], + platformOs: "linux", dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -423,6 +464,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); expect(def.name).toBe("postgres-init"); @@ -441,6 +483,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).not.toContain("set -e"); @@ -451,6 +494,7 @@ describe("makePostgresInitService", () => { postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).toContain("authenticator"); @@ -462,6 +506,7 @@ describe("makePostgresInitService", () => { postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; @@ -477,6 +522,7 @@ describe("makePostgresInitService", () => { postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).not.toMatch(/sh .+migrate\.sh/); @@ -490,6 +536,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: true, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).not.toContain("alter default privileges"); @@ -501,6 +548,7 @@ describe("makePostgresInitService", () => { postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, autoExposeNewTables: false, + dependencies: [{ service: "postgres", condition: "healthy" }], }); const script = def.args?.[1] as string; expect(script).toContain(REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL); @@ -515,6 +563,90 @@ describe("makePostgresInitService", () => { }); describe("docker-backed auxiliary services", () => { + it("defines realtime command, topology, environment, and readiness locally", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makeRealtimeServiceDocker({ + image: dockerImageForService("realtime", DEFAULT_VERSIONS.realtime), + apiPort: API_PORT, + port: 54330, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + jwtSecret: JWT_SECRET, + jwtJwks: "test-jwks", + tenantId: "realtime-dev", + encryptionKey: "supabaserealtime", + secretKeyBase: "test-secret-key-base", + maxHeaderLength: 4096, + platformOs: "linux", + dependencies, + }); + + expect(def.args).toContain(`supabase-realtime-${API_PORT}`); + expect(def.args).toContain("54330:54330"); + expect(def.args).toContain("DB_HOST=host.docker.internal"); + expect(def.dependencies).toEqual(dependencies); + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Exec", command: "curl" }), + ); + }); + + it("defines storage mounts, cleanup, topology, and readiness locally", () => { + const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; + const def = makeStorageServiceDocker({ + image: dockerImageForService("storage", DEFAULT_VERSIONS.storage), + apiPort: API_PORT, + port: 54331, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + dataDir: "/tmp/supabase/storage", + anonKey: "anon-key", + serviceKey: "service-key", + jwtSecret: JWT_SECRET, + jwtJwks: "test-jwks", + fileSizeLimit: "50MiB", + enableImageTransformation: true, + imgproxyUrl: "http://host.docker.internal:54332", + s3ProtocolEnabled: true, + cleanupDataDirOnExit: true, + platformOs: "linux", + dependencies, + }); + + expect(def.args).toContain(`supabase-storage-${API_PORT}`); + expect(def.args).toContain("/tmp/supabase/storage:/var/lib/storage"); + expect(def.args).toContain("54331:54331"); + expect(def.dependencies).toEqual(dependencies); + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Http", port: 54331, path: "/status" }), + ); + expect(def.supervision?.orphanCleanup).toContainEqual({ + _tag: "RemovePath", + path: "/tmp/supabase/storage", + recursive: true, + }); + }); + + it("defines postgres metadata command, topology, environment, and readiness locally", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makePgmetaServiceDocker({ + image: dockerImageForService("pgmeta", DEFAULT_VERSIONS.pgmeta), + apiPort: API_PORT, + port: 54336, + dbHost: "host.docker.internal", + dbPort: DB_PORT, + platformOs: "linux", + dependencies, + }); + + expect(def.args).toContain(`supabase-pgmeta-${API_PORT}`); + expect(def.args).toContain("54336:54336"); + expect(def.args).toContain("PG_META_DB_HOST=host.docker.internal"); + expect(def.dependencies).toEqual(dependencies); + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Http", port: 54336, path: "/health" }), + ); + }); + it("uses a host HTTP readiness probe for mailpit", () => { const def = makeMailpitServiceDocker({ image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), @@ -522,15 +654,8 @@ describe("docker-backed auxiliary services", () => { webPort: 54323, smtpPort: 54324, pop3Port: 54325, - networkArgs: [ - ...LINUX_HOST_GATEWAY_ARGS, - "-p", - "54323:54323", - "-p", - "54324:54324", - "-p", - "54325:54325", - ], + platformOs: "linux", + dependencies: [], }); expect(def.healthCheck?.probe).toEqual({ @@ -548,7 +673,7 @@ describe("docker-backed auxiliary services", () => { apiPort: API_PORT, port: 54326, dataDir: "/tmp/supabase/storage", - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54326:54326"], + platformOs: "linux", dependencies: [{ service: "storage", condition: "healthy" }], }); @@ -569,7 +694,7 @@ describe("docker-backed auxiliary services", () => { serviceHost: "127.0.0.1", analyticsPort: 54327, analyticsApiKey: "test-api-key", - networkArgs: [], + platformOs: "darwin", dependencies: [{ service: "analytics", condition: "healthy" }], }); @@ -591,13 +716,11 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), apiPort: API_PORT, hostPort: 54328, - listenPort: 4000, - nodeHost: "0.0.0.0", + platformOs: "darwin", dbHost: "127.0.0.1", dbPort: DB_PORT, apiKey: "test-api-key", backend: "postgres", - networkArgs: ["-p", "54328:4000"], dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -624,13 +747,11 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), apiPort: API_PORT, hostPort: 54328, - listenPort: 4000, - nodeHost: "0.0.0.0", + platformOs: "linux", dbHost: "host.docker.internal", dbPort: DB_PORT, apiKey: "test-api-key", backend: "postgres", - networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54328:4000"], dependencies: [{ service: "postgres", condition: "healthy" }], }); @@ -646,6 +767,8 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("pooler", DEFAULT_VERSIONS.pooler), apiPort: API_PORT, hostAdminPort: 54329, + hostPort: 54330, + platformOs: "linux", dbHost: "127.0.0.1", dbPort: DB_PORT, poolMode: "transaction", @@ -655,12 +778,6 @@ describe("docker-backed auxiliary services", () => { tenantId: "pooler-dev", encryptionKey: "12345678901234567890123456789012", secretKeyBase: "1234567890123456789012345678901234567890123456789012345678901234", - networkArgs: [ - "-p", - `54329:${poolerContainerPorts.admin}`, - "-p", - `54330:${poolerContainerPorts.transaction}`, - ], dependencies: [{ service: "postgres", condition: "healthy" }], }); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index 87e21a6ab9..cf6f88e33a 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -18,7 +19,7 @@ interface DockerStorageOptions { readonly enableImageTransformation: boolean; readonly imgproxyUrl: string; readonly s3ProtocolEnabled: boolean; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; readonly cleanupDataDirOnExit?: boolean; } @@ -45,9 +46,9 @@ const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => dockerRunService({ name: "storage", - containerName: `supabase-storage-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:${STORAGE_DATA_DIR}`], env: { PORT: String(opts.port), @@ -75,7 +76,7 @@ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: storageHealthCheck(opts.port), orphanCleanup: orphanCleanup(opts), }); diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index 4b139abc96..f84c24c6a8 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -18,7 +19,7 @@ interface DockerStudioOptions { readonly analyticsBackend: "postgres" | "bigquery"; readonly analyticsUrl: string; readonly analyticsApiKey: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -36,9 +37,9 @@ const studioHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef => dockerRunService({ name: "studio", - containerName: `supabase-studio-${opts.apiPort}`, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), env: { PORT: String(opts.port), CURRENT_CLI_VERSION: "local", @@ -64,6 +65,6 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", PGRST_DB_MAX_ROWS: "1000", }, - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: studioHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts index 90aa557f04..c7b41c700a 100644 --- a/packages/stack/src/services/vector.ts +++ b/packages/stack/src/services/vector.ts @@ -1,4 +1,6 @@ import { existsSync } from "node:fs"; +import { dockerContainerName } from "../CleanupTargets.ts"; +import { dockerNetworkArgs } from "../Platform.ts"; import { dockerExecHealthCheck, dockerRunService, @@ -12,7 +14,7 @@ interface DockerVectorOptions { readonly serviceHost: string; readonly analyticsPort: number; readonly analyticsApiKey: string; - readonly networkArgs: ReadonlyArray; + readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -40,7 +42,7 @@ sinks: `; export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { - const containerName = `supabase-vector-${opts.apiPort}`; + const containerName = dockerContainerName("vector", opts.apiPort); const dockerSocket = process.env.DOCKER_HOST?.startsWith("unix://") ? process.env.DOCKER_HOST.slice("unix://".length) : "/var/run/docker.sock"; @@ -48,9 +50,9 @@ export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { return dockerRunService({ name: "vector", - containerName, + apiPort: opts.apiPort, image: opts.image, - networkArgs: opts.networkArgs, + networkArgs: dockerNetworkArgs(opts.platformOs, []), volumes, env: { DOCKER_HOST: "unix:///var/run/docker.sock", @@ -62,7 +64,7 @@ export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { ${VECTOR_CONFIG(opts.serviceHost, opts.analyticsPort, opts.analyticsApiKey)}EOF `, ], - dependsOn: opts.dependencies, + dependencies: opts.dependencies, healthCheck: dockerExecHealthCheck( containerName, "sh",