@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
package/src/compose.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  /**
4
4
  * P14 — production docker-compose.yml generator.
5
5
  *
6
- * Emits the production stack: Postgres + Caddy + the two Caelo services
7
- * (admin, gateway). Idempotent: same input →
8
- * byte-identical output. The CLI's `init` / `up` / `upgrade` sub-commands
6
+ * Emits the production stack: Postgres + Caddy + the Caelo services
7
+ * (admin, gateway and — #1328 — the MCP-Auth-Proxy). Idempotent: same
8
+ * input → byte-identical output. The CLI's `init` / `up` / `upgrade` sub-commands
9
9
  * write this to `.caelo/docker-compose.yml`.
10
10
  *
11
11
  * #696 — the admin and gateway run the PREBUILT, cosign-verified release
@@ -27,23 +27,39 @@
27
27
  * every fresh `docker compose up` fail.
28
28
  */
29
29
 
30
- import { CADDY_REDIRECTS_FILE } from "@caelo-cms/shared";
30
+ import { CADDY_REDIRECTS_FILE, SELF_HOSTED_CA_FILE, verifyFullQuery } from "@caelo-cms/shared";
31
31
  import { caddyRedirectReloadCommand } from "./caddy-redirects.js";
32
32
  import { DEFAULT_MAX_CONNECTIONS, stackPoolBudget } from "./pool-budget-stack.js";
33
+ import {
34
+ SELF_HOSTED_DB_HOST,
35
+ SELF_HOSTED_TLS_DIR,
36
+ SELF_HOSTED_TLS_FILES,
37
+ } from "./self-hosted-db-tls.js";
33
38
  import {
34
39
  ADMIN_MEMORY_DEFAULT,
40
+ GATEWAY_ENVIRONMENT_ENV,
35
41
  GATEWAY_TRUSTED_PROXY_HOPS,
42
+ MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS,
36
43
  memoryQuantityMiB,
44
+ parseSchedulerIntervalMinutes,
37
45
  publicSiteUrl,
46
+ REMOTE_MCP_MODE,
47
+ REMOTE_MCP_MODE_ENV,
38
48
  TRUSTED_PROXY_HOPS_ENV,
39
49
  } from "./stack-contract.js";
40
50
  import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
41
51
 
42
- /** The admin + gateway release images, each `ghcr.io/caelo-cms/<svc>@sha256:…`. */
52
+ /** The release images, each `ghcr.io/caelo-cms/<svc>@sha256:…`. */
43
53
  export interface ReleaseImages {
44
54
  readonly mode: "release";
45
55
  readonly admin: string;
46
56
  readonly gateway: string;
57
+ /**
58
+ * #1328 — the MCP-Auth-Proxy. Absent only for a release recorded before
59
+ * the proxy shipped (a rollback target in release.json): its Compose file
60
+ * then has no proxy service, exactly as that release ran.
61
+ */
62
+ readonly "mcp-auth-proxy"?: string;
47
63
  }
48
64
 
49
65
  /**
@@ -70,6 +86,8 @@ export const COMPOSE_POOL_BUDGET = stackPoolBudget("self-hosted", {
70
86
  maxConnections: DEFAULT_MAX_CONNECTIONS["self-hosted"],
71
87
  adminMaxInstances: 1,
72
88
  gatewayMaxInstances: 1,
89
+ // #1329 — one staging gateway container (P3b-2) next to the live one.
90
+ stagingGatewayMaxInstances: 1,
73
91
  });
74
92
 
75
93
  /** The budget's env as Compose `environment:` lines for `service`. */
@@ -102,11 +120,20 @@ export const MEDIA_ROOT = "/data/media";
102
120
  /** The gateway's HTTP port inside the Compose network. */
103
121
  export const COMPOSE_GATEWAY_PORT = 8090;
104
122
 
