@caelo-cms/provisioning 0.11.10 → 0.11.11

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.
Files changed (212) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adopt-existing.d.ts +6 -2
  4. package/dist/adopt-existing.d.ts.map +1 -1
  5. package/dist/adopt-existing.js +57 -0
  6. package/dist/adopt-existing.js.map +1 -1
  7. package/dist/agent/command.d.ts +5 -2
  8. package/dist/agent/command.d.ts.map +1 -1
  9. package/dist/agent/command.js +1 -1
  10. package/dist/agent/command.js.map +1 -1
  11. package/dist/agent-db.js +25 -8
  12. package/dist/cli-routing.js +1 -1
  13. package/dist/cli-routing.js.map +1 -1
  14. package/dist/cli.js +59 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/cloudsql-tls.d.ts +30 -0
  17. package/dist/cloudsql-tls.d.ts.map +1 -1
  18. package/dist/cloudsql-tls.js +7 -1
  19. package/dist/cloudsql-tls.js.map +1 -1
  20. package/dist/compose.d.ts +14 -0
  21. package/dist/compose.d.ts.map +1 -1
  22. package/dist/compose.js +54 -1
  23. package/dist/compose.js.map +1 -1
  24. package/dist/converge-db-trust.d.ts +44 -0
  25. package/dist/converge-db-trust.d.ts.map +1 -0
  26. package/dist/converge-db-trust.js +158 -0
  27. package/dist/converge-db-trust.js.map +1 -0
  28. package/dist/converge-job-gcp.d.ts +5 -2
  29. package/dist/converge-job-gcp.d.ts.map +1 -1
  30. package/dist/converge-job-gcp.js +9 -2
  31. package/dist/converge-job-gcp.js.map +1 -1
  32. package/dist/converge-job-preflight.d.ts.map +1 -1
  33. package/dist/converge-job-preflight.js +8 -0
  34. package/dist/converge-job-preflight.js.map +1 -1
  35. package/dist/converge-job.d.ts +17 -0
  36. package/dist/converge-job.d.ts.map +1 -1
  37. package/dist/converge-job.js +12 -0
  38. package/dist/converge-job.js.map +1 -1
  39. package/dist/gcp-names.d.ts +13 -0
  40. package/dist/gcp-names.d.ts.map +1 -1
  41. package/dist/gcp-names.js +21 -0
  42. package/dist/gcp-names.js.map +1 -1
  43. package/dist/index.d.ts +2 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/infra-contract.d.ts +1 -1
  48. package/dist/infra-contract.d.ts.map +1 -1
  49. package/dist/infra-contract.js +22 -1
  50. package/dist/infra-contract.js.map +1 -1
  51. package/dist/install-job.d.ts +4 -2
  52. package/dist/install-job.d.ts.map +1 -1
  53. package/dist/install-job.js +4 -1
  54. package/dist/install-job.js.map +1 -1
  55. package/dist/install-state.d.ts +7 -0
  56. package/dist/install-state.d.ts.map +1 -1
  57. package/dist/install-state.js.map +1 -1
  58. package/dist/lifecycle.d.ts.map +1 -1
  59. package/dist/lifecycle.js +3 -0
  60. package/dist/lifecycle.js.map +1 -1
  61. package/dist/migration-runner.d.ts.map +1 -1
  62. package/dist/migration-runner.js +8 -3
  63. package/dist/migration-runner.js.map +1 -1
  64. package/dist/migration-targets.d.ts +11 -0
  65. package/dist/migration-targets.d.ts.map +1 -0
  66. package/dist/migration-targets.js +12 -0
  67. package/dist/migration-targets.js.map +1 -0
  68. package/dist/operator-access.d.ts +10 -0
  69. package/dist/operator-access.d.ts.map +1 -1
  70. package/dist/operator-access.js +5 -0
  71. package/dist/operator-access.js.map +1 -1
  72. package/dist/pool-budget-stack.d.ts +13 -1
  73. package/dist/pool-budget-stack.d.ts.map +1 -1
  74. package/dist/pool-budget-stack.js +11 -1
  75. package/dist/pool-budget-stack.js.map +1 -1
  76. package/dist/public-role-isolation.d.ts +7 -3
  77. package/dist/public-role-isolation.d.ts.map +1 -1
  78. package/dist/public-role-isolation.js +25 -13
  79. package/dist/public-role-isolation.js.map +1 -1
  80. package/dist/release-contract.d.ts.map +1 -1
  81. package/dist/release-contract.js +3 -0
  82. package/dist/release-contract.js.map +1 -1
  83. package/dist/runtime-secrets.d.ts +2 -0
  84. package/dist/runtime-secrets.d.ts.map +1 -1
  85. package/dist/runtime-secrets.js +19 -15
  86. package/dist/runtime-secrets.js.map +1 -1
  87. package/dist/scheduler-setup.d.ts +75 -0
  88. package/dist/scheduler-setup.d.ts.map +1 -0
  89. package/dist/scheduler-setup.js +331 -0
  90. package/dist/scheduler-setup.js.map +1 -0
  91. package/dist/self-hosted-roles.d.ts +16 -9
  92. package/dist/self-hosted-roles.d.ts.map +1 -1
  93. package/dist/self-hosted-roles.js +49 -15
  94. package/dist/self-hosted-roles.js.map +1 -1
  95. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  96. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  97. package/dist/self-hosted-scheduler-interval.js +68 -0
  98. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  99. package/dist/self-hosted-upgrade.d.ts +7 -4
  100. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  101. package/dist/self-hosted-upgrade.js +9 -5
  102. package/dist/self-hosted-upgrade.js.map +1 -1
  103. package/dist/stack-contract.d.ts +67 -6
  104. package/dist/stack-contract.d.ts.map +1 -1
  105. package/dist/stack-contract.js +87 -6
  106. package/dist/stack-contract.js.map +1 -1
  107. package/dist/staging-database.d.ts +22 -0
  108. package/dist/staging-database.d.ts.map +1 -0
  109. package/dist/staging-database.js +117 -0
  110. package/dist/staging-database.js.map +1 -0
  111. package/dist/upgrade/converge.d.ts +5 -0
  112. package/dist/upgrade/converge.d.ts.map +1 -1
  113. package/dist/upgrade/converge.js +110 -10
  114. package/dist/upgrade/converge.js.map +1 -1
  115. package/dist/upgrade/upgrade.d.ts +6 -0
  116. package/dist/upgrade/upgrade.d.ts.map +1 -1
  117. package/dist/upgrade/upgrade.js +3 -0
  118. package/dist/upgrade/upgrade.js.map +1 -1
  119. package/dist/upgrader-gcp.d.ts +5 -4
  120. package/dist/upgrader-gcp.d.ts.map +1 -1
  121. package/dist/upgrader-gcp.js +5 -4
  122. package/dist/upgrader-gcp.js.map +1 -1
  123. package/dist/wizard.d.ts +7 -0
  124. package/dist/wizard.d.ts.map +1 -1
  125. package/dist/wizard.js +11 -0
  126. package/dist/wizard.js.map +1 -1
  127. package/dist/wizards/gcp-cost.d.ts +31 -0
  128. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  129. package/dist/wizards/gcp-cost.js +81 -11
  130. package/dist/wizards/gcp-cost.js.map +1 -1
  131. package/dist/wizards/gcp.d.ts.map +1 -1
  132. package/dist/wizards/gcp.js +87 -11
  133. package/dist/wizards/gcp.js.map +1 -1
  134. package/package.json +2 -2
  135. package/src/adapter.ts +6 -0
  136. package/src/admin-memory.test.ts +1 -1
  137. package/src/adopt-existing.test.ts +46 -0
  138. package/src/adopt-existing.ts +58 -2
  139. package/src/agent/command.ts +6 -3
  140. package/src/caddy-redirects.test.ts +1 -1
  141. package/src/cli-routing.test.ts +1 -1
  142. package/src/cli-routing.ts +1 -1
  143. package/src/cli.ts +63 -8
  144. package/src/cloudsql-tls.test.ts +30 -0
  145. package/src/cloudsql-tls.ts +10 -3
  146. package/src/compose.ts +68 -0
  147. package/src/converge-db-trust.test.ts +237 -0
  148. package/src/converge-db-trust.ts +212 -0
  149. package/src/converge-job-gcp.ts +9 -1
  150. package/src/converge-job-preflight.test.ts +5 -0
  151. package/src/converge-job-preflight.ts +8 -0
  152. package/src/converge-job.test.ts +70 -0
  153. package/src/converge-job.ts +32 -0
  154. package/src/database-tls-builders.test.ts +3 -2
  155. package/src/deployed-release.test.ts +3 -0
  156. package/src/gateway-env-isolation.test.ts +40 -7
  157. package/src/gcp-names.ts +25 -0
  158. package/src/index.test.ts +3 -3
  159. package/src/index.ts +6 -0
  160. package/src/infra-contract.test.ts +5 -3
  161. package/src/infra-contract.ts +22 -1
  162. package/src/install-job.ts +4 -1
  163. package/src/install-state.ts +7 -0
  164. package/src/lifecycle.ts +3 -0
  165. package/src/migration-runner.ts +14 -9
  166. package/src/migration-targets.ts +12 -0
  167. package/src/operator-access.test.ts +15 -0
  168. package/src/operator-access.ts +14 -0
  169. package/src/pool-budget-stack.ts +15 -0
  170. package/src/pool-budget-stacks.test.ts +38 -13
  171. package/src/public-role-isolation.test.ts +18 -8
  172. package/src/public-role-isolation.ts +31 -17
  173. package/src/release-contract.ts +3 -0
  174. package/src/runtime-secrets.test.ts +18 -8
  175. package/src/runtime-secrets.ts +37 -18
  176. package/src/scheduler-setup.test.ts +201 -0
  177. package/src/scheduler-setup.ts +375 -0
  178. package/src/self-hosted-backup.test.ts +1 -0
  179. package/src/self-hosted-compose.test.ts +72 -1
  180. package/src/self-hosted-roles.integration.test.ts +2 -0
  181. package/src/self-hosted-roles.test.ts +49 -19
  182. package/src/self-hosted-roles.ts +60 -20
  183. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  184. package/src/self-hosted-scheduler-interval.ts +79 -0
  185. package/src/self-hosted-start.test.ts +1 -0
  186. package/src/self-hosted-upgrade.test.ts +2 -1
  187. package/src/self-hosted-upgrade.ts +14 -8
  188. package/src/stack-contract.test.ts +31 -3
  189. package/src/stack-contract.ts +113 -6
  190. package/src/stack-converge-secrets.test.ts +2 -0
  191. package/src/stack-converge.test.ts +6 -4
  192. package/src/staging-database.test.ts +114 -0
  193. package/src/staging-database.ts +148 -0
  194. package/src/upgrade/converge.ts +130 -9
  195. package/src/upgrade/upgrade.test.ts +85 -2
  196. package/src/upgrade/upgrade.ts +9 -0
  197. package/src/upgrader-gcp.ts +5 -4
  198. package/src/wizard.ts +19 -0
  199. package/src/wizards/gcp-cost.test.ts +54 -1
  200. package/src/wizards/gcp-cost.ts +101 -11
  201. package/src/wizards/gcp.ts +109 -8
  202. package/stacks/aws/Pulumi.yaml +1 -1
  203. package/stacks/aws/index.ts +41 -1
  204. package/stacks/azure/index.ts +51 -3
  205. package/stacks/gcp/Pulumi.yaml +1 -1
  206. package/stacks/gcp/README.md +1 -0
  207. package/stacks/gcp/index.ts +50 -1
  208. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  209. package/stacks/gcp-firebase/index.ts +50 -1
  210. package/stacks/self-hosted/Pulumi.yaml +3 -0
  211. package/stacks/self-hosted/README.md +4 -0
  212. package/stacks/self-hosted/index.ts +29 -4
