@caelo-cms/provisioning 0.10.47 → 0.10.49

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 (321) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/apply.d.ts +45 -0
  4. package/dist/agent/apply.d.ts.map +1 -0
  5. package/dist/agent/apply.js +83 -0
  6. package/dist/agent/apply.js.map +1 -0
  7. package/dist/agent/cli-provenance.d.ts +42 -0
  8. package/dist/agent/cli-provenance.d.ts.map +1 -0
  9. package/dist/agent/cli-provenance.js +138 -0
  10. package/dist/agent/cli-provenance.js.map +1 -0
  11. package/dist/agent/command.d.ts +79 -0
  12. package/dist/agent/command.d.ts.map +1 -0
  13. package/dist/agent/command.js +454 -0
  14. package/dist/agent/command.js.map +1 -0
  15. package/dist/agent/config.d.ts +30 -0
  16. package/dist/agent/config.d.ts.map +1 -0
  17. package/dist/agent/config.js +60 -0
  18. package/dist/agent/config.js.map +1 -0
  19. package/dist/agent/lock.d.ts +13 -0
  20. package/dist/agent/lock.d.ts.map +1 -0
  21. package/dist/agent/lock.js +100 -0
  22. package/dist/agent/lock.js.map +1 -0
  23. package/dist/agent/queue.d.ts +35 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +50 -0
  26. package/dist/agent/queue.js.map +1 -0
  27. package/dist/agent/run.d.ts +97 -0
  28. package/dist/agent/run.d.ts.map +1 -0
  29. package/dist/agent/run.js +198 -0
  30. package/dist/agent/run.js.map +1 -0
  31. package/dist/agent/systemd.d.ts +55 -0
  32. package/dist/agent/systemd.d.ts.map +1 -0
  33. package/dist/agent/systemd.js +171 -0
  34. package/dist/agent/systemd.js.map +1 -0
  35. package/dist/agent/units.d.ts +44 -0
  36. package/dist/agent/units.d.ts.map +1 -0
  37. package/dist/agent/units.js +127 -0
  38. package/dist/agent/units.js.map +1 -0
  39. package/dist/agent-db.js +18402 -0
  40. package/dist/cli.js +119 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/cloudsql-iam-user.d.ts +39 -0
  43. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  44. package/dist/cloudsql-iam-user.js +119 -0
  45. package/dist/cloudsql-iam-user.js.map +1 -0
  46. package/dist/compose.d.ts +16 -0
  47. package/dist/compose.d.ts.map +1 -1
  48. package/dist/compose.js +26 -1
  49. package/dist/compose.js.map +1 -1
  50. package/dist/dns/index.d.ts +2 -0
  51. package/dist/dns/index.d.ts.map +1 -1
  52. package/dist/dns/index.js +3 -2
  53. package/dist/dns/index.js.map +1 -1
  54. package/dist/dns/manual.d.ts +5 -0
  55. package/dist/dns/manual.d.ts.map +1 -1
  56. package/dist/dns/manual.js +28 -12
  57. package/dist/dns/manual.js.map +1 -1
  58. package/dist/gcloud.d.ts +2 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +1 -1
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/gcp-names.d.ts +15 -0
  63. package/dist/gcp-names.d.ts.map +1 -1
  64. package/dist/gcp-names.js +23 -0
  65. package/dist/gcp-names.js.map +1 -1
  66. package/dist/index.d.ts +6 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/infra-contract.d.ts +1 -1
  71. package/dist/infra-contract.js +7 -1
  72. package/dist/infra-contract.js.map +1 -1
  73. package/dist/install-job-runner.d.ts +13 -0
  74. package/dist/install-job-runner.d.ts.map +1 -0
  75. package/dist/install-job-runner.js +155 -0
  76. package/dist/install-job-runner.js.map +1 -0
  77. package/dist/install-job.d.ts +210 -0
  78. package/dist/install-job.d.ts.map +1 -0
  79. package/dist/install-job.js +449 -0
  80. package/dist/install-job.js.map +1 -0
  81. package/dist/install-state.d.ts +10 -1
  82. package/dist/install-state.d.ts.map +1 -1
  83. package/dist/install-state.js +10 -2
  84. package/dist/install-state.js.map +1 -1
  85. package/dist/lifecycle.d.ts.map +1 -1
  86. package/dist/lifecycle.js +19 -4
  87. package/dist/lifecycle.js.map +1 -1
  88. package/dist/migration-runner.d.ts +5 -0
  89. package/dist/migration-runner.d.ts.map +1 -1
  90. package/dist/migration-runner.js +1 -1
  91. package/dist/migration-runner.js.map +1 -1
  92. package/dist/operator-access.d.ts +14 -8
  93. package/dist/operator-access.d.ts.map +1 -1
  94. package/dist/operator-access.js +16 -102
  95. package/dist/operator-access.js.map +1 -1
  96. package/dist/release-manifest.d.ts +2 -41
  97. package/dist/release-manifest.d.ts.map +1 -1
  98. package/dist/release-manifest.js +18 -121
  99. package/dist/release-manifest.js.map +1 -1
  100. package/dist/run-mode.d.ts +27 -0
  101. package/dist/run-mode.d.ts.map +1 -0
  102. package/dist/run-mode.js +22 -0
  103. package/dist/run-mode.js.map +1 -0
  104. package/dist/runtime-secrets.d.ts +19 -0
  105. package/dist/runtime-secrets.d.ts.map +1 -1
  106. package/dist/runtime-secrets.js +44 -0
  107. package/dist/runtime-secrets.js.map +1 -1
  108. package/dist/self-hosted-roles.d.ts +22 -1
  109. package/dist/self-hosted-roles.d.ts.map +1 -1
  110. package/dist/self-hosted-roles.js +81 -12
  111. package/dist/self-hosted-roles.js.map +1 -1
  112. package/dist/self-hosted-upgrade.d.ts +17 -0
  113. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  114. package/dist/self-hosted-upgrade.js +16 -1
  115. package/dist/self-hosted-upgrade.js.map +1 -1
  116. package/dist/stack-contract.d.ts +6 -0
  117. package/dist/stack-contract.d.ts.map +1 -1
  118. package/dist/stack-contract.js +10 -1
  119. package/dist/stack-contract.js.map +1 -1
  120. package/dist/upgrade/converge.d.ts +6 -0
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +59 -2
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  125. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  126. package/dist/upgrade/gcp-lookup.js +25 -0
  127. package/dist/upgrade/gcp-lookup.js.map +1 -1
  128. package/dist/upgrade/release-images.d.ts +26 -2
  129. package/dist/upgrade/release-images.d.ts.map +1 -1
  130. package/dist/upgrade/release-images.js +35 -2
  131. package/dist/upgrade/release-images.js.map +1 -1
  132. package/dist/upgrade/upgrade.d.ts +5 -0
  133. package/dist/upgrade/upgrade.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade.js +28 -1
  135. package/dist/upgrade/upgrade.js.map +1 -1
  136. package/dist/upgrader-assess.d.ts +85 -0
  137. package/dist/upgrader-assess.d.ts.map +1 -0
  138. package/dist/upgrader-assess.js +122 -0
  139. package/dist/upgrader-assess.js.map +1 -0
  140. package/dist/upgrader-gcp.d.ts +17 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -0
  142. package/dist/upgrader-gcp.js +87 -0
  143. package/dist/upgrader-gcp.js.map +1 -0
  144. package/dist/upgrader-job.d.ts +133 -0
  145. package/dist/upgrader-job.d.ts.map +1 -0
  146. package/dist/upgrader-job.js +259 -0
  147. package/dist/upgrader-job.js.map +1 -0
  148. package/dist/upgrader-reporter.d.ts +35 -0
  149. package/dist/upgrader-reporter.d.ts.map +1 -0
  150. package/dist/upgrader-reporter.js +79 -0
  151. package/dist/upgrader-reporter.js.map +1 -0
  152. package/dist/upgrader-setup.d.ts +59 -0
  153. package/dist/upgrader-setup.d.ts.map +1 -0
  154. package/dist/upgrader-setup.js +308 -0
  155. package/dist/upgrader-setup.js.map +1 -0
  156. package/dist/wizard.d.ts +6 -0
  157. package/dist/wizard.d.ts.map +1 -1
  158. package/dist/wizard.js +32 -4
  159. package/dist/wizard.js.map +1 -1
  160. package/dist/wizards/gcp-firebase.d.ts +3 -0
  161. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  162. package/dist/wizards/gcp-firebase.js +1 -0
  163. package/dist/wizards/gcp-firebase.js.map +1 -1
  164. package/dist/wizards/gcp-install-job.d.ts +74 -0
  165. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  166. package/dist/wizards/gcp-install-job.js +398 -0
  167. package/dist/wizards/gcp-install-job.js.map +1 -0
  168. package/dist/wizards/gcp-prompts.d.ts +3 -0
  169. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  170. package/dist/wizards/gcp-prompts.js +25 -0
  171. package/dist/wizards/gcp-prompts.js.map +1 -0
  172. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  173. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  174. package/dist/wizards/gcp-pulumi.js +1 -1
  175. package/dist/wizards/gcp-pulumi.js.map +1 -1
  176. package/dist/wizards/gcp.d.ts +40 -0
  177. package/dist/wizards/gcp.d.ts.map +1 -1
  178. package/dist/wizards/gcp.js +223 -59
  179. package/dist/wizards/gcp.js.map +1 -1
  180. package/package.json +6 -4
  181. package/src/adapter.ts +97 -0
  182. package/src/adc-impersonation.ts +176 -0
  183. package/src/admin-domain.test.ts +254 -0
  184. package/src/admin-domain.ts +245 -0
  185. package/src/admin-memory.test.ts +151 -0
  186. package/src/agent/apply.test.ts +185 -0
  187. package/src/agent/apply.ts +124 -0
  188. package/src/agent/cli-provenance.test.ts +159 -0
  189. package/src/agent/cli-provenance.ts +191 -0
  190. package/src/agent/command.test.ts +77 -0
  191. package/src/agent/command.ts +564 -0
  192. package/src/agent/config.ts +86 -0
  193. package/src/agent/lock-config.test.ts +97 -0
  194. package/src/agent/lock.ts +110 -0
  195. package/src/agent/queue.test.ts +10 -0
  196. package/src/agent/queue.ts +91 -0
  197. package/src/agent/run.test.ts +437 -0
  198. package/src/agent/run.ts +295 -0
  199. package/src/agent/systemd.ts +209 -0
  200. package/src/agent/units.test.ts +223 -0
  201. package/src/agent/units.ts +163 -0
  202. package/src/azure-front-door.test.ts +119 -0
  203. package/src/azure-front-door.ts +56 -0
  204. package/src/bootstrap-token.ts +20 -0
  205. package/src/caddy.ts +112 -0
  206. package/src/cdn-copy.ts +91 -0
  207. package/src/cert-wait.test.ts +194 -0
  208. package/src/cert-wait.ts +163 -0
  209. package/src/checkpoint-secrets.test.ts +380 -0
  210. package/src/checkpoint-secrets.ts +283 -0
  211. package/src/cli-routing.test.ts +45 -0
  212. package/src/cli-routing.ts +43 -0
  213. package/src/cli.ts +1491 -0
  214. package/src/cloudsql-iam-user.ts +147 -0
  215. package/src/compose.ts +334 -0
  216. package/src/default-firewall.test.ts +411 -0
  217. package/src/default-firewall.ts +342 -0
  218. package/src/deployed-release.test.ts +231 -0
  219. package/src/deployed-release.ts +220 -0
  220. package/src/dns/cloudflare.ts +182 -0
  221. package/src/dns/groups.test.ts +76 -0
  222. package/src/dns/groups.ts +86 -0
  223. package/src/dns/index.ts +56 -0
  224. package/src/dns/manual.ts +130 -0
  225. package/src/dns/types.ts +27 -0
  226. package/src/gateway-credentials.test.ts +180 -0
  227. package/src/gateway-credentials.ts +144 -0
  228. package/src/gcloud-components.test.ts +76 -0
  229. package/src/gcloud-components.ts +85 -0
  230. package/src/gcloud-retry.ts +41 -0
  231. package/src/gcloud-runner.test.ts +61 -0
  232. package/src/gcloud.test.ts +99 -0
  233. package/src/gcloud.ts +322 -0
  234. package/src/gcp-names.ts +135 -0
  235. package/src/index.test.ts +332 -0
  236. package/src/index.ts +114 -0
  237. package/src/infra-contract.ts +32 -0
  238. package/src/install-job-runner.ts +166 -0
  239. package/src/install-job.test.ts +566 -0
  240. package/src/install-job.ts +542 -0
  241. package/src/install-state.test.ts +239 -0
  242. package/src/install-state.ts +583 -0
  243. package/src/internal-token.ts +35 -0
  244. package/src/lifecycle.ts +900 -0
  245. package/src/mcp-iap-stacks.test.ts +58 -0
  246. package/src/mcp-iap.test.ts +149 -0
  247. package/src/mcp-iap.ts +131 -0
  248. package/src/media-volume.test.ts +296 -0
  249. package/src/migration-runner.ts +365 -0
  250. package/src/operator-access-stacks.test.ts +55 -0
  251. package/src/operator-access.test.ts +332 -0
  252. package/src/operator-access.ts +554 -0
  253. package/src/ops-backend.test.ts +320 -0
  254. package/src/ops-backend.ts +414 -0
  255. package/src/provisioner-identity.test.ts +482 -0
  256. package/src/provisioner-identity.ts +407 -0
  257. package/src/redirects-emit.ts +175 -0
  258. package/src/region-data.ts +428 -0
  259. package/src/regions.test.ts +277 -0
  260. package/src/regions.ts +370 -0
  261. package/src/release-contract.ts +57 -0
  262. package/src/release-manifest.test.ts +300 -0
  263. package/src/release-manifest.ts +310 -0
  264. package/src/run-mode.ts +40 -0
  265. package/src/runtime-secrets.test.ts +627 -0
  266. package/src/runtime-secrets.ts +754 -0
  267. package/src/self-command.test.ts +14 -0
  268. package/src/self-command.ts +21 -0
  269. package/src/self-hosted-compose.test.ts +177 -0
  270. package/src/self-hosted-release.ts +240 -0
  271. package/src/self-hosted-roles.integration.test.ts +161 -0
  272. package/src/self-hosted-roles.test.ts +124 -0
  273. package/src/self-hosted-roles.ts +203 -0
  274. package/src/self-hosted-upgrade.test.ts +394 -0
  275. package/src/self-hosted-upgrade.ts +548 -0
  276. package/src/stack-contract-parity.test.ts +199 -0
  277. package/src/stack-contract.test.ts +391 -0
  278. package/src/stack-contract.ts +888 -0
  279. package/src/stack-converge-secrets.test.ts +421 -0
  280. package/src/stack-converge.test.ts +833 -0
  281. package/src/stack-converge.ts +909 -0
  282. package/src/staging-admin.test.ts +246 -0
  283. package/src/staging-admin.ts +324 -0
  284. package/src/state-attach.test.ts +211 -0
  285. package/src/state-attach.ts +132 -0
  286. package/src/state-backend.test.ts +145 -0
  287. package/src/state-backend.ts +262 -0
  288. package/src/state-command.ts +293 -0
  289. package/src/state-migrate.test.ts +479 -0
  290. package/src/state-migrate.ts +434 -0
  291. package/src/state-pulumi.ts +289 -0
  292. package/src/state-store.test.ts +212 -0
  293. package/src/state-store.ts +243 -0
  294. package/src/upgrade/app-roll.ts +486 -0
  295. package/src/upgrade/backup.ts +109 -0
  296. package/src/upgrade/converge.ts +437 -0
  297. package/src/upgrade/gcp-lookup.ts +167 -0
  298. package/src/upgrade/health.test.ts +279 -0
  299. package/src/upgrade/health.ts +305 -0
  300. package/src/upgrade/release-images.test.ts +119 -0
  301. package/src/upgrade/release-images.ts +393 -0
  302. package/src/upgrade/reporter.ts +46 -0
  303. package/src/upgrade/upgrade.test.ts +626 -0
  304. package/src/upgrade/upgrade.ts +249 -0
  305. package/src/upgrader-assess.test.ts +306 -0
  306. package/src/upgrader-assess.ts +250 -0
  307. package/src/upgrader-gcp.ts +123 -0
  308. package/src/upgrader-job.test.ts +411 -0
  309. package/src/upgrader-job.ts +502 -0
  310. package/src/upgrader-reporter.test.ts +46 -0
  311. package/src/upgrader-reporter.ts +105 -0
  312. package/src/upgrader-setup.test.ts +169 -0
  313. package/src/upgrader-setup.ts +355 -0
  314. package/src/wizard.ts +425 -0
  315. package/src/wizards/gcp-cost.ts +188 -0
  316. package/src/wizards/gcp-firebase.ts +72 -0
  317. package/src/wizards/gcp-install-job.ts +506 -0
  318. package/src/wizards/gcp-prompts.ts +26 -0
  319. package/src/wizards/gcp-pulumi.test.ts +246 -0
  320. package/src/wizards/gcp-pulumi.ts +516 -0
  321. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,169 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The upgrader job's setup (upgrader-setup.ts) against a fake gcloud. The