123
+ /** #1328 — the MCP-Auth-Proxy's HTTP port inside the Compose network. */
124
+ export const COMPOSE_MCP_AUTH_PROXY_PORT = 8095;
125
+
126
+ /** #1328 — the Compose service name of the MCP-Auth-Proxy. */
127
+ export const COMPOSE_MCP_AUTH_PROXY_SERVICE = "caelo-mcp-auth-proxy";
128
+
105
129
  /**
106
- * The Compose services that run Caelo code — what `restore` stops while it
107
- * rewrites the databases, and what the health checks probe. Workers run
108
- * inside the admin (CLAUDE.md §11.B Tier 5); there is no orchestrator or
109
- * runner service.
130
+ * The Compose services that touch the databases — what `backup` and
131
+ * `restore` stop while they read or rewrite them. Workers run inside the
132
+ * admin (CLAUDE.md §11.B Tier 5); there is no orchestrator or runner
133
+ * service. The MCP-Auth-Proxy (#1328) is deliberately not here: it holds no
134
+ * database login, so it keeps running, and a release from before it has no
135
+ * such service for `up` to start. Its health is checked wherever the
136
+ * install starts (self-hosted-start.ts) or rolls (self-hosted-upgrade.ts).
110
137
  */
111
138
  export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
112
139
 
@@ -117,6 +144,7 @@ export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
117
144
  */
118
145
  export const COMPOSE_ADMIN_UPSTREAM = `caelo-admin:${COMPOSE_ADMIN_PORT}`;
119
146
  export const COMPOSE_GATEWAY_UPSTREAM = `caelo-gateway:${COMPOSE_GATEWAY_PORT}`;
147
+ export const COMPOSE_MCP_AUTH_PROXY_UPSTREAM = `${COMPOSE_MCP_AUTH_PROXY_SERVICE}:${COMPOSE_MCP_AUTH_PROXY_PORT}`;
120
148
 
121
149
  /**
122
150
  * The Caddy image, pinned to the release the generated Caddyfile is
@@ -192,6 +220,8 @@ export function ghcrImageDigest(service: ReleaseService, ref: string): string |
192
220
  export interface RolePasswords {
193
221
  readonly admin: string;
194
222
  readonly public: string;
223
+ /** #1329 — public_staging_role's, the staging gateway's login on cms_public_staging. */
224
+ readonly publicStaging: string;
195
225
  }
196
226
 