@@ -9,6 +9,8 @@ import {
9
9
  COMPOSE_MCP_AUTH_PROXY_SERVICE,
10
10
  COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
11
11
  COMPOSE_POOL_BUDGET,
12
+ COMPOSE_SCHEDULER_SERVICE,
13
+ COMPOSE_SCHEDULER_TICK_URL,
12
14
  type ComposeSpec,
13
15
  composeProjectName,
14
16
  generateDockerCompose,
@@ -22,7 +24,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
22
24
  const base: Omit<ComposeSpec, "images"> = {
23
25
  domain: "example.com",
24
26
  postgresPassword: "pgpw",
25
- rolePasswords: { admin: "adminpw", public: "publicpw" },
27
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
26
28
  caeloSecretKek: "0".repeat(64),
27
29
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
28
30
  internalSecret: "i".repeat(48),
@@ -410,3 +412,72 @@ describe("self-hosted Compose — the MCP-Auth-Proxy (#1328)", () => {
410
412
  ).toThrow(/mcp-auth-proxy image/);
411
413
  });
412
414
  });
415
+
416
+ describe("self-hosted Compose — the scheduler tick (#1140)", () => {
417
+ const yaml = generateDockerCompose({
418
+ ...base,
419
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
420
+ });
421
+ const scheduler = service(yaml, COMPOSE_SCHEDULER_SERVICE);
422
+
423
+ it("runs the admin image's clock against the admin's tick endpoint", () => {
424
+ expect(scheduler).toContain(`image: ${ADMIN}`);
425
+ expect(scheduler).toContain(
426
+ 'command: ["bun", "--bun", "/app/packages/admin-core/src/scheduler/clock.ts"]',
427
+ );
428
+ expect(COMPOSE_SCHEDULER_TICK_URL).toBe("http://caelo-admin:5173/api/internal/scheduler/tick");
429
+ expect(scheduler).toContain(`CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"`);
430
+ expect(scheduler).toContain("depends_on: [caelo-admin]");
431
+ });
432
+
433
+ it("ticks every 5 minutes by default and follows the knob", () => {
434
+ expect(scheduler).toContain('CAELO_SCHEDULER_INTERVAL_MINUTES: "5"');
435
+ const fast = generateDockerCompose({
436
+ ...base,
437
+ schedulerIntervalMinutes: 1,
438
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
439
+ });
440
+ expect(service(fast, COMPOSE_SCHEDULER_SERVICE)).toContain(
441
+ 'CAELO_SCHEDULER_INTERVAL_MINUTES: "1"',
442
+ );
443
+ });
444
+
445
+ it("refuses an interval outside 1-15 minutes", () => {
446
+ for (const bad of [0, 16, 2.5]) {
447
+ expect(() =>
448
+ generateDockerCompose({
449
+ ...base,
450
+ schedulerIntervalMinutes: bad,
451
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
452
+ }),
453
+ ).toThrow("schedulerIntervalMinutes");
454
+ }
455
+ });
456
+
457
+ it("holds only the internal secret it signs with: no database, no KEK, no mount", () => {
458
+ expect(scheduler).toContain(`CAELO_INTERNAL_SECRET: "${"i".repeat(48)}"`);
459
+ expect(scheduler).not.toMatch(/DATABASE|PASSWORD|KEK|postgres:\/\/|volumes:/);
460
+ expect(scheduler).not.toContain("adminpw");
461
+ expect(scheduler).not.toContain("0".repeat(64));
462
+ });
463
+
464
+ it("runs locked down: read-only root, no capabilities, no privilege escalation", () => {
465
+ expect(scheduler).toContain("read_only: true");
466
+ expect(scheduler).toContain('cap_drop: ["ALL"]');
467
+ expect(scheduler).toContain('security_opt: ["no-new-privileges:true"]');
468
+ });
469
+
470
+ it("runs the checkout's clock in dev mode", () => {
471
+ const dev = generateDockerCompose({ ...base, images: { mode: "dev" } });
472
+ expect(service(dev, COMPOSE_SCHEDULER_SERVICE)).toContain(
473
+ "command: bun run packages/admin-core/src/scheduler/clock.ts",
474
+ );
475
+ });
476
+
477
+ it("is part of every rendered Compose file, so an upgrade of an older install adds it", () => {
478
+ // self-hosted-upgrade.ts rolls onto renderCompose(<new images>), which is
479
+ // this generator: the next upgrade of an install without the service
480
+ // writes a Compose file that has it, and `docker compose up -d` starts it.
481
+ expect(yaml).toContain(`\n ${COMPOSE_SCHEDULER_SERVICE}:\n`);
482
+ });
483
+ });
@@ -26,6 +26,7 @@ const required = [
26
26
  "POSTGRES_PORT",
27
27
  "ADMIN_ROLE_PASSWORD",
28
28
  "PUBLIC_ROLE_PASSWORD",
29
+ "PUBLIC_STAGING_ROLE_PASSWORD",
29
30
  "ADMIN_DATABASE_URL",
30
31
  "PUBLIC_DATABASE_URL",
31
32
  ] as const;
