diff --git a/packages/stack/src/runtime/ContainerName.ts b/packages/stack/src/runtime/ContainerName.ts index 13c845ef42..a506ac8a27 100644 --- a/packages/stack/src/runtime/ContainerName.ts +++ b/packages/stack/src/runtime/ContainerName.ts @@ -15,6 +15,14 @@ const composeSegment = (value: string): string => .replaceAll(/[^a-z0-9_-]+/gu, "-") .slice(0, 40); +/** Groups a stack's containers, helpers included, as one compose project. */ +export const composeProjectFor = (stackId: string, project: string | undefined): string => + [ + "supabase", + project === undefined ? "stack" : composeSegment(project) || "stack", + stackId.slice(0, 12).toLowerCase(), + ].join("-"); + /** Names a container and sets compose grouping labels; one-shots get a `-task` segment. */ export const identifyContainer = (spec: ContainerIdentityInput, token: string, oneOff: boolean) => { const project = spec.project === undefined ? undefined : nameSegment(spec.project); @@ -28,10 +36,9 @@ export const identifyContainer = (spec: ContainerIdentityInput, token: string, o ] .filter((segment): segment is string => segment !== undefined && segment.length > 0) .join("-"); - const composeProject = [ - "supabase", - spec.project === undefined ? "stack" : composeSegment(spec.project) || "stack", - spec.stackId.slice(0, 12).toLowerCase(), - ].join("-"); - return { name, composeProject, composeService: service ?? "task" }; + return { + name, + composeProject: composeProjectFor(spec.stackId, spec.project), + composeService: service ?? "task", + }; }; diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index 3b2cc63541..08afa7397d 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -460,6 +460,49 @@ describe("database component", { timeout: 180_000 }, () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + it.live("groups the database and its storage helper under one compose project", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const stackId = "stack-compose-group"; + const root = yield* makeDockerDatabaseRoot("stack-database-group-", stackId); + const database = yield* makeDatabase({ + stackId, + instanceId: "database", + project: "my.app", + root, + cacheRoot: artifactCacheRoot, + runtime: "docker", + }); + const service = yield* makeService(database.definition, { + id: "database:group", + config, + }); + yield* service.start; + yield* service.ready; + const listed = yield* runDocker([ + "ps", + "--filter", + `label=com.supabase.stack-root=${path.resolve(root)}`, + "--format", + '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}', + ]); + const rows = listed.output + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.trim().split("|")); + expect(rows.some(([name]) => name?.startsWith("supabase-db-helper-"))).toBe(true); + expect(new Set(rows.map(([, project]) => project))).toEqual( + new Set(["supabase-my-app-stack-compos"]), + ); + expect(new Set(rows.map(([, , group]) => group))).toEqual( + new Set(["database", "database-helper"]), + ); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + it.live("shuts PostgreSQL down fast while a client stays connected across stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 4f4b52017c..943cd9958e 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -521,6 +521,7 @@ export const makeDatabase = ( runtime: options.runtime, stackId: String(options.stackId), instanceId: options.instanceId, + ...(options.project === undefined ? {} : { project: options.project }), instanceRoot, root: options.root, cacheRoot: options.cacheRoot, diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 26c80142b1..5bf4fcec34 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -16,6 +16,7 @@ import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/u import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; import { failureMessage } from "../internal/failure-message.ts"; import type { ContainerRuntime } from "../runtime/Container.ts"; +import { composeProjectFor } from "../runtime/ContainerName.ts"; import type { DatabaseRuntime } from "../services/Database.ts"; import { DatabaseSnapshotError, @@ -116,6 +117,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") readonly runtime: DatabaseRuntime; readonly stackId: string; readonly instanceId: string; + readonly project?: string; readonly instanceRoot: string; readonly root: string; readonly cacheRoot: string; @@ -128,6 +130,12 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }): Effect.Effect => Effect.gen(function* () { const markerPath = options.path.join(options.instanceRoot, ".supabase-database-storage.json"); + const composeHelperLabels = [ + "--label", + `com.docker.compose.project=${composeProjectFor(options.stackId, options.project)}`, + "--label", + "com.docker.compose.service=database-helper", + ]; const stateRoot = options.path.dirname(options.path.dirname(options.root)); const dataNamespace = `instance-${options.stackId}-${options.instanceId}`; const hash = (value: string) => @@ -588,6 +596,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") `com.supabase.instance=${options.instanceId}`, "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, + ...composeHelperLabels, ...mountArgs(mounts), preparedImage, "/bin/sh", @@ -677,6 +686,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") `com.supabase.stack=${options.stackId}`, "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, + ...composeHelperLabels, ...mountArgs(mounts), preparedImage, "/bin/sh",