197
227
  export interface ComposeSpec {
@@ -213,6 +243,15 @@ export interface ComposeSpec {
213
243
  * the gateway never gets it.
214
244
  */
215
245
  readonly internalSecret: string;
246
+ /**
247
+ * #1338 — SHA-256 (hex) of the database server certificate the Postgres
248
+ * service mounts (`.caelo/postgres-tls/server.crt`). It is part of the
249
+ * service's environment so a renewed certificate changes the service's
250
+ * definition and `docker compose up -d` recreates Postgres: a bind-mounted
251
+ * file whose content changes does not, and Postgres would keep serving
252
+ * the old certificate until verify-full clients refuse it.
253
+ */
254
+ readonly dbTlsServerCertSha256: string;
216
255
  readonly anthropicApiKey?: string;
217
256
  readonly resendApiKey?: string;
218
257
  readonly diskSize: string;
@@ -223,8 +262,14 @@ export interface ComposeSpec {
223
262
  * busy host never OOM-kills the admin at the limit.
224
263
  */
225
264
  readonly adminMemory?: string;
226
- /** Which admin + gateway images run (see {@link ComposeImages}). */
265
+ /** Which Caelo images run (see {@link ComposeImages}). */
227
266
  readonly images: ComposeImages;
267
+ /**
268
+ * #1140 — the `schedulerIntervalMinutes` knob: how often the scheduler
269
+ * container calls the admin's tick (1–15, default 5; stack-contract.ts
270
+ * SCHEDULER_INTERVAL_MINUTES).
271
+ */
272
+ readonly schedulerIntervalMinutes?: number;
228
273
  /**
229
274
  * The host ports Caddy listens on (`80` / `443` for a public install —
230
275
  * ACME's HTTP and TLS-ALPN challenges need exactly those).
@@ -274,6 +319,11 @@ export function generateDockerCompose(spec: ComposeSpec): string {
274
319
  if (spec.internalSecret.length < 32) {
275
320
  throw new Error("internalSecret is shorter than 32 characters (.caelo/internal-secret.json)");
276
321
  }
322
+ if (!/^[0-9a-f]{64}$/.test(spec.dbTlsServerCertSha256)) {
323
+ throw new Error(
324
+ "dbTlsServerCertSha256 is not a SHA-256 hex digest of .caelo/postgres-tls/server.crt",
325
+ );
326
+ }
277
327
  return `# SPDX-License-Identifier: MPL-2.0
278
328
  # Generated by cms-provision — do not edit; re-run \`bunx cms-provision up\`.
279
329
  # Domain: ${spec.domain}
@@ -281,10 +331,16 @@ export function generateDockerCompose(spec: ComposeSpec): string {
281
331
  name: ${spec.project}
282
332
 
283
333
  services:
284
- postgres:
334
+ ${SELF_HOSTED_DB_HOST}:
285
335
  image: postgres:16-alpine
286
- # The pool budget (COMPOSE_POOL_BUDGET) is computed from this number.
287
- command: ["postgres", "-c", "max_connections=${DEFAULT_MAX_CONNECTIONS["self-hosted"]}"]
336
+ # #1338 — TLS with the install's own certificate for the host name
337
+ # \`${SELF_HOSTED_DB_HOST}\` (self-hosted-db-tls.ts); every client connects with
338
+ # verify-full. Postgres refuses a key file it does not own, and a bind
339
+ # mount keeps the host's owner, so the entrypoint copies the pair to a
340
+ # container-local path owned by postgres first.
341
+ entrypoint: ${POSTGRES_TLS_ENTRYPOINT}
342
+ # The pool budget (COMPOSE_POOL_BUDGET) is computed from max_connections.
343
+ command: ["postgres", "-c", "max_connections=${DEFAULT_MAX_CONNECTIONS["self-hosted"]}", "-c", "ssl=on", "-c", "ssl_cert_file=${POSTGRES_TLS_RUNTIME_DIR}/server.crt", "-c", "ssl_key_file=${POSTGRES_TLS_RUNTIME_DIR}/server.key", "-c", "ssl_min_protocol_version=TLSv1.2"]
288
344
  restart: unless-stopped
289
345
  environment:
290
346
  POSTGRES_PASSWORD: "${escapeYaml(spec.postgresPassword)}"
@@ -292,9 +348,14 @@ services:
292
348
  POSTGRES_DB: caelo
293
349
  ADMIN_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.admin)}"
294
350
  PUBLIC_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.public)}"
351
+ PUBLIC_STAGING_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.publicStaging)}"
352
+ # Recreates Postgres when its certificate is renewed (see the spec).
353
+ CAELO_DB_TLS_SERVER_CERT_SHA256: "${spec.dbTlsServerCertSha256}"
295
354
  ${postgresAgent(spec.agent)} volumes:
296
355
  - caelo-pg:/var/lib/postgresql/data
297
356
  - ./bootstrap.sh:/docker-entrypoint-initdb.d/00_bootstrap.sh:ro
357
+ - ./${SELF_HOSTED_TLS_DIR}/${SELF_HOSTED_TLS_FILES.serverCert}:${POSTGRES_TLS_MOUNT_DIR}/server.crt:ro
358
+ - ./${SELF_HOSTED_TLS_DIR}/${SELF_HOSTED_TLS_FILES.serverKey}:${POSTGRES_TLS_MOUNT_DIR}/server.key:ro
298
359
  healthcheck:
299
360
  test: ["CMD-SHELL", "pg_isready -U caelo"]
300
361
  interval: 10s
@@ -317,7 +378,8 @@ ${postgresAgent(spec.agent)} volumes:
317
378
  ${adminService(spec, adminMemoryMiB, env)}
318
379
 
319
380
  ${gatewayService(spec)}
320
-
381
+ ${mcpAuthProxyService(spec.images)}
382
+ ${schedulerService(spec)}
321
383
  volumes:
322
384
  caelo-pg:
323
385
  caelo-caddy-data:
@@ -369,10 +431,11 @@ function serviceRuntime(
369
431
  ...volumes(["../..:/app", ...mounts]),
370
432
  ].join("\n");
371
433
  }
372
- const digest = ghcrImageDigest(service, images[service]);
434
+ const ref = images[service] ?? "";
435
+ const digest = ghcrImageDigest(service, ref);
373
436
  if (!digest) {
374
437
  throw new Error(
375
- `${service} image ${JSON.stringify(images[service])} is not ${ghcrRepository(service)}@sha256:<digest>`,
438
+ `${service} image ${JSON.stringify(ref)} is not ${ghcrRepository(service)}@sha256:<digest>`,
376
439
  );
377
440
  }
378
441
  // The image's own CMD starts the service; no source is mounted.
@@ -398,6 +461,7 @@ ${serviceRuntime(
398
461
  `./${BOOTSTRAP_DIR}:/app/.caelo`,
399
462
  `./output:${SITE_OUTPUT_ROOT}`,
400
463
  `./${MEDIA_DIR}:${MEDIA_ROOT}`,
464
+ DATABASE_CA_MOUNT,
401
465
  ],
402
466
  )}
403
467
  restart: unless-stopped
@@ -407,6 +471,8 @@ ${serviceRuntime(
407
471
  environment:
408
472
  ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
409
473
  PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
474
+ # #1329 — the staging database's plugin schemas and projection.
475
+ PUBLIC_STAGING_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public_staging")}"
410
476
  CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
411
477
  CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
412
478
  CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
@@ -422,7 +488,7 @@ ${env("RESEND_API_KEY", spec.resendApiKey)}`;
422
488
 
423
489
  function gatewayService(spec: ComposeSpec): string {
424
490
  return ` caelo-gateway:
425
- ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts")}
491
+ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts", [DATABASE_CA_MOUNT])}
426
492
  restart: unless-stopped
427
493
  depends_on:
428
494
  postgres: { condition: service_healthy }
@@ -430,23 +496,126 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
430
496
  # public_role on cms_public is its only database login (#613, #635,
431
497
  # CLAUDE.md §2: the public side has no path to the authoring database).
432
498
  PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
499
+ # #1329 — the live site's gateway: it refuses anything of the staging data.
500
+ ${GATEWAY_ENVIRONMENT_ENV}: "live"
433
501
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
434
502
  # Caddy is the one proxy in front (client-ip.ts in the gateway).
435
503
  ${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
436
504
  ${poolEnvLines("gateway")}
437
- # #806 — no remote MCP here: it rides Google IAP, which this install
438
- # does not have. CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL /
439
- # CAELO_OPERATOR_OAUTH_CLIENT_FILE stay unset, so the gateway's /api/mcp/*
440
- # answers 503 remote_mcp_not_configured and Settings → MCP says
441
- # "not available on this install".
442
505
  NODE_ENV: production`;
443
506
  }
444
507
 
445
- /** A compose-network database URL, password URL-encoded and YAML-escaped. */
508
+ /**
509
+ * #1328 — the MCP-Auth-Proxy: remote MCP's sign-in broker, its own
510
+ * container so it never shares a process with visitor handlers. Remote MCP
511
+ * needs the admin behind Google IAP, which a Compose install does not have,
512
+ * so it runs with `CAELO_REMOTE_MCP=unavailable` and answers every request
513
+ * but its health check with one fixed 404 (E14 a of #763: the service
514
+ * exists on every provider, its attack surface one branch). Caddy serves it
515
+ * on its own host (`mcp.<domain>`, caddy.ts), never on the site's origin.
516
+ *
517
+ * It gets no database URL, no secret and no mount: there is nothing it
518
+ * could read. The container is locked down on top — read-only root
519
+ * filesystem, every capability dropped, no privilege escalation — because
520
+ * it is the Caelo service that holds operators' Google tokens wherever
521
+ * remote MCP runs. Empty for a release recorded before the proxy shipped
522
+ * ({@link ReleaseImages}).
523
+ */
524
+ function mcpAuthProxyService(images: ComposeImages): string {
525
+ if (images.mode === "release" && images["mcp-auth-proxy"] === undefined) return "";
526
+ return `
527
+ ${COMPOSE_MCP_AUTH_PROXY_SERVICE}:
528
+ ${serviceRuntime(images, "mcp-auth-proxy", "bun run apps/mcp-auth-proxy/src/server.ts")}
529
+ restart: unless-stopped
530
+ read_only: true
531
+ tmpfs: ["/tmp"]
532
+ cap_drop: ["ALL"]
533
+ security_opt: ["no-new-privileges:true"]
534
+ environment:
535
+ CAELO_PROVIDER: "self-hosted"
536
+ ${REMOTE_MCP_MODE_ENV}: "${REMOTE_MCP_MODE["self-hosted"]}"
537
+ MCP_AUTH_PROXY_PORT: "${COMPOSE_MCP_AUTH_PROXY_PORT}"
538
+ # Caddy is the one proxy in front (@caelo-cms/shared client-address.ts).
539
+ ${TRUSTED_PROXY_HOPS_ENV}: "${MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS["self-hosted"]}"
540
+ NODE_ENV: production
541
+ `;
542
+ }
543
+
544
+ /** #1140 — the Compose service that calls the admin's scheduler tick. */
545
+ export const COMPOSE_SCHEDULER_SERVICE = "caelo-scheduler";
546
+
547
+ /** Where the admin image (and the dev checkout) ships the scheduler clock. */
548
+ export const SCHEDULER_CLOCK_SCRIPT = "packages/admin-core/src/scheduler/clock.ts";
549
+
550
+ /** The admin's tick endpoint on the Compose network. */
551
+ export const COMPOSE_SCHEDULER_TICK_URL = `http://${COMPOSE_ADMIN_UPSTREAM}/api/internal/scheduler/tick`;
552
+
553
+ /**
554
+ * #1140 — the scheduler: the self-hosted counterpart of the Cloud Scheduler
555
+ * job on Google Cloud. Every `schedulerIntervalMinutes` it POSTs the admin's
556
+ * `/api/internal/scheduler/tick` with an internal token (scope
557
+ * `scheduler.tick`); the admin runs whatever its due-table says is due. The
558
+ * clock only calls, it does no work itself, so the admin's background work
559
+ * runs inside a request on every provider.
560
+ *
561
+ * It runs the admin image with another entry point (dependency-free, node
562
+ * built-ins only) and holds only the internal secret it signs with: no
563
+ * database URL, no KEK, no mount, read-only root filesystem, every
564
+ * capability dropped. A rollback to a release from before #1140 renders it
565
+ * too; that admin image has no clock script, so the container exits and
566
+ * Docker restarts it with back-off — that release runs its work in-process.
567
+ */
568
+ function schedulerService(spec: ComposeSpec): string {
569
+ const minutes = parseSchedulerIntervalMinutes(spec.schedulerIntervalMinutes);
570
+ const runtime =
571
+ spec.images.mode === "dev"
572
+ ? serviceRuntime(spec.images, "admin", `bun run ${SCHEDULER_CLOCK_SCRIPT}`)
573
+ : [
574
+ serviceRuntime(spec.images, "admin", ""),
575
+ ` command: ["bun", "--bun", "/app/${SCHEDULER_CLOCK_SCRIPT}"]`,
576
+ ].join("\n");
577
+ return `
578
+ ${COMPOSE_SCHEDULER_SERVICE}:
579
+ ${runtime}
580
+ restart: unless-stopped
581
+ read_only: true
582
+ tmpfs: ["/tmp"]
583
+ cap_drop: ["ALL"]
584
+ security_opt: ["no-new-privileges:true"]
585
+ depends_on: [caelo-admin]
586
+ environment:
587
+ CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
588
+ CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"
589
+ CAELO_SCHEDULER_INTERVAL_MINUTES: "${minutes}"
590
+ NODE_ENV: production
591
+ `;
592
+ }
593
+
594
+ /**
595
+ * A compose-network database URL, password URL-encoded and YAML-escaped.
596
+ * #1338 — verify-full against the install's own CA (self-hosted-db-tls.ts),
597
+ * mounted at shared `SELF_HOSTED_CA_FILE` ({@link DATABASE_CA_MOUNT}).
598
+ */
446
599
  function dbUrl(role: string, password: string, database: string): string {
447
- return escapeYaml(`postgres://${role}:${encodeURIComponent(password)}@postgres:5432/${database}`);
600
+ return escapeYaml(
601
+ `postgres://${role}:${encodeURIComponent(password)}@${SELF_HOSTED_DB_HOST}:5432/${database}?${verifyFullQuery(SELF_HOSTED_CA_FILE)}`,
602
+ );
448
603
  }
449
604
 
605
+ /** Where the server certificate + key are bind-mounted into Postgres (host-owned). */
606
+ const POSTGRES_TLS_MOUNT_DIR = "/caelo-tls";
607
+ /** Where the entrypoint copies them, owned by postgres (Postgres refuses others' keys). */
608
+ const POSTGRES_TLS_RUNTIME_DIR = "/var/lib/postgresql/caelo-tls";
609
+ /** The Postgres entrypoint: copy the TLS pair into place, then the image's own entrypoint. */
610
+ const POSTGRES_TLS_ENTRYPOINT = JSON.stringify([
611
+ "/bin/sh",
612
+ "-c",
613
+ `install -d -o postgres -g postgres -m 0700 ${POSTGRES_TLS_RUNTIME_DIR} && install -o postgres -g postgres -m 0644 ${POSTGRES_TLS_MOUNT_DIR}/server.crt ${POSTGRES_TLS_RUNTIME_DIR}/server.crt && install -o postgres -g postgres -m 0600 ${POSTGRES_TLS_MOUNT_DIR}/server.key ${POSTGRES_TLS_RUNTIME_DIR}/server.key && exec docker-entrypoint.sh "$$@"`,
614
+ "--",
615
+ ]);
616
+ /** The install's database CA, read-only, where every verify-full URL names it. */
617
+ const DATABASE_CA_MOUNT = `./${SELF_HOSTED_TLS_DIR}/${SELF_HOSTED_TLS_FILES.caCert}:${SELF_HOSTED_CA_FILE}:ro`;
618
+
450
619
  function escapeYaml(s: string): string {
451
620
  // Conservative escape for double-quoted YAML strings.
452
621
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -44,7 +44,14 @@ export const ConvergeCheckpointSchema = InstallJobStatusSchema.extend({
44
44
  backupId: z.string().min(1),
45
45
  })
46
46
  .optional(),
47
- priorRevisions: z.object({ admin: z.string().min(1), gateway: z.string().min(1) }).optional(),
47
+ priorRevisions: z
48
+ .object({
49
+ admin: z.string().min(1),
50
+ gateway: z.string().min(1),
51
+ // #1328 — absent in a checkpoint an older release wrote.
52
+ "mcp-auth-proxy": z.string().min(1).optional(),
53
+ })
54
+ .optional(),
48
55
  });
49
56
  /**
50
57
  * The ops-bucket prefix of the converge job's own files — outside the
@@ -0,0 +1,237 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * converge-db-trust.ts (#1373) against a fake gcloud and a fake Cloud SQL
5
+ * Admin API: which host the converge job dials before the database TLS
6
+ * step, and whose certificate it accepts there.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ convergeDatabaseTarget,
12
+ normalizedSha1,
13
+ serverIdentityProblem,
14
+ } from "./converge-db-trust.js";
15
+ import type { GcloudResult } from "./gcloud.js";
16
+ import type { HttpFetch } from "./runtime-secrets.js";
17
+
18
+ const DNS = "abc123.p-label.europe-west1.sql-psa.goog";
19
+ const IP = "10.20.0.3";
20
+ const PLACEMENT = { projectId: "p", region: "europe-west1" };
21
+ const OWN_CA = "-----BEGIN CERTIFICATE-----\nper-instance\n-----END CERTIFICATE-----";
22
+ const SHARED_BUNDLE = "-----BEGIN CERTIFICATE-----\nshared\n-----END CERTIFICATE-----";
23
+ const ACTIVE = "a212b6cf51b3736b34a5d4a3194b200db6b3532a";
24
+ const OLD = "0123456789abcdef0123456789abcdef01234567";
25
+
26
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
27
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
28
+
29
+ /** The admin service as `gcloud run services describe --format=json` prints it. */
30
+ function adminService(host: string): string {
31
+ return JSON.stringify({
32
+ spec: {
33
+ template: {
34
+ metadata: {
35
+ annotations: {
36
+ "run.googleapis.com/network-interfaces": JSON.stringify([
37
+ { network: "net", subnetwork: "sub" },
38
+ ]),
39
+ },
40
+ },
41
+ spec: {
42
+ containers: [
43
+ {
44
+ image: "img@sha256:x",
45
+ env: [
46
+ {
47
+ name: "ADMIN_DATABASE_URL",
48
+ value: `postgresql://admin_role@${host}:5432/cms_admin?sslmode=require`,
49
+ },
50
+ {
51
+ name: "ADMIN_DATABASE_PASSWORD",
52
+ valueFrom: { secretKeyRef: { name: "pw", key: "latest" } },
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ },
60
+ });
61
+ }
62
+
63
+ function gcloud(opts: { host: string; instance?: object; describeFails?: boolean }) {
64
+ const calls: string[] = [];
65
+ const run = async (args: string[]) => {
66
+ const line = args.join(" ");
67
+ calls.push(line);
68
+ if (line.startsWith("run services list")) return ok("caelo-production-admin-abc1234\n");
69
+ if (line.startsWith("run services describe")) return ok(adminService(opts.host));
70
+ if (line.startsWith("sql instances list")) return ok("caelo-production-pg-1\n");
71
+ if (line.startsWith("sql instances describe")) {
72
+ return opts.describeFails ? fail("PERMISSION_DENIED") : ok(JSON.stringify(opts.instance));
73
+ }
74
+ if (line.startsWith("auth print-access-token")) return ok("ya29.token\n");
75
+ return fail(`unexpected: ${line}`);
76
+ };
77
+ return { run, calls };
78
+ }
79
+
80
+ function api(body: object, status = 200): HttpFetch {
81
+ return async () => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
82
+ }
83
+
84
+ const perInstance = {
85
+ serverCaCert: { cert: OWN_CA },
86
+ settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
87
+ };
88
+ const shared = {
89
+ serverCaCert: { cert: OWN_CA },
90
+ settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_CAS_CA" } },
91
+ };
92
+
93
+ describe("convergeDatabaseTarget", () => {
94
+ it("an install on the DNS name connects with verify-full and asks Cloud SQL nothing", async () => {
95
+ const g = gcloud({ host: DNS });
96
+ expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
97
+ kind: "dns",
98
+ host: DNS,
99
+ });
100
+ expect(g.calls.some((c) => c.startsWith("sql "))).toBe(false);
101
+ });
102
+
103
+ it("on the IP with the per-instance CA it trusts exactly that CA", async () => {
104
+ const g = gcloud({ host: IP, instance: perInstance });
105
+ const target = await convergeDatabaseTarget(PLACEMENT, {
106
+ run: g.run,
107
+ http: async () => {
108
+ throw new Error("the per-instance CA needs no certificate list");
109
+ },
110
+ });
111
+ expect(target).toEqual({ kind: "ip", host: IP, caPem: OWN_CA, pinnedSha1: null });
112
+ });
113
+
114
+ it("on the IP with the shared CA active it trusts the shared bundle and pins the instance's certificates", async () => {
115
+ const g = gcloud({ host: IP, instance: shared });
116
+ const target = await convergeDatabaseTarget(PLACEMENT, {
117
+ run: g.run,
118
+ http: api({ activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: ACTIVE }] }),
119
+ readSharedCaBundle: () => SHARED_BUNDLE,
120
+ });
121
+ expect(target).toEqual({ kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] });
122
+ });
123
+
124
+ it("moved to the shared CA but not rotated in: pinned to the one certificate the API names active", async () => {
125
+ const g = gcloud({ host: IP, instance: shared });
126
+ const target = await convergeDatabaseTarget(PLACEMENT, {
127
+ run: g.run,
128
+ http: api({ activeVersion: OLD.toUpperCase(), serverCerts: [] }),
129
+ readSharedCaBundle: () => SHARED_BUNDLE,
130
+ });
131
+ expect(target).toEqual({
132
+ kind: "ip",
133
+ host: IP,
134
+ caPem: `${OWN_CA}\n${SHARED_BUNDLE}`,
135
+ pinnedSha1: [OLD],
136
+ });
137
+ });
138
+
139
+ it("shared CA: a listing that does not positively name the active certificate is refused (fail closed)", async () => {
140
+ const answers: { body: unknown; why: RegExp }[] = [
141
+ { body: {}, why: /does not name the active certificate/ },
142
+ { body: { serverCerts: [{ sha1Fingerprint: ACTIVE }] }, why: /does not name/ },
143
+ { body: { activeVersion: "", serverCerts: [] }, why: /does not name/ },
144
+ { body: { activeVersion: "old-per-instance", serverCerts: [] }, why: /does not name/ },
145
+ {
146
+ body: { activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: "nope" }] },
147
+ why: /does not name/,
148
+ },
149
+ { body: { activeVersion: ACTIVE, serverCerts: [{}] }, why: /does not name/ },
150
+ { body: { activeVersion: ACTIVE, serverCerts: "x" }, why: /does not name/ },
151
+ ];
152
+ for (const a of answers) {
153
+ await expect(
154
+ convergeDatabaseTarget(PLACEMENT, {
155
+ run: gcloud({ host: IP, instance: shared }).run,
156
+ http: api(a.body as object),
157
+ readSharedCaBundle: () => SHARED_BUNDLE,
158
+ }),
159
+ ).rejects.toThrow(a.why);
160
+ }
161
+ const notJson: HttpFetch = async () => ({ ok: true, status: 200, text: async () => "<html>" });
162
+ await expect(
163
+ convergeDatabaseTarget(PLACEMENT, {
164
+ run: gcloud({ host: IP, instance: shared }).run,
165
+ http: notJson,
166
+ readSharedCaBundle: () => SHARED_BUNDLE,
167
+ }),
168
+ ).rejects.toThrow(/answered no JSON/);
169
+ });
170
+
171
+ it("refuses a CA mode it cannot reason about", async () => {
172
+ await expect(
173
+ convergeDatabaseTarget(PLACEMENT, {
174
+ run: gcloud({
175
+ host: IP,
176
+ instance: {
177
+ serverCaCert: { cert: OWN_CA },
178
+ settings: { ipConfiguration: { serverCaMode: "CUSTOMER_MANAGED_CAS_CA" } },
179
+ },
180
+ }).run,
181
+ }),
182
+ ).rejects.toThrow(/CUSTOMER_MANAGED_CAS_CA/);
183
+ });
184
+
185
+ it("an instance that names no CA mode is on the per-instance default", async () => {
186
+ const g = gcloud({ host: IP, instance: { serverCaCert: { cert: OWN_CA } } });
187
+ expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
188
+ kind: "ip",
189
+ host: IP,
190
+ caPem: OWN_CA,
191
+ pinnedSha1: null,
192
+ });
193
+ });
194
+
195
+ it("refuses to connect when it cannot tell whom to trust", async () => {
196
+ await expect(
197
+ convergeDatabaseTarget(PLACEMENT, { run: gcloud({ host: IP, describeFails: true }).run }),
198
+ ).rejects.toThrow("PERMISSION_DENIED");
199
+ await expect(
200
+ convergeDatabaseTarget(PLACEMENT, {
201
+ run: gcloud({
202
+ host: IP,
203
+ instance: {
204
+ settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
205
+ },
206
+ }).run,
207
+ }),
208
+ ).rejects.toThrow("names no server CA certificate");
209
+ await expect(
210
+ convergeDatabaseTarget(PLACEMENT, {
211
+ run: gcloud({ host: IP, instance: shared }).run,
212
+ http: api({ error: "denied" }, 403),
213
+ }),
214
+ ).rejects.toThrow("HTTP 403");
215
+ });
216
+ });
217
+
218
+ describe("serverIdentityProblem", () => {
219
+ const pinned = { kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] } as const;
220
+
221
+ it("accepts a pinned certificate in node:tls's colon notation", () => {
222
+ const colons = ACTIVE.toUpperCase().replace(/(..)(?!$)/g, "$1:");
223
+ expect(serverIdentityProblem(pinned, colons)).toBeNull();
224
+ });
225
+
226
+ it("refuses a certificate the instance does not list", () => {
227
+ expect(serverIdentityProblem(pinned, "00:11")).toContain("does not list");
228
+ });
229
+
230
+ it("without a pin the verified chain to the instance's own CA decides", () => {
231
+ expect(serverIdentityProblem({ ...pinned, pinnedSha1: null }, "00:11")).toBeNull();
232
+ });
233
+
234
+ it("normalizes fingerprints", () => {
235
+ expect(normalizedSha1("AB:cd:01")).toBe("abcd01");
236
+ });
237
+ });