@@ -63,6 +64,7 @@ const psql: SuperuserPsql = async (script) => {
63
64
  const passwords = {
64
65
  admin: env.ADMIN_ROLE_PASSWORD as string,
65
66
  public: env.PUBLIC_ROLE_PASSWORD as string,
67
+ publicStaging: env.PUBLIC_STAGING_ROLE_PASSWORD as string,
66
68
  };
67
69
 
68
70
  describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
@@ -4,16 +4,16 @@ import { describe, expect, it } from "bun:test";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
6
  import {
7
- applyPublicRoleLimits,
7
+ applyPreMigrationRoles,
8
8
  convergeSelfHostedRoles,
9
9
  decideUp,
10
10
  PUBLIC_ROLE_LIMITS,
11
- publicRoleLimitsScript,
11
+ preMigrationRolesScript,
12
12
  roleConvergenceScript,
13
13
  selfHostedBootstrapScript,
14
14
  } from "./self-hosted-roles.js";
15
15
 
16
- const PASSWORDS = { admin: "a'pw", public: "p-pw" };
16
+ const PASSWORDS = { admin: "a'pw", public: "p-pw", publicStaging: "s'pw" };
17
17
 
18
18
  describe("roleConvergenceScript (#613 review)", () => {
19
19
  const script = roleConvergenceScript(PASSWORDS);
@@ -28,13 +28,13 @@ describe("roleConvergenceScript (#613 review)", () => {
28
28
  expect(script).not.toContain("CREATE ROLE gateway_role");
29
29
  });
30
30
 
31
- it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
31
+ it("drops the former gateway_role with what it holds in every database, and stops on the first error (#635, #1329)", () => {
32
32
  expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
33
33
  const publicDrop = script.indexOf("\\connect cms_public");
34
34
  const adminDrop = script.indexOf("\\connect cms_admin");
35
35
  expect(publicDrop).toBeGreaterThan(-1);
36
36
  expect(adminDrop).toBeGreaterThan(publicDrop);
37
- expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
37
+ expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(3);
38
38
  expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
39
39
  expect(script).not.toContain("caelo_grant_gateway_role");
40
40
  });
@@ -132,27 +132,56 @@ describe("decideUp — `cms-provision up` never switches URLs onto unapplied pas
132
132
  });
133
133
  });
