@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
@@ -115,7 +115,7 @@ describe("ensureGeneratedSecrets", () => {
115
115
  { ...secretsInstall, replication: regional },
116
116
  { run },
117
117
  );
118
- expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("present"));
118
+ expect(outcomes.map((o) => o.status)).toEqual(Array(7).fill("present"));
119
119
  expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
120
120
  });
121
121
 
@@ -126,7 +126,7 @@ describe("ensureGeneratedSecrets", () => {
126
126
  { ...secretsInstall, replication: regional },
127
127
  { run, generate: () => `generated-value-${++n}` },
128
128
  );
129
- expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
129
+ expect(outcomes.map((o) => o.status)).toEqual(Array(7).fill("applied"));
130
130
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
131
131
  // #806 — the seal key is 32 random bytes in base64 (not the injected hex generator).
132
132
  const sealKey = creates.find((c) => c.line.includes("mcp-oauth-seal-key"))?.stdin ?? "";
@@ -145,10 +145,15 @@ describe("ensureGeneratedSecrets", () => {
145
145
  line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
146
146
  stdin: "generated-value-3",
147
147
  },
148
+ // #1329 — the staging gateway's role password: its own, never the live one's.
149
+ {
150
+ line: "secrets create caelo-production-public-staging-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
151
+ stdin: "generated-value-4",
152
+ },
148
153
  // #650 — the staging access gate's key.
149
154
  {
150
155
  line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
151
- stdin: "generated-value-4",
156
+ stdin: "generated-value-5",
152
157
  },
153
158
  // #806 — remote MCP's OAuth client starts as the "not configured"
154
159
  // placeholder; the Owner writes the real one from Settings → MCP.
@@ -178,11 +183,12 @@ describe("ensureGeneratedSecrets", () => {
178
183
  { run, generate: () => "generated" },
179
184
  );
180
185
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
181
- expect(creates.map((c) => c.stdin).slice(0, 5)).toEqual([
186
+ expect(creates.map((c) => c.stdin).slice(0, 6)).toEqual([
182
187
  "operator-set",
183
188
  "generated",
184
189
  "generated",
185
190
  "generated",
191
+ "generated",
186
192
  "{}",
187
193
  ]);
188
194
  for (const c of calls) expect(c.line).not.toContain("operator-set");
@@ -194,6 +200,7 @@ describe("ensureGeneratedSecrets", () => {
194
200
  "secrets versions list caelo-production-internal-secret": [ok("")],
195
201
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
196
202
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
203
+ "secrets versions list caelo-production-public-staging-role-password": [ok("v/1")],
197
204
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
198
205
  "secrets versions list caelo-production-operator-oauth-client": [ok("v/1")],
199
206
  "secrets versions list caelo-production-mcp-oauth-seal-key": [ok("v/1")],
@@ -209,6 +216,7 @@ describe("ensureGeneratedSecrets", () => {
209
216
  "present",
210
217
  "present",
211
218
  "present",
219
+ "present",
212
220
  ]);
213
221
  expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
214
222
  {
@@ -243,7 +251,7 @@ describe("ensureGeneratedSecrets", () => {
243
251
  { ...secretsInstall, replication: { kind: "automatic" } },
244
252
  { run, generate: () => "v" },
245
253
  );
246
- expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
254
+ expect(outcomes.map((o) => o.status)).toEqual(Array(7).fill("applied"));
247
255
  expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
248
256
  const creates = lines().filter((l) => l.startsWith("secrets create"));
249
257
  // One refused automatic attempt; then every secret — the refused one
@@ -253,6 +261,7 @@ describe("ensureGeneratedSecrets", () => {
253
261
  "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
254
262
  "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
255
263
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
264
+ "secrets create caelo-production-public-staging-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
256
265
  // #652 — the staging gate's key follows the same path.
257
266
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
258
267
  "secrets create caelo-production-operator-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
@@ -284,7 +293,7 @@ describe("ensureGeneratedSecrets", () => {
284
293
  { run, generate: () => "v" },
285
294
  );
286
295
  expect(outcomes.every((o) => o.status === "failed")).toBe(true);
287
- expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(6);
296
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(7);
288
297
  });
289
298
  });
290
299
 
@@ -314,7 +323,7 @@ describe("rotateRuntimeSecret", () => {
314
323
  projectId: "acme",
315
324
  region: "europe-west1",
316
325
  env: "production",
317
- services: { admin: "adm-svc", gateway: "gw-svc" },
326
+ services: { admin: "adm-svc", gateway: "gw-svc", "mcp-auth-proxy": "px-svc" },
318
327
  sqlInstance: "caelo-production-pg-1",
319
328
  };
320
329
  const ref = (name: string, secret: string, version = "latest") => ({
@@ -329,6 +338,7 @@ describe("rotateRuntimeSecret", () => {
329
338
  service([
330
339
  ref("ADMIN_DATABASE_PASSWORD", "postgres-password"),
331
340
  ref("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
341
+ ref("PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD", "postgres-password"),
332
342
  ref("CAELO_SECRET_KEK", "secret-kek"),
333
343
  ref("CAELO_INTERNAL_SECRET", "internal-secret"),
334
344
  ref("CAELO_TOOL_APPROVAL_SECRET", "tool-approval-secret"),
@@ -337,6 +347,10 @@ describe("rotateRuntimeSecret", () => {
337
347
  "run services describe gw-svc": [
338
348
  service([ref("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
339
349
  ],
350
+ // #1328 — the proxy reads none of the rotatable database secrets.
351
+ "run services describe px-svc": [
352
+ service([ref("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]),
353
+ ],
340
354
  "auth print-access-token": [ok("tok\n")],
341
355
  });
342
356
 
@@ -451,7 +465,10 @@ describe("rotateRuntimeSecret", () => {
451
465
  const { run, lines } = fakeGcloud({
452
466
  "run services describe adm-svc": [
453
467
  service([
454
- { name: "ADMIN_DATABASE_URL", value: "postgres://admin_role:pw@10.0.0.3:5432/cms_admin" },
468
+ {
469
+ name: "ADMIN_DATABASE_URL",
470
+ value: "postgres://admin_role:pw@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
471
+ },
455
472
  ]),
456
473
  ],
457
474
  });
@@ -534,17 +551,23 @@ describe("parseAdminConfig", () => {
534
551
  it("marks an admin still on an inline-password URL (jobs on its image need upgrade first)", () => {
535
552
  const cfg = parseAdminConfig(
536
553
  admin([
537
- { name: "ADMIN_DATABASE_URL", value: "postgres://admin_role:pw@10.0.0.3:5432/cms_admin" },
554
+ {
555
+ name: "ADMIN_DATABASE_URL",
556
+ value: "postgres://admin_role:pw@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
557
+ },
538
558
  ]),
539
559
  );
540
- expect(cfg?.databaseHost).toBe("10.0.0.3");
560
+ expect(cfg?.databaseHost).toBe("db1.p-label.europe-west1.sql-psa.goog");
541
561
  expect(cfg?.secretEnv).toBe(false);
542
562
  });
543
563
 
544
564
  it("marks an admin that reads the password from Secret Manager", () => {
545
565
  const cfg = parseAdminConfig(
546
566
  admin([
547
- { name: "ADMIN_DATABASE_URL", value: "postgresql://admin_role@10.0.0.3:5432/cms_admin" },
567
+ {
568
+ name: "ADMIN_DATABASE_URL",
569
+ value: "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
570
+ },
548
571
  {
549
572
  name: "ADMIN_DATABASE_PASSWORD",
550
573
  valueFrom: {
@@ -559,10 +582,10 @@ describe("parseAdminConfig", () => {
559
582
 
560
583
  describe("migrationJobEnvArgs", () => {
561
584
  it("gives the job password-less URLs and the password as a secret reference", () => {
562
- const args = migrationJobEnvArgs("10.20.0.3");
585
+ const args = migrationJobEnvArgs("db1.p-label.europe-west1.sql-psa.goog");
563
586
  expect(args).toEqual([
564
- "--set-env-vars=ADMIN_DATABASE_URL=postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require,PUBLIC_ADMIN_DATABASE_URL=postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
565
- "--set-secrets=ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest",
587
+ "--set-env-vars=ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,PUBLIC_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,PUBLIC_STAGING_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
588
+ "--set-secrets=ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest",
566
589
  ]);
567
590
  });
568
591
  });
@@ -5,7 +5,8 @@
5
5
  * Pulumi) creates and maintains, through gcloud:
6
6
  *
7
7
  * - {@link ensureGatewayServiceAccount}: the gateway's own run SA, so it
8
- * can be granted only the secrets the gateway reads.
8
+ * can be granted only the secrets the gateway reads; the same for the
9
+ * MCP-Auth-Proxy ({@link ensureMcpAuthProxyServiceAccount}, #1328).
9
10
  * - {@link ensureGeneratedSecrets}: the secrets in
10
11
  * `CLI_GENERATED_SECRETS` (stack-contract.ts). The wizard runs it before
11
12
  * `pulumi up`, upgrade on every roll, so installs that predate a secret
@@ -23,9 +24,16 @@
23
24
  import { randomBytes } from "node:crypto";
24
25
  import { gcloud as defaultGcloud } from "./gcloud.js";
25
26
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
26
- import { gatewayServiceAccountEmail, gatewayServiceAccountId, gcpSecretId } from "./gcp-names.js";
27
+ import {
28
+ gatewayServiceAccountEmail,
29
+ gatewayServiceAccountId,
30
+ gcpSecretId,
31
+ mcpAuthProxyServiceAccountEmail,
32
+ mcpAuthProxyServiceAccountId,
33
+ } from "./gcp-names.js";
27
34
  import {
28
35
  CLI_GENERATED_SECRETS,
36
+ CLOUD_RUN_SLUGS,
29
37
  type CloudRunSlug,
30
38
  DATABASE_ROLE_SECRET,
31
39
  type DatabaseRole,
@@ -84,14 +92,47 @@ const ALREADY_EXISTS = /ALREADY_EXISTS|already exists/i;
84
92
  // ===========================================================================
85
93
 
86
94
  /** Create the gateway's run SA unless it exists. */
87
- export async function ensureGatewayServiceAccount(
95
+ export function ensureGatewayServiceAccount(
96
+ install: { readonly projectId: string; readonly env: string },
97
+ deps: RuntimeDeps = {},
98
+ ): Promise<EnsureOutcome> {
99
+ return ensureServiceAccount(
100
+ install.projectId,
101
+ gatewayServiceAccountId(install.env),
102
+ gatewayServiceAccountEmail(install.projectId, install.env),
103
+ `Caelo ${install.env} gateway`,
104
+ deps,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * #1328 — create the MCP-Auth-Proxy's run SA unless it exists. It gets no
110
+ * role here or anywhere: only `secretAccessor` on its two secrets
111
+ * (stack-contract.ts runtimeSecretBindings).
112
+ */
113
+ export function ensureMcpAuthProxyServiceAccount(
88
114
  install: { readonly projectId: string; readonly env: string },
89
115
  deps: RuntimeDeps = {},
116
+ ): Promise<EnsureOutcome> {
117
+ return ensureServiceAccount(
118
+ install.projectId,
119
+ mcpAuthProxyServiceAccountId(install.env),
120
+ mcpAuthProxyServiceAccountEmail(install.projectId, install.env),
121
+ `Caelo ${install.env} MCP-Auth-Proxy`,
122
+ deps,
123
+ );
124
+ }
125
+
126
+ async function ensureServiceAccount(
127
+ projectId: string,
128
+ accountId: string,
129
+ email: string,
130
+ displayName: string,
131
+ deps: RuntimeDeps,
90
132
  ): Promise<EnsureOutcome> {
91
133
  const run = deps.run ?? defaultGcloud;
92
- const email = gatewayServiceAccountEmail(install.projectId, install.env);
93
134
  const id = `service account ${email}`;
94
- const project = `--project=${install.projectId}`;
135
+ const project = `--project=${projectId}`;
95
136
  const describe = await run([
96
137
  "iam",
97
138
  "service-accounts",
@@ -108,9 +149,9 @@ export async function ensureGatewayServiceAccount(
108
149
  "iam",
109
150
  "service-accounts",
110
151
  "create",
111
- gatewayServiceAccountId(install.env),
152
+ accountId,
112
153
  project,
113
- `--display-name=Caelo ${install.env} gateway`,
154
+ `--display-name=${displayName}`,
114
155
  "--quiet",
115
156
  ]);
116
157
  if (create.ok || ALREADY_EXISTS.test(create.stderr)) return { id, status: "applied" };
@@ -470,7 +511,7 @@ async function checkRotationReaders(
470
511
  const isSecret = (live: { kind: string; secret?: string } | undefined) =>
471
512
  live?.kind === "secret" &&
472
513
  (live.secret === secretId || (live.secret?.endsWith(`/secrets/${secretId}`) ?? false));
473
- for (const service of ["admin", "gateway"] as const) {
514
+ for (const service of CLOUD_RUN_SLUGS) {
474
515
  const vars = Object.entries(SERVICE_SECRET_ENV[service])
475
516
  .filter(([, s]) => s === secret)
476
517
  .map(([name]) => name);
@@ -531,6 +572,12 @@ async function setSqlUserPassword(
531
572
  http: HttpFetch,
532
573
  sleep: Sleep,
533
574
  user: { projectId: string; instance: string; role: string; password: string },
575
+ /**
576
+ * `update` sets an existing user's password (`users.update`); `create`
577
+ * adds the user with it (`users.insert`, #1329: the staging gateway's
578
+ * login on an install from before it).
579
+ */
580
+ mode: "update" | "create",
534
581
  ): Promise<{ ok: true } | { ok: false; error: string }> {
535
582
  const token = await run(["auth", "print-access-token"]);
536
583
  const bearer = token.ok ? token.stdout.trim() : "";
@@ -546,11 +593,17 @@ async function setSqlUserPassword(
546
593
  ok: false as const,
547
594
  error: `${what}: HTTP ${res.status} ${(await res.text()).slice(0, 500)}`,
548
595
  });
549
- const put = await http(
550
- `${project}/instances/${encodeURIComponent(user.instance)}/users?name=${encodeURIComponent(user.role)}`,
551
- { method: "PUT", headers, body: JSON.stringify({ name: user.role, password: user.password }) },
552
- );
553
- if (!put.ok) return failed("update the user", put);
596
+ const users = `${project}/instances/${encodeURIComponent(user.instance)}/users`;
597
+ const body = JSON.stringify({ name: user.role, password: user.password });
598
+ const put =
599
+ mode === "update"
600
+ ? await http(`${users}?name=${encodeURIComponent(user.role)}`, {
601
+ method: "PUT",
602
+ headers,
603
+ body,
604
+ })
605
+ : await http(users, { method: "POST", headers, body });
606
+ if (!put.ok) return failed(mode === "update" ? "update the user" : "create the user", put);
554
607
  type Operation = {
555
608
  name?: string;
556
609
  status?: string;
@@ -652,12 +705,13 @@ export async function rotateRuntimeSecret(
652
705
  }
653
706
  const previous = current.stdout;
654
707
  const setPassword = (role: string, password: string) =>
655
- setSqlUserPassword(run, http, sleep, {
656
- projectId: target.projectId,
657
- instance,
658
- role,
659
- password,
660
- });
708
+ setSqlUserPassword(
709
+ run,
710
+ http,
711
+ sleep,
712
+ { projectId: target.projectId, instance, role, password },
713
+ "update",
714
+ );
661
715
  const changed: string[] = [];
662
716
  const restore = async (): Promise<string> => {
663
717
  const failed: string[] = [];
@@ -696,7 +750,7 @@ export async function rotateRuntimeSecret(
696
750
  steps.push(`added a new version to ${secretId}`);
697
751
 
698
752
  const failures: string[] = [];
699
- for (const service of ["admin", "gateway"] as const) {
753
+ for (const service of CLOUD_RUN_SLUGS) {
700
754
  if (!serviceSecrets(service).includes(secret)) continue;
701
755
  const name = target.services[service];
702
756
  const [update, flip] = rollArgs(name, target.region, target.projectId) as [string[], string[]];
@@ -766,7 +820,12 @@ export async function readSecretValue(
766
820
  export async function ensureDatabaseRolePassword(
767
821
  target: DatabaseRoleTarget,
768
822
  role: DatabaseRole,
769
- deps: RuntimeDeps & { readonly http?: HttpFetch; readonly password?: string } = {},
823
+ deps: RuntimeDeps & {
824
+ readonly http?: HttpFetch;
825
+ readonly password?: string;
826
+ /** Create the Cloud SQL user with the password instead (it does not exist yet, #1329). */
827
+ readonly create?: boolean;
828
+ } = {},
770
829
  ): Promise<EnsureOutcome> {
771
830
  const run = deps.run ?? defaultGcloud;
772
831
  const sleep = deps.sleep ?? realSleep;
@@ -778,11 +837,12 @@ export async function ensureDatabaseRolePassword(
778
837
  if (!value.ok) return { id, status: "failed", error: value.error };
779
838
  password = value.value;
780
839
  }
781
- const set = await setSqlUserPassword(run, http, sleep, {
782
- projectId: target.projectId,
783
- instance: target.sqlInstance,
784
- role,
785
- password,
786
- });
840
+ const set = await setSqlUserPassword(
841
+ run,
842
+ http,
843
+ sleep,
844
+ { projectId: target.projectId, instance: target.sqlInstance, role, password },
845
+ deps.create === true ? "create" : "update",
846
+ );
787
847
  return set.ok ? { id, status: "applied" } : { id, status: "failed", error: set.error };
788
848
  }
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The scheduler tick's setup (scheduler-setup.ts) against a fake gcloud. The
5
+ * load-bearing assertions are the trust boundary (#1140): the scheduler's
6
+ * account may sign as itself, pass the admin's IAP, start workflows and
7
+ * write logs — no secret, database, storage, run or IAM-admin right — and
8
+ * the one Cloud Scheduler job starts the workflow at the knob's cadence.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import type { GcloudResult } from "./gcloud.js";
13
+ import {
14
+ ensureSchedulerTick,
15
+ SCHEDULER_FORBIDDEN_ROLES,
16
+ SCHEDULER_TICK_PATH,
17
+ type SchedulerTickTarget,
18
+ schedulerWorkflowSource,
19
+ tickOrigin,
20
+ } from "./scheduler-setup.js";
21
+
22
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
23
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
24
+
25
+ const SA = "caelo-production-scheduler@acme.iam.gserviceaccount.com";
26
+ const MEMBER = `serviceAccount:${SA}`;
27
+
28
+ const TARGET: SchedulerTickTarget = {
29
+ projectId: "acme",
30
+ region: "europe-west1",
31
+ env: "production",
32
+ adminUrl: "https://admin.acme.com",
33
+ resource: { kind: "backend-services", service: "caelo-production-admin-backend-1a2b" },
34
+ intervalMinutes: 5,
35
+ };
36
+
37
+ function fakeGcloud(overrides: [RegExp, GcloudResult][] = []) {
38
+ const calls: string[][] = [];
39
+ const sources: string[] = [];
40
+ const rules: [RegExp, GcloudResult][] = [
41
+ ...overrides,
42
+ [/^iam service-accounts describe/, fail("NOT_FOUND")],
43
+ [/^scheduler jobs describe/, fail("NOT_FOUND")],
44
+ ];
45
+ const run = async (args: string[]) => {
46
+ calls.push(args);
47
+ const src = args.find((a) => a.startsWith("--source="));
48
+ if (src) sources.push(await Bun.file(src.slice("--source=".length)).text());
49
+ const line = args.join(" ");
50
+ return rules.find(([re]) => re.test(line))?.[1] ?? ok("");
51
+ };
52
+ return { run, calls, sources };
53
+ }
54
+
55
+ const flag = (args: string[], name: string) =>
56
+ args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
57
+
58
+ function grants(calls: string[][]) {
59
+ return calls
60
+ .filter((c) => c.includes("add-iam-policy-binding"))
61
+ .map((c) => ({
62
+ where: c.slice(0, c.indexOf("add-iam-policy-binding") + 2).join(" "),
63
+ member: flag(c, "member"),
64
+ role: flag(c, "role") as string,
65
+ }));
66
+ }
67
+
68
+ const sleep = async () => {};
69
+
70
+ describe("ensureSchedulerTick", () => {
71
+ it("grants the scheduler only self-signing, IAP access, workflow start and logs", async () => {
72
+ const { run, calls } = fakeGcloud();
73
+ const r = await ensureSchedulerTick(TARGET, { run, sleep });
74
+ expect(r.ok).toBe(true);
75
+ const mine = grants(calls).filter((g) => g.member === MEMBER);
76
+ expect(mine.map((g) => [g.where, g.role])).toEqual([
77
+ [`iam service-accounts add-iam-policy-binding ${SA}`, "roles/iam.serviceAccountTokenCreator"],
78
+ ["projects add-iam-policy-binding acme", "roles/workflows.invoker"],
79
+ ["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
80
+ [
81
+ "iap web add-iam-policy-binding --resource-type=backend-services",
82
+ "roles/iap.httpsResourceAccessor",
83
+ ],
84
+ ]);
85
+ for (const g of mine) {
86
+ for (const forbidden of SCHEDULER_FORBIDDEN_ROLES) {
87
+ expect(g.role.startsWith(forbidden)).toBe(false);
88
+ }
89
+ }
90
+ // Nobody else is granted anything.
91
+ expect(grants(calls).every((g) => g.member === MEMBER)).toBe(true);
92
+ // The IAP grant names the admin's resource.
93
+ const iap = calls.find((c) => c[0] === "iap") as string[];
94
+ expect(iap).toContain("--resource-type=backend-services");
95
+ expect(iap).toContain("--service=caelo-production-admin-backend-1a2b");
96
+ });
97
+
98
+ it("signs as itself only: token creator on its own account, never on another", async () => {
99
+ const { run, calls } = fakeGcloud();
100
+ await ensureSchedulerTick(TARGET, { run, sleep });
101
+ const saGrants = calls.filter(
102
+ (c) => c[0] === "iam" && c[1] === "service-accounts" && c[2] === "add-iam-policy-binding",
103
+ );
104
+ expect(saGrants.map((c) => c[3])).toEqual([SA]);
105
+ });
106
+
107
+ it("deploys the workflow as the scheduler account and schedules it every 5 minutes", async () => {
108
+ const { run, calls, sources } = fakeGcloud();
109
+ await ensureSchedulerTick(TARGET, { run, sleep });
110
+ const deploy = calls.find((c) => c[0] === "workflows" && c[1] === "deploy") as string[];
111
+ expect(deploy[2]).toBe("caelo-production-scheduler-tick");
112
+ expect(flag(deploy, "service-account")).toBe(SA);
113
+ expect(flag(deploy, "location")).toBe("europe-west1");
114
+ expect(sources[0]).toContain(`url: "https://admin.acme.com${SCHEDULER_TICK_PATH}"`);
115
+ const create = calls.find(
116
+ (c) => c[0] === "scheduler" && c[1] === "jobs" && c[2] === "create",
117
+ ) as string[];
118
+ expect(create[4]).toBe("caelo-production-scheduler-tick");
119
+ expect(flag(create, "schedule")).toBe("*/5 * * * *");
120
+ expect(flag(create, "time-zone")).toBe("Etc/UTC");
121
+ expect(flag(create, "uri")).toBe(
122
+ "https://workflowexecutions.googleapis.com/v1/projects/acme/locations/europe-west1/workflows/caelo-production-scheduler-tick/executions",
123
+ );
124
+ expect(flag(create, "oauth-service-account-email")).toBe(SA);
125
+ // OAuth (a googleapis.com target), never an OIDC token IAP would refuse.
126
+ expect(create.some((a) => a.startsWith("--oidc"))).toBe(false);
127
+ });
128
+
129
+ it("updates the existing job on a re-run, at the knob's new cadence", async () => {
130
+ const { run, calls } = fakeGcloud([[/^scheduler jobs describe/, ok("projects/acme/…")]]);
131
+ const r = await ensureSchedulerTick({ ...TARGET, intervalMinutes: 1 }, { run, sleep });
132
+ expect(r.ok).toBe(true);
133
+ const update = calls.find(
134
+ (c) => c[0] === "scheduler" && c[1] === "jobs" && c[2] === "update",
135
+ ) as string[];
136
+ expect(flag(update, "schedule")).toBe("* * * * *");
137
+ expect(calls.some((c) => c[0] === "scheduler" && c[2] === "create")).toBe(false);
138
+ });
139
+
140
+ it("refuses an interval outside 1-15 minutes before touching the project", async () => {
141
+ const { run, calls } = fakeGcloud();
142
+ const r = await ensureSchedulerTick({ ...TARGET, intervalMinutes: 30 }, { run, sleep });
143
+ expect(r.ok).toBe(false);
144
+ expect(r.ok ? "" : r.error).toContain("between 1 and 15");
145
+ expect(calls).toEqual([]);
146
+ });
147
+
148
+ it("refuses an admin URL that is not a plain https origin", async () => {
149
+ for (const bad of ["http://admin.acme.com", "https://admin.acme.com/x", "https://a@b.com"]) {
150
+ const { run, calls } = fakeGcloud();
151
+ const r = await ensureSchedulerTick({ ...TARGET, adminUrl: bad }, { run, sleep });
152
+ expect(r.ok).toBe(false);
153
+ expect(calls).toEqual([]);
154
+ }
155
+ });
156
+
157
+ it("stops at the first failing step and says which", async () => {
158
+ const { run, calls } = fakeGcloud([[/^workflows deploy/, fail("PERMISSION_DENIED: nope")]]);
159
+ const r = await ensureSchedulerTick(TARGET, { run, sleep });
160
+ expect(r.ok).toBe(false);
161
+ expect(r.ok ? "" : r.error).toContain("deploy workflow caelo-production-scheduler-tick");
162
+ expect(calls.some((c) => c[0] === "scheduler")).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("schedulerWorkflowSource", () => {
167
+ const src = schedulerWorkflowSource({ serviceAccount: SA, origin: "https://admin.acme.com" });
168
+
169
+ it("signs a JWT for the admin origin as the scheduler itself", () => {
170
+ expect(src).toContain(`iss: "${SA}"`);
171
+ expect(src).toContain(`sub: "${SA}"`);
172
+ expect(src).toContain('aud: "https://admin.acme.com/*"');
173
+ expect(src).toContain(
174
+ `url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SA}:signJwt"`,
175
+ );
176
+ expect(src).toContain("type: OAuth2");
177
+ expect(src).toContain(`exp: \${now + 600}`);
178
+ });
179
+
180
+ it("posts the tick with the signed JWT and a bounded timeout", () => {
181
+ expect(src).toContain('url: "https://admin.acme.com/api/internal/scheduler/tick"');
182
+ expect(src).toContain(`Authorization: \${"Bearer " + signed.body.signedJwt}`);
183
+ expect(src).toMatch(/timeout: (\d+)/);
184
+ const timeout = Number(/timeout: (\d+)/.exec(src)?.[1]);
185
+ expect(timeout).toBeGreaterThan(300);
186
+ expect(timeout).toBeLessThanOrEqual(1800);
187
+ });
188
+ });
189
+
190
+ describe("tickOrigin", () => {
191
+ it("accepts the admin's origin, with or without a trailing slash", () => {
192
+ expect(tickOrigin("https://admin.acme.com/")).toEqual({
193
+ ok: true,
194
+ origin: "https://admin.acme.com",
195
+ });
196
+ expect(tickOrigin("https://caelo-production-admin-x-ew.a.run.app")).toEqual({
197
+ ok: true,
198
+ origin: "https://caelo-production-admin-x-ew.a.run.app",
199
+ });
200
+ });
201
+ });