From a861dc823b6aabe477c8574bcd1cf4fe24dc6681 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 12 Nov 2026 16:37:59 +0100 Subject: [PATCH] feat(cli): container cpu and memory limits for local stack services Adds a `[local.services..container]` table with `memory` and `cpus`. Container runtimes apply them as `--memory`, `--memory-swap`, and `--cpus` on every launch, so limits survive stop and start; the native runtime ignores the table. Limit edits show as configuration drift in `stack status` until the next stop and start. --- .../src/command-internal/docker-suggest.ts | 2 +- apps/cli/src/command-internal/stack-config.ts | 19 +- ...start.container-limits.integration.test.ts | 232 ++++++++++++ .../src/config/cli-config-codec-schemas.ts | 17 + .../config/cli-config-contract.unit.test.ts | 5 + apps/cli/src/config/cli-config-flags.ts | 2 + .../src/config/cli-config-key-annotations.ts | 2 +- apps/cli/src/config/cli-config-key.ts | 9 +- apps/cli/src/config/cli-config-keys.ts | 2 + apps/docs/public/schemas/config/v1.json | 330 +++++++++++++++++- packages/config/src/local.ts | 75 +++- packages/config/src/local.unit.test.ts | 42 +++ packages/stack/src/composition/Supabase.ts | 1 + packages/stack/src/runtime/Container.ts | 22 ++ packages/stack/src/services/Database.ts | 12 +- packages/stack/src/services/ProcessRecipe.ts | 3 + packages/stack/src/services/Recipe.ts | 4 +- 17 files changed, 751 insertions(+), 28 deletions(-) create mode 100644 apps/cli/src/commands/experimental/stack/start/start.container-limits.integration.test.ts diff --git a/apps/cli/src/command-internal/docker-suggest.ts b/apps/cli/src/command-internal/docker-suggest.ts index 483ae36735..ec1f01784b 100644 --- a/apps/cli/src/command-internal/docker-suggest.ts +++ b/apps/cli/src/command-internal/docker-suggest.ts @@ -10,7 +10,7 @@ export const SUGGEST_DOCKER_START = /** Remediation hint shown when the OOM killer kills a container. */ export const SUGGEST_CONTAINER_MEMORY_LIMIT = - "The container ran out of memory and was killed. Raise the memory allocated to your container runtime (Docker Desktop: Settings > Resources), or free up memory on the host, and try again."; + "The container ran out of memory and was killed. Raise the memory allocated to your container runtime (Docker Desktop: Settings > Resources), raise `memory` in `[local.services..container]` if you set one, or free up memory on the host, and try again."; /** Docker CLI wordings for a daemon that cannot be reached, shared by every daemon-down matcher. */ export const DOCKER_DAEMON_CONNECTION_FAILURE_PATTERN = diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 09079ec40f..713440cf5d 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -34,6 +34,7 @@ import { resolveAuthConfig } from "./stack-auth-config.ts"; import { resolveSmtpEnabled } from "./smtp-enabled.ts"; import { parseDuration } from "./duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; +import { ramInBytes } from "./size-units.ts"; import { stackArtifactSelection } from "./stack-artifact-selection.ts"; import { type LockRequest, @@ -634,11 +635,26 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( Effect.provideService(Path.Path, path), Effect.mapError(toStackConfigError), ); + // The memory string is schema-validated, so `ramInBytes` cannot throw here. + const withContainerLimits = (creation: ServiceCreationType): ServiceCreationType => { + const container = validatedConfig.local?.services?.[creation.service]?.container; + return container === undefined + ? creation + : { + ...creation, + container: { + ...(container.memory === undefined + ? {} + : { memoryBytes: ramInBytes(container.memory) }), + ...(container.cpus === undefined ? {} : { cpus: container.cpus }), + }, + }; + }; const createCreations = ( stackId: string, ): Effect.Effect, StackConfigError> => Effect.map(emailTemplates, (templates) => { - return [ + const creations: ReadonlyArray = [ { service: "database", pin: selection.database, @@ -828,6 +844,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ] : []), ]; + return creations.map(withContainerLimits); }); return { selection, diff --git a/apps/cli/src/commands/experimental/stack/start/start.container-limits.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.container-limits.integration.test.ts new file mode 100644 index 0000000000..ef9a350a69 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.container-limits.integration.test.ts @@ -0,0 +1,232 @@ +import { BunCrypto, BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { FetchHttpClient } from "effect/http"; +import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../../tests/helpers/config-values-layer.ts"; +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { destroyTestStacks } from "../../../../../tests/helpers/stack-cleanup.ts"; +import { CommandPlatformApiFactory } from "../../../../auth/command-platform-api-factory.service.ts"; +import { containerCliOutput } from "../../../../command-internal/container-cli.ts"; +import { dbConnectionLayer } from "../../../../command-internal/db-connection.sql-pg.layer.ts"; +import { DebugFlag, ExperimentalFlag, YesFlag } from "../../../../command-internal/global-flags.ts"; +import { stackCatalogSetupLayer } from "../../../../command-internal/stack-catalog-setup.ts"; +import { + StackApi, + stackApiLayer, + stackTargetResolverLayer, +} from "../../../../command-internal/stack-target.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import type { Output } from "../../../../shared/output/output.service.ts"; +import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { stackStatus } from "../status/status.handler.ts"; +import { stackStart } from "./start.handler.ts"; + +const excluded = [ + "rest", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +]; +const flags = { + exclude: excluded, + stack: Option.none(), + stackId: Option.none(), + runtime: "docker" as const, + preparation: "on-demand" as const, + frozen: false, + eager: true, +}; + +const config = (database: string, auth: string) => ` +project_id = "stack-start-container-limits" + +[api] +enabled = true + +[auth] +enabled = true + +[realtime] +enabled = false + +[storage] +enabled = false + +[edge_runtime] +enabled = false + +[studio] +enabled = false + +[analytics] +enabled = false + +[db.pooler] +enabled = false + +[local_smtp] +enabled = false + +[local.services.database.container] +${database} + +[local.services.auth.container] +${auth} +`; + +const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); + +const makeLayer = (root: string, output: Layer.Layer = mockOutput().layer) => { + const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); + const resolver = stackTargetResolverLayer.pipe( + Layer.provide(Layer.mergeAll(BunServices.layer, settings, liveStackApi)), + ); + const tty = mockTty({ stdinIsTty: false, stdoutIsTty: false }); + return Layer.mergeAll( + cliConfigValuesTestLayer, + FetchHttpClient.layer, + runtimeInfoLayer, + settings, + liveStackApi, + resolver, + output, + mockTelemetryStateTracked().layer, + stackCatalogSetupLayer, + dbConnectionLayer, + Layer.succeed(ExperimentalFlag, false), + Layer.succeed(DebugFlag, false), + Layer.succeed(YesFlag, false), + Layer.succeed(CliArgs, { args: ["stack", "start"] }), + Layer.succeed(CommandPlatformApiFactory, { make: Effect.die("unused") }), + stdinLayer.pipe(Layer.provide(tty)), + tty, + ).pipe(Layer.provideMerge(BunServices.layer)); +}; + +const mib = 1024 * 1024; + +/** Reads the limits Docker recorded for one service container of the given stack. */ +const serviceLimits = Effect.fn("StartContainerLimitsTest.serviceLimits")(function* ( + stackId: string, + service: string, +) { + const spawner = yield* ChildProcessSpawner; + const listed = yield* containerCliOutput(spawner, [ + "container", + "ls", + "--all", + "--quiet", + "--filter", + `label=com.supabase.stack=${stackId}`, + "--filter", + `label=com.supabase.service=${service}`, + ]); + const ids = listed.stdout.split("\n").filter((line) => line !== ""); + expect(ids).toHaveLength(1); + const inspected = yield* containerCliOutput(spawner, [ + "container", + "inspect", + "--format", + "{{.HostConfig.Memory}} {{.HostConfig.MemorySwap}} {{.HostConfig.NanoCpus}}", + ...ids, + ]); + const [memory, memorySwap, nanoCpus] = inspected.stdout.trim().split(" ").map(Number); + return { memory, memorySwap, nanoCpus }; +}); + +const limits = (memoryMiB: number, cpus: number) => ({ + memory: memoryMiB * mib, + memorySwap: memoryMiB * mib, + nanoCpus: cpus * 1_000_000_000, +}); + +describe("experimental stack start container limits", () => { + it.live( + "applies configured limits to each service, keeps them across restarts, and reports edits as drift", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-limits-" }); + const configPath = path.join(root, "supabase", "config.toml"); + yield* fs.makeDirectory(path.join(root, "supabase"), { recursive: true }); + yield* fs.writeFileString( + configPath, + config('memory = "768MiB"\ncpus = 1.5', 'memory = "256MiB"\ncpus = 0.5'), + ); + const stateRoot = path.join(root, "stacks"); + const cacheRoot = path.join(root, "cache"); + yield* Effect.ensuring( + Effect.gen(function* () { + const api = yield* StackApi; + // Each CLI invocation loads config.toml in a fresh process, so each command gets fresh layers. + const start = Effect.suspend(() => stackStart(flags)).pipe( + Effect.scoped, + Effect.provide(makeLayer(root)), + ); + const drift = Effect.gen(function* () { + const out = mockOutput({ format: "json" }); + yield* stackStatus({ + stack: Option.none(), + stackId: Option.none(), + env: false, + overrideName: [], + }).pipe(Effect.scoped, Effect.provide(makeLayer(root, out.layer))); + return out.messages.find((message) => message.type === "success")?.data; + }); + const stop = (id: string) => + api.open({ id, stateRoot, cacheRoot }).pipe( + Effect.flatMap((stack) => stack.stop), + Effect.scoped, + ); + + const stackId = yield* start; + expect(yield* serviceLimits(stackId, "database")).toEqual(limits(768, 1.5)); + expect(yield* serviceLimits(stackId, "auth")).toEqual(limits(256, 0.5)); + + yield* stop(stackId); + expect(yield* start).toBe(stackId); + expect(yield* serviceLimits(stackId, "database")).toEqual(limits(768, 1.5)); + expect(yield* serviceLimits(stackId, "auth")).toEqual(limits(256, 0.5)); + + yield* fs.writeFileString( + configPath, + config('memory = "1GiB"\ncpus = 2', 'memory = "512MiB"\ncpus = 1'), + ); + expect(yield* drift).toMatchObject({ + config_drift: { + status: "changed", + paths: expect.arrayContaining([ + "services.database.container.memoryBytes", + "services.database.container.cpus", + "services.auth.container.memoryBytes", + "services.auth.container.cpus", + ]), + }, + }); + + yield* stop(stackId); + expect(yield* start).toBe(stackId); + expect(yield* serviceLimits(stackId, "database")).toEqual(limits(1024, 2)); + expect(yield* serviceLimits(stackId, "auth")).toEqual(limits(512, 1)); + expect(yield* drift).toMatchObject({ config_drift: { status: "unchanged" } }); + }), + Effect.gen(function* () { + const api = yield* StackApi; + yield* destroyTestStacks(api, stateRoot, cacheRoot); + }), + ).pipe(Effect.provide(makeLayer(root))); + }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), + { timeout: 300_000 }, + ); +}); diff --git a/apps/cli/src/config/cli-config-codec-schemas.ts b/apps/cli/src/config/cli-config-codec-schemas.ts index 2b4d7908a3..9aa5d64258 100644 --- a/apps/cli/src/config/cli-config-codec-schemas.ts +++ b/apps/cli/src/config/cli-config-codec-schemas.ts @@ -83,6 +83,23 @@ export const uintSchemas = integerSchemas(parseUintUpTo(MAX_UINT), Number.MAX_SA export const portSchemas = integerSchemas(parsePort, MAX_PORT); +const parseFloatText = (raw: string) => { + const value = Number(raw); + return raw.trim() === "" || !Number.isFinite(value) ? undefined : value; +}; + +const floatFromString = Schema.String.pipe( + Schema.decodeTo(Schema.Finite, { + decode: parsed(parseFloatText), + encode: SchemaGetter.transform((value: number) => String(value)), + }), +); + +export const floatSchemas: CliConfigCodecSchemas = { + fromString: floatFromString, + fromDocument: Schema.Union([floatFromString, Schema.Finite]), +}; + export const stringSchemas: CliConfigCodecSchemas = { fromString: Schema.String, fromDocument: Schema.String, diff --git a/apps/cli/src/config/cli-config-contract.unit.test.ts b/apps/cli/src/config/cli-config-contract.unit.test.ts index 742375f0e1..1b63367f30 100644 --- a/apps/cli/src/config/cli-config-contract.unit.test.ts +++ b/apps/cli/src/config/cli-config-contract.unit.test.ts @@ -43,6 +43,11 @@ const samplePair = (key: AnyCliConfigKey): readonly [Sample, Sample] => { { env: "7", typed: 7 }, { env: "8", typed: 8 }, ]; + case "float": + return [ + { env: "1.5", typed: 1.5 }, + { env: "2.5", typed: 2.5 }, + ]; case "port": return [ { env: "6001", typed: 6001 }, diff --git a/apps/cli/src/config/cli-config-flags.ts b/apps/cli/src/config/cli-config-flags.ts index ccecb3407b..9ee4e98d98 100644 --- a/apps/cli/src/config/cli-config-flags.ts +++ b/apps/cli/src/config/cli-config-flags.ts @@ -88,6 +88,8 @@ const baseFlag = ( case "uint": case "port": return parsedFlag(Flag.Int(name), codec, name, path); + case "float": + return parsedFlag(Flag.String(name), codec, name, path); case "literal": return parsedFlag(Flag.Literals(name, codec.literals ?? []), codec, name, path); case "string": diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index 0562dbc514..a517fd8aa2 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -83,7 +83,7 @@ export const CLI_CONFIG_ENV_NAME_PATHS: Readonly> = { local: - "local stack service versions are pinned in the config file and supabase/supabase.lock only", + "local stack service versions and container limits are set in the config file (versions also in supabase/supabase.lock) only", }; /** Removed `SUPABASE_*` config overrides with the key and variable that replace them; a non-empty value fails config resolution. */ diff --git a/apps/cli/src/config/cli-config-key.ts b/apps/cli/src/config/cli-config-key.ts index 7135734cd5..1935157a13 100644 --- a/apps/cli/src/config/cli-config-key.ts +++ b/apps/cli/src/config/cli-config-key.ts @@ -10,6 +10,7 @@ import { globEntries, globListSchemas, literalSchemas, + floatSchemas, portSchemas, stringSchemas, uintSchemas, @@ -82,7 +83,7 @@ export interface CliConfigSources { readonly context: CliConfigKeyContext; } -type CliConfigCodecKind = "bool" | "uint" | "port" | "string" | "commaList" | "literal"; +type CliConfigCodecKind = "bool" | "uint" | "float" | "port" | "string" | "commaList" | "literal"; export interface CliConfigCodec { readonly kind: CliConfigCodecKind; @@ -133,6 +134,12 @@ export const uintCodec = codecFromSchemas({ schemas: uintSchemas, }); +export const floatCodec = codecFromSchemas({ + kind: "float", + expected: "a finite number", + schemas: floatSchemas, +}); + export const portCodec = codecFromSchemas({ kind: "port", expected: "a port (0-65535)", diff --git a/apps/cli/src/config/cli-config-keys.ts b/apps/cli/src/config/cli-config-keys.ts index 7890e3446e..1970ae5fd2 100644 --- a/apps/cli/src/config/cli-config-keys.ts +++ b/apps/cli/src/config/cli-config-keys.ts @@ -24,6 +24,7 @@ import { uintCodec, literalCodec, makeCliConfigKey, + floatCodec, portCodec, stringCodec, type CliConfigKey, @@ -78,6 +79,7 @@ const codecForLeaf = (segments: ReadonlyArray, node: SchemaAST.AST) => { return boolCodec; case "Number": { const last = segments[segments.length - 1] ?? ""; + if (last === "cpus") return floatCodec; return last === "port" || last.endsWith("_port") ? portCodec : uintCodec; } case "String": diff --git a/apps/docs/public/schemas/config/v1.json b/apps/docs/public/schemas/config/v1.json index beea5731fb..f02b065a0b 100644 --- a/apps/docs/public/schemas/config/v1.json +++ b/apps/docs/public/schemas/config/v1.json @@ -4449,8 +4449,8 @@ "properties": { "version": { "type": "string", - "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", - "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + "description": "Version specifier for every service without its own entry: `linked` (the linked project's version) or `bundled` (the version shipped with this CLI).", + "examples": ["bundled", "linked"] } }, "required": ["version"], @@ -4463,9 +4463,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "auth": { @@ -4475,9 +4500,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "rest": { @@ -4487,9 +4537,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "realtime": { @@ -4499,9 +4574,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "storage": { @@ -4511,9 +4611,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "imgproxy": { @@ -4523,9 +4648,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "functions": { @@ -4535,9 +4685,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "studio": { @@ -4547,9 +4722,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "pgmeta": { @@ -4559,9 +4759,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "mail": { @@ -4571,9 +4796,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "analytics": { @@ -4583,9 +4833,34 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false }, "pooler": { @@ -4595,14 +4870,39 @@ "type": "string", "description": "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", "examples": ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"] + }, + "container": { + "type": "object", + "properties": { + "memory": { + "type": "string", + "description": "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + "examples": ["512MiB", "1GiB"] + }, + "cpus": { + "description": "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + "examples": [0.5, 1, 2], + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "string", + "pattern": "^env\\((.*)\\)$" + } + ] + } + }, + "additionalProperties": false, + "description": "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table." } }, - "required": ["version"], "additionalProperties": false } }, "additionalProperties": false, - "description": "Per-service version specifiers for the local stack, keyed by service." + "description": "Per-service settings for the local stack, keyed by service: version specifiers and container resource limits." } }, "additionalProperties": false, diff --git a/packages/config/src/local.ts b/packages/config/src/local.ts index dd790fea30..439f8e9e1a 100644 --- a/packages/config/src/local.ts +++ b/packages/config/src/local.ts @@ -22,11 +22,72 @@ function versionIssue(key: string, version: string): string | undefined { return undefined; } +const sizeUnits: Readonly> = { + "": 1, + k: 1024, + m: 1024 ** 2, + g: 1024 ** 3, + t: 1024 ** 4, + p: 1024 ** 5, +}; +const sizePattern = /^(\d+(?:\.\d+)?) ?(?:([kmgtp])(?:i?b)?|b)?$/iu; + +function sizeInBytes(text: string): number | undefined { + const match = sizePattern.exec(text); + const amount = match?.[1]; + return amount === undefined + ? undefined + : Math.trunc(Number(amount) * (sizeUnits[(match?.[2] ?? "").toLowerCase()] ?? 1)); +} + +const memory = Schema.String.annotate({ + description: + "Memory limit for the service container, as a size such as `512MiB` or `1GiB` (1024-based, case-insensitive; a bare number is bytes). Swap is limited to the same amount.", + examples: ["512MiB", "1GiB"], + tags, +}).check( + Schema.isPattern(sizePattern, { message: "must be a size such as 512MiB or 1GiB" }), + Schema.makeFilter((value: string) => { + const bytes = sizeInBytes(value); + return bytes === undefined || (bytes >= 1 && Number.isSafeInteger(bytes)) + ? undefined + : "must be at least 1 byte and less than 8 PiB"; + }), +); + +const cpus = Schema.Finite.annotate({ + description: + "CPU limit for the service container, in CPUs; fractions are allowed (for example `0.5`).", + examples: [0.5, 1, 2], + tags, +}).check(Schema.isGreaterThan(0, { message: "must be greater than 0" })); + +const container = Schema.Struct({ + memory: Schema.optionalKey(memory), + cpus: Schema.optionalKey(cpus), +}).annotate({ + description: + "Resource limits applied to the service container. Used only when the local stack runs in containers (Docker or Podman); the native runtime ignores this table.", + tags, +}); + const serviceEntry = Schema.Struct({ + version: Schema.optionalKey( + Schema.String.annotate({ + description: + "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", + examples: ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"], + tags, + }), + ), + container: Schema.optionalKey(container), +}); + +const defaultEntry = Schema.Struct({ version: Schema.String.annotate({ description: - "Version specifier: `linked` (the linked project's version), `bundled` (the version shipped with this CLI), or an upstream version optionally followed by `-r`. A `-orioledb` version is only valid for `database`.", - examples: ["bundled", "v2.197.0", "v1.30.2-r1", "17.6.1.200-orioledb"], + "Version specifier for every service without its own entry: `linked` (the linked project's version) or `bundled` (the version shipped with this CLI).", + examples: ["bundled", "linked"], tags, }), }); @@ -34,7 +95,7 @@ const serviceEntry = Schema.Struct({ const entry = Schema.optionalKey(serviceEntry); const services = Schema.Struct({ - default: entry, + default: Schema.optionalKey(defaultEntry), database: entry, auth: entry, rest: entry, @@ -49,14 +110,16 @@ const services = Schema.Struct({ pooler: entry, }) .annotate({ - description: "Per-service version specifiers for the local stack, keyed by service.", + description: + "Per-service settings for the local stack, keyed by service: version specifiers and container resource limits.", tags, }) .check( Schema.makeFilter( - (value: { readonly [key: string]: { readonly version: string } | undefined }) => { + (value: { readonly [key: string]: { readonly version?: string } | undefined }) => { for (const [key, entry] of Object.entries(value)) { - const issue = entry === undefined ? undefined : versionIssue(key, entry.version); + const version = entry?.version; + const issue = version === undefined ? undefined : versionIssue(key, version); if (issue !== undefined) { return { path: [key, "version"], issue }; } diff --git a/packages/config/src/local.unit.test.ts b/packages/config/src/local.unit.test.ts index 5711458ace..99615e2f65 100644 --- a/packages/config/src/local.unit.test.ts +++ b/packages/config/src/local.unit.test.ts @@ -63,4 +63,46 @@ describe("[local.services]", () => { ])("rejects %s version %j", (key, version, message) => { expect(() => decode(services(`${key} = { version = "${version}" }`))).toThrow(message); }); + + test("accepts a container-only entry with no version", () => { + const document = decode( + services( + [ + "[local.services.database.container]", + 'memory = "768MiB"', + "cpus = 1.5", + "[local.services.rest.container]", + 'memory = "1g"', + ].join("\n"), + ), + ); + + expect(document.local?.services?.database).toEqual({ + container: { memory: "768MiB", cpus: 1.5 }, + }); + expect(document.local?.services?.rest).toEqual({ container: { memory: "1g" } }); + }); + + test.each([ + ["an unknown unit", 'memory = "12XB"', /"auth"\]\["container"\]\["memory"\]/u], + ["no number", 'memory = "MiB"', /"auth"\]\["container"\]\["memory"\]/u], + ["a zero size", 'memory = "0"', /at least 1 byte/u], + ["an unrepresentable size", 'memory = "8PiB"', /less than 8 PiB/u], + ["an overflowing size", `memory = "${"9".repeat(309)}"`, /less than 8 PiB/u], + ["zero cpus", "cpus = 0", /greater than 0[\s\S]*"auth"\]\["container"\]\["cpus"\]/u], + ["negative cpus", "cpus = -1", /"auth"\]\["container"\]\["cpus"\]/u], + ["string cpus", 'cpus = "2"', /"auth"\]\["container"\]\["cpus"\]/u], + ])("rejects a container with %s", (_name, line, message) => { + expect(() => decode(services(`[local.services.auth.container]\n${line}`))).toThrow(message); + }); + + test("rejects container limits under default", () => { + expect(() => + decode( + services( + '[local.services.default]\nversion = "bundled"\n[local.services.default.container]\nmemory = "1GiB"', + ), + ), + ).toThrow(/"default"\]\["container"\]/u); + }); }); diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index 2b5983482a..bb7f35b96b 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -305,6 +305,7 @@ const comparable = ( pin: creation.pin, endpoints: withSharedApiPort(creation, sharedPort), config, + container: creation.container, }; }; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 6e6dbe50b6..74d3afc410 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -33,6 +33,13 @@ export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly kind?: StackFailureKind; }> {} +/** Resource limits for a service's container; native processes ignore them. */ +export const ContainerResources = Schema.Struct({ + memoryBytes: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), + cpus: Schema.optionalKey(Schema.Finite.check(Schema.isGreaterThan(0))), +}); +export interface ContainerResources extends Schema.Schema.Type {} + interface ContainerSpec { readonly image: string; readonly stackId: string; @@ -59,6 +66,8 @@ interface ContainerSpec { readonly stopSignal?: "SIGINT" | "SIGTERM"; /** `uid:gid` the container runs as, overriding the image's own user. */ readonly user?: string; + /** Memory (with swap pinned to the same value, so it cannot exceed it) and CPU limits. */ + readonly resources?: ContainerResources; } export interface ContainerProcess { @@ -770,6 +779,10 @@ export const makeContainerRuntime = (options: { "image.name": image, ...(spec.service === undefined ? {} : { "container.service": spec.service }), "container.ports": spec.ports ?? [], + ...(spec.resources?.memoryBytes === undefined + ? {} + : { "container.memory_bytes": spec.resources.memoryBytes }), + ...(spec.resources?.cpus === undefined ? {} : { "container.cpus": spec.resources.cpus }), }); for (const [key, value] of Object.entries(spec.env)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || /[\0\r\n]/u.test(value)) { @@ -863,6 +876,15 @@ export const makeContainerRuntime = (options: { ]), ...(spec.workingDir === undefined ? [] : ["--workdir", spec.workingDir]), ...(spec.stopSignal === undefined ? [] : ["--stop-signal", spec.stopSignal]), + ...(spec.resources?.memoryBytes === undefined + ? [] + : [ + "--memory", + String(spec.resources.memoryBytes), + "--memory-swap", + String(spec.resources.memoryBytes), + ]), + ...(spec.resources?.cpus === undefined ? [] : ["--cpus", String(spec.resources.cpus)]), ...(spec.ports ?? []).flatMap((port) => ["--publish", `127.0.0.1::${port}`]), ...(spec.entrypoint === undefined ? [] : ["--entrypoint", spec.entrypoint]), image, diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index 150d5e19ec..9987dd1e37 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -32,6 +32,7 @@ import { import { makeContainerRuntime, type ContainerProcess, + type ContainerResources, type ContainerRuntime, type EngineTarget, type HostGateway, @@ -128,13 +129,19 @@ export interface DatabaseConfig extends Schema.Schema.Type ({ ...creation.config, pin: creation.pin }); + readonly container?: ContainerResources; +}): PinnedDatabaseConfig => ({ + ...creation.config, + pin: creation.pin, + ...(creation.container === undefined ? {} : { container: creation.container }), +}); export const DatabaseEndpoints = Schema.Struct({ sql: Schema.optionalKey(EndpointIntent) }); export interface DatabaseEndpoints extends Schema.Schema.Type {} @@ -853,6 +860,7 @@ export const makeDatabase = ( }, ], ports: [5432], + ...(config.container === undefined ? {} : { resources: config.container }), // SIGTERM is PostgreSQL's smart shutdown, which waits for every client to disconnect. stopSignal: "SIGINT", ...(config.stopGraceSeconds === undefined diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index c2dc2914ef..d65e3f450b 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -867,6 +867,9 @@ export const makeProcessRecipe = mounts: launchMounts, user: userForMounts(launchMounts, spec.callerPaths?.(context.config) ?? []), ports: [...containerDesired.values()].map((endpoint) => endpoint.port), + ...(context.config.container === undefined + ? {} + : { resources: context.config.container }), }) .pipe( Effect.catchTag("ContainerLaunchError", ({ failure, process }) => diff --git a/packages/stack/src/services/Recipe.ts b/packages/stack/src/services/Recipe.ts index 88e4b96ef3..c14f5b6947 100644 --- a/packages/stack/src/services/Recipe.ts +++ b/packages/stack/src/services/Recipe.ts @@ -4,7 +4,7 @@ import { ArtifactPin, type ServiceKind } from "../Artifacts.ts"; import type { PortError } from "../Ports.ts"; import type { LaunchOutput } from "../runtime/Session.ts"; import type { ServiceDefinition } from "../Service.ts"; -import type { EngineTarget, HostGateway } from "../runtime/Container.ts"; +import { ContainerResources, type EngineTarget, type HostGateway } from "../runtime/Container.ts"; import type { DockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; type CatalogRuntime = "native" | "docker" | "podman"; @@ -40,6 +40,7 @@ export const serviceCreation = < pin: ArtifactPin, config, endpoints: Schema.optionalKey(endpoints), + container: Schema.optionalKey(ContainerResources), }); export interface CatalogOptions { @@ -79,6 +80,7 @@ export interface RecipeCreation { readonly pin: ArtifactPin; readonly config: C; readonly endpoints?: unknown; + readonly container?: ContainerResources; } export interface ProcessRecipeResult {