Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/src/command-internal/docker-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<service>.container]` if you set one, or free up memory on the host, and try again.";
Comment on lines 12 to +13

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe for later: this hint only shows in legacy functions serve, which ignores the table, and a stack service over its limit just exits with 137 and no hint

what if legacy kept its old hint and the memory one went on that stack error?


/** Docker CLI wordings for a daemon that cannot be reached, shared by every daemon-down matcher. */
export const DOCKER_DAEMON_CONNECTION_FAILURE_PATTERN =
Expand Down
19 changes: 18 additions & 1 deletion apps/cli/src/command-internal/stack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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,
Expand Down Expand Up @@ -644,11 +645,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 }),
},
};
Comment on lines +649 to +661

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: an empty [local.services.auth.container] turns into container: {}, so a running stack reports it as drift

could it just return creation when neither memory nor cpus is set?

};
const createCreations = (
stackId: string,
): Effect.Effect<ReadonlyArray<ServiceCreationType>, StackConfigError> =>
Effect.map(emailTemplates, (templates) => {
return [
const creations: ReadonlyArray<ServiceCreationType> = [
{
service: "database",
pin: selection.database,
Expand Down Expand Up @@ -841,6 +857,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
]
: []),
];
return creations.map(withContainerLimits);
});
return {
selection,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>(),
stackId: Option.none<string>(),
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<Output> = 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 },
);
});
17 changes: 17 additions & 0 deletions apps/cli/src/config/cli-config-codec-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,23 @@ export const positiveIntSchemas = integerSchemas(

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<number> = {
fromString: floatFromString,
fromDocument: Schema.Union([floatFromString, Schema.Finite]),
};

export const stringSchemas: CliConfigCodecSchemas<string> = {
fromString: Schema.String,
fromDocument: Schema.String,
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/config/cli-config-contract.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/config/cli-config-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ const baseFlag = <X>(
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":
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/config/cli-config-key-annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const CLI_CONFIG_ENV_NAME_PATHS: Readonly<Record<string, ReadonlyArray<st
/** Schema sections whose keys take no `SUPABASE_*` override, with the reason. */
export const CLI_CONFIG_NO_ENV_SECTIONS: Readonly<Record<string, string>> = {
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. */
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/src/config/cli-config-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
globEntries,
globListSchemas,
literalSchemas,
floatSchemas,
portSchemas,
positiveIntSchemas,
stringSchemas,
Expand Down Expand Up @@ -83,7 +84,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<X> {
readonly kind: CliConfigCodecKind;
Expand Down Expand Up @@ -134,6 +135,12 @@ export const uintCodec = codecFromSchemas({
schemas: uintSchemas,
});

export const floatCodec = codecFromSchemas({
kind: "float",
expected: "a finite number",
schemas: floatSchemas,
});

export const positiveIntCodec = codecFromSchemas({
kind: "uint",
expected: "a positive integer",
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/config/cli-config-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
uintCodec,
literalCodec,
makeCliConfigKey,
floatCodec,
portCodec,
stringCodec,
type CliConfigKey,
Expand Down Expand Up @@ -78,6 +79,7 @@ const codecForLeaf = (segments: ReadonlyArray<string>, 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":
Expand Down
Loading
Loading