5
+ * load-bearing assertions are the trust boundary: the upgrader's service
6
+ * account gets exactly the app-roll rights — never IAM-policy, service
7
+ * account admin, secret, compute or project rights — and the admin's run SA
8
+ * can start the job (without overrides) and read its runs, nothing else.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import type { GcloudResult } from "./gcloud.js";
13
+ import {
14
+ ensureUpgraderJob,
15
+ UPGRADER_FORBIDDEN_ROLES,
16
+ UPGRADER_SQL_BACKUP_ROLE,
17
+ type UpgraderSetupTarget,
18
+ } from "./upgrader-setup.js";
19
+
20
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
21
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
22
+
23
+ const UPGRADER = "serviceAccount:caelo-production-upgrader@acme.iam.gserviceaccount.com";
24
+ const ADMIN_SA = "serviceAccount:caelo-production-run-sa@acme.iam.gserviceaccount.com";
25
+ const OPS = `europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/ops@sha256:${"c".repeat(64)}`;
26
+
27
+ const TARGET: UpgraderSetupTarget = {
28
+ projectId: "acme",
29
+ region: "europe-west1",
30
+ env: "production",
31
+ opsImageRef: OPS,
32
+ network: "caelo-production-vpc",
33
+ subnet: "caelo-production-subnet",
34
+ };
35
+
36
+ function fakeGcloud(overrides: [RegExp, GcloudResult][] = []) {
37
+ const calls: string[][] = [];
38
+ const rules: [RegExp, GcloudResult][] = [
39
+ ...overrides,
40
+ [/^iam roles describe/, fail("NOT_FOUND")],
41
+ [/^iam service-accounts describe/, fail("NOT_FOUND")],
42
+ [/^sql instances list/, ok("caelo-production-pg-abc\n")],
43
+ [/^sql instances describe/, ok('{"settings":{"databaseFlags":[]}}')],
44
+ [/^sql users list/, ok("")],
45
+ ];
46
+ const run = async (args: string[]) => {
47
+ calls.push(args);
48
+ const line = args.join(" ");
49
+ return rules.find(([re]) => re.test(line))?.[1] ?? ok("");
50
+ };
51
+ return { run, calls };
52
+ }
53
+
54
+ const flag = (args: string[], name: string) =>
55
+ args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
56
+
57
+ /** Every IAM grant the setup made, as {where, member, role, condition}. */
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
+ condition: flag(c, "condition"),
66
+ }));
67
+ }
68
+
69
+ describe("ensureUpgraderJob", () => {
70
+ it("grants the upgrader exactly the app-roll rights", async () => {
71
+ const { run, calls } = fakeGcloud();
72
+ const r = await ensureUpgraderJob(TARGET, { run, sleep: async () => {} });
73
+ expect(r.ok).toBe(true);
74
+ const mine = grants(calls).filter((g) => g.member === UPGRADER);
75
+ expect(mine.map((g) => `${g.where} → ${g.role}`).sort()).toEqual(
76
+ [
77
+ "projects add-iam-policy-binding acme → roles/run.developer",
78
+ "projects add-iam-policy-binding acme → roles/cloudsql.instanceUser",
79
+ "projects add-iam-policy-binding acme → roles/logging.logWriter",
80
+ "projects add-iam-policy-binding acme → projects/acme/roles/caeloUpgraderSqlBackup",
81
+ "iam service-accounts add-iam-policy-binding caelo-production-run-sa@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
82
+ "iam service-accounts add-iam-policy-binding caelo-production-gateway-sa@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
83
+ "iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
84
+ "iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
85
+ "storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
86
+ ].sort(),
87
+ );
88
+ // The bucket grant only reaches install.json, not the Pulumi state.
89
+ const bucket = mine.find((g) => g.role === "roles/storage.objectUser");
90
+ expect(bucket?.condition).toContain(
91
+ 'resource.name.startsWith("projects/_/buckets/acme-caelo-ops/objects/install/")',
92
+ );
93
+ });
94
+
95
+ it("never grants a forbidden role, a setIamPolicy permission, or a project-wide iam role", async () => {
96
+ const { run, calls } = fakeGcloud();
97
+ await ensureUpgraderJob(TARGET, { run, sleep: async () => {} });
98
+ for (const g of grants(calls).filter((g) => g.member === UPGRADER)) {
99
+ for (const forbidden of UPGRADER_FORBIDDEN_ROLES) {
100
+ expect(g.role.startsWith(forbidden)).toBe(false);
101
+ }
102
+ if (g.role.startsWith("roles/iam.")) expect(g.where).toStartWith("iam service-accounts");
103
+ }
104
+ const role = calls.find((c) => c.slice(0, 3).join(" ") === "iam roles create");
105
+ const perms = (flag(role ?? [], "permissions") ?? "").split(",");
106
+ expect(perms.sort()).toEqual([...UPGRADER_SQL_BACKUP_ROLE.permissions].sort());
107
+ // Nothing but the one write it needs: create a backup.
108
+ const writes = perms.filter((p) =>
109
+ /\.(setIamPolicy|restoreBackup|update|delete|create)$/.test(p),
110
+ );
111
+ expect(writes).toEqual(["cloudsql.backupRuns.create"]);
112
+ });
113
+
114
+ it("the admin may start the job without overrides and read it — nothing else", async () => {
115
+ const { run, calls } = fakeGcloud();
116
+ await ensureUpgraderJob(TARGET, { run, sleep: async () => {} });
117
+ const admin = grants(calls).filter((g) => g.member === ADMIN_SA);
118
+ expect(admin.map((g) => `${g.where} → ${g.role}`).sort()).toEqual([
119
+ "run jobs add-iam-policy-binding caelo-production-upgrader → roles/run.jobsExecutor",
120
+ "run jobs add-iam-policy-binding caelo-production-upgrader → roles/run.viewer",
121
+ ]);
122
+ expect(calls.flat().some((a) => a.includes("WithOverrides"))).toBe(false);
123
+ });
124
+
125
+ it("deploys the job with no input but the database: ops image by digest, own SA, no env", async () => {
126
+ const { run, calls } = fakeGcloud();
127
+ await ensureUpgraderJob(TARGET, { run, sleep: async () => {} });
128
+ const deploy = calls.find((c) => c.slice(0, 3).join(" ") === "run jobs deploy") ?? [];
129
+ expect(deploy[3]).toBe("caelo-production-upgrader");
130
+ expect(flag(deploy, "image")).toBe(OPS);
131
+ expect(flag(deploy, "service-account")).toBe(UPGRADER.replace("serviceAccount:", ""));
132
+ expect(flag(deploy, "command")).toBe("caelo-ops");
133
+ expect(flag(deploy, "args")).toBe("upgrader");
134
+ expect(deploy.some((a) => /env-vars|secrets/.test(a))).toBe(false);
135
+ const assign = calls.find((c) => c.slice(0, 3).join(" ") === "sql users assign-roles");
136
+ expect(assign?.[3]).toBe("caelo-production-upgrader@acme.iam");
137
+ expect(flag(assign ?? [], "database-roles")).toBe("platform_updater");
138
+ });
139
+
140
+ it("refuses an image not pinned by digest, before touching anything", async () => {
141
+ const { run, calls } = fakeGcloud();
142
+ const r = await ensureUpgraderJob(
143
+ { ...TARGET, opsImageRef: "ghcr.io/caelo-cms/ops:latest" },
144
+ { run },
145
+ );
146
+ expect(r.ok).toBe(false);
147
+ expect(calls).toEqual([]);
148
+ });
149
+
150
+ it("needs the install's state in its own ops bucket", async () => {
151
+ const { run, calls } = fakeGcloud([
152
+ [/^storage buckets describe/, fail("404 bucket not found")],
153
+ ]);
154
+ const r = await ensureUpgraderJob(TARGET, { run });
155
+ expect(r.ok ? "" : r.error).toContain("cms-provision state migrate");
156
+ expect(grants(calls)).toEqual([]);
157
+ });
158
+
159
+ it("stops at the first failed grant and says which", async () => {
160
+ const { run, calls } = fakeGcloud([
161
+ [/add-iam-policy-binding acme .*run\.developer/, fail("PERMISSION_DENIED")],
162
+ ]);
163
+ const r = await ensureUpgraderJob(TARGET, { run, sleep: async () => {} });
164
+ expect(r.ok ? "" : r.error).toContain(
165
+ "grant the upgrader roles/run.developer: PERMISSION_DENIED",
166
+ );
167
+ expect(calls.some((c) => c.slice(0, 3).join(" ") === "run jobs deploy")).toBe(false);
168
+ });
169
+ });
@@ -0,0 +1,355 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Set up (and converge) the upgrader job on a Google Cloud install (`gcp`,
5
+ * `gcp-firebase`), epic #696 Phase 2. Run by the wizard after migrations and
6
+ * by `cms-provision upgrade`'s converge, both as the operator's own gcloud
7
+ * identity — never by the admin, never by the job itself.
8
+ *
9
+ * Trust boundary (same shape as operator-access.ts). The admin may only
10
+ * START the job: `roles/run.jobsExecutor` on that one job — `run.jobs.run`
11
+ * without `run.jobs.runWithOverrides` (cloud.google.com/run/docs/reference/
12
+ * iam/roles) — plus `roles/run.viewer` on it to read its runs. It cannot
13
+ * change the job's image, command, args or env, and it holds no other
14
+ * right the job has. What the job installs comes from the request row
15
+ * (just a version) and the signed release manifest (upgrader-job.ts).
16
+ *
17
+ * The job's own service account, `caelo-<env>-upgrader`, may do exactly
18
+ * what an app-roll needs (upgrade/app-roll.ts):
19
+ *
20
+ * - `roles/run.developer` on the project: deploy admin/gateway revisions,
21
+ * move traffic, recreate the migration job, update its own job image.
22
+ * It includes no `*.setIamPolicy`.
23
+ * - `roles/iam.serviceAccountUser` on exactly the admin's and the
24
+ * gateway's runtime service accounts (a revision runs as them) and on
25
+ * itself (its own job) — not on the project.
26
+ * - the custom role `caeloUpgraderSqlBackup`: create/read Cloud SQL
27
+ * backups and read the instance — no restore, no instance changes.
28
+ * - `roles/cloudsql.instanceUser` + a Cloud SQL IAM database user that
29
+ * is a member of `platform_updater` (migration 0267) only.
30
+ * - `roles/iam.serviceAccountTokenCreator` on caelo-mcp only: the deep
31
+ * health check signs its IAP credential as that account
32
+ * (upgrade/health.ts). The operator-access sync job keeps it there.
33
+ * - `roles/storage.objectUser` on the ops bucket, conditioned to the
34
+ * `install/` prefix (install.json) — not the Pulumi state.
35
+ * - `roles/logging.logWriter`.
36
+ *
37
+ * It gets no `*.setIamPolicy`, no service-account admin, no secret
38
+ * creation, no compute and no resourcemanager rights: whatever needs them
39
+ * is a converge, which only the operator runs ("Run in Cloud Shell").
40
+ * {@link UPGRADER_FORBIDDEN_ROLES} names that line; the tests assert no
41
+ * grant crosses it.
42
+ */
43
+
44
+ import { ensureCloudSqlIamUser } from "./cloudsql-iam-user.js";
45
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
46
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
47
+ import {
48
+ gatewayServiceAccountEmail,
49
+ mcpIapServiceAccountEmail,
50
+ runServiceAccountEmail,
51
+ upgraderJobName,
52
+ upgraderServiceAccountEmail,
53
+ upgraderServiceAccountId,
54
+ } from "./gcp-names.js";
55
+ import { readAdminConfig } from "./migration-runner.js";
56
+ import { type CustomRoleSpec, customRoleName, ensureCustomRole } from "./operator-access.js";
57
+ import { OPS_INSTALL_PREFIX, opsBucketName } from "./state-backend.js";
58
+
59
+ /** Cloud SQL backups for the pre-upgrade backup — nothing else on the instance. */
60
+ export const UPGRADER_SQL_BACKUP_ROLE: CustomRoleSpec = {
61
+ roleId: "caeloUpgraderSqlBackup",
62
+ title: "Caelo upgrader: Cloud SQL backups",
63
+ description:
64
+ "Lets the Caelo upgrader job take and read on-demand Cloud SQL backups before an update. No restore, no instance changes.",
65
+ permissions: [
66
+ "cloudsql.backupRuns.create",
67
+ "cloudsql.backupRuns.get",
68
+ "cloudsql.backupRuns.list",
69
+ "cloudsql.instances.get",
70
+ "cloudsql.instances.list",
71
+ ],
72
+ };
73
+
74
+ /** Project-level roles of the upgrader's service account. */
75
+ export const UPGRADER_PROJECT_ROLES = [
76
+ "roles/run.developer",
77
+ "roles/cloudsql.instanceUser",
78
+ "roles/logging.logWriter",
79
+ ] as const;
80
+
81
+ /**
82
+ * Roles the upgrader must never hold anywhere — they would let it change
83
+ * IAM, manage service accounts or secrets, or touch compute/projects.
84
+ * Prefix matches (`roles/compute.` = every compute role).
85
+ */
86
+ export const UPGRADER_FORBIDDEN_ROLES = [
87
+ "roles/owner",
88
+ "roles/editor",
89
+ "roles/run.admin",
90
+ "roles/iam.serviceAccountAdmin",
91
+ "roles/iam.serviceAccountKeyAdmin",
92
+ "roles/iam.securityAdmin",
93
+ "roles/iam.roleAdmin",
94
+ "roles/resourcemanager.",
95
+ "roles/secretmanager.admin",
96
+ "roles/secretmanager.secretVersionAdder",
97
+ "roles/compute.",
98
+ "roles/cloudsql.admin",
99
+ "roles/cloudsql.editor",
100
+ "roles/storage.admin",
101
+ "roles/storage.objectAdmin",
102
+ ] as const;
103
+
104
+ /** Database role migration 0267 creates for the job. */
105
+ export const UPGRADER_DB_ROLE = "platform_updater";
106
+
107
+ /** The job's resources: enough for a backup + roll; the job ends within it. */
108
+ const JOB_TASK_TIMEOUT = "60m";
109
+
110
+ export interface UpgraderSetupTarget {
111
+ readonly projectId: string;
112
+ readonly region: string;
113
+ /** Pulumi stack name (`production`). */
114
+ readonly env: string;
115
+ /** The caelo-ops image to run, digest-pinned (`…/ops@sha256:…`). */
116
+ readonly opsImageRef: string;
117
+ /** VPC the admin uses to reach Cloud SQL's private IP. */
118
+ readonly network: string;
119
+ readonly subnet: string;
120
+ }
121
+
122
+ export type UpgraderSetupResult =
123
+ | { readonly ok: true; readonly done: string[] }
124
+ | { readonly ok: false; readonly done: string[]; readonly error: string };
125
+
126
+ /** The ops bucket condition: install.json and progress.json, not the Pulumi state. */
127
+ export function installStateCondition(bucket: string): string {
128
+ return `expression=resource.name.startsWith("projects/_/buckets/${bucket}/objects/${OPS_INSTALL_PREFIX}/"),title=caelo-upgrader-install-state,description=Only the install metadata under ${OPS_INSTALL_PREFIX}/`;
129
+ }
130
+
131
+ /** Ensure the upgrader job and everything it needs. Never throws for a gcloud failure. */
132
+ export async function ensureUpgraderJob(
133
+ target: UpgraderSetupTarget,
134
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
135
+ ): Promise<UpgraderSetupResult> {
136
+ const run = deps.run ?? defaultGcloud;
137
+ const sleep = deps.sleep ?? realSleep;
138
+ const retry = (args: string[]) => runWithRetry(run, sleep, args);
139
+ const { projectId, region, env } = target;
140
+ const project = `--project=${projectId}`;
141
+ const sa = upgraderServiceAccountEmail(projectId, env);
142
+ const member = `serviceAccount:${sa}`;
143
+ const runSaMember = `serviceAccount:${runServiceAccountEmail(projectId, env)}`;
144
+ const job = upgraderJobName(env);
145
+ const bucket = opsBucketName(projectId);
146
+ const done: string[] = [];
147
+ const fail = (step: string, r: GcloudResult): UpgraderSetupResult => ({
148
+ ok: false,
149
+ done,
150
+ error: `${step}: ${r.stderr.trim()}`,
151
+ });
152
+
153
+ if (!/@sha256:[0-9a-f]{64}$/.test(target.opsImageRef)) {
154
+ return {
155
+ ok: false,
156
+ done,
157
+ error: `the upgrader image must be pinned by digest, got ${target.opsImageRef}`,
158
+ };
159
+ }
160
+
161
+ // The job keeps install.json in the install's own ops bucket; an install
162
+ // whose state still lives on the operator's machine has none.
163
+ const bucketCheck = await run([
164
+ "storage",
165
+ "buckets",
166
+ "describe",
167
+ `gs://${bucket}`,
168
+ project,
169
+ "--format=value(name)",
170
+ ]);
171
+ if (!bucketCheck.ok) {
172
+ return {
173
+ ok: false,
174
+ done,
175
+ error: `the ops bucket gs://${bucket} is not there (${bucketCheck.stderr.trim()}). Move the install's state into its own project first: cms-provision state migrate`,
176
+ };
177
+ }
178
+
179
+ // 1. The backup role.
180
+ const roleError = await ensureCustomRole(run, projectId, UPGRADER_SQL_BACKUP_ROLE);
181
+ if (roleError) return { ok: false, done, error: roleError };
182
+ done.push(`custom role ${UPGRADER_SQL_BACKUP_ROLE.roleId}`);
183
+
184
+ // 2. The job's own service account.
185
+ const describe = await run([
186
+ "iam",
187
+ "service-accounts",
188
+ "describe",
189
+ sa,
190
+ project,
191
+ "--format=value(email)",
192
+ ]);
193
+ if (!describe.ok) {
194
+ const create = await run([
195
+ "iam",
196
+ "service-accounts",
197
+ "create",
198
+ upgraderServiceAccountId(env),
199
+ "--display-name=Caelo upgrader",
200
+ "--description=Installs signed Caelo releases started from the admin; may roll the services and back up Cloud SQL, never change IAM.",
201
+ project,
202
+ ]);
203
+ if (!create.ok && !/already exists/i.test(create.stderr)) return fail(`create ${sa}`, create);
204
+ }
205
+ done.push(`service account ${sa}`);
206
+
207
+ // 3. Project-level roles.
208
+ for (const role of [
209
+ ...UPGRADER_PROJECT_ROLES,
210
+ customRoleName(projectId, UPGRADER_SQL_BACKUP_ROLE.roleId),
211
+ ]) {
212
+ const r = await retry([
213
+ "projects",
214
+ "add-iam-policy-binding",
215
+ projectId,
216
+ `--member=${member}`,
217
+ `--role=${role}`,
218
+ "--condition=None",
219
+ "--quiet",
220
+ "--format=none",
221
+ ]);
222
+ if (!r.ok) return fail(`grant the upgrader ${role}`, r);
223
+ done.push(`upgrader: ${role} on the project`);
224
+ }
225
+
226
+ // 4. Resource-level grants on single service accounts.
227
+ const onAccounts: { account: string; role: string; why: string }[] = [
228
+ {
229
+ account: runServiceAccountEmail(projectId, env),
230
+ role: "roles/iam.serviceAccountUser",
231
+ why: "admin revisions + the migration job run as it",
232
+ },
233
+ {
234
+ account: gatewayServiceAccountEmail(projectId, env),
235
+ role: "roles/iam.serviceAccountUser",
236
+ why: "gateway revisions run as it",
237
+ },
238
+ { account: sa, role: "roles/iam.serviceAccountUser", why: "the job updates its own image" },
239
+ {
240
+ account: mcpIapServiceAccountEmail(projectId),
241
+ role: "roles/iam.serviceAccountTokenCreator",
242
+ why: "the health check signs its IAP credential as caelo-mcp",
243
+ },
244
+ ];
245
+ for (const g of onAccounts) {
246
+ const r = await retry([
247
+ "iam",
248
+ "service-accounts",
249
+ "add-iam-policy-binding",
250
+ g.account,
251
+ `--member=${member}`,
252
+ `--role=${g.role}`,
253
+ "--condition=None",
254
+ project,
255
+ "--quiet",
256
+ "--format=none",
257
+ ]);
258
+ if (!r.ok) return fail(`grant the upgrader ${g.role} on ${g.account} (${g.why})`, r);
259
+ done.push(`upgrader: ${g.role} on ${g.account}`);
260
+ }
261
+
262
+ // 5. install.json in the ops bucket — and nothing else there.
263
+ const bucketGrant = await retry([
264
+ "storage",
265
+ "buckets",
266
+ "add-iam-policy-binding",
267
+ `gs://${bucket}`,
268
+ `--member=${member}`,
269
+ "--role=roles/storage.objectUser",
270
+ `--condition=${installStateCondition(bucket)}`,
271
+ project,
272
+ "--quiet",
273
+ "--format=none",
274
+ ]);
275
+ if (!bucketGrant.ok)
276
+ return fail(`grant the upgrader install.json in gs://${bucket}`, bucketGrant);
277
+ done.push(`upgrader: roles/storage.objectUser on gs://${bucket}/${OPS_INSTALL_PREFIX}/`);
278
+
279
+ // 6. Its database login (migration 0267's platform_updater role).
280
+ const sqlUser = await ensureCloudSqlIamUser(run, {
281
+ projectId,
282
+ env,
283
+ serviceAccountEmail: sa,
284
+ databaseRole: UPGRADER_DB_ROLE,
285
+ });
286
+ done.push(...sqlUser.done);
287
+ if (!sqlUser.ok) return { ok: false, done, error: sqlUser.error };
288
+
289
+ // 7. The job: the ops image, its own SA, no env, no input but the database.
290
+ const deploy = await run([
291
+ "run",
292
+ "jobs",
293
+ "deploy",
294
+ job,
295
+ `--image=${target.opsImageRef}`,
296
+ `--region=${region}`,
297
+ project,
298
+ `--service-account=${sa}`,
299
+ `--network=${target.network}`,
300
+ `--subnet=${target.subnet}`,
301
+ "--vpc-egress=private-ranges-only",
302
+ "--command=caelo-ops",
303
+ "--args=upgrader",
304
+ "--max-retries=0",
305
+ `--task-timeout=${JOB_TASK_TIMEOUT}`,
306
+ "--memory=1Gi",
307
+ "--cpu=1",
308
+ "--quiet",
309
+ ]);
310
+ if (!deploy.ok) return fail(`deploy job ${job}`, deploy);
311
+ done.push(`job ${job} (${target.opsImageRef.split("@")[1]?.slice(0, 19)}...)`);
312
+
313
+ // 8. The admin may start it (no overrides) and read its runs — nothing else.
314
+ for (const role of ["roles/run.jobsExecutor", "roles/run.viewer"]) {
315
+ const r = await retry([
316
+ "run",
317
+ "jobs",
318
+ "add-iam-policy-binding",
319
+ job,
320
+ `--region=${region}`,
321
+ project,
322
+ `--member=${runSaMember}`,
323
+ `--role=${role}`,
324
+ "--quiet",
325
+ "--format=none",
326
+ ]);
327
+ if (!r.ok) return fail(`grant the admin ${role} on ${job}`, r);
328
+ done.push(`admin: ${role} on ${job}`);
329
+ }
330
+ return { ok: true, done };
331
+ }
332
+
333
+ /**
334
+ * {@link ensureUpgraderJob}'s target, with the VPC read from the deployed
335
+ * admin service (the job reaches Cloud SQL's private IP the same way).
336
+ */
337
+ export async function resolveUpgraderTarget(
338
+ opts: {
339
+ readonly projectId: string;
340
+ readonly region: string;
341
+ readonly env: string;
342
+ readonly opsImageRef: string;
343
+ },
344
+ deps: { run?: GcloudRunner } = {},
345
+ ): Promise<{ ok: true; target: UpgraderSetupTarget } | { ok: false; error: string }> {
346
+ const cfg = await readAdminConfig(opts, deps.run ?? defaultGcloud);
347
+ if (!cfg) {
348
+ return { ok: false, error: "could not read the admin service's VPC (is the admin deployed?)" };
349
+ }
350
+ const short = (ref: string) => ref.split("/").pop() ?? ref;
351
+ return {
352
+ ok: true,
353
+ target: { ...opts, network: short(cfg.networkRef), subnet: short(cfg.subnetRef) },
354
+ };
355
+ }