@caelo-cms/provisioning 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1254 — each adapter's connection budget (CLAUDE.md §11.B / §11.C): what
5
+ * its Postgres reserves for itself, which jobs it runs, and the default
6
+ * `max_connections` it provisions. The formula itself is shared
7
+ * (`@caelo-cms/shared` pool-budget.ts); this file only states each
8
+ * provider's inputs, so every stack sizes its pools the same way and a
9
+ * stack that sets a number the formula refuses fails at `pulumi preview`,
10
+ * not under load.
11
+ *
12
+ * docs/dev/connection-budget.md explains the reserves.
13
+ */
14
+
15
+ import {
16
+ computePoolBudget,
17
+ type PoolBudget,
18
+ type PoolBudgetInput,
19
+ poolBudgetEnv,
20
+ } from "@caelo-cms/shared";
21
+
22
+ /** The adapters that provision a Postgres server. */
23
+ export type BudgetProvider = "gcp" | "gcp-firebase" | "aws" | "azure" | "self-hosted";
24
+
25
+ /** The fixed reserves of one provider (the part of {@link PoolBudgetInput} it decides). */
26
+ export interface ProviderReserves {
27
+ readonly maintenanceReserve: number;
28
+ readonly migrationsReserve: number;
29
+ readonly jobsReserve: number;
30
+ readonly concurrentBuilds: number;
31
+ }
32
+
33
+ /**
34
+ * Reserves per provider.
35
+ *
36
+ * - maintenance: Postgres keeps `superuser_reserved_connections` (3) for
37
+ * itself; Cloud SQL's `cloudsqladmin` and RDS's `rdsadmin` agents take
38
+ * about two more; Azure Flexible Server keeps 15 for replication and
39
+ * monitoring.
40
+ * - migrations: one connection per database of a migration run.
41
+ * - jobs: the GCP installs run the operator-access sync, upgrader and
42
+ * converge jobs (one serial query stream each, plus one spare); the other
43
+ * adapters run lifecycle commands from the operator's machine (backup).
44
+ * - builds: a Stage, a Publish live and a plugin-data build can overlap.
45
+ */
46
+ export const PROVIDER_RESERVES: Readonly<Record<BudgetProvider, ProviderReserves>> = {
47
+ gcp: { maintenanceReserve: 5, migrationsReserve: 2, jobsReserve: 4, concurrentBuilds: 2 },
48
+ "gcp-firebase": {
49
+ maintenanceReserve: 5,
50
+ migrationsReserve: 2,
51
+ jobsReserve: 4,
52
+ concurrentBuilds: 2,
53
+ },
54
+ aws: { maintenanceReserve: 5, migrationsReserve: 2, jobsReserve: 2, concurrentBuilds: 2 },
55
+ azure: { maintenanceReserve: 15, migrationsReserve: 2, jobsReserve: 2, concurrentBuilds: 2 },
56
+ "self-hosted": {
57
+ maintenanceReserve: 3,
58
+ migrationsReserve: 2,
59
+ jobsReserve: 2,
60
+ concurrentBuilds: 2,
61
+ },
62
+ };
63
+
64
+ /**
65
+ * `max_connections` each adapter provisions unless the operator sets the
66
+ * `maxConnections` knob. Every stack sets the server parameter to this value
67
+ * explicitly, so the budget never depends on a tier's implicit default
68
+ * (db-f1-micro's is 25, RDS derives it from instance memory).
69
+ */
70
+ export const DEFAULT_MAX_CONNECTIONS: Readonly<Record<BudgetProvider, number>> = {
71
+ gcp: 100,
72
+ "gcp-firebase": 100,
73
+ aws: 100,
74
+ // The default Standard_B2s server allows up to 429.
75
+ azure: 429,
76
+ "self-hosted": 100,
77
+ };
78
+
79
+ /**
80
+ * Gateway instances a GCP stack lets Cloud Run start (`gatewayMaxInstances`
81
+ * knob). Before #1254 it was a hard-coded 100, which at even the minimum
82
+ * pool of 2 asks for twice the server's 100 slots; ten instances serve
83
+ * 800 concurrent visitor requests at Cloud Run's default concurrency.
84
+ */
85
+ export const DEFAULT_GATEWAY_MAX_INSTANCES = 10;
86
+
87
+ /** The server and scale-out a stack sizes its pools for. */
88
+ export interface StackScale {
89
+ readonly maxConnections: number;
90
+ /** Every instance that opens the admin's pools (admin-side services included). */
91
+ readonly adminMaxInstances: number;
92
+ readonly gatewayMaxInstances: number;
93
+ }
94
+
95
+ /** A stack's budget plus the env each service sets from it. */
96
+ export interface StackPoolBudget {
97
+ readonly budget: PoolBudget;
98
+ readonly env: {
99
+ readonly admin: { readonly name: string; readonly value: string }[];
100
+ readonly gateway: { readonly name: string; readonly value: string }[];
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The budget of a `provider` install at `scale`.
106
+ *
107
+ * @throws Error (from the shared formula) naming the connections needed when
108
+ * the server is too small for the configured scale-out.
109
+ */
110
+ export function stackPoolBudget(provider: BudgetProvider, scale: StackScale): StackPoolBudget {
111
+ const budget = computePoolBudget({ ...PROVIDER_RESERVES[provider], ...scale });
112
+ return {
113
+ budget,
114
+ env: { admin: poolBudgetEnv(budget, "admin"), gateway: poolBudgetEnv(budget, "gateway") },
115
+ };
116
+ }
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1254 — every adapter sizes its pools from the connection budget
5
+ * (CLAUDE.md §11.C: all four adapters). Pins the numbers each stack's
6
+ * defaults produce and that each stack actually sets them on its services.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { COMPOSE_POOL_BUDGET } from "./compose.js";
13
+ import {
14
+ type BudgetProvider,
15
+ DEFAULT_GATEWAY_MAX_INSTANCES,
16
+ DEFAULT_MAX_CONNECTIONS,
17
+ stackPoolBudget,
18
+ } from "./pool-budget-stack.js";
19
+
20
+ const stackSource = (provider: string): string =>
21
+ readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
22
+
23
+ /** Each stack's default scale-out, as its index.ts reads it. */
24
+ const DEFAULT_SCALE: Record<BudgetProvider, { admin: number; gateway: number }> = {
25
+ gcp: { admin: 5, gateway: DEFAULT_GATEWAY_MAX_INSTANCES },
26
+ "gcp-firebase": { admin: 5, gateway: DEFAULT_GATEWAY_MAX_INSTANCES },
27
+ aws: { admin: 5, gateway: DEFAULT_GATEWAY_MAX_INSTANCES },
28
+ // admin + orchestrator + runner apps, 10 replicas each.
29
+ azure: { admin: 30, gateway: 10 },
30
+ "self-hosted": { admin: 1, gateway: 1 },
31
+ };
32
+
33
+ /** [cms_admin pool per admin instance, connections left unassigned]. */
34
+ const EXPECTED: Record<BudgetProvider, [number, number]> = {
35
+ gcp: [5, 1],
36
+ "gcp-firebase": [5, 1],
37
+ aws: [5, 3],
38
+ azure: [7, 12],
39
+ "self-hosted": [77, 0],
40
+ };
41
+
42
+ describe("stack pool budgets at the default config", () => {
43
+ for (const provider of Object.keys(EXPECTED) as BudgetProvider[]) {
44
+ it(`${provider}: pools fit max_connections ${DEFAULT_MAX_CONNECTIONS[provider]}`, () => {
45
+ const { budget, env } = stackPoolBudget(provider, {
46
+ maxConnections: DEFAULT_MAX_CONNECTIONS[provider],
47
+ adminMaxInstances: DEFAULT_SCALE[provider].admin,
48
+ gatewayMaxInstances: DEFAULT_SCALE[provider].gateway,
49
+ });
50
+ expect([budget.adminPoolMax, budget.unassigned]).toEqual(EXPECTED[provider]);
51
+ expect(env.admin).toEqual([
52
+ { name: "CAELO_DB_POOL_MAX", value: String(EXPECTED[provider][0]) },
53
+ { name: "CAELO_DB_PUBLIC_POOL_MAX", value: "4" },
54
+ ]);
55
+ expect(env.gateway).toEqual([{ name: "CAELO_DB_PUBLIC_POOL_MAX", value: "2" }]);
56
+ });
57
+ }
58
+
59
+ it("refuses the pre-#1254 GCP gateway scale-out of 100 on 100 slots", () => {
60
+ expect(() =>
61
+ stackPoolBudget("gcp", {
62
+ maxConnections: 100,
63
+ adminMaxInstances: 5,
64
+ gatewayMaxInstances: 100,
65
+ }),
66
+ ).toThrow(/Raise maxConnections/);
67
+ });
68
+ });
69
+
70
+ describe("stacks set the budget on their services", () => {
71
+ for (const provider of ["gcp", "gcp-firebase"]) {
72
+ it(`${provider}: admin + gateway env, gateway scale-out from the knob`, () => {
73
+ const src = stackSource(provider);
74
+ expect(src).toContain(`stackPoolBudget("${provider}"`);
75
+ expect(src).toContain("...poolBudget.env.admin");
76
+ expect(src).toContain("...poolBudget.env.gateway");
77
+ expect(src).toContain("maxInstances: gatewayMaxInstances");
78
+ expect(src).not.toContain("maxInstances: 100");
79
+ });
80
+ }
81
+
82
+ it("aws: pins max_connections and exports the task env", () => {
83
+ const src = stackSource("aws");
84
+ expect(src).toContain('stackPoolBudget("aws"');
85
+ expect(src).toContain('name: "max_connections"');
86
+ expect(src).toContain("export const poolBudgetEnvOut = poolBudget.env");
87
+ });
88
+
89
+ it("azure: pins max_connections and sets the env on every app", () => {
90
+ const src = stackSource("azure");
91
+ expect(src).toContain('stackPoolBudget("azure"');
92
+ expect(src).toContain('configurationName: "max_connections"');
93
+ expect(src).toContain("...poolBudget.env.admin");
94
+ expect(src).toContain("...poolBudget.env.gateway");
95
+ });
96
+
97
+ it("self-hosted: the Compose budget is the default one (yaml: self-hosted-compose.test.ts)", () => {
98
+ expect(COMPOSE_POOL_BUDGET.budget.adminPoolMax).toBe(EXPECTED["self-hosted"][0]);
99
+ });
100
+ });
@@ -6,6 +6,7 @@ import {
6
6
  COMPOSE_ADMIN_UPSTREAM,
7
7
  COMPOSE_APP_SERVICES,
8
8
  COMPOSE_GATEWAY_UPSTREAM,
9
+ COMPOSE_POOL_BUDGET,
9
10
  type ComposeSpec,
10
11
  composeProjectName,
11
12
  generateDockerCompose,
@@ -59,6 +60,28 @@ describe("self-hosted Compose — internal secret (#770)", () => {
59
60
  });
60
61
  });
61
62
 
63
+ describe("self-hosted Compose — connection budget (#1254)", () => {
64
+ const yaml = generateDockerCompose({
65
+ ...base,
66
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
67
+ });
68
+
69
+ it("starts Postgres with the max_connections the budget is computed from", () => {
70
+ expect(service(yaml, "postgres")).toContain(
71
+ 'command: ["postgres", "-c", "max_connections=100"]',
72
+ );
73
+ });
74
+
75
+ it("sizes the admin's two pools and the gateway's one", () => {
76
+ expect(service(yaml, "caelo-admin")).toContain(
77
+ ` CAELO_DB_POOL_MAX: "${COMPOSE_POOL_BUDGET.budget.adminPoolMax}"`,
78
+ );
79
+ expect(service(yaml, "caelo-admin")).toContain(' CAELO_DB_PUBLIC_POOL_MAX: "4"');
80
+ expect(service(yaml, "caelo-gateway")).toContain(' CAELO_DB_PUBLIC_POOL_MAX: "2"');
81
+ expect(service(yaml, "caelo-gateway")).not.toContain("CAELO_DB_POOL_MAX:");
82
+ });
83
+ });
84
+
62
85
  describe("self-hosted Compose — release images (#696)", () => {
63
86
  const yaml = generateDockerCompose({
64
87
  ...base,
@@ -132,11 +132,15 @@ describe.each(["gcp", "gcp-firebase"] as const)("%s stack ↔ upgrade parity", (
132
132
  });
133
133
 
134
134
  it("builds the admin + gateway env from the shared contracts", () => {
135
+ // #1254 — plus the pool env of the stack's connection budget: derived
136
+ // from stack config (max_connections, instance counts) an upgrade roll
137
+ // cannot see, so it is stack-owned like the remote-MCP env; a roll
138
+ // never removes a var the contract does not own (planEnvUpdate).
135
139
  expect(src).toMatch(
136
- /serviceName: "admin",[\s\S]*?contractEnv: \[\s*\.\.\.adminEnvContract\([\s\S]*?\.\.\.remoteMcpEnv\.admin,?\s*\]/,
140
+ /serviceName: "admin",[\s\S]*?contractEnv: \[\s*\.\.\.adminEnvContract\([\s\S]*?\.\.\.remoteMcpEnv\.admin,\s*(\/\/[^\n]*\n\s*)?\.\.\.poolBudget\.env\.admin,?\s*\]/,
137
141
  );
138
142
  expect(src).toMatch(
139
- /serviceName: "gateway",[\s\S]*?contractEnv: \[\s*\.\.\.gatewayEnvContract\([\s\S]*?\.\.\.remoteMcpEnv\.gateway,?\s*\]/,
143
+ /serviceName: "gateway",[\s\S]*?contractEnv: \[\s*\.\.\.gatewayEnvContract\([\s\S]*?\.\.\.remoteMcpEnv\.gateway,\s*\.\.\.poolBudget\.env\.gateway,?\s*\]/,
140
144
  );
141
145
  expect(src).toContain("envs: [...args.contractEnv],");
142
146
  });
@@ -28,6 +28,18 @@ config:
28
28
  type: string
29
29
  default: db.t4g.small
30
30
  description: RDS instance class. db.t4g.small is the cheapest Multi-AZ-capable Graviton class.
31
+ caelo-aws:maxConnections:
32
+ type: string
33
+ default: "100"
34
+ description: RDS max_connections (parameter group); every service's pool size is computed from it (docs/dev/connection-budget.md).
35
+ caelo-aws:adminMaxInstances:
36
+ type: string
37
+ default: "5"
38
+ description: Most admin tasks at once; part of the connection budget.
39
+ caelo-aws:gatewayMaxInstances:
40
+ type: string
41
+ default: "10"
42
+ description: Most gateway tasks at once; part of the connection budget (2 connections each).
31
43
  caelo-aws:fargateCpu:
32
44
  type: string
33
45
  default: "512"
@@ -7,6 +7,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
7
7
  | Concern | AWS resource |
8
8
  |---|---|
9
9
  | Managed Postgres | RDS Postgres 16 Multi-AZ + automated backups + encryption (`db.t4g.small` default) |
10
+ | Connection budget | `max_connections` pinned by a parameter group (`maxConnections`, default 100); the pool sizes for `adminMaxInstances` 5 / `gatewayMaxInstances` 10 are exported as `poolBudgetEnvOut` for the task definitions (#749): cms_admin 7, admin cms_public 4, gateway 2 per task (docs/dev/connection-budget.md) |
10
11
  | Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
11
12
  | CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
12
13
  | Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
@@ -39,6 +39,11 @@ import {
39
39
  secretsReadPolicy,
40
40
  } from "../../dist/aws-task-roles.js";
41
41
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
42
+ import {
43
+ DEFAULT_GATEWAY_MAX_INSTANCES,
44
+ DEFAULT_MAX_CONNECTIONS,
45
+ stackPoolBudget,
46
+ } from "../../dist/pool-budget-stack.js";
42
47
  import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB } from "../../dist/stack-contract.js";
43
48
  import {
44
49
  CLOUDFRONT_SVG_BEHAVIORS,
@@ -68,6 +73,25 @@ const _fargateMemoryMb = cfg.get("fargateMemoryMb") ?? "1024";
68
73
  // ignored, and the admin task definition sizes itself from it.
69
74
  const adminMemory = cfg.get("adminMemory") ?? ADMIN_MEMORY_DEFAULT;
70
75
  const _adminMemoryMiB = memoryQuantityMiB(adminMemory);
76
+ // #1254 — the connection budget. RDS derives max_connections from instance
77
+ // memory; the parameter group below pins it, so the pool sizes computed
78
+ // here hold on every instance class. The task definitions (#749) set
79
+ // `poolBudgetEnvOut` on the admin and gateway tasks and scale to the same
80
+ // instance counts.
81
+ const maxConnections = Number.parseInt(
82
+ cfg.get("maxConnections") ?? String(DEFAULT_MAX_CONNECTIONS.aws),
83
+ 10,
84
+ );
85
+ const adminMaxInstances = Number.parseInt(cfg.get("adminMaxInstances") ?? "5", 10);
86
+ const gatewayMaxInstances = Number.parseInt(
87
+ cfg.get("gatewayMaxInstances") ?? String(DEFAULT_GATEWAY_MAX_INSTANCES),
88
+ 10,
89
+ );
90
+ const poolBudget = stackPoolBudget("aws", {
91
+ maxConnections,
92
+ adminMaxInstances,
93
+ gatewayMaxInstances,
94
+ });
71
95
  if (_adminMemoryMiB === null) {
72
96
  throw new Error(`caelo-aws:adminMemory "${adminMemory}" is not a memory quantity (e.g. 2Gi)`);
73
97
  }
@@ -141,10 +165,20 @@ const dbSecurityGroup = new aws.ec2.SecurityGroup(`${namePrefix}-db-sg`, {
141
165
  egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
142
166
  });
143
167
 
168
+ // #1254 — max_connections is the input of the pool budget, so it is set,
169
+ // never left to the instance class's memory-derived default.
170
+ const dbParameters = new aws.rds.ParameterGroup(`${namePrefix}-pg-params`, {
171
+ family: "postgres16",
172
+ parameters: [
173
+ { name: "max_connections", value: String(maxConnections), applyMethod: "pending-reboot" },
174
+ ],
175
+ });
176
+
144
177
  const db = new aws.rds.Instance(`${namePrefix}-pg`, {
145
178
  engine: "postgres",
146
179
  engineVersion: "16.4",
147
180
  instanceClass: rdsInstanceClass,
181
+ parameterGroupName: dbParameters.name,
148
182
  allocatedStorage: 20,
149
183
  storageEncrypted: true,
150
184
  multiAz: env === "production",
@@ -529,6 +563,8 @@ export const rdsAddressOut = db.address;
529
563
  // The task roles the admin-side and gateway task definitions run as.
530
564
  export const adminTaskRoleArnOut = taskRoles.admin.arn;
531
565
  export const gatewayTaskRoleArnOut = taskRoles.gateway.arn;
566
+ // #1254 — the pool env the admin and gateway task definitions set.
567
+ export const poolBudgetEnvOut = poolBudget.env;
532
568
 
533
569
  // Note: the bootstrap token (and other secrets like postgres-password,
534
570
  // CSRF/cookie secrets, Anthropic key) are stored in Pulumi's encrypted
@@ -38,3 +38,7 @@ config:
38
38
  type: string
39
39
  default: Standard_B2s
40
40
  description: Postgres flexible-server SKU. B2s = burstable; bump to GP_Standard_D2s_v3 for production.
41
+ caelo-azure:maxConnections:
42
+ type: string
43
+ default: "429"
44
+ description: Postgres max_connections (server parameter; must not exceed the SKU's limit, 429 on B2s). Every app's pool size is computed from it (docs/dev/connection-budget.md).
@@ -7,6 +7,7 @@ Pulumi stack provisioning Caelo on Azure managed services. Implements the shared
7
7
  | Concern | Azure resource |
8
8
  |---|---|
9
9
  | Managed Postgres | Azure DB for PostgreSQL flexible server (zone-redundant in production) + automated backups (geo-redundant in production) |
10
+ | Connection budget | `max_connections` pinned as a server parameter (`maxConnections`, default 429, the Standard_B2s limit); the admin, orchestrator and runner apps (10 replicas each) get a cms_admin pool of 7 and a cms_public pool of 4, the gateway a pool of 2 (docs/dev/connection-budget.md) |
10
11
  | Blob storage | Storage Account with a private `media` container and static-website hosting (`$web`) |
11
12
  | CDN / Edge | Azure Front Door Standard: the primary domain serves the static website as files, with a Front Door managed certificate; no edge compute on the request path |
12
13
  | Container runtime | Four Container Apps (admin / gateway / orchestrator / runner) in one managed environment |
@@ -39,6 +39,7 @@ import * as pulumi from "@pulumi/pulumi";
39
39
  import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
40
40
  import { azureFrontDoorDnsRecords } from "../../dist/azure-front-door.js";
41
41
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
42
+ import { DEFAULT_MAX_CONNECTIONS, stackPoolBudget } from "../../dist/pool-budget-stack.js";
42
43
  import {
43
44
  ADMIN_MEMORY_DEFAULT,
44
45
  azureContainerAppResources,
@@ -67,6 +68,22 @@ if (legacyLocation !== undefined) {
67
68
  }
68
69
  const location = cfg.require("region");
69
70
  const flexibleServerSku = cfg.get("flexibleServerSku") ?? "Standard_B2s";
71
+ // #1254 — the server's max_connections, set explicitly below (the default
72
+ // Standard_B2s allows up to 429); every app's pool size is computed from it.
73
+ const maxConnections = Number.parseInt(
74
+ cfg.get("maxConnections") ?? String(DEFAULT_MAX_CONNECTIONS.azure),
75
+ 10,
76
+ );
77
+ /** Replicas each Container App may scale to (see containerApp). */
78
+ const MAX_REPLICAS = 10;
79
+ // The admin, orchestrator and runner apps all open the admin's two pools,
80
+ // so the budget counts their replicas together as admin instances.
81
+ const ADMIN_SIDE_APPS = 3;
82
+ const poolBudget = stackPoolBudget("azure", {
83
+ maxConnections,
84
+ adminMaxInstances: ADMIN_SIDE_APPS * MAX_REPLICAS,
85
+ gatewayMaxInstances: MAX_REPLICAS,
86
+ });
70
87
  // #553 — the admin runs the Lighthouse quality audit; same knob + default
71
88
  // as every adapter (stack-contract.ts). Container Apps only accepts fixed
72
89
  // CPU/memory pairs (2 GiB per vCPU), so the admin's CPU follows its memory
@@ -149,6 +166,18 @@ const pgServer = new azure.dbforpostgresql.Server(`${namePrefix}-pg`, {
149
166
  network: { publicNetworkAccess: "Disabled" },
150
167
  });
151
168
 
169
+ // #1254 — pinned so the pool budget never depends on the SKU's default.
170
+ const _pgMaxConnections = new azure.dbforpostgresql.Configuration(
171
+ `${namePrefix}-pg-max-connections`,
172
+ {
173
+ resourceGroupName: rg.name,
174
+ serverName: pgServer.name,
175
+ configurationName: "max_connections",
176
+ value: String(maxConnections),
177
+ source: "user-override",
178
+ },
179
+ );
180
+
152
181
  const _pgAdmin = new azure.dbforpostgresql.Database(`${namePrefix}-cms-admin-db`, {
153
182
  resourceGroupName: rg.name,
154
183
  serverName: pgServer.name,
@@ -425,7 +454,7 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
425
454
  },
426
455
  },
427
456
  template: {
428
- scale: { minReplicas: env === "production" ? 1 : 0, maxReplicas: 10 },
457
+ scale: { minReplicas: env === "production" ? 1 : 0, maxReplicas: MAX_REPLICAS },
429
458
  containers: [
430
459
  {
431
460
  name: args.serviceName,
@@ -453,6 +482,7 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
453
482
  const adminDatabaseEnv = [
454
483
  { name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
455
484
  { name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl },
485
+ ...poolBudget.env.admin,
456
486
  ];
457
487
  const adminApp = containerApp({
458
488
  serviceName: "admin",
@@ -464,7 +494,10 @@ const adminApp = containerApp({
464
494
  });
465
495
  const _gatewayApp = containerApp({
466
496
  serviceName: "gateway",
467
- databaseEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
497
+ databaseEnv: [
498
+ { name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl },
499
+ ...poolBudget.env.gateway,
500
+ ],
468
501
  // Which X-Forwarded-For entry is the visitor (api-gateway client-ip.ts).
469
502
  // #806 — no remote-MCP env: remote MCP rides Google IAP (gcp stacks
470
503
  // only); unset, the gateway answers /api/mcp/* with 503
@@ -57,22 +57,30 @@ config:
57
57
  type: string
58
58
  default: "5"
59
59
  description: |
60
- Admin Cloud Run max instances. Default 5 caps the connection-pool
61
- footprint at ~100 Postgres conns (Bun SQL pool max=10, two pools per
62
- admin instance). Bump alongside maxConnections + cloudSqlTier when
63
- sizing for heavy editorial traffic.
60
+ Admin Cloud Run max instances. Each instance gets an equal share of
61
+ the connection budget (docs/dev/connection-budget.md): at the
62
+ defaults, a cms_admin pool of 7. Bump alongside maxConnections +
63
+ cloudSqlTier when sizing for heavy editorial traffic; a scale-out the
64
+ server cannot afford fails at preview with the max_connections it needs.
64
65
  caelo-gcp:maxConnections:
65
66
  type: string
66
67
  default: "100"
67
68
  description: |
68
- Postgres max_connections database flag. Default 100 matches the safe
69
- ceiling for adminMaxInstances=5 + light gateway traffic. Cloud SQL's
70
- db-f1-micro defaults to 25 — too low for any sustained admin load.
71
- Tune up alongside cloudSqlTier when scaling out.
69
+ Postgres max_connections database flag; every service's pool size is
70
+ computed from it (#1254). Cloud SQL's db-f1-micro defaults to 25 —
71
+ too low for any sustained admin load. Tune up alongside cloudSqlTier
72
+ when scaling out.
72
73
  caelo-gcp:gatewayMinInstances:
73
74
  type: string
74
75
  default: "0"
75
76
  description: Gateway Cloud Run min instances. 0 = scale to zero. 1 = no cold start (+~$15/mo).
77
+ caelo-gcp:gatewayMaxInstances:
78
+ type: string
79
+ default: "10"
80
+ description: |
81
+ Gateway Cloud Run max instances. Part of the connection budget (2
82
+ connections each); 10 serve ~800 concurrent visitor requests. Raise
83
+ together with maxConnections.
76
84
  caelo-gcp:iapAllowlist:
77
85
  type: string
78
86
  default: ""
@@ -26,11 +26,15 @@ pulumi config set caelo-gcp:project my-gcp-project
26
26
  # Required (#607): no default, fixed after install.
27
27
  pulumi config set caelo-gcp:region europe-west1
28
28
 
29
- # Optional: scale up beyond the safe-by-default ceiling. The defaults
30
- # size for a small editorial install (admin maxScale 5 × Bun SQL pool
31
- # max 10 × 2 pools = 100 conns, matching Postgres max_connections=100).
29
+ # Optional: scale up beyond the safe-by-default ceiling. Every service's
30
+ # pool size is computed from max_connections and the instance counts
31
+ # (docs/dev/connection-budget.md): at the defaults (max_connections 100,
32
+ # 5 admin, 10 gateway instances) each admin instance gets a cms_admin
33
+ # pool of 7. A scale-out the server cannot afford fails at preview with
34
+ # the max_connections it needs.
32
35
  # pulumi config set caelo-gcp:adminMaxInstances 10
33
36
  # pulumi config set caelo-gcp:maxConnections 200
37
+ # pulumi config set caelo-gcp:gatewayMaxInstances 20
34
38
 
35
39
  # Build + push images. Operator does this once + on every release.
36
40
  gcloud auth configure-docker us-central1-docker.pkg.dev
@@ -31,6 +31,11 @@ import {
31
31
  staticPublisherServiceAccountId,
32
32
  } from "../../dist/gcp-names.js";
33
33
  import { createSiteUrlMap } from "../../dist/gcp-url-map.js";
34
+ import {
35
+ DEFAULT_GATEWAY_MAX_INSTANCES,
36
+ DEFAULT_MAX_CONNECTIONS,
37
+ stackPoolBudget,
38
+ } from "../../dist/pool-budget-stack.js";
34
39
  import { mcpArmorRuleArgs } from "../../dist/remote-mcp-edge.js";
35
40
  import {
36
41
  ADMIN_MEMORY_DEFAULT,
@@ -96,13 +101,14 @@ const adminMinInstances = Number.parseInt(cfg.get("adminMinInstances") ?? "0", 1
96
101
  // #553 — the admin also runs the Lighthouse quality audit; same knob and
97
102
  // default on every adapter (stack-contract.ts ADMIN_MEMORY_DEFAULT).
98
103
  const adminMemory = cfg.get("adminMemory") ?? ADMIN_MEMORY_DEFAULT;
99
- // adminMaxInstances default is intentionally low. Each admin process holds
100
- // TWO Bun SQL pools (admin_role on cms_admin + admin_role on cms_public),
101
- // each with default pool-max=10 → up to ~20 connections per instance under
102
- // sustained load. With db-f1-micro's tunable max_connections, even 5 admin
103
- // instances saturate the pool (5 × 20 = 100 = our default ceiling below).
104
- // Operators on larger SQL tiers should bump maxConnections AND
105
- // adminMaxInstances together.
104
+ // adminMaxInstances default is intentionally low. Every admin instance
105
+ // holds two pools (cms_admin + cms_public) plus a LISTEN connection, and
106
+ // all of them share the server's max_connections with the gateway, the
107
+ // generator and the jobs. #1254 sizes the pools from that budget
108
+ // (src/pool-budget-stack.ts) instead of Bun's default 10 per pool; a
109
+ // scale-out the server cannot afford fails at preview, naming the
110
+ // connections it needs. Operators on larger SQL tiers bump maxConnections
111
+ // AND adminMaxInstances together.
106
112
  //
107
113
  // Why 5, not 10 (Cloud Run's default)? An incident on production showed
108
114
  // admin scaling to 10 under bursty editor traffic, demanding 200 slots
@@ -111,11 +117,23 @@ const adminMemory = cfg.get("adminMemory") ?? ADMIN_MEMORY_DEFAULT;
111
117
  // installs don't trip the same wall.
112
118
  const adminMaxInstances = Number.parseInt(cfg.get("adminMaxInstances") ?? "5", 10);
113
119
  const gatewayMinInstances = Number.parseInt(cfg.get("gatewayMinInstances") ?? "0", 10);
114
- // Postgres connection slot ceiling. Cloud SQL's db-f1-micro defaults to 25,
115
- // which 1 admin pool exhausts under load (Bun SQL pool-max=10, two pools per
116
- // admin instance, plus gateway + migrations + backup-agent overhead). 100 is
117
- // the safe floor for a small editorial install — tune up alongside SQL tier.
118
- const maxConnections = Number.parseInt(cfg.get("maxConnections") ?? "100", 10);
120
+ // #1254 — the gateway's scale-out is part of the connection budget: each
121
+ // instance holds a cms_public pool.
122
+ const gatewayMaxInstances = Number.parseInt(
123
+ cfg.get("gatewayMaxInstances") ?? String(DEFAULT_GATEWAY_MAX_INSTANCES),
124
+ 10,
125
+ );
126
+ // Postgres connection slot ceiling, set as a database flag below (Cloud
127
+ // SQL's db-f1-micro defaults to 25). The pool budget is computed from it.
128
+ const maxConnections = Number.parseInt(
129
+ cfg.get("maxConnections") ?? String(DEFAULT_MAX_CONNECTIONS.gcp),
130
+ 10,
131
+ );
132
+ const poolBudget = stackPoolBudget("gcp", {
133
+ maxConnections,
134
+ adminMaxInstances,
135
+ gatewayMaxInstances,
136
+ });
119
137
  const wafAdaptiveProtection = cfg.getBoolean("wafAdaptiveProtection") ?? false;
120
138
  const backupRetentionDays = Number.parseInt(cfg.get("backupRetentionDays") ?? "7", 10);
121
139
  // IAP allowlist: comma-list of "user:<email>" or "group:<group@domain>".
@@ -292,11 +310,9 @@ const sqlInstance = new gcp.sql.DatabaseInstance(
292
310
  diskSize: 20,
293
311
  diskType: "PD_SSD",
294
312
  diskAutoresize: true,
295
- // db-f1-micro defaults max_connections=25, which a single
296
- // admin Cloud Run instance can exhaust under load (Bun SQL
297
- // pool-max=10, two pools per admin process). Override at
298
- // instance level so the cap matches the Cloud Run scale-out
299
- // headroom defined by adminMaxInstances + gatewayMaxInstances.
313
+ // db-f1-micro defaults max_connections=25. Set explicitly: the
314
+ // services' pool sizes are computed from this exact number
315
+ // (poolBudget above), so the server must have it.
300
316
  databaseFlags: [
301
317
  { name: "max_connections", value: maxConnections.toString() },
302
318
  // The operator-access sync job logs in as its service account (Cloud
@@ -787,13 +803,18 @@ const adminSvc = cloudRunService({
787
803
  // in Cloud Run logs. 3600s is the platform max; no cost penalty for
788
804
  // unused timeout (Cloud Run bills running time, not allocated time).
789
805
  timeout: "3600s",
790
- contractEnv: [...adminEnvContract(envContractInputs), ...remoteMcpEnv.admin],
806
+ contractEnv: [
807
+ ...adminEnvContract(envContractInputs),
808
+ ...remoteMcpEnv.admin,
809
+ // #1254 — pool sizes from the connection budget.
810
+ ...poolBudget.env.admin,
811
+ ],
791
812
  mediaVolume: adminMediaVolumeTemplate(adminMediaVolume(project, env)),
792
813
  });
793
814
  const gatewaySvc = cloudRunService({
794
815
  serviceName: "gateway",
795
816
  minInstances: gatewayMinInstances,
796
- maxInstances: 100,
817
+ maxInstances: gatewayMaxInstances,
797
818
  memory: "512Mi",
798
819
  // #806 — remote MCP: chat turns stream through the gateway for minutes,
799
820
  // so it gets the admin's 3600 s instead of Cloud Run's 300 s default
@@ -802,7 +823,11 @@ const gatewaySvc = cloudRunService({
802
823
  timeout: `${GATEWAY_TIMEOUT_SECONDS}s`,
803
824
  // public_role on cms_public only: no cms_admin login of any role
804
825
  // (#613, #635, CLAUDE.md §2).
805
- contractEnv: [...gatewayEnvContract(envContractInputs), ...remoteMcpEnv.gateway],
826
+ contractEnv: [
827
+ ...gatewayEnvContract(envContractInputs),
828
+ ...remoteMcpEnv.gateway,
829
+ ...poolBudget.env.gateway,
830
+ ],
806
831
  });
807
832
 
808
833
  // =========================================================================
@@ -63,11 +63,15 @@ config:
63
63
  caelo-gcp-firebase:maxConnections:
64
64
  type: string
65
65
  default: "100"
66
- description: Postgres max_connections.
66
+ description: Postgres max_connections; every service's pool size is computed from it (docs/dev/connection-budget.md).
67
67
  caelo-gcp-firebase:gatewayMinInstances:
68
68
  type: string
69
69
  default: "0"
70
70
  description: Gateway Cloud Run min instances.
71
+ caelo-gcp-firebase:gatewayMaxInstances:
72
+ type: string
73
+ default: "10"
74
+ description: Gateway Cloud Run max instances; part of the connection budget (2 connections each).
71
75
  caelo-gcp-firebase:iapAllowlist:
72
76
  type: string
73
77
  default: ""