134
134
 
135
- describe("public_role's limits (#1338, H3)", () => {
136
- const statements = [
137
- `ALTER ROLE public_role CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
138
- `ALTER ROLE public_role SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
139
- `ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
140
- `ALTER ROLE public_role SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
135
+ describe("the staging database and its login (#1329)", () => {
136
+ const staging = [
137
+ "SELECT 'CREATE ROLE public_staging_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'public_staging_role') \\gexec",
138
+ "SELECT 'CREATE DATABASE cms_public_staging OWNER admin_role' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cms_public_staging') \\gexec",
139
+ "REVOKE ALL ON DATABASE cms_public_staging FROM PUBLIC;",
140
+ "GRANT CONNECT ON DATABASE cms_public_staging TO public_staging_role, admin_role;",
141
+ "REVOKE ALL ON DATABASE cms_public_staging FROM public_role;",
142
+ "REVOKE ALL ON DATABASE cms_public FROM public_staging_role;",
141
143
  ];
142
144
 
143
- it("are set on the first start, on every `up`, and alone before an upgrade's migrations", () => {
145
+ it("are created on the first start, on every `up`, and before an upgrade's migrations", () => {
146
+ for (const script of [roleConvergenceScript(PASSWORDS), preMigrationRolesScript("s'pw")]) {
147
+ for (const st of staging) expect(script).toContain(st);
148
+ expect(script).toContain("ALTER ROLE public_staging_role WITH LOGIN PASSWORD 's''pw';");
149
+ }
150
+ const bootstrap = selfHostedBootstrapScript({ agent: false });
151
+ for (const st of staging) expect(bootstrap).toContain(st);
152
+ expect(bootstrap).toContain(
153
+ "ALTER ROLE public_staging_role WITH LOGIN PASSWORD :'public_staging_role_pw';",
154
+ );
155
+ expect(bootstrap).toContain('-v public_staging_role_pw="$PUBLIC_STAGING_ROLE_PASSWORD"');
156
+ });
157
+
158
+ it("drops the former gateway_role's leftovers in the staging database too", () => {
159
+ expect(roleConvergenceScript(PASSWORDS)).toContain("\\connect cms_public_staging");
160
+ });
161
+ });
162
+
163
+ describe("the gateway logins' limits (#1338, H3; #1329)", () => {
164
+ const statements = ["public_role", "public_staging_role"].flatMap((role) => [
165
+ `ALTER ROLE ${role} CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
166
+ `ALTER ROLE ${role} SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
167
+ `ALTER ROLE ${role} SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
168
+ `ALTER ROLE ${role} SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
169
+ ]);
170
+
171
+ it("are set on the first start, on every `up`, and before an upgrade's migrations", () => {
144
172
  for (const script of [
145
173
  selfHostedBootstrapScript({ agent: false }),
146
174
  roleConvergenceScript(PASSWORDS),
147
- publicRoleLimitsScript(),
175
+ preMigrationRolesScript("s'pw"),
148
176
  ]) {
149
177
  for (const st of statements) expect(script).toContain(st);
150
178
  }
151
- // The pre-migration script touches nothing else (no role drop, no grant).
152
- expect(publicRoleLimitsScript().split("\n").filter(Boolean)).toEqual([
153
- "\\set ON_ERROR_STOP on",
154
- ...statements,
155
- ]);
179
+ // The pre-migration script drops no role and touches no other database:
180
+ // only the staging database with its login, then the limits.
181
+ const lines = preMigrationRolesScript("s'pw").split("\n").filter(Boolean);
182
+ expect(lines[0]).toBe("\\set ON_ERROR_STOP on");
183
+ expect(lines.slice(-statements.length)).toEqual(statements);
184
+ expect(lines.some((l) => /DROP|\\connect/.test(l))).toBe(false);
156
185
  });
157
186
 
158
187
  it("match the values packages/migrations/src/bootstrap.sh sets (dev, CI, Cloud SQL bootstrap)", () => {
@@ -165,7 +194,8 @@ describe("public_role's limits (#1338, H3)", () => {
165
194
 
166
195
  it("refuse to run against a stopped database", async () => {
167
196
  let ran = false;
168
- const r = await applyPublicRoleLimits({
197
+ const r = await applyPreMigrationRoles({
198
+ stagingPassword: "s",
169
199
  probe: async () => false,
170
200
  psql: async () => {
171
201
  ran = true;
@@ -13,8 +13,10 @@
13
13
  * as the Postgres superuser BEFORE it writes the new Compose file: it
14
14
  * creates missing roles and databases and sets each role's password to the
15
15
  * configured one. It also drops the former `gateway_role` (#635: the
16
- * gateway has no cms_admin login), with whatever it still holds. Every
17
- * statement is idempotent.
16
+ * gateway has no cms_admin login), with whatever it still holds, and
17
+ * (#1329) creates the staging database `cms_public_staging` with the
18
+ * staging gateway's login `public_staging_role` on an install from before
19
+ * it. Every statement is idempotent.
18
20
  *
19
21
  * Passwords travel on psql's stdin, never on argv.
20
22
  */
@@ -35,7 +37,10 @@ function literal(s: string): string {
35
37
  export const AGENT_DB_ROLE = "caelo_agent";
36
38
 
37
39
  /** The application roles bootstrap.sh and the convergence create. */
38
- const APP_ROLES = ["admin_role", "public_role"] as const;
40
+ const APP_ROLES = ["admin_role", "public_role", "public_staging_role"] as const;
41
+
42
+ /** #1329 — the gateway logins that carry {@link PUBLIC_ROLE_LIMITS}: the live and the staging one. */
43
+ const GATEWAY_LOGINS = ["public_role", "public_staging_role"] as const;
39
44
  type AppRole = (typeof APP_ROLES)[number];
40
45
 
41
46
  /**
@@ -47,7 +52,8 @@ type AppRole = (typeof APP_ROLES)[number];
47
52
  * (packages/migrations role-audit.ts) refuses an install without them.
48
53
  * Self-hosted admin_role has no CREATEROLE, so the superuser applies them
49
54
  * here — on the volume's first start (bootstrap.sh), on every `up`, and
50
- * before the migrations of every upgrade ({@link publicRoleLimitsScript}).
55
+ * before the migrations of every upgrade ({@link preMigrationRolesScript}).
56
+ * Since #1329 the staging gateway's public_staging_role carries the same.
51
57
  */
52
58
  export const PUBLIC_ROLE_LIMITS = {
53
59
  connectionLimit: 30,
@@ -56,31 +62,57 @@ export const PUBLIC_ROLE_LIMITS = {
56
62
  workMem: "1MB",
57
63
  } as const;
58
64
 
59
- /** The statements that apply {@link PUBLIC_ROLE_LIMITS} (idempotent). */
65
+ /** The statements that apply {@link PUBLIC_ROLE_LIMITS} to both gateway logins (idempotent). */
60
66
  function publicRoleLimitsSql(): string[] {
61
67
  const l = PUBLIC_ROLE_LIMITS;
68
+ return GATEWAY_LOGINS.flatMap((role) => [
69
+ `ALTER ROLE ${role} CONNECTION LIMIT ${l.connectionLimit};`,
70
+ `ALTER ROLE ${role} SET statement_timeout = '${l.statementTimeout}';`,
71
+ `ALTER ROLE ${role} SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
72
+ `ALTER ROLE ${role} SET work_mem = '${l.workMem}';`,
73
+ ]);
74
+ }
75
+
76
+ /** How a role's password appears in the SQL: a literal, or a psql variable. */
77
+ type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
78
+
79
+ /**
80
+ * #1329 — the staging database and its gateway login, with each gateway
81
+ * login on its own database only (the same as bootstrap.sh and cms_admin
82
+ * migration 0530). Idempotent: an install from before #1329 gets both, an
83
+ * install that has them gets the password re-applied.
84
+ */
85
+ function stagingDatabaseSql(password: PasswordSql): string[] {
62
86
  return [
63
- `ALTER ROLE public_role CONNECTION LIMIT ${l.connectionLimit};`,
64
- `ALTER ROLE public_role SET statement_timeout = '${l.statementTimeout}';`,
65
- `ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
66
- `ALTER ROLE public_role SET work_mem = '${l.workMem}';`,
87
+ "SELECT 'CREATE ROLE public_staging_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'public_staging_role') \\gexec",
88
+ `ALTER ROLE public_staging_role WITH LOGIN PASSWORD ${password("public_staging_role")};`,
89
+ "SELECT 'CREATE DATABASE cms_public_staging OWNER admin_role' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cms_public_staging') \\gexec",
90
+ "REVOKE ALL ON DATABASE cms_public_staging FROM PUBLIC;",
91
+ "GRANT CONNECT ON DATABASE cms_public_staging TO public_staging_role, admin_role;",
92
+ "REVOKE ALL ON DATABASE cms_public_staging FROM public_role;",
93
+ "REVOKE ALL ON DATABASE cms_public FROM public_staging_role;",
94
+ "REVOKE ALL ON DATABASE cms_admin FROM public_staging_role;",
67
95
  ];
68
96
  }
69
97
 
70
98
  /**
71
- * The psql script (superuser) that applies only {@link PUBLIC_ROLE_LIMITS}.
72
- * The self-hosted upgrade runs it BEFORE the migrations: their role audit
73
- * refuses public_role without the limits, and the full convergence must
99
+ * The psql script (superuser) the self-hosted upgrade runs BEFORE the
100
+ * migrations: the gateway logins' {@link PUBLIC_ROLE_LIMITS} (#1338) and
101
+ * (#1329) the staging database with its login. The migrations' role audit
102
+ * refuses either gateway login without its limits, and the
103
+ * cms_public_staging run needs the database; the full convergence must
74
104
  * wait until after them (it drops roles a migration retired). Fails when
75
105
  * public_role does not exist — the install's roles were never created.
76
106
  */
77
- export function publicRoleLimitsScript(): string {
78
- return ["\\set ON_ERROR_STOP on", ...publicRoleLimitsSql(), ""].join("\n");
107
+ export function preMigrationRolesScript(stagingPassword: string): string {
108
+ return [
109
+ "\\set ON_ERROR_STOP on",
110
+ ...stagingDatabaseSql(() => literal(stagingPassword)),
111
+ ...publicRoleLimitsSql(),
112
+ "",
113
+ ].join("\n");
79
114
  }
80
115
 
81
- /** How a role's password appears in the SQL: a literal, or a psql variable. */
82
- type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
83
-
84
116
  /**
85
117
  * The convergence SQL (psql, as the superuser, connected to any database).
86
118
  * `password` renders each role's password; `agent` adds the agent's login.
@@ -111,11 +143,15 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
111
143
  "REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
112
144
  "GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
113
145
  "GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
146
+ // #1329 — after the other two databases exist: its revokes name them.
147
+ ...stagingDatabaseSql(password),
114
148
  // #635 — the gateway's former cms_admin login goes, with whatever it
115
149
  // still holds in either database (the superuser may drop what
116
150
  // migration 0285 could only revoke).
117
151
  "\\connect cms_public",
118
152
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
153
+ "\\connect cms_public_staging",
154
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
119
155
  "\\connect cms_admin",
120
156
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
121
157
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
@@ -136,6 +172,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
136
172
  const byRole: Record<AppRole, string> = {
137
173
  admin_role: passwords.admin,
138
174
  public_role: passwords.public,
175
+ public_staging_role: passwords.publicStaging,
139
176
  };
140
177
  return convergenceSql(
141
178
  (role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
@@ -147,6 +184,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
147
184
  const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
148
185
  admin_role: "ADMIN_ROLE_PASSWORD",
149
186
  public_role: "PUBLIC_ROLE_PASSWORD",
187
+ public_staging_role: "PUBLIC_STAGING_ROLE_PASSWORD",
150
188
  [AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
151
189
  };
152
190
 
@@ -185,18 +223,20 @@ ${convergenceSql((role) => `:'${role}_pw'`, opts.agent)}CAELO_SQL
185
223
  export type SuperuserPsql = (script: string) => Promise<{ exitCode: number; stderr: string }>;
186
224
 
187
225
  /**
188
- * Apply {@link publicRoleLimitsScript} through the running Postgres
226
+ * Apply {@link preMigrationRolesScript} through the running Postgres
189
227
  * container. Unlike {@link convergeSelfHostedRoles} a stopped database is a
190
228
  * failure: the upgrade runs it right before migrations that need it.
191
229
  */
192
- export async function applyPublicRoleLimits(deps: {
230
+ export async function applyPreMigrationRoles(deps: {
193
231
  readonly probe: () => Promise<boolean>;
194
232
  readonly psql: SuperuserPsql;
233
+ /** #1329 — public_staging_role's password from `.caelo/config.json`. */
234
+ readonly stagingPassword: string;
195
235
  }): Promise<ConvergeOutcome> {
196
236
  if (!(await deps.probe())) {
197
237
  return { ok: false, reachable: false, error: "the Postgres container is not running" };
198
238
  }
199
- const r = await deps.psql(publicRoleLimitsScript());
239
+ const r = await deps.psql(preMigrationRolesScript(deps.stagingPassword));
200
240
  if (r.exitCode === 0) return { ok: true };
201
241
  return { ok: false, reachable: true, error: r.stderr.trim() || `psql exited ${r.exitCode}` };
202
242
  }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1140 — the self-hosted Pulumi stack and the CLI render the same Compose
5
+ * file, so the scheduler interval `init` recorded survives a `pulumi up`,
6
+ * and a disagreeing Pulumi key is refused instead of winning silently.
7
+ */
8
+
9
+ import { afterAll, describe, expect, it } from "bun:test";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ recordedSchedulerIntervalMinutes,
15
+ selfHostedSchedulerIntervalMinutes,
16
+ } from "./self-hosted-scheduler-interval.js";
17
+
18
+ const dir = mkdtempSync(join(tmpdir(), "caelo-scheduler-interval-"));
19
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
20
+
21
+ function config(name: string, body: string): string {
22
+ const path = join(dir, name);
23
+ writeFileSync(path, body);
24
+ return path;
25
+ }
26
+
27
+ describe("recordedSchedulerIntervalMinutes", () => {
28
+ it("reads what init recorded", () => {
29
+ expect(
30
+ recordedSchedulerIntervalMinutes(config("a.json", '{"schedulerIntervalMinutes":2}')),
31
+ ).toBe(2);
32
+ });
33
+
34
+ it("is undefined without a config.json or without the key", () => {
35
+ expect(recordedSchedulerIntervalMinutes(join(dir, "missing.json"))).toBeUndefined();
36
+ expect(recordedSchedulerIntervalMinutes(config("b.json", '{"domain":"x"}'))).toBeUndefined();
37
+ });
38
+
39
+ it("refuses a broken file or an out-of-range value", () => {
40
+ expect(() => recordedSchedulerIntervalMinutes(config("c.json", "{"))).toThrow("not valid JSON");
41
+ expect(() =>
42
+ recordedSchedulerIntervalMinutes(config("d.json", '{"schedulerIntervalMinutes":30}')),
43
+ ).toThrow("between 1 and 15");
44
+ expect(() =>
45
+ recordedSchedulerIntervalMinutes(config("e.json", '{"schedulerIntervalMinutes":"2"}')),
46
+ ).toThrow("not a number");
47
+ });
48
+ });
49
+
50
+ describe("selfHostedSchedulerIntervalMinutes", () => {
51
+ it("keeps the recorded interval when the Pulumi key is unset", () => {
52
+ expect(selfHostedSchedulerIntervalMinutes(undefined, 2, "config.json")).toBe(2);
53
+ expect(selfHostedSchedulerIntervalMinutes(undefined, undefined, "config.json")).toBeUndefined();
54
+ });
55
+
56
+ it("takes the Pulumi key on a Pulumi-only install, or when both agree", () => {
57
+ expect(selfHostedSchedulerIntervalMinutes(3, undefined, "config.json")).toBe(3);
58
+ expect(selfHostedSchedulerIntervalMinutes(2, 2, "config.json")).toBe(2);
59
+ });
60
+
61
+ it("refuses a Pulumi key that disagrees with the record", () => {
62
+ expect(() => selfHostedSchedulerIntervalMinutes(5, 2, "config.json")).toThrow(
63
+ "config.json records 2",
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1140 — the self-hosted scheduler interval has two places it can be set:
5
+ * `cms-provision init --scheduler-interval-minutes` records it in
6
+ * `.caelo/config.json` (which every CLI render — `up`, `upgrade`, agent
7
+ * apply — reads), and the Pulumi stack has the config key
8
+ * `caelo-self-hosted:schedulerIntervalMinutes`. Both rewrite the same
9
+ * `docker-compose.yml`, so if each read only its own source a `pulumi up`
10
+ * over a CLI install would silently put the interval back to the default,
11
+ * and the next CLI `upgrade` would undo a Pulumi-only value the same way.
12
+ *
13
+ * The recorded `.caelo/config.json` is the install's one record (the CLI
14
+ * renders from nothing else), so the stack takes the interval from it; the
15
+ * Pulumi key may restate it but a different value is refused rather than
16
+ * one side winning silently (CLAUDE.md §2: no fallbacks).
17
+ */
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { parseSchedulerIntervalMinutes } from "./stack-contract.js";
21
+
22
+ /**
23
+ * Read the interval `init` recorded in a `.caelo/config.json`.
24
+ *
25
+ * @param configPath - the install's `config.json`.
26
+ * @returns the recorded minutes, or undefined when there is no config.json
27
+ * (a Pulumi-only install) or it records no interval (the default).
28
+ * @throws when the file is not JSON or the recorded value is out of bounds.
29
+ */
30
+ export function recordedSchedulerIntervalMinutes(configPath: string): number | undefined {
31
+ if (!existsSync(configPath)) return undefined;
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
35
+ } catch (e) {
36
+ throw new Error(`${configPath} is not valid JSON: ${(e as Error).message}`);
37
+ }
38
+ const raw =
39
+ parsed !== null && typeof parsed === "object"
40
+ ? (parsed as { schedulerIntervalMinutes?: unknown }).schedulerIntervalMinutes
41
+ : undefined;
42
+ if (raw === undefined) return undefined;
43
+ if (typeof raw !== "number") {
44
+ throw new Error(
45
+ `${configPath}: schedulerIntervalMinutes ${JSON.stringify(raw)} is not a number of minutes`,
46
+ );
47
+ }
48
+ return parseSchedulerIntervalMinutes(raw, `${configPath}: schedulerIntervalMinutes`);
49
+ }
50
+
51
+ /**
52
+ * The interval the self-hosted Pulumi stack renders into the Compose file.
53
+ *
54
+ * @param pulumiValue - `caelo-self-hosted:schedulerIntervalMinutes`, if set.
55
+ * @param recorded - what {@link recordedSchedulerIntervalMinutes} read.
56
+ * @param configPath - named in the refusal.
57
+ * @returns the minutes to render; undefined means neither source sets one
58
+ * (the generator's default).
59
+ * @throws when both are set and differ.
60
+ */
61
+ export function selfHostedSchedulerIntervalMinutes(
62
+ pulumiValue: number | undefined,
63
+ recorded: number | undefined,
64
+ configPath: string,
65
+ ): number | undefined {
66
+ if (pulumiValue === undefined) return recorded;
67
+ const minutes = parseSchedulerIntervalMinutes(
68
+ pulumiValue,
69
+ "caelo-self-hosted:schedulerIntervalMinutes",
70
+ );
71
+ if (recorded !== undefined && recorded !== minutes) {
72
+ throw new Error(
73
+ `caelo-self-hosted:schedulerIntervalMinutes is ${minutes} but ${configPath} records ${recorded}; ` +
74
+ "the CLI renders the Compose file from config.json, so the two must agree. " +
75
+ `Set the Pulumi key to ${recorded}, or remove it (\`pulumi config rm caelo-self-hosted:schedulerIntervalMinutes\`) to use the recorded value.`,
76
+ );
77
+ }
78
+ return minutes;
79
+ }
@@ -36,6 +36,7 @@ describe("startSelfHosted (#707 — one command ends with a serving install)", (
36
36
  "up -d --wait postgres",
37
37
  `${MIGRATE} admin`,
38
38
  `${MIGRATE} public`,
39
+ `${MIGRATE} public-staging`,
39
40
  "up -d --remove-orphans",
40
41
  "config --services",
41
42
  "exec -T caelo-admin bun -e <probe>",
@@ -151,7 +151,7 @@ function deps(
151
151
  runner.calls.push("converge roles");
152
152
  return roles;
153
153
  },
154
- applyPublicRoleLimits: async () => {
154
+ applyPreMigrationRoles: async () => {
155
155
  runner.calls.push("public role limits");
156
156
  return { ok: true } as const;
157
157
  },
@@ -187,6 +187,7 @@ describe("upgradeSelfHosted", () => {
187
187
  "public role limits",
188
188
  "migrate admin",
189
189
  "migrate public",
190
+ "migrate public-staging",
190
191
  "converge roles",
191
192
  "up caelo-admin",
192
193
  "health admin",
@@ -12,7 +12,9 @@
12
12
  * 3b. #1338 — recreate Postgres when the Compose file changed it (TLS with
13
13
  * the install's own certificate, self-hosted-db-tls.ts: the new images
14
14
  * connect with verify-full only; the old containers keep connecting,
15
- * Postgres still accepts them) and apply public_role's limits as the
15
+ * Postgres still accepts them) and, as the superuser, create the
16
+ * staging database with its gateway login (#1329) and apply both gateway
17
+ * logins' limits as the
16
18
  * superuser (the migrations' role audit refuses public_role without
17
19
  * them, and the self-hosted admin_role cannot set them);
18
20
  * 4. run the migrations from the NEW admin image (`docker compose run`,
@@ -65,6 +67,7 @@ import {
65
67
  MEDIA_DIR,
66
68
  type ReleaseImages,
67
69
  } from "./compose.js";
70
+ import { MIGRATION_TARGETS } from "./migration-targets.js";
68
71
  import { selfCommand } from "./self-command.js";
69
72
  import {
70
73
  type HostRunner,
@@ -125,11 +128,13 @@ export interface SelfHostedUpgradeDeps {
125
128
  */
126
129
  readonly convergeRoles: () => Promise<ConvergeOutcome>;
127
130
  /**
128
- * #1338 — applies public_role's limits as the Postgres superuser
129
- * (`applyPublicRoleLimits`), before the migrations whose role audit
131
+ * #1338 / #1329 — as the Postgres superuser (`applyPreMigrationRoles`):
132
+ * creates the staging database and its gateway login on an install from
133
+ * before them, and applies both gateway logins' limits, before the
134
+ * migrations that run on that database and whose role audit
130
135
  * checks them.
131
136
  */
132
- readonly applyPublicRoleLimits: () => Promise<ConvergeOutcome>;
137
+ readonly applyPreMigrationRoles: () => Promise<ConvergeOutcome>;
133
138
  /**
134
139
  * Re-writes the Caddyfile from the install's config + domains table and
135
140
  * reloads Caddy (#891): the edge headers (the SVG CSP, nosniff) live in
@@ -401,12 +406,12 @@ export async function upgradeSelfHosted(
401
406
  back.exitCode === 0,
402
407
  );
403
408
  }
404
- const limits = await deps.applyPublicRoleLimits();
409
+ const limits = await deps.applyPreMigrationRoles();
405
410
  if (!limits.ok) {
406
411
  restoreCompose(file, previousCompose);
407
412
  return fail(
408
413
  "roles",
409
- `Could not apply public_role's connection and statement limits: ${limits.error}\nThe previous release keeps running; no migration ran. Database backup: ${backup.dir}`,
414
+ `Could not prepare the database roles for the migrations (the staging database cms_public_staging and the gateway logins' connection and statement limits): ${limits.error}\nThe previous release keeps running; no migration ran. Database backup: ${backup.dir}`,
410
415
  true,
411
416
  );
412
417
  }
@@ -600,7 +605,8 @@ export async function dumpDatabases(
600
605
  }
601
606
 
602
607
  /**
603
- * Apply the committed migrations (cms_admin, then cms_public) with the
608
+ * Apply the committed migrations (cms_admin, then cms_public and its staging
609
+ * twin cms_public_staging, #1329) with the
604
610
  * admin image the Compose file names, as a one-off container — the
605
611
  * self-hosted equivalent of the GCP `caelo-migrate-<target>` Cloud Run job.
606
612
  * drizzle's bookkeeping makes re-runs apply only new migrations.
@@ -609,7 +615,7 @@ export async function runMigrations(
609
615
  caeloDir: string,
610
616
  runner: HostRunner,
611
617
  ): Promise<{ ok: true } | { ok: false; error: string }> {
612
- for (const target of ["admin", "public"] as const) {
618
+ for (const target of MIGRATION_TARGETS) {
613
619
  const r = await runner.run(
614
620
  compose(
615
621
  caeloDir,