@caelo-cms/provisioning 0.11.9 → 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 (352) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/admin-domain.d.ts +10 -1
  4. package/dist/admin-domain.d.ts.map +1 -1
  5. package/dist/admin-domain.js +13 -3
  6. package/dist/admin-domain.js.map +1 -1
  7. package/dist/adopt-existing.d.ts +12 -3
  8. package/dist/adopt-existing.d.ts.map +1 -1
  9. package/dist/adopt-existing.js +143 -0
  10. package/dist/adopt-existing.js.map +1 -1
  11. package/dist/agent/command.d.ts +23 -0
  12. package/dist/agent/command.d.ts.map +1 -1
  13. package/dist/agent/command.js +32 -9
  14. package/dist/agent/command.js.map +1 -1
  15. package/dist/agent/run.d.ts +2 -0
  16. package/dist/agent/run.d.ts.map +1 -1
  17. package/dist/agent/run.js +5 -1
  18. package/dist/agent/run.js.map +1 -1
  19. package/dist/agent-db.js +69 -10
  20. package/dist/aws-task-roles.d.ts +4 -2
  21. package/dist/aws-task-roles.d.ts.map +1 -1
  22. package/dist/aws-task-roles.js +4 -1
  23. package/dist/aws-task-roles.js.map +1 -1
  24. package/dist/caddy.d.ts +21 -4
  25. package/dist/caddy.d.ts.map +1 -1
  26. package/dist/caddy.js +27 -3
  27. package/dist/caddy.js.map +1 -1
  28. package/dist/cli-routing.js +1 -1
  29. package/dist/cli-routing.js.map +1 -1
  30. package/dist/cli.js +68 -7
  31. package/dist/cli.js.map +1 -1
  32. package/dist/cloudsql-tls.d.ts +93 -0
  33. package/dist/cloudsql-tls.d.ts.map +1 -0
  34. package/dist/cloudsql-tls.js +295 -0
  35. package/dist/cloudsql-tls.js.map +1 -0
  36. package/dist/compose.d.ts +43 -6
  37. package/dist/compose.d.ts.map +1 -1
  38. package/dist/compose.js +148 -23
  39. package/dist/compose.js.map +1 -1
  40. package/dist/converge-checkpoint.d.ts +1 -0
  41. package/dist/converge-checkpoint.d.ts.map +1 -1
  42. package/dist/converge-checkpoint.js +8 -1
  43. package/dist/converge-checkpoint.js.map +1 -1
  44. package/dist/converge-db-trust.d.ts +44 -0
  45. package/dist/converge-db-trust.d.ts.map +1 -0
  46. package/dist/converge-db-trust.js +158 -0
  47. package/dist/converge-db-trust.js.map +1 -0
  48. package/dist/converge-job-gcp.d.ts +5 -2
  49. package/dist/converge-job-gcp.d.ts.map +1 -1
  50. package/dist/converge-job-gcp.js +11 -13
  51. package/dist/converge-job-gcp.js.map +1 -1
  52. package/dist/converge-job-preflight.d.ts.map +1 -1
  53. package/dist/converge-job-preflight.js +8 -0
  54. package/dist/converge-job-preflight.js.map +1 -1
  55. package/dist/converge-job.d.ts +17 -0
  56. package/dist/converge-job.d.ts.map +1 -1
  57. package/dist/converge-job.js +12 -0
  58. package/dist/converge-job.js.map +1 -1
  59. package/dist/deployed-release.d.ts +4 -1
  60. package/dist/deployed-release.d.ts.map +1 -1
  61. package/dist/deployed-release.js +18 -8
  62. package/dist/deployed-release.js.map +1 -1
  63. package/dist/gcp-names.d.ts +34 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +45 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/gcp-url-map.d.ts.map +1 -1
  68. package/dist/gcp-url-map.js +6 -2
  69. package/dist/gcp-url-map.js.map +1 -1
  70. package/dist/index.d.ts +3 -2
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +2 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/infra-contract.d.ts +1 -1
  75. package/dist/infra-contract.d.ts.map +1 -1
  76. package/dist/infra-contract.js +45 -1
  77. package/dist/infra-contract.js.map +1 -1
  78. package/dist/install-job.d.ts +4 -2
  79. package/dist/install-job.d.ts.map +1 -1
  80. package/dist/install-job.js +4 -1
  81. package/dist/install-job.js.map +1 -1
  82. package/dist/install-state.d.ts +13 -0
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +17 -4
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +20 -8
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  90. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  91. package/dist/mcp-auth-proxy-setup.js +280 -0
  92. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  93. package/dist/migration-runner.d.ts.map +1 -1
  94. package/dist/migration-runner.js +8 -3
  95. package/dist/migration-runner.js.map +1 -1
  96. package/dist/migration-targets.d.ts +11 -0
  97. package/dist/migration-targets.d.ts.map +1 -0
  98. package/dist/migration-targets.js +12 -0
  99. package/dist/migration-targets.js.map +1 -0
  100. package/dist/operator-access.d.ts +10 -0
  101. package/dist/operator-access.d.ts.map +1 -1
  102. package/dist/operator-access.js +5 -0
  103. package/dist/operator-access.js.map +1 -1
  104. package/dist/pool-budget-stack.d.ts +13 -1
  105. package/dist/pool-budget-stack.d.ts.map +1 -1
  106. package/dist/pool-budget-stack.js +11 -1
  107. package/dist/pool-budget-stack.js.map +1 -1
  108. package/dist/public-role-isolation.d.ts +7 -3
  109. package/dist/public-role-isolation.d.ts.map +1 -1
  110. package/dist/public-role-isolation.js +25 -13
  111. package/dist/public-role-isolation.js.map +1 -1
  112. package/dist/release-contract.d.ts.map +1 -1
  113. package/dist/release-contract.js +3 -0
  114. package/dist/release-contract.js.map +1 -1
  115. package/dist/release-manifest.d.ts +25 -1
  116. package/dist/release-manifest.d.ts.map +1 -1
  117. package/dist/release-manifest.js +36 -3
  118. package/dist/release-manifest.js.map +1 -1
  119. package/dist/remote-mcp.d.ts +55 -18
  120. package/dist/remote-mcp.d.ts.map +1 -1
  121. package/dist/remote-mcp.js +307 -77
  122. package/dist/remote-mcp.js.map +1 -1
  123. package/dist/runtime-secrets.d.ts +11 -0
  124. package/dist/runtime-secrets.d.ts.map +1 -1
  125. package/dist/runtime-secrets.js +40 -25
  126. package/dist/runtime-secrets.js.map +1 -1
  127. package/dist/scheduler-setup.d.ts +75 -0
  128. package/dist/scheduler-setup.d.ts.map +1 -0
  129. package/dist/scheduler-setup.js +331 -0
  130. package/dist/scheduler-setup.js.map +1 -0
  131. package/dist/seal-key-rotation.d.ts +7 -7
  132. package/dist/seal-key-rotation.d.ts.map +1 -1
  133. package/dist/seal-key-rotation.js +22 -22
  134. package/dist/seal-key-rotation.js.map +1 -1
  135. package/dist/self-hosted-db-tls.d.ts +37 -0
  136. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  137. package/dist/self-hosted-db-tls.js +220 -0
  138. package/dist/self-hosted-db-tls.js.map +1 -0
  139. package/dist/self-hosted-release.d.ts +3 -1
  140. package/dist/self-hosted-release.d.ts.map +1 -1
  141. package/dist/self-hosted-release.js +33 -9
  142. package/dist/self-hosted-release.js.map +1 -1
  143. package/dist/self-hosted-roles.d.ts +43 -2
  144. package/dist/self-hosted-roles.d.ts.map +1 -1
  145. package/dist/self-hosted-roles.js +87 -1
  146. package/dist/self-hosted-roles.js.map +1 -1
  147. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  148. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  149. package/dist/self-hosted-scheduler-interval.js +68 -0
  150. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  151. package/dist/self-hosted-start.d.ts +0 -21
  152. package/dist/self-hosted-start.d.ts.map +1 -1
  153. package/dist/self-hosted-start.js +39 -1
  154. package/dist/self-hosted-start.js.map +1 -1
  155. package/dist/self-hosted-upgrade.d.ts +19 -7
  156. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  157. package/dist/self-hosted-upgrade.js +78 -25
  158. package/dist/self-hosted-upgrade.js.map +1 -1
  159. package/dist/stack-contract.d.ts +234 -28
  160. package/dist/stack-contract.d.ts.map +1 -1
  161. package/dist/stack-contract.js +397 -39
  162. package/dist/stack-contract.js.map +1 -1
  163. package/dist/stack-converge.d.ts +31 -0
  164. package/dist/stack-converge.d.ts.map +1 -1
  165. package/dist/stack-converge.js +71 -30
  166. package/dist/stack-converge.js.map +1 -1
  167. package/dist/staging-database.d.ts +22 -0
  168. package/dist/staging-database.d.ts.map +1 -0
  169. package/dist/staging-database.js +117 -0
  170. package/dist/staging-database.js.map +1 -0
  171. package/dist/upgrade/app-roll.d.ts +25 -4
  172. package/dist/upgrade/app-roll.d.ts.map +1 -1
  173. package/dist/upgrade/app-roll.js +63 -17
  174. package/dist/upgrade/app-roll.js.map +1 -1
  175. package/dist/upgrade/converge.d.ts +5 -0
  176. package/dist/upgrade/converge.d.ts.map +1 -1
  177. package/dist/upgrade/converge.js +207 -20
  178. package/dist/upgrade/converge.js.map +1 -1
  179. package/dist/upgrade/health.d.ts +31 -7
  180. package/dist/upgrade/health.d.ts.map +1 -1
  181. package/dist/upgrade/health.js +41 -17
  182. package/dist/upgrade/health.js.map +1 -1
  183. package/dist/upgrade/image-forms.d.ts +1 -1
  184. package/dist/upgrade/image-forms.d.ts.map +1 -1
  185. package/dist/upgrade/image-forms.js +24 -7
  186. package/dist/upgrade/image-forms.js.map +1 -1
  187. package/dist/upgrade/release-images.d.ts +12 -3
  188. package/dist/upgrade/release-images.d.ts.map +1 -1
  189. package/dist/upgrade/release-images.js +13 -4
  190. package/dist/upgrade/release-images.js.map +1 -1
  191. package/dist/upgrade/upgrade.d.ts +19 -3
  192. package/dist/upgrade/upgrade.d.ts.map +1 -1
  193. package/dist/upgrade/upgrade.js +37 -2
  194. package/dist/upgrade/upgrade.js.map +1 -1
  195. package/dist/upgrader-gcp.d.ts +13 -0
  196. package/dist/upgrader-gcp.d.ts.map +1 -1
  197. package/dist/upgrader-gcp.js +50 -1
  198. package/dist/upgrader-gcp.js.map +1 -1
  199. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  200. package/dist/upgrader-iam-preflight.js +6 -1
  201. package/dist/upgrader-iam-preflight.js.map +1 -1
  202. package/dist/upgrader-job.d.ts +1 -1
  203. package/dist/upgrader-job.d.ts.map +1 -1
  204. package/dist/upgrader-job.js +20 -14
  205. package/dist/upgrader-job.js.map +1 -1
  206. package/dist/upgrader-records.d.ts +1 -1
  207. package/dist/upgrader-records.d.ts.map +1 -1
  208. package/dist/upgrader-records.js +26 -7
  209. package/dist/upgrader-records.js.map +1 -1
  210. package/dist/upgrader-setup.d.ts.map +1 -1
  211. package/dist/upgrader-setup.js +10 -5
  212. package/dist/upgrader-setup.js.map +1 -1
  213. package/dist/wizard.d.ts +7 -0
  214. package/dist/wizard.d.ts.map +1 -1
  215. package/dist/wizard.js +11 -0
  216. package/dist/wizard.js.map +1 -1
  217. package/dist/wizards/gcp-cost.d.ts +31 -0
  218. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  219. package/dist/wizards/gcp-cost.js +103 -18
  220. package/dist/wizards/gcp-cost.js.map +1 -1
  221. package/dist/wizards/gcp.d.ts.map +1 -1
  222. package/dist/wizards/gcp.js +129 -27
  223. package/dist/wizards/gcp.js.map +1 -1
  224. package/package.json +2 -2
  225. package/src/adapter.ts +6 -0
  226. package/src/admin-domain.test.ts +9 -8
  227. package/src/admin-domain.ts +13 -3
  228. package/src/admin-memory.test.ts +2 -1
  229. package/src/adopt-existing.test.ts +85 -0
  230. package/src/adopt-existing.ts +141 -3
  231. package/src/agent/command.test.ts +49 -1
  232. package/src/agent/command.ts +39 -9
  233. package/src/agent/run.test.ts +19 -0
  234. package/src/agent/run.ts +11 -2
  235. package/src/aws-task-roles.ts +5 -2
  236. package/src/caddy-redirects.docker.test.ts +2 -0
  237. package/src/caddy-redirects.test.ts +4 -1
  238. package/src/caddy-validate.test.ts +7 -1
  239. package/src/caddy.test.ts +39 -1
  240. package/src/caddy.ts +36 -4
  241. package/src/checkpoint-secrets.test.ts +14 -6
  242. package/src/cli-routing.test.ts +1 -1
  243. package/src/cli-routing.ts +1 -1
  244. package/src/cli.ts +71 -5
  245. package/src/cloudsql-tls.test.ts +255 -0
  246. package/src/cloudsql-tls.ts +359 -0
  247. package/src/compose.ts +193 -24
  248. package/src/converge-checkpoint.ts +8 -1
  249. package/src/converge-db-trust.test.ts +237 -0
  250. package/src/converge-db-trust.ts +212 -0
  251. package/src/converge-job-gcp.ts +11 -12
  252. package/src/converge-job-preflight.test.ts +5 -0
  253. package/src/converge-job-preflight.ts +8 -0
  254. package/src/converge-job.test.ts +70 -0
  255. package/src/converge-job.ts +32 -0
  256. package/src/database-tls-builders.test.ts +56 -0
  257. package/src/deployed-release.test.ts +107 -40
  258. package/src/deployed-release.ts +30 -9
  259. package/src/gateway-env-isolation.test.ts +50 -12
  260. package/src/gcp-names.ts +56 -0
  261. package/src/gcp-url-map.test.ts +6 -8
  262. package/src/gcp-url-map.ts +6 -2
  263. package/src/index.test.ts +10 -4
  264. package/src/index.ts +7 -0
  265. package/src/infra-contract.test.ts +7 -3
  266. package/src/infra-contract.ts +45 -1
  267. package/src/install-job.ts +4 -1
  268. package/src/install-state.ts +33 -4
  269. package/src/lifecycle.ts +23 -11
  270. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  271. package/src/mcp-auth-proxy-setup.ts +374 -0
  272. package/src/mcp-iap-stacks.test.ts +1 -1
  273. package/src/media-volume.test.ts +1 -1
  274. package/src/migration-runner.ts +14 -9
  275. package/src/migration-targets.ts +12 -0
  276. package/src/operator-access-stacks.test.ts +1 -1
  277. package/src/operator-access.test.ts +20 -4
  278. package/src/operator-access.ts +14 -0
  279. package/src/pool-budget-stack.ts +15 -0
  280. package/src/pool-budget-stacks.test.ts +39 -14
  281. package/src/public-role-isolation.test.ts +18 -8
  282. package/src/public-role-isolation.ts +31 -17
  283. package/src/release-contract.ts +3 -0
  284. package/src/release-manifest.test.ts +25 -0
  285. package/src/release-manifest.ts +59 -4
  286. package/src/remote-mcp-stack-config.test.ts +19 -9
  287. package/src/remote-mcp.test.ts +252 -57
  288. package/src/remote-mcp.ts +336 -77
  289. package/src/runtime-secrets.test.ts +37 -14
  290. package/src/runtime-secrets.ts +87 -27
  291. package/src/scheduler-setup.test.ts +201 -0
  292. package/src/scheduler-setup.ts +375 -0
  293. package/src/seal-key-rotation.test.ts +2 -2
  294. package/src/seal-key-rotation.ts +25 -25
  295. package/src/self-hosted-backup.test.ts +1 -0
  296. package/src/self-hosted-compose.test.ts +180 -6
  297. package/src/self-hosted-db-tls.test.ts +111 -0
  298. package/src/self-hosted-db-tls.ts +287 -0
  299. package/src/self-hosted-release-resolve.test.ts +71 -0
  300. package/src/self-hosted-release.ts +41 -11
  301. package/src/self-hosted-roles.integration.test.ts +2 -0
  302. package/src/self-hosted-roles.test.ts +87 -3
  303. package/src/self-hosted-roles.ts +101 -3
  304. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  305. package/src/self-hosted-scheduler-interval.ts +79 -0
  306. package/src/self-hosted-start.test.ts +35 -2
  307. package/src/self-hosted-start.ts +20 -2
  308. package/src/self-hosted-upgrade.test.ts +104 -14
  309. package/src/self-hosted-upgrade.ts +123 -31
  310. package/src/stack-contract-parity.test.ts +79 -12
  311. package/src/stack-contract.test.ts +199 -31
  312. package/src/stack-contract.ts +472 -45
  313. package/src/stack-converge-secrets.test.ts +28 -13
  314. package/src/stack-converge.test.ts +203 -28
  315. package/src/stack-converge.ts +92 -30
  316. package/src/staging-database.test.ts +114 -0
  317. package/src/staging-database.ts +148 -0
  318. package/src/state-store.test.ts +2 -1
  319. package/src/upgrade/app-roll.ts +97 -19
  320. package/src/upgrade/converge.ts +249 -19
  321. package/src/upgrade/health.test.ts +63 -25
  322. package/src/upgrade/health.ts +54 -22
  323. package/src/upgrade/image-forms.test.ts +34 -0
  324. package/src/upgrade/image-forms.ts +23 -8
  325. package/src/upgrade/release-images.test.ts +5 -1
  326. package/src/upgrade/release-images.ts +22 -6
  327. package/src/upgrade/upgrade.test.ts +438 -46
  328. package/src/upgrade/upgrade.ts +64 -6
  329. package/src/upgrader-gcp.ts +66 -3
  330. package/src/upgrader-iam-preflight.test.ts +19 -0
  331. package/src/upgrader-iam-preflight.ts +6 -0
  332. package/src/upgrader-job.test.ts +101 -27
  333. package/src/upgrader-job.ts +27 -14
  334. package/src/upgrader-records.test.ts +97 -12
  335. package/src/upgrader-records.ts +28 -11
  336. package/src/upgrader-setup.test.ts +2 -0
  337. package/src/upgrader-setup.ts +10 -4
  338. package/src/wizard.ts +19 -0
  339. package/src/wizards/gcp-cost.test.ts +103 -0
  340. package/src/wizards/gcp-cost.ts +125 -18
  341. package/src/wizards/gcp.ts +164 -22
  342. package/stacks/aws/Pulumi.yaml +1 -1
  343. package/stacks/aws/index.ts +72 -14
  344. package/stacks/azure/index.ts +108 -21
  345. package/stacks/gcp/Pulumi.yaml +1 -1
  346. package/stacks/gcp/README.md +1 -0
  347. package/stacks/gcp/index.ts +133 -24
  348. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  349. package/stacks/gcp-firebase/index.ts +212 -34
  350. package/stacks/self-hosted/Pulumi.yaml +3 -0
  351. package/stacks/self-hosted/README.md +6 -1
  352. package/stacks/self-hosted/index.ts +40 -5
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1328 — resolving a self-hosted release: the MCP-Auth-Proxy image is
5
+ * optional only for a release that has none (the registry says its tag does
6
+ * not exist), never because the registry could not be asked.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { type HostRunner, resolveVerifiedRelease } from "./self-hosted-release.js";
11
+
12
+ const D = (c: string) => `sha256:${c.repeat(64)}`;
13
+
14
+ /** GHCR with a token challenge; `proxy` decides the MCP-Auth-Proxy tag's answer. */
15
+ function ghcr(proxy: "present" | 404 | 503): typeof fetch {
16
+ return (async (input: string | URL | Request, init?: RequestInit) => {
17
+ const url = String(input);
18
+ if (url.startsWith("https://ghcr.io/token")) return Response.json({ token: "t" });
19
+ if (!new Headers(init?.headers).get("authorization")) {
20
+ return new Response(null, {
21
+ status: 401,
22
+ headers: { "www-authenticate": 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' },
23
+ });
24
+ }
25
+ if (url.includes("/caelo-cms/mcp-auth-proxy/")) {
26
+ if (proxy !== "present") return new Response(null, { status: proxy });
27
+ return new Response(null, { status: 200, headers: { "docker-content-digest": D("c") } });
28
+ }
29
+ const digest = url.includes("/caelo-cms/admin/") ? D("a") : D("b");
30
+ return new Response(null, { status: 200, headers: { "docker-content-digest": digest } });
31
+ }) as typeof fetch;
32
+ }
33
+
34
+ function runner(): HostRunner & { calls: string[] } {
35
+ const calls: string[] = [];
36
+ return {
37
+ calls,
38
+ async run(argv) {
39
+ calls.push(argv.join(" "));
40
+ return { exitCode: 0, stdout: "", stderr: "" };
41
+ },
42
+ };
43
+ }
44
+
45
+ describe("resolveVerifiedRelease (#1328)", () => {
46
+ it("resolves and verifies all three images of a release that ships the proxy", async () => {
47
+ const r = runner();
48
+ const res = await resolveVerifiedRelease("0.12.0", { runner: r, fetchImpl: ghcr("present") });
49
+ expect(res.ok).toBe(true);
50
+ if (!res.ok) return;
51
+ expect(res.images["mcp-auth-proxy"]).toBe(`ghcr.io/caelo-cms/mcp-auth-proxy@${D("c")}`);
52
+ expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(3);
53
+ });
54
+
55
+ it("a release from before the proxy (its tag does not exist) resolves without it", async () => {
56
+ const r = runner();
57
+ const res = await resolveVerifiedRelease("0.11.0", { runner: r, fetchImpl: ghcr(404) });
58
+ expect(res.ok).toBe(true);
59
+ if (!res.ok) return;
60
+ expect("mcp-auth-proxy" in res.images).toBe(false);
61
+ expect(res.images.admin).toBe(`ghcr.io/caelo-cms/admin@${D("a")}`);
62
+ expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(2);
63
+ });
64
+
65
+ it("a registry outage on the proxy tag fails instead of installing without it", async () => {
66
+ const res = await resolveVerifiedRelease("0.12.0", { runner: runner(), fetchImpl: ghcr(503) });
67
+ expect(res.ok).toBe(false);
68
+ if (res.ok) return;
69
+ expect(res.error).toContain("mcp-auth-proxy:0.12.0");
70
+ });
71
+ });
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * `.caelo/release.json` is the install metadata for self-hosted (the cloud
8
8
  * providers keep theirs in `~/.caelo-<id>/install.json`): the release
9
- * version, the digest-pinned admin + gateway image references the Compose
10
- * file runs, and a short history of installs/upgrades/rollbacks. It holds no
9
+ * version, the digest-pinned image references the Compose file runs (admin,
10
+ * gateway, and since #1328 the MCP-Auth-Proxy), and a short history of installs/upgrades/rollbacks. It holds no
11
11
  * secrets (config.json does), so it is safe to read from tooling and
12
12
  * backups; writes refuse secret material like install.json's do.
13
13
  *
@@ -27,6 +27,7 @@ import {
27
27
  GHCR_NAMESPACE,
28
28
  GHCR_REGISTRY,
29
29
  RELEASE_SERVICES,
30
+ type ReleaseService,
30
31
  resolveRegistryDigest,
31
32
  } from "./upgrade/release-images.js";
32
33
 
@@ -103,7 +104,12 @@ export interface ReleaseHistoryEntry {
103
104
  export interface SelfHostedRelease {
104
105
  /** The release tag the images were resolved from (e.g. `0.10.43`). */
105
106
  readonly version: string;
106
- readonly images: { readonly admin: string; readonly gateway: string };
107
+ readonly images: {
108
+ readonly admin: string;
109
+ readonly gateway: string;
110
+ /** #1328 — absent in records of releases from before the MCP-Auth-Proxy. */
111
+ readonly "mcp-auth-proxy"?: string;
112
+ };
107
113
  readonly installedAt: string;
108
114
  /** Newest last; capped at {@link HISTORY_LIMIT}. */
109
115
  readonly history: readonly ReleaseHistoryEntry[];
@@ -117,7 +123,17 @@ export function releasePath(caeloDir: string): string {
117
123
 
118
124
  /** The images of a recorded release, as the Compose generator takes them. */
119
125
  export function releaseImages(release: SelfHostedRelease): ReleaseImages {
120
- return { mode: "release", admin: release.images.admin, gateway: release.images.gateway };
126
+ return { mode: "release", ...release.images };
127
+ }
128
+
129
+ /** The image references of `images`, as release.json stores them. */
130
+ function recordedImages(images: ReleaseImages): SelfHostedRelease["images"] {
131
+ const proxy = images["mcp-auth-proxy"];
132
+ return {
133
+ admin: images.admin,
134
+ gateway: images.gateway,
135
+ ...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}),
136
+ };
121
137
  }
122
138
 
123
139
  /**
@@ -131,10 +147,12 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
131
147
  const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<SelfHostedRelease>;
132
148
  const admin = raw.images?.admin ?? "";
133
149
  const gateway = raw.images?.gateway ?? "";
150
+ const proxy = raw.images?.["mcp-auth-proxy"];
134
151
  if (
135
152
  typeof raw.version !== "string" ||
136
153
  !ghcrImageDigest("admin", admin) ||
137
- !ghcrImageDigest("gateway", gateway)
154
+ !ghcrImageDigest("gateway", gateway) ||
155
+ (proxy !== undefined && !ghcrImageDigest("mcp-auth-proxy", proxy))
138
156
  ) {
139
157
  throw new Error(
140
158
  `${path} is malformed (needs version + digest-pinned ghcr.io/caelo-cms images); fix it or re-run \`${selfCommand("upgrade", "--version", "<x.y.z>")}\`.`,
@@ -142,7 +160,7 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
142
160
  }
143
161
  return {
144
162
  version: raw.version,
145
- images: { admin, gateway },
163
+ images: { admin, gateway, ...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}) },
146
164
  installedAt: raw.installedAt ?? "",
147
165
  history: Array.isArray(raw.history) ? raw.history : [],
148
166
  };
@@ -162,7 +180,7 @@ export function recordRelease(
162
180
  const at = now.toISOString();
163
181
  const record: SelfHostedRelease = {
164
182
  version: next.version,
165
- images: { admin: next.images.admin, gateway: next.images.gateway },
183
+ images: recordedImages(next.images),
166
184
  installedAt:
167
185
  prior && prior.version === next.version && entry.outcome === "rolled-back"
168
186
  ? prior.installedAt
@@ -195,7 +213,7 @@ export type VerifiedRelease =
195
213
  | { readonly ok: false; readonly error: string };
196
214
 
197
215
  /**
198
- * Resolve the admin + gateway images of release `tag` in GHCR to digests,
216
+ * Resolve every release image of `tag` (RELEASE_SERVICES) in GHCR to digests,
199
217
  * then cosign-verify each digest against the Caelo release workflow
200
218
  * identity (unless `skipVerify`, the escape hatch for forks). The returned
201
219
  * references are digest-pinned, so what was verified is exactly what runs.
@@ -209,7 +227,7 @@ export async function resolveVerifiedRelease(
209
227
  readonly onProgress?: (message: string) => void;
210
228
  },
211
229
  ): Promise<VerifiedRelease> {
212
- const refs: Partial<Record<"admin" | "gateway", string>> = {};
230
+ const refs: Partial<Record<ReleaseService, string>> = {};
213
231
  for (const service of RELEASE_SERVICES) {
214
232
  deps.onProgress?.(`Resolving ${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}:${tag}`);
215
233
  const r = await resolveRegistryDigest(
@@ -218,6 +236,14 @@ export async function resolveVerifiedRelease(
218
236
  tag,
219
237
  deps.fetchImpl,
220
238
  );
239
+ if (!r.ok && r.notFound && service === "mcp-auth-proxy") {
240
+ // #1328 — releases from before the MCP-Auth-Proxy ship no image of it,
241
+ // and they stay installable and rollback targets: their Compose file
242
+ // simply has no proxy (ReleaseImages models the absence). Only a
243
+ // registry verdict that the tag does not exist means that; an outage
244
+ // still fails below, never silently installs a release without it.
245
+ continue;
246
+ }
221
247
  if (!r.ok) {
222
248
  return {
223
249
  ok: false,
@@ -228,10 +254,12 @@ export async function resolveVerifiedRelease(
228
254
  }
229
255
  refs[service] = ghcrImageRef(service, r.digest);
230
256
  }
257
+ const proxy = refs["mcp-auth-proxy"];
231
258
  const images: ReleaseImages = {
232
259
  mode: "release",
233
260
  admin: refs.admin ?? "",
234
261
  gateway: refs.gateway ?? "",
262
+ ...(proxy ? { "mcp-auth-proxy": proxy } : {}),
235
263
  };
236
264
  if (deps.skipVerify) return { ok: true, images };
237
265
 
@@ -246,13 +274,15 @@ export async function resolveVerifiedRelease(
246
274
  };
247
275
  }
248
276
  for (const service of RELEASE_SERVICES) {
277
+ const ref = images[service];
278
+ if (ref === undefined) continue;
249
279
  deps.onProgress?.(`Verifying the signature of ${service}`);
250
- const v = await deps.runner.run(cosignVerifyArgv(images[service]));
280
+ const v = await deps.runner.run(cosignVerifyArgv(ref));
251
281
  if (v.exitCode !== 0) {
252
282
  return {
253
283
  ok: false,
254
284
  error:
255
- `cosign verify FAILED for ${images[service]}: its signature does not match the Caelo release workflow.\n` +
285
+ `cosign verify FAILED for ${ref}: its signature does not match the Caelo release workflow.\n` +
256
286
  "Either the registry was compromised, the image is a fork's, or sigstore had an outage (retry).\n" +
257
287
  `cosign: ${v.stderr.trim().slice(0, 500)}`,
258
288
  };
@@ -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)", () => {
@@ -1,14 +1,19 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
+ import { readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
4
6
  import {
7
+ applyPreMigrationRoles,
5
8
  convergeSelfHostedRoles,
6
9
  decideUp,
10
+ PUBLIC_ROLE_LIMITS,
11
+ preMigrationRolesScript,
7
12
  roleConvergenceScript,
8
13
  selfHostedBootstrapScript,
9
14
  } from "./self-hosted-roles.js";
10
15
 
11
- const PASSWORDS = { admin: "a'pw", public: "p-pw" };
16
+ const PASSWORDS = { admin: "a'pw", public: "p-pw", publicStaging: "s'pw" };
12
17
 
13
18
  describe("roleConvergenceScript (#613 review)", () => {
14
19
  const script = roleConvergenceScript(PASSWORDS);
@@ -23,13 +28,13 @@ describe("roleConvergenceScript (#613 review)", () => {
23
28
  expect(script).not.toContain("CREATE ROLE gateway_role");
24
29
  });
25
30
 
26
- 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)", () => {
27
32
  expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
28
33
  const publicDrop = script.indexOf("\\connect cms_public");
29
34
  const adminDrop = script.indexOf("\\connect cms_admin");
30
35
  expect(publicDrop).toBeGreaterThan(-1);
31
36
  expect(adminDrop).toBeGreaterThan(publicDrop);
32
- expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
37
+ expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(3);
33
38
  expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
34
39
  expect(script).not.toContain("caelo_grant_gateway_role");
35
40
  });
@@ -126,3 +131,82 @@ describe("decideUp — `cms-provision up` never switches URLs onto unapplied pas
126
131
  expect(d).toMatchObject({ kind: "proceed", markConverged: false });
127
132
  });
128
133
  });
134
+
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;",
143
+ ];
144
+
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", () => {
172
+ for (const script of [
173
+ selfHostedBootstrapScript({ agent: false }),
174
+ roleConvergenceScript(PASSWORDS),
175
+ preMigrationRolesScript("s'pw"),
176
+ ]) {
177
+ for (const st of statements) expect(script).toContain(st);
178
+ }
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);
185
+ });
186
+
187
+ it("match the values packages/migrations/src/bootstrap.sh sets (dev, CI, Cloud SQL bootstrap)", () => {
188
+ const bootstrap = readFileSync(
189
+ resolve(import.meta.dir, "../../migrations/src/bootstrap.sh"),
190
+ "utf8",
191
+ );
192
+ for (const st of statements) expect(bootstrap).toContain(st);
193
+ });
194
+
195
+ it("refuse to run against a stopped database", async () => {
196
+ let ran = false;
197
+ const r = await applyPreMigrationRoles({
198
+ stagingPassword: "s",
199
+ probe: async () => false,
200
+ psql: async () => {
201
+ ran = true;
202
+ return { exitCode: 0, stderr: "" };
203
+ },
204
+ });
205
+ expect(r).toEqual({
206
+ ok: false,
207
+ reachable: false,
208
+ error: "the Postgres container is not running",
209
+ });
210
+ expect(ran).toBe(false);
211
+ });
212
+ });
@@ -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,12 +37,82 @@ 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
 
46
+ /**
47
+ * #1338 (gateway hardening H3) — what bounds public_role, the gateway's
48
+ * login, on the database: at most this many connections, no statement or
49
+ * idle transaction that runs away, little sort memory per query. The same
50
+ * values as packages/migrations/src/bootstrap.sh and the cms_admin
51
+ * migration that sets them where admin_role may (Cloud SQL); the role audit
52
+ * (packages/migrations role-audit.ts) refuses an install without them.
53
+ * Self-hosted admin_role has no CREATEROLE, so the superuser applies them
54
+ * here — on the volume's first start (bootstrap.sh), on every `up`, and
55
+ * before the migrations of every upgrade ({@link preMigrationRolesScript}).
56
+ * Since #1329 the staging gateway's public_staging_role carries the same.
57
+ */
58
+ export const PUBLIC_ROLE_LIMITS = {
59
+ connectionLimit: 30,
60
+ statementTimeout: "5s",
61
+ idleInTransactionSessionTimeout: "15s",
62
+ workMem: "1MB",
63
+ } as const;
64
+
65
+ /** The statements that apply {@link PUBLIC_ROLE_LIMITS} to both gateway logins (idempotent). */
66
+ function publicRoleLimitsSql(): string[] {
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
+
41
76
  /** How a role's password appears in the SQL: a literal, or a psql variable. */
42
77
  type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
43
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[] {
86
+ return [
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;",
95
+ ];
96
+ }
97
+
98
+ /**
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
104
+ * wait until after them (it drops roles a migration retired). Fails when
105
+ * public_role does not exist — the install's roles were never created.
106
+ */
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");
114
+ }
115
+
44
116
  /**
45
117
  * The convergence SQL (psql, as the superuser, connected to any database).
46
118
  * `password` renders each role's password; `agent` adds the agent's login.
@@ -52,6 +124,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
52
124
  `SELECT 'CREATE ROLE ${role} NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') \\gexec`,
53
125
  `ALTER ROLE ${role} WITH LOGIN PASSWORD ${password(role)};`,
54
126
  ]),
127
+ ...publicRoleLimitsSql(),
55
128
  // The upgrader's role (migration 0267 creates it only with CREATEROLE,
56
129
  // which the self-hosted admin_role lacks).
57
130
  "SELECT 'CREATE ROLE platform_updater NOLOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'platform_updater') \\gexec",
@@ -70,11 +143,15 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
70
143
  "REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
71
144
  "GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
72
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),
73
148
  // #635 — the gateway's former cms_admin login goes, with whatever it
74
149
  // still holds in either database (the superuser may drop what
75
150
  // migration 0285 could only revoke).
76
151
  "\\connect cms_public",
77
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 $$;",
78
155
  "\\connect cms_admin",
79
156
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
80
157
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
@@ -95,6 +172,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
95
172
  const byRole: Record<AppRole, string> = {
96
173
  admin_role: passwords.admin,
97
174
  public_role: passwords.public,
175
+ public_staging_role: passwords.publicStaging,
98
176
  };
99
177
  return convergenceSql(
100
178
  (role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
@@ -106,6 +184,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
106
184
  const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
107
185
  admin_role: "ADMIN_ROLE_PASSWORD",
108
186
  public_role: "PUBLIC_ROLE_PASSWORD",
187
+ public_staging_role: "PUBLIC_STAGING_ROLE_PASSWORD",
109
188
  [AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
110
189
  };
111
190
 
@@ -143,6 +222,25 @@ ${convergenceSql((role) => `:'${role}_pw'`, opts.agent)}CAELO_SQL
143
222
  /** Runs a psql script as the superuser; resolves to psql's exit code + stderr. */
144
223
  export type SuperuserPsql = (script: string) => Promise<{ exitCode: number; stderr: string }>;
145
224
 
225
+ /**
226
+ * Apply {@link preMigrationRolesScript} through the running Postgres
227
+ * container. Unlike {@link convergeSelfHostedRoles} a stopped database is a
228
+ * failure: the upgrade runs it right before migrations that need it.
229
+ */
230
+ export async function applyPreMigrationRoles(deps: {
231
+ readonly probe: () => Promise<boolean>;
232
+ readonly psql: SuperuserPsql;
233
+ /** #1329 — public_staging_role's password from `.caelo/config.json`. */
234
+ readonly stagingPassword: string;
235
+ }): Promise<ConvergeOutcome> {
236
+ if (!(await deps.probe())) {
237
+ return { ok: false, reachable: false, error: "the Postgres container is not running" };
238
+ }
239
+ const r = await deps.psql(preMigrationRolesScript(deps.stagingPassword));
240
+ if (r.exitCode === 0) return { ok: true };
241
+ return { ok: false, reachable: true, error: r.stderr.trim() || `psql exited ${r.exitCode}` };
242
+ }
243
+
146
244
  export type ConvergeOutcome =
147
245
  | { readonly ok: true }
148
246
  /** `reachable: false` — the database is not running, nothing was attempted. */
@@ -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
+ }