From 61045f6c95cb8f4e3731847189193540d8c0fb16 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 28 Sep 2026 20:52:30 +0200 Subject: [PATCH 1/4] fix(stack): name containers by project and service Co-Authored-By: Claude Opus 5.5 --- packages/stack/src/Owner.ts | 4 ++ packages/stack/src/StackHost.ts | 3 + packages/stack/src/host/CommandRunner.ts | 4 ++ packages/stack/src/identity/Identity.ts | 11 +++- .../src/runtime/Container.integration.test.ts | 62 +++++++++++++++++++ packages/stack/src/runtime/Container.ts | 43 ++++++++++++- packages/stack/src/services/Catalog.ts | 1 + .../src/services/Database.integration.test.ts | 4 +- packages/stack/src/services/Database.ts | 6 ++ packages/stack/src/services/ProcessRecipe.ts | 4 ++ packages/stack/src/services/Recipe.ts | 2 + 11 files changed, 140 insertions(+), 4 deletions(-) diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index e23b037923..87513c94b1 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -61,6 +61,7 @@ import { } from "./services/Catalog.ts"; import type { CatalogError } from "./services/Recipe.ts"; import * as Container from "./runtime/Container.ts"; +import { projectSegmentFor } from "./identity/Identity.ts"; import { stackError, type OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; @@ -176,12 +177,14 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { >(); const ownerScope = Context.get(services, Scope.Scope); const crypto = Context.get(services, Crypto.Crypto); + const path = Context.get(services, Path.Path); const network = yield* Network.Service; const orchestrator = yield* Orchestrator.make(); const helpers = yield* makeDockerHelperRegistry(yield* crypto.randomUUIDv4); const definitionGate = yield* Semaphore.make(1); const draining = yield* Ref.make(false); const { id: stackId, runtime } = options.saved; + const project = projectSegmentFor(options.saved.identity, path); const routeKeys = { publishableKey: options.saved.credentials?.publishableKey ?? "", secretKey: options.saved.credentials?.secretKey ?? "", @@ -285,6 +288,7 @@ const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { makeServiceRecipe(creation, { stackId, instanceId: id, + project, root: options.root, cacheRoot: options.cacheRoot, runtime, diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 53abcb01eb..cf6928c543 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -38,6 +38,7 @@ import { type HostEndpoint, type ShutdownFailure, } from "./HostProcess.ts"; +import { projectSegmentFor } from "./identity/Identity.ts"; import * as Owner from "./Owner.ts"; import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; import * as State from "./State.ts"; @@ -425,6 +426,7 @@ export const runStackHost = Effect.fn("StackHost.run")( const dataRootPath = path.join(options.stateRoot, saved.id, "data"); yield* fs.makeDirectory(dataRootPath, { recursive: true }); const dataRoot = yield* fs.realPath(dataRootPath); + const project = projectSegmentFor(saved.identity, path); yield* Owner.sweepContainers(saved, dataRoot).pipe( Effect.mapError((cause) => hostError( @@ -443,6 +445,7 @@ export const runStackHost = Effect.fn("StackHost.run")( }), CommandRunner.layer({ stackId: saved.id, + project, root: dataRoot, cacheRoot: options.cacheRoot, runtime: saved.runtime, diff --git a/packages/stack/src/host/CommandRunner.ts b/packages/stack/src/host/CommandRunner.ts index a383c69067..371490abb2 100644 --- a/packages/stack/src/host/CommandRunner.ts +++ b/packages/stack/src/host/CommandRunner.ts @@ -71,6 +71,7 @@ const failure = (cause: unknown) => /** Creates an attached byte-stream runner whose invocation scope owns each finite process. */ const makeCommandRunner = (options: { readonly stackId: string; + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; @@ -218,6 +219,8 @@ const makeCommandRunner = (options: { image, stackId: options.stackId, instanceId: jobId, + service: initialization?.service, + project: options.project, env: postgresCommand?.env ?? initialization?.env ?? {}, args: postgresCommand?.args ?? initialization?.args ?? [], entrypoint: @@ -299,6 +302,7 @@ const makeCommandRunner = (options: { export const layer = (options: { readonly stackId: string; + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; diff --git a/packages/stack/src/identity/Identity.ts b/packages/stack/src/identity/Identity.ts index bef867a950..fb0eccafac 100644 --- a/packages/stack/src/identity/Identity.ts +++ b/packages/stack/src/identity/Identity.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, FileSystem, Schema } from "effect"; +import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { InvalidProjectRootError, InvalidStackIdentityError } from "./Errors.ts"; import { StackIdSchema } from "./StackId.ts"; @@ -110,3 +110,12 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f ), ); }); + +/** + * Names a stack for container grouping labels: the project root's folder name, falling back to + * the stack name when the root has none (for example the filesystem root). + */ +export const projectSegmentFor = (identity: StackIdentity, path: Path.Path): string => { + const base = path.basename(identity.projectRoot); + return base.trim().length > 0 ? base : identity.stackName; +}; diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index d2fc743df5..922332501e 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -11,6 +11,7 @@ import { Layer, Option, Ref, + Schema, Sink, Stream, } from "effect"; @@ -137,6 +138,53 @@ describe("container process adapter", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("names and labels a service container for compose-style grouping", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "9".repeat(64), + instanceId: "naming-service", + project: "My Cool App", + service: "auth", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + expect(process.id).toMatch(/^supabase-My-Cool-App-auth-[0-9a-f]{12}$/u); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.service"]).toBe("auth"); + expect(labels["com.docker.compose.project"]).toBe(`supabase-my-cool-app-${"9".repeat(12)}`); + expect(labels["com.docker.compose.service"]).toBe("auth"); + expect(labels["com.docker.compose.oneoff"]).toBeUndefined(); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("marks a one-shot container's name and compose labels as a task", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "9".repeat(64), + instanceId: "naming-task", + project: "My Cool App", + service: "auth", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(process.id).toMatch(/^supabase-My-Cool-App-auth-task-[0-9a-f]{12}$/u); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.service"]).toBe("auth"); + expect(labels["com.docker.compose.service"]).toBe("auth"); + expect(labels["com.docker.compose.oneoff"]).toBe("True"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "publishes two private ports and keeps the second service alive after the first stops", () => @@ -1169,6 +1217,20 @@ const exists = (id: string) => return Number(yield* child.exitCode) === 0; }); +const inspectLabels = (id: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["inspect", "--format={{json .Config.Labels}}", id], { + stdin: "ignore", + }), + ); + const output = yield* child.stdout.pipe(Stream.decodeText, Stream.mkString); + return yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(output.trim()); + }); + const removeExternally = (id: string) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 441bdde76e..3337c97d9a 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -30,6 +30,10 @@ interface ContainerSpec { readonly image: string; readonly stackId: string; readonly instanceId: string; + /** Labels the container with its service kind so stack log collectors can route it. */ + readonly service?: string; + /** Groups this stack's containers under one name in Docker Desktop/OrbStack. */ + readonly project?: string; readonly env: Readonly>; readonly args?: ReadonlyArray; readonly entrypoint?: string; @@ -105,6 +109,29 @@ const engineUnreachable = (error: ContainerError) => const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; +const NAME_UNSAFE = /[^a-zA-Z0-9_.-]+/gu; +/** Keeps a name segment within docker's `[a-zA-Z0-9_.-]` alphabet and a readable length. */ +const sanitizeNameSegment = (value: string): string => + value.replaceAll(NAME_UNSAFE, "-").slice(0, 40); + +/** Names the container and sets compose grouping labels; one-shots get a `-task` segment and `oneoff`. */ +const identifyContainer = ( + spec: Pick, + token: string, + oneOff: boolean, +) => { + const stackShort = spec.stackId.slice(0, 12); + const project = spec.project === undefined ? undefined : sanitizeNameSegment(spec.project); + const service = spec.service === undefined ? undefined : sanitizeNameSegment(spec.service); + const shortToken = token.replaceAll("-", "").slice(0, 12); + const name = ["supabase", project, service, oneOff ? "task" : undefined, shortToken] + .filter((segment): segment is string => segment !== undefined && segment.length > 0) + .join("-"); + const composeProject = `supabase-${project ?? "stack"}-${stackShort}`.toLowerCase(); + const composeService = service ?? "task"; + return { name, composeProject, composeService }; +}; + const PULL_MAX_RETRIES = 4; const pullBackoff = Schedule.exponential("2 seconds").pipe(Schedule.jittered); @@ -261,6 +288,7 @@ export const makeContainerRuntime = (options: { const launch = Effect.fn("Container.launch")(function* ( spec: ContainerSpec, interactive = false, + oneOff = false, ) { const owner = yield* Scope.Scope; const image = (yield* Ref.get(mirrored)).get(spec.image) ?? spec.image; @@ -292,7 +320,7 @@ export const makeContainerRuntime = (options: { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => errorFor("identity", cause)), ); - const name = `supabase-${token}`; + const { name, composeProject, composeService } = identifyContainer(spec, token, oneOff); const args = [ "create", "--pull", @@ -309,6 +337,12 @@ export const makeContainerRuntime = (options: { `com.supabase.instance=${spec.instanceId}`, "--label", `com.supabase.stack-root=${stackRoot}`, + ...(spec.service === undefined ? [] : ["--label", `com.supabase.service=${spec.service}`]), + "--label", + `com.docker.compose.project=${composeProject}`, + "--label", + `com.docker.compose.service=${composeService}`, + ...(oneOff ? ["--label", "com.docker.compose.oneoff=True"] : []), "--env-file", envPath, ...(spec.mounts ?? []).flatMap((mount) => [ @@ -549,7 +583,12 @@ export const makeContainerRuntime = (options: { }), ); }); - return { prepare, prepareImage, launch, launchCommand: (spec) => launch(spec, true) }; + return { + prepare, + prepareImage, + launch, + launchCommand: (spec) => launch(spec, true, true), + }; }); export const removeStackContainers = Effect.fn("Container.removeStackContainers")( diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index ce36b7944a..54d264eb8c 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -322,6 +322,7 @@ export const makeServiceRecipe = Effect.fn("Catalog.makeServiceRecipe")( const component = yield* makeDatabase({ stackId: options.stackId, instanceId: options.instanceId, + project: options.project, root: options.root, cacheRoot: options.cacheRoot, runtime: options.runtime, diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index 33731f14df..3b2cc63541 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -490,9 +490,11 @@ describe("database component", { timeout: 180_000 }, () => { "--format", "{{.Names}}", ]); + // The shared volume helper carries the same stack-root/instance labels; exclude it by name. const containers = listed.output .split("\n") - .filter((name) => /^supabase-[0-9a-f]{8}-[0-9a-f-]+$/u.test(name)); + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !name.startsWith("supabase-db-helper-")); expect(containers).toHaveLength(1); const container = containers.join(""); const startedAt = yield* runDocker([ diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index fbcde0f5f3..dac0effc19 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -121,6 +121,8 @@ export class DatabaseError extends Data.TaggedError("DatabaseError")<{ export interface DatabaseOptions { readonly stackId: StackId | string; readonly instanceId: string; + /** Sanitized stack project name; groups this stack's containers in Docker Desktop/OrbStack. */ + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: DatabaseRuntime; @@ -539,6 +541,8 @@ export const makeDatabase = ( image: artifact.image, stackId: String(options.stackId), instanceId: options.instanceId, + service: "database", + project: options.project, entrypoint: "/usr/bin/busybox", args, env: {}, @@ -720,6 +724,8 @@ export const makeDatabase = ( image: image.image, stackId: String(options.stackId), instanceId: options.instanceId, + service: "database", + project: options.project, env: { PGDATA: "/var/lib/postgresql/data", PGSODIUM_KEY_FILE: "/etc/postgresql-custom/pgsodium_root.key", diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 9d1bf18e04..3b8265e360 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -732,6 +732,8 @@ export const makeProcessRecipe = image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, + service: spec.service, + project: options.project, env: command.env, entrypoint: command.entrypoint, args: command.args, @@ -780,6 +782,8 @@ export const makeProcessRecipe = image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, + service: spec.service, + project: options.project, env: yield* spec.env(context.config, containerDesired, true), entrypoint: spec.containerEntrypoint?.(context.config), args: yield* spec.args(context.config, containerDesired, { container: true }), diff --git a/packages/stack/src/services/Recipe.ts b/packages/stack/src/services/Recipe.ts index 93cc5df0e6..fc744bbefa 100644 --- a/packages/stack/src/services/Recipe.ts +++ b/packages/stack/src/services/Recipe.ts @@ -45,6 +45,8 @@ export const serviceCreation = < export interface CatalogOptions { readonly stackId: string; readonly instanceId: string; + /** Sanitized stack project name; groups this stack's containers in Docker Desktop/OrbStack. */ + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: CatalogRuntime; From e0f1c5429d4a170922060be2d2642a1537f5afee Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 28 Sep 2026 21:10:57 +0200 Subject: [PATCH 2/4] fix(stack): keep compose project labels valid and label database command containers Co-Authored-By: Claude Opus 5.5 --- packages/stack/src/host/CommandRunner.ts | 2 +- .../stack/src/identity/Identity.unit.test.ts | 26 +++++++++ packages/stack/src/runtime/Container.ts | 24 +------- packages/stack/src/runtime/ContainerName.ts | 37 ++++++++++++ .../src/runtime/ContainerName.unit.test.ts | 58 +++++++++++++++++++ packages/stack/src/services/Database.ts | 2 +- packages/stack/src/services/Recipe.ts | 2 +- 7 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 packages/stack/src/identity/Identity.unit.test.ts create mode 100644 packages/stack/src/runtime/ContainerName.ts create mode 100644 packages/stack/src/runtime/ContainerName.unit.test.ts diff --git a/packages/stack/src/host/CommandRunner.ts b/packages/stack/src/host/CommandRunner.ts index 371490abb2..c88eb60229 100644 --- a/packages/stack/src/host/CommandRunner.ts +++ b/packages/stack/src/host/CommandRunner.ts @@ -219,7 +219,7 @@ const makeCommandRunner = (options: { image, stackId: options.stackId, instanceId: jobId, - service: initialization?.service, + service: initialization?.service ?? "database", project: options.project, env: postgresCommand?.env ?? initialization?.env ?? {}, args: postgresCommand?.args ?? initialization?.args ?? [], diff --git a/packages/stack/src/identity/Identity.unit.test.ts b/packages/stack/src/identity/Identity.unit.test.ts new file mode 100644 index 0000000000..1292c182c1 --- /dev/null +++ b/packages/stack/src/identity/Identity.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Path } from "effect"; +import { projectSegmentFor } from "./Identity.ts"; + +const identity = (projectRoot: string) => ({ + projectRoot, + branchContext: "ordinary-workspace", + stackName: "default", +}); + +describe("projectSegmentFor", () => { + it.effect("uses the project folder name", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/work/my.app"), path)).toBe("my.app"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to the stack name for the filesystem root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/"), path)).toBe("default"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 3337c97d9a..259548b7b0 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -18,6 +18,7 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { identifyContainer } from "./ContainerName.ts"; export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly operation: string; @@ -109,29 +110,6 @@ const engineUnreachable = (error: ContainerError) => const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; -const NAME_UNSAFE = /[^a-zA-Z0-9_.-]+/gu; -/** Keeps a name segment within docker's `[a-zA-Z0-9_.-]` alphabet and a readable length. */ -const sanitizeNameSegment = (value: string): string => - value.replaceAll(NAME_UNSAFE, "-").slice(0, 40); - -/** Names the container and sets compose grouping labels; one-shots get a `-task` segment and `oneoff`. */ -const identifyContainer = ( - spec: Pick, - token: string, - oneOff: boolean, -) => { - const stackShort = spec.stackId.slice(0, 12); - const project = spec.project === undefined ? undefined : sanitizeNameSegment(spec.project); - const service = spec.service === undefined ? undefined : sanitizeNameSegment(spec.service); - const shortToken = token.replaceAll("-", "").slice(0, 12); - const name = ["supabase", project, service, oneOff ? "task" : undefined, shortToken] - .filter((segment): segment is string => segment !== undefined && segment.length > 0) - .join("-"); - const composeProject = `supabase-${project ?? "stack"}-${stackShort}`.toLowerCase(); - const composeService = service ?? "task"; - return { name, composeProject, composeService }; -}; - const PULL_MAX_RETRIES = 4; const pullBackoff = Schedule.exponential("2 seconds").pipe(Schedule.jittered); diff --git a/packages/stack/src/runtime/ContainerName.ts b/packages/stack/src/runtime/ContainerName.ts new file mode 100644 index 0000000000..13c845ef42 --- /dev/null +++ b/packages/stack/src/runtime/ContainerName.ts @@ -0,0 +1,37 @@ +/** The stack fields that name a container and group it for Docker Desktop/OrbStack. */ +interface ContainerIdentityInput { + readonly stackId: string; + readonly service?: string; + readonly project?: string; +} + +const nameSegment = (value: string): string => + value.replaceAll(/[^a-zA-Z0-9_.-]+/gu, "-").slice(0, 40); + +// Compose project names allow only lowercase letters, digits, dashes, and underscores. +const composeSegment = (value: string): string => + value + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/gu, "-") + .slice(0, 40); + +/** 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); + const service = spec.service === undefined ? undefined : nameSegment(spec.service); + const name = [ + "supabase", + project, + service, + oneOff ? "task" : undefined, + token.replaceAll("-", "").slice(0, 12), + ] + .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" }; +}; diff --git a/packages/stack/src/runtime/ContainerName.unit.test.ts b/packages/stack/src/runtime/ContainerName.unit.test.ts new file mode 100644 index 0000000000..a663dc7a6b --- /dev/null +++ b/packages/stack/src/runtime/ContainerName.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { identifyContainer } from "./ContainerName.ts"; + +const stackId = "0123456789abcdef0123"; +const token = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; + +describe("identifyContainer", () => { + it("names a service container after its project and service", () => { + expect( + identifyContainer({ stackId, project: "my-app", service: "studio" }, token, false), + ).toEqual({ + name: "supabase-my-app-studio-a1b2c3d4e5f6", + composeProject: "supabase-my-app-0123456789ab", + composeService: "studio", + }); + }); + + it("marks one-shot containers with a task segment but keeps the service for grouping", () => { + const identity = identifyContainer( + { stackId, project: "my-app", service: "auth" }, + token, + true, + ); + expect(identity.name).toBe("supabase-my-app-auth-task-a1b2c3d4e5f6"); + expect(identity.composeService).toBe("auth"); + }); + + it("keeps dotted folder names in the container name but not in the compose project", () => { + const identity = identifyContainer( + { stackId, project: "My.App", service: "rest" }, + token, + false, + ); + expect(identity.name).toBe("supabase-My.App-rest-a1b2c3d4e5f6"); + expect(identity.composeProject).toBe("supabase-my-app-0123456789ab"); + }); + + it("replaces characters outside the docker name alphabet and bounds the length", () => { + const identity = identifyContainer( + { stackId, project: `café ${"x".repeat(60)}`, service: "database" }, + token, + false, + ); + expect(identity.name).toMatch(/^supabase-caf-x+-database-a1b2c3d4e5f6$/u); + expect(identity.name.length).toBeLessThanOrEqual( + "supabase-".length + 40 + "-database-".length + 12, + ); + expect(identity.composeProject).toMatch(/^supabase-[a-z0-9_-]{1,40}-0123456789ab$/u); + }); + + it("falls back to generic segments when the project and service are unknown", () => { + expect(identifyContainer({ stackId }, token, true)).toEqual({ + name: "supabase-task-a1b2c3d4e5f6", + composeProject: "supabase-stack-0123456789ab", + composeService: "task", + }); + }); +}); diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index dac0effc19..3ae0ee0dae 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -121,7 +121,7 @@ export class DatabaseError extends Data.TaggedError("DatabaseError")<{ export interface DatabaseOptions { readonly stackId: StackId | string; readonly instanceId: string; - /** Sanitized stack project name; groups this stack's containers in Docker Desktop/OrbStack. */ + /** Project folder name; the container runtime sanitizes it into names and grouping labels. */ readonly project?: string; readonly root: string; readonly cacheRoot: string; diff --git a/packages/stack/src/services/Recipe.ts b/packages/stack/src/services/Recipe.ts index fc744bbefa..b28f36b438 100644 --- a/packages/stack/src/services/Recipe.ts +++ b/packages/stack/src/services/Recipe.ts @@ -45,7 +45,7 @@ export const serviceCreation = < export interface CatalogOptions { readonly stackId: string; readonly instanceId: string; - /** Sanitized stack project name; groups this stack's containers in Docker Desktop/OrbStack. */ + /** Project folder name; the container runtime sanitizes it into names and grouping labels. */ readonly project?: string; readonly root: string; readonly cacheRoot: string; From 61a829c0ace18bb1413205621b02b7b907aae718 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 28 Sep 2026 23:32:29 +0200 Subject: [PATCH 3/4] fix(stack): group database helper containers with their stack Co-Authored-By: Claude Opus 5.5 --- packages/stack/src/runtime/ContainerName.ts | 19 +++++--- .../src/services/Database.integration.test.ts | 43 +++++++++++++++++++ packages/stack/src/services/Database.ts | 1 + .../src/storage/DockerDatabaseStorage.ts | 10 +++++ 4 files changed, 67 insertions(+), 6 deletions(-) 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 3ae0ee0dae..a859f622c1 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -503,6 +503,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 3b016a22b8..e17f655acd 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, makeSnapshotStore } from "../services/DatabaseSnapshot.ts"; import type { DockerHelperRegistry } from "./DockerHelperRegistry.ts"; @@ -109,6 +110,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; @@ -121,6 +123,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) => @@ -581,6 +589,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", @@ -670,6 +679,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", From 6470cfa94be94fd840bde5ce612d68288f69a8ab Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 29 Sep 2026 07:57:36 +0200 Subject: [PATCH 4/4] fix(stack): include non-default stack names in container names and escape the name filter Co-Authored-By: Claude Opus 5.5 --- packages/stack/src/identity/Identity.ts | 11 +++++++---- packages/stack/src/identity/Identity.unit.test.ts | 11 +++++++++-- packages/stack/src/runtime/Container.ts | 3 ++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/stack/src/identity/Identity.ts b/packages/stack/src/identity/Identity.ts index fb0eccafac..379d04a2a4 100644 --- a/packages/stack/src/identity/Identity.ts +++ b/packages/stack/src/identity/Identity.ts @@ -4,6 +4,8 @@ import { InvalidProjectRootError, InvalidStackIdentityError } from "./Errors.ts" import { StackIdSchema } from "./StackId.ts"; import { resolveGitBranchContext } from "./GitBranchContext.ts"; +const DEFAULT_STACK_NAME = "default"; + export interface StackIdentity { readonly projectRoot: string; readonly branchContext: string; @@ -88,7 +90,7 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f }); } - const stackName = options.name ?? "default"; + const stackName = options.name ?? DEFAULT_STACK_NAME; if (stackName.trim().length === 0) { return yield* identityFailure("The stack name must not be blank", { name: options.name }); } @@ -112,10 +114,11 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f }); /** - * Names a stack for container grouping labels: the project root's folder name, falling back to - * the stack name when the root has none (for example the filesystem root). + * Names a stack for container names and grouping labels: the project root's folder name plus any + * non-default stack name, or the stack name alone when the root has no folder name. */ export const projectSegmentFor = (identity: StackIdentity, path: Path.Path): string => { const base = path.basename(identity.projectRoot); - return base.trim().length > 0 ? base : identity.stackName; + if (base.trim().length === 0) return identity.stackName; + return identity.stackName === DEFAULT_STACK_NAME ? base : `${base}-${identity.stackName}`; }; diff --git a/packages/stack/src/identity/Identity.unit.test.ts b/packages/stack/src/identity/Identity.unit.test.ts index 1292c182c1..b714d0e820 100644 --- a/packages/stack/src/identity/Identity.unit.test.ts +++ b/packages/stack/src/identity/Identity.unit.test.ts @@ -3,10 +3,10 @@ import { NodeServices } from "@effect/platform-node"; import { Effect, Path } from "effect"; import { projectSegmentFor } from "./Identity.ts"; -const identity = (projectRoot: string) => ({ +const identity = (projectRoot: string, stackName = "default") => ({ projectRoot, branchContext: "ordinary-workspace", - stackName: "default", + stackName, }); describe("projectSegmentFor", () => { @@ -17,6 +17,13 @@ describe("projectSegmentFor", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("appends a non-default stack name so sibling stacks stay distinguishable", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/work/my.app", "test"), path)).toBe("my.app-test"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("falls back to the stack name for the filesystem root", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 259548b7b0..9b7742fe24 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -390,7 +390,8 @@ export const makeContainerRuntime = (options: { "--all", "--no-trunc", "--filter", - `name=^/?${name}$`, + // Docker matches this as a regex; `.` is the only metacharacter a name can hold. + `name=^/?${name.replaceAll(".", "\\.")}$`, "--format", "{{.State}}", ],