@caelo-cms/provisioning 0.10.46 → 0.10.48

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 (342) hide show
  1. package/dist/adc-impersonation.d.ts.map +1 -1
  2. package/dist/adc-impersonation.js +23 -1
  3. package/dist/adc-impersonation.js.map +1 -1
  4. package/dist/agent/apply.d.ts +45 -0
  5. package/dist/agent/apply.d.ts.map +1 -0
  6. package/dist/agent/apply.js +83 -0
  7. package/dist/agent/apply.js.map +1 -0
  8. package/dist/agent/cli-provenance.d.ts +42 -0
  9. package/dist/agent/cli-provenance.d.ts.map +1 -0
  10. package/dist/agent/cli-provenance.js +138 -0
  11. package/dist/agent/cli-provenance.js.map +1 -0
  12. package/dist/agent/command.d.ts +79 -0
  13. package/dist/agent/command.d.ts.map +1 -0
  14. package/dist/agent/command.js +454 -0
  15. package/dist/agent/command.js.map +1 -0
  16. package/dist/agent/config.d.ts +30 -0
  17. package/dist/agent/config.d.ts.map +1 -0
  18. package/dist/agent/config.js +60 -0
  19. package/dist/agent/config.js.map +1 -0
  20. package/dist/agent/lock.d.ts +13 -0
  21. package/dist/agent/lock.d.ts.map +1 -0
  22. package/dist/agent/lock.js +100 -0
  23. package/dist/agent/lock.js.map +1 -0
  24. package/dist/agent/queue.d.ts +35 -0
  25. package/dist/agent/queue.d.ts.map +1 -0
  26. package/dist/agent/queue.js +50 -0
  27. package/dist/agent/queue.js.map +1 -0
  28. package/dist/agent/run.d.ts +97 -0
  29. package/dist/agent/run.d.ts.map +1 -0
  30. package/dist/agent/run.js +198 -0
  31. package/dist/agent/run.js.map +1 -0
  32. package/dist/agent/systemd.d.ts +55 -0
  33. package/dist/agent/systemd.d.ts.map +1 -0
  34. package/dist/agent/systemd.js +171 -0
  35. package/dist/agent/systemd.js.map +1 -0
  36. package/dist/agent/units.d.ts +44 -0
  37. package/dist/agent/units.d.ts.map +1 -0
  38. package/dist/agent/units.js +127 -0
  39. package/dist/agent/units.js.map +1 -0
  40. package/dist/agent-db.js +18362 -0
  41. package/dist/cli.js +122 -16
  42. package/dist/cli.js.map +1 -1
  43. package/dist/cloudsql-iam-user.d.ts +39 -0
  44. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  45. package/dist/cloudsql-iam-user.js +119 -0
  46. package/dist/cloudsql-iam-user.js.map +1 -0
  47. package/dist/compose.d.ts +16 -0
  48. package/dist/compose.d.ts.map +1 -1
  49. package/dist/compose.js +26 -1
  50. package/dist/compose.js.map +1 -1
  51. package/dist/dns/index.d.ts +2 -0
  52. package/dist/dns/index.d.ts.map +1 -1
  53. package/dist/dns/index.js +3 -2
  54. package/dist/dns/index.js.map +1 -1
  55. package/dist/dns/manual.d.ts +5 -0
  56. package/dist/dns/manual.d.ts.map +1 -1
  57. package/dist/dns/manual.js +28 -12
  58. package/dist/dns/manual.js.map +1 -1
  59. package/dist/gcloud.d.ts +2 -0
  60. package/dist/gcloud.d.ts.map +1 -1
  61. package/dist/gcloud.js +1 -1
  62. package/dist/gcloud.js.map +1 -1
  63. package/dist/gcp-names.d.ts +15 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +23 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/infra-contract.d.ts +1 -1
  72. package/dist/infra-contract.js +7 -1
  73. package/dist/infra-contract.js.map +1 -1
  74. package/dist/install-job-runner.d.ts +13 -0
  75. package/dist/install-job-runner.d.ts.map +1 -0
  76. package/dist/install-job-runner.js +155 -0
  77. package/dist/install-job-runner.js.map +1 -0
  78. package/dist/install-job.d.ts +210 -0
  79. package/dist/install-job.d.ts.map +1 -0
  80. package/dist/install-job.js +449 -0
  81. package/dist/install-job.js.map +1 -0
  82. package/dist/install-state.d.ts +19 -1
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +31 -3
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +24 -5
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/migration-runner.d.ts +5 -0
  90. package/dist/migration-runner.d.ts.map +1 -1
  91. package/dist/migration-runner.js +1 -1
  92. package/dist/migration-runner.js.map +1 -1
  93. package/dist/operator-access.d.ts +14 -8
  94. package/dist/operator-access.d.ts.map +1 -1
  95. package/dist/operator-access.js +16 -102
  96. package/dist/operator-access.js.map +1 -1
  97. package/dist/run-mode.d.ts +27 -0
  98. package/dist/run-mode.d.ts.map +1 -0
  99. package/dist/run-mode.js +22 -0
  100. package/dist/run-mode.js.map +1 -0
  101. package/dist/runtime-secrets.d.ts +19 -0
  102. package/dist/runtime-secrets.d.ts.map +1 -1
  103. package/dist/runtime-secrets.js +44 -0
  104. package/dist/runtime-secrets.js.map +1 -1
  105. package/dist/self-hosted-roles.d.ts +22 -1
  106. package/dist/self-hosted-roles.d.ts.map +1 -1
  107. package/dist/self-hosted-roles.js +81 -12
  108. package/dist/self-hosted-roles.js.map +1 -1
  109. package/dist/self-hosted-upgrade.d.ts +17 -0
  110. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  111. package/dist/self-hosted-upgrade.js +16 -1
  112. package/dist/self-hosted-upgrade.js.map +1 -1
  113. package/dist/stack-contract.d.ts +6 -0
  114. package/dist/stack-contract.d.ts.map +1 -1
  115. package/dist/stack-contract.js +10 -1
  116. package/dist/stack-contract.js.map +1 -1
  117. package/dist/state-attach.d.ts +23 -0
  118. package/dist/state-attach.d.ts.map +1 -0
  119. package/dist/state-attach.js +112 -0
  120. package/dist/state-attach.js.map +1 -0
  121. package/dist/state-backend.d.ts +11 -0
  122. package/dist/state-backend.d.ts.map +1 -1
  123. package/dist/state-backend.js +20 -0
  124. package/dist/state-backend.js.map +1 -1
  125. package/dist/state-command.d.ts +20 -0
  126. package/dist/state-command.d.ts.map +1 -0
  127. package/dist/state-command.js +237 -0
  128. package/dist/state-command.js.map +1 -0
  129. package/dist/state-migrate.d.ts +121 -0
  130. package/dist/state-migrate.d.ts.map +1 -0
  131. package/dist/state-migrate.js +290 -0
  132. package/dist/state-migrate.js.map +1 -0
  133. package/dist/state-pulumi.d.ts +17 -0
  134. package/dist/state-pulumi.d.ts.map +1 -0
  135. package/dist/state-pulumi.js +218 -0
  136. package/dist/state-pulumi.js.map +1 -0
  137. package/dist/state-store.d.ts +27 -0
  138. package/dist/state-store.d.ts.map +1 -1
  139. package/dist/state-store.js +65 -0
  140. package/dist/state-store.js.map +1 -1
  141. package/dist/upgrade/converge.d.ts +6 -0
  142. package/dist/upgrade/converge.d.ts.map +1 -1
  143. package/dist/upgrade/converge.js +59 -2
  144. package/dist/upgrade/converge.js.map +1 -1
  145. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  146. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  147. package/dist/upgrade/gcp-lookup.js +25 -0
  148. package/dist/upgrade/gcp-lookup.js.map +1 -1
  149. package/dist/upgrade/release-images.d.ts +12 -0
  150. package/dist/upgrade/release-images.d.ts.map +1 -1
  151. package/dist/upgrade/release-images.js +31 -0
  152. package/dist/upgrade/release-images.js.map +1 -1
  153. package/dist/upgrade/upgrade.d.ts +5 -0
  154. package/dist/upgrade/upgrade.d.ts.map +1 -1
  155. package/dist/upgrade/upgrade.js +28 -1
  156. package/dist/upgrade/upgrade.js.map +1 -1
  157. package/dist/upgrader-assess.d.ts +82 -0
  158. package/dist/upgrader-assess.d.ts.map +1 -0
  159. package/dist/upgrader-assess.js +116 -0
  160. package/dist/upgrader-assess.js.map +1 -0
  161. package/dist/upgrader-gcp.d.ts +17 -0
  162. package/dist/upgrader-gcp.d.ts.map +1 -0
  163. package/dist/upgrader-gcp.js +87 -0
  164. package/dist/upgrader-gcp.js.map +1 -0
  165. package/dist/upgrader-job.d.ts +131 -0
  166. package/dist/upgrader-job.d.ts.map +1 -0
  167. package/dist/upgrader-job.js +259 -0
  168. package/dist/upgrader-job.js.map +1 -0
  169. package/dist/upgrader-reporter.d.ts +35 -0
  170. package/dist/upgrader-reporter.d.ts.map +1 -0
  171. package/dist/upgrader-reporter.js +79 -0
  172. package/dist/upgrader-reporter.js.map +1 -0
  173. package/dist/upgrader-setup.d.ts +59 -0
  174. package/dist/upgrader-setup.d.ts.map +1 -0
  175. package/dist/upgrader-setup.js +308 -0
  176. package/dist/upgrader-setup.js.map +1 -0
  177. package/dist/wizard.d.ts +6 -0
  178. package/dist/wizard.d.ts.map +1 -1
  179. package/dist/wizard.js +32 -4
  180. package/dist/wizard.js.map +1 -1
  181. package/dist/wizards/gcp-firebase.d.ts +3 -0
  182. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  183. package/dist/wizards/gcp-firebase.js +1 -0
  184. package/dist/wizards/gcp-firebase.js.map +1 -1
  185. package/dist/wizards/gcp-install-job.d.ts +74 -0
  186. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  187. package/dist/wizards/gcp-install-job.js +398 -0
  188. package/dist/wizards/gcp-install-job.js.map +1 -0
  189. package/dist/wizards/gcp-prompts.d.ts +3 -0
  190. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  191. package/dist/wizards/gcp-prompts.js +25 -0
  192. package/dist/wizards/gcp-prompts.js.map +1 -0
  193. package/dist/wizards/gcp-pulumi.d.ts +9 -2
  194. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  195. package/dist/wizards/gcp-pulumi.js +8 -6
  196. package/dist/wizards/gcp-pulumi.js.map +1 -1
  197. package/dist/wizards/gcp.d.ts +40 -0
  198. package/dist/wizards/gcp.d.ts.map +1 -1
  199. package/dist/wizards/gcp.js +291 -83
  200. package/dist/wizards/gcp.js.map +1 -1
  201. package/package.json +6 -4
  202. package/src/adapter.ts +97 -0
  203. package/src/adc-impersonation.ts +176 -0
  204. package/src/admin-domain.test.ts +254 -0
  205. package/src/admin-domain.ts +245 -0
  206. package/src/admin-memory.test.ts +151 -0
  207. package/src/agent/apply.test.ts +185 -0
  208. package/src/agent/apply.ts +124 -0
  209. package/src/agent/cli-provenance.test.ts +159 -0
  210. package/src/agent/cli-provenance.ts +191 -0
  211. package/src/agent/command.test.ts +77 -0
  212. package/src/agent/command.ts +564 -0
  213. package/src/agent/config.ts +86 -0
  214. package/src/agent/lock-config.test.ts +97 -0
  215. package/src/agent/lock.ts +110 -0
  216. package/src/agent/queue.test.ts +10 -0
  217. package/src/agent/queue.ts +91 -0
  218. package/src/agent/run.test.ts +382 -0
  219. package/src/agent/run.ts +295 -0
  220. package/src/agent/systemd.ts +209 -0
  221. package/src/agent/units.test.ts +223 -0
  222. package/src/agent/units.ts +163 -0
  223. package/src/azure-front-door.test.ts +119 -0
  224. package/src/azure-front-door.ts +56 -0
  225. package/src/bootstrap-token.ts +20 -0
  226. package/src/caddy.ts +112 -0
  227. package/src/cdn-copy.ts +91 -0
  228. package/src/cert-wait.test.ts +194 -0
  229. package/src/cert-wait.ts +163 -0
  230. package/src/checkpoint-secrets.test.ts +380 -0
  231. package/src/checkpoint-secrets.ts +283 -0
  232. package/src/cli-routing.test.ts +45 -0
  233. package/src/cli-routing.ts +43 -0
  234. package/src/cli.ts +1491 -0
  235. package/src/cloudsql-iam-user.ts +147 -0
  236. package/src/compose.ts +334 -0
  237. package/src/default-firewall.test.ts +411 -0
  238. package/src/default-firewall.ts +342 -0
  239. package/src/deployed-release.test.ts +231 -0
  240. package/src/deployed-release.ts +220 -0
  241. package/src/dns/cloudflare.ts +182 -0
  242. package/src/dns/groups.test.ts +76 -0
  243. package/src/dns/groups.ts +86 -0
  244. package/src/dns/index.ts +56 -0
  245. package/src/dns/manual.ts +130 -0
  246. package/src/dns/types.ts +27 -0
  247. package/src/gateway-credentials.test.ts +180 -0
  248. package/src/gateway-credentials.ts +144 -0
  249. package/src/gcloud-components.test.ts +76 -0
  250. package/src/gcloud-components.ts +85 -0
  251. package/src/gcloud-retry.ts +41 -0
  252. package/src/gcloud-runner.test.ts +61 -0
  253. package/src/gcloud.test.ts +99 -0
  254. package/src/gcloud.ts +322 -0
  255. package/src/gcp-names.ts +135 -0
  256. package/src/index.test.ts +332 -0
  257. package/src/index.ts +114 -0
  258. package/src/infra-contract.ts +32 -0
  259. package/src/install-job-runner.ts +166 -0
  260. package/src/install-job.test.ts +566 -0
  261. package/src/install-job.ts +542 -0
  262. package/src/install-state.test.ts +239 -0
  263. package/src/install-state.ts +583 -0
  264. package/src/internal-token.ts +35 -0
  265. package/src/lifecycle.ts +900 -0
  266. package/src/mcp-iap-stacks.test.ts +58 -0
  267. package/src/mcp-iap.test.ts +149 -0
  268. package/src/mcp-iap.ts +131 -0
  269. package/src/media-volume.test.ts +296 -0
  270. package/src/migration-runner.ts +365 -0
  271. package/src/operator-access-stacks.test.ts +55 -0
  272. package/src/operator-access.test.ts +332 -0
  273. package/src/operator-access.ts +554 -0
  274. package/src/ops-backend.test.ts +320 -0
  275. package/src/ops-backend.ts +414 -0
  276. package/src/provisioner-identity.test.ts +482 -0
  277. package/src/provisioner-identity.ts +407 -0
  278. package/src/redirects-emit.ts +175 -0
  279. package/src/region-data.ts +428 -0
  280. package/src/regions.test.ts +277 -0
  281. package/src/regions.ts +370 -0
  282. package/src/release-contract.ts +57 -0
  283. package/src/release-manifest.test.ts +293 -0
  284. package/src/release-manifest.ts +430 -0
  285. package/src/run-mode.ts +40 -0
  286. package/src/runtime-secrets.test.ts +627 -0
  287. package/src/runtime-secrets.ts +754 -0
  288. package/src/self-command.test.ts +14 -0
  289. package/src/self-command.ts +21 -0
  290. package/src/self-hosted-compose.test.ts +177 -0
  291. package/src/self-hosted-release.ts +240 -0
  292. package/src/self-hosted-roles.integration.test.ts +161 -0
  293. package/src/self-hosted-roles.test.ts +124 -0
  294. package/src/self-hosted-roles.ts +203 -0
  295. package/src/self-hosted-upgrade.test.ts +394 -0
  296. package/src/self-hosted-upgrade.ts +548 -0
  297. package/src/stack-contract-parity.test.ts +199 -0
  298. package/src/stack-contract.test.ts +391 -0
  299. package/src/stack-contract.ts +888 -0
  300. package/src/stack-converge-secrets.test.ts +421 -0
  301. package/src/stack-converge.test.ts +833 -0
  302. package/src/stack-converge.ts +909 -0
  303. package/src/staging-admin.test.ts +246 -0
  304. package/src/staging-admin.ts +324 -0
  305. package/src/state-attach.test.ts +211 -0
  306. package/src/state-attach.ts +132 -0
  307. package/src/state-backend.test.ts +145 -0
  308. package/src/state-backend.ts +262 -0
  309. package/src/state-command.ts +293 -0
  310. package/src/state-migrate.test.ts +479 -0
  311. package/src/state-migrate.ts +434 -0
  312. package/src/state-pulumi.ts +289 -0
  313. package/src/state-store.test.ts +212 -0
  314. package/src/state-store.ts +243 -0
  315. package/src/upgrade/app-roll.ts +486 -0
  316. package/src/upgrade/backup.ts +109 -0
  317. package/src/upgrade/converge.ts +437 -0
  318. package/src/upgrade/gcp-lookup.ts +167 -0
  319. package/src/upgrade/health.test.ts +279 -0
  320. package/src/upgrade/health.ts +305 -0
  321. package/src/upgrade/release-images.test.ts +119 -0
  322. package/src/upgrade/release-images.ts +391 -0
  323. package/src/upgrade/reporter.ts +46 -0
  324. package/src/upgrade/upgrade.test.ts +626 -0
  325. package/src/upgrade/upgrade.ts +249 -0
  326. package/src/upgrader-assess.test.ts +265 -0
  327. package/src/upgrader-assess.ts +233 -0
  328. package/src/upgrader-gcp.ts +123 -0
  329. package/src/upgrader-job.test.ts +408 -0
  330. package/src/upgrader-job.ts +499 -0
  331. package/src/upgrader-reporter.test.ts +46 -0
  332. package/src/upgrader-reporter.ts +105 -0
  333. package/src/upgrader-setup.test.ts +169 -0
  334. package/src/upgrader-setup.ts +355 -0
  335. package/src/wizard.ts +425 -0
  336. package/src/wizards/gcp-cost.ts +188 -0
  337. package/src/wizards/gcp-firebase.ts +72 -0
  338. package/src/wizards/gcp-install-job.ts +506 -0
  339. package/src/wizards/gcp-prompts.ts +26 -0
  340. package/src/wizards/gcp-pulumi.test.ts +246 -0
  341. package/src/wizards/gcp-pulumi.ts +516 -0
  342. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,414 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The install's ops bucket + Pulumi state key in its own GCP project (#696,
5
+ * D2). Ensured by the GCP wizard for every new install (before the cost
6
+ * confirmation — both are near-free, see gcp-cost.ts) and by
7
+ * the state migration for an existing one.
8
+ *
9
+ * - `gs://<project>-caelo-ops`, in the install region: object versioning
10
+ * on (every Pulumi checkpoint write is recoverable), uniform bucket-level
11
+ * access (no per-object ACLs), public access prevention enforced, and a
12
+ * lifecycle rule that deletes non-current versions after
13
+ * {@link NONCURRENT_VERSION_DAYS} days — Pulumi rewrites its checkpoint on
14
+ * every resource step, so unbounded versions would grow without end.
15
+ * - Cloud KMS key ring `caelo`, key `pulumi-state` (symmetric, software
16
+ * protection, no automatic rotation: each rotation adds a billed key
17
+ * version, and the key only wraps Pulumi's data key).
18
+ * - IAM: the operator and `caelo-provisioner` get object access on the
19
+ * bucket and encrypt/decrypt on the key — nobody else is granted
20
+ * anything. The bucket's convenience bindings for project viewers and
21
+ * editors are removed; public principals are refused outright. Project
22
+ * Owners keep access through their project role (that cannot be taken
23
+ * away at the bucket); docs-site/pages/install-gcp.md says so.
24
+ *
25
+ * Every step reads before it writes, so a re-run is a no-op and an operator
26
+ * without IAM-write permission can still run against an ops backend someone
27
+ * else ensured. Nothing here ever deletes a bucket, an object or a key.
28
+ */
29
+
30
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { gcloud as defaultGcloud } from "./gcloud.js";
34
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
35
+ import { operatorMember } from "./provisioner-identity.js";
36
+ import {
37
+ gcsBackendRecord,
38
+ OPS_KEY_RING,
39
+ OPS_STATE_KEY,
40
+ opsBucketName,
41
+ type StateBackendRecord,
42
+ } from "./state-backend.js";
43
+
44
+ /** Days a superseded Pulumi checkpoint version stays recoverable. */
45
+ export const NONCURRENT_VERSION_DAYS = 30;
46
+
47
+ /** Role the operator + provisioner hold on the ops bucket. */
48
+ export const OPS_BUCKET_ROLE = "roles/storage.objectAdmin";
49
+ /** Role the operator + provisioner hold on the state key. */
50
+ export const OPS_KEY_ROLE = "roles/cloudkms.cryptoKeyEncrypterDecrypter";
51
+
52
+ /** APIs the ops backend needs. */
53
+ const OPS_APIS = ["storage.googleapis.com", "cloudkms.googleapis.com"] as const;
54
+
55
+ /** Principals that must never appear on the ops bucket. */
56
+ const PUBLIC_MEMBERS = new Set(["allUsers", "allAuthenticatedUsers"]);
57
+
58
+ /** gcloud's wording for a resource that does not exist (yet). */
59
+ const NOT_FOUND = /NOT_FOUND|NotFound|not found|404|does not exist/i;
60
+
61
+ /** Everything {@link ensureOpsBackend} needs to know about the install. */
62
+ export interface OpsBackendTarget {
63
+ readonly projectId: string;
64
+ /** The install region; bucket and key ring live here. */
65
+ readonly region: string;
66
+ /** The gcloud account running the command (gets access). */
67
+ readonly operatorAccount: string;
68
+ /** `caelo-provisioner@<project>.iam.gserviceaccount.com` (gets access). */
69
+ readonly provisionerEmail: string;
70
+ }
71
+
72
+ export type OpsBackendResult =
73
+ | {
74
+ readonly ok: true;
75
+ readonly record: StateBackendRecord;
76
+ /** What was done (or, with `dryRun`, would be done); empty when all was in place. */
77
+ readonly actions: readonly string[];
78
+ }
79
+ | { readonly ok: false; readonly error: string };
80
+
81
+ interface BucketDescription {
82
+ versioning_enabled?: boolean;
83
+ uniform_bucket_level_access?: boolean;
84
+ public_access_prevention?: string;
85
+ lifecycle_config?: unknown;
86
+ }
87
+
88
+ interface IamPolicy {
89
+ bindings?: { role?: string; members?: string[]; condition?: unknown }[];
90
+ }
91
+
92
+ function parseJson<T>(text: string, fallback: T): T {
93
+ try {
94
+ return (JSON.parse(text || "null") as T | null) ?? fallback;
95
+ } catch {
96
+ return fallback;
97
+ }
98
+ }
99
+
100
+ /** The lifecycle rule the ops bucket carries — and no other. */
101
+ const OPS_LIFECYCLE_RULE = {
102
+ action: { type: "Delete" },
103
+ condition: { daysSinceNoncurrentTime: NONCURRENT_VERSION_DAYS, isLive: false },
104
+ } as const;
105
+
106
+ /**
107
+ * Whether a bucket's lifecycle is exactly {@link OPS_LIFECYCLE_RULE}. Any
108
+ * other rule — a shorter window, a prefix filter, a rule deleting live
109
+ * objects — could cut the recovery window or delete the state itself, so
110
+ * it is replaced, not tolerated.
111
+ */
112
+ export function isOpsLifecycle(config: unknown): boolean {
113
+ const rules = (config as { rule?: unknown } | null | undefined)?.rule;
114
+ if (!Array.isArray(rules) || rules.length !== 1) return false;
115
+ const r = rules[0] as { action?: Record<string, unknown>; condition?: Record<string, unknown> };
116
+ const cond = r.condition ?? {};
117
+ return (
118
+ r.action?.type === "Delete" &&
119
+ Object.keys(r.action).length === 1 &&
120
+ cond.daysSinceNoncurrentTime === NONCURRENT_VERSION_DAYS &&
121
+ cond.isLive === false &&
122
+ Object.keys(cond).length === 2
123
+ );
124
+ }
125
+
126
+ const hasBinding = (policy: IamPolicy, role: string, member: string): boolean =>
127
+ (policy.bindings ?? []).some(
128
+ (b) =>
129
+ b.role === role &&
130
+ b.condition == null &&
131
+ (b.members ?? []).some((m) => m.toLowerCase() === member.toLowerCase()),
132
+ );
133
+
134
+ const reason = (stderr: string, exitCode: number) =>
135
+ stderr.trim().split("\n").slice(-3).join(" ") || `gcloud exited ${exitCode}`;
136
+
137
+ /**
138
+ * Ensure the ops bucket + state key exist with the settings and IAM above.
139
+ * Idempotent; never throws for a gcloud failure (the result says what
140
+ * failed and what to run). With `dryRun` it only reads and reports what it
141
+ * would change.
142
+ */
143
+ export async function ensureOpsBackend(
144
+ target: OpsBackendTarget,
145
+ deps: { run?: GcloudRunner; sleep?: Sleep; dryRun?: boolean } = {},
146
+ ): Promise<OpsBackendResult> {
147
+ const run = deps.run ?? defaultGcloud;
148
+ const sleep = deps.sleep ?? realSleep;
149
+ const dryRun = deps.dryRun ?? false;
150
+ const { projectId, region } = target;
151
+ const project = `--project=${projectId}`;
152
+ const bucket = opsBucketName(projectId);
153
+ const bucketUrl = `gs://${bucket}`;
154
+ const members = [
155
+ operatorMember(target.operatorAccount),
156
+ `serviceAccount:${target.provisionerEmail}`,
157
+ ];
158
+ const actions: string[] = [];
159
+ const act = (what: string) => actions.push(dryRun ? `would ${what}` : what);
160
+ const fail = (what: string, r: { stderr: string; exitCode: number }): OpsBackendResult => ({
161
+ ok: false,
162
+ error: `${what} failed: ${reason(r.stderr, r.exitCode)}`,
163
+ });
164
+
165
+ // --- APIs -------------------------------------------------------------
166
+ // Read first: an operator without serviceusage.services.enable can still
167
+ // re-run an install whose APIs are on.
168
+ const enabled = await run([
169
+ "services",
170
+ "list",
171
+ "--enabled",
172
+ project,
173
+ "--format=value(config.name)",
174
+ ]);
175
+ if (!enabled.ok) return fail(`Listing the enabled APIs of ${projectId}`, enabled);
176
+ const on = new Set(enabled.stdout.split("\n").map((l) => l.trim()));
177
+ const missingApis = OPS_APIS.filter((a) => !on.has(a));
178
+ if (missingApis.length > 0) {
179
+ act(`enable ${missingApis.join(", ")}`);
180
+ if (!dryRun) {
181
+ const apis = await run(["services", "enable", ...missingApis, project]);
182
+ if (!apis.ok) return fail(`Enabling ${missingApis.join(", ")}`, apis);
183
+ }
184
+ }
185
+
186
+ // --- Bucket -----------------------------------------------------------
187
+ const described = await run([
188
+ "storage",
189
+ "buckets",
190
+ "describe",
191
+ bucketUrl,
192
+ project,
193
+ "--format=json",
194
+ ]);
195
+ let bucketExists = described.ok;
196
+ if (!described.ok && !NOT_FOUND.test(described.stderr)) {
197
+ return {
198
+ ok: false,
199
+ error: `Could not read ${bucketUrl}: ${reason(described.stderr, described.exitCode)}. If the name is taken by a bucket outside project ${projectId}, the install can't use it — tell the maintainers (the name is <project>-caelo-ops).`,
200
+ };
201
+ }
202
+ if (bucketExists) {
203
+ // Bucket names are global and `--project` does not scope `describe`: a
204
+ // bucket of that name in a project the operator can also reach would
205
+ // otherwise receive this install's state.
206
+ const owned = await run(["storage", "buckets", "list", project, "--format=value(name)"]);
207
+ if (!owned.ok) return fail(`Listing the buckets of project ${projectId}`, owned);
208
+ const names = owned.stdout.split("\n").map((l) =>
209
+ l
210
+ .trim()
211
+ .replace(/^gs:\/\//, "")
212
+ .replace(/\/$/, ""),
213
+ );
214
+ if (!names.includes(bucket)) {
215
+ return {
216
+ ok: false,
217
+ error: `${bucketUrl} exists but does not belong to project ${projectId}. The install's state must live in its own project; delete or rename that bucket, or tell the maintainers (the name is <project>-caelo-ops).`,
218
+ };
219
+ }
220
+ }
221
+ if (!bucketExists) {
222
+ act(`create ${bucketUrl} in ${region}`);
223
+ if (!dryRun) {
224
+ const created = await run([
225
+ "storage",
226
+ "buckets",
227
+ "create",
228
+ bucketUrl,
229
+ project,
230
+ `--location=${region}`,
231
+ "--default-storage-class=STANDARD",
232
+ "--uniform-bucket-level-access",
233
+ "--public-access-prevention",
234
+ ]);
235
+ if (!created.ok) return fail(`Creating ${bucketUrl}`, created);
236
+ bucketExists = true;
237
+ }
238
+ }
239
+ const desc: BucketDescription = described.ok ? parseJson(described.stdout, {}) : {};
240
+ // A bucket created above already has uniform access + public access
241
+ // prevention; `buckets create` has no versioning flag.
242
+ const updates: string[] = [];
243
+ if (desc.versioning_enabled !== true) updates.push("--versioning");
244
+ if (described.ok && desc.uniform_bucket_level_access !== true) {
245
+ updates.push("--uniform-bucket-level-access");
246
+ }
247
+ if (described.ok && desc.public_access_prevention !== "enforced") {
248
+ updates.push("--public-access-prevention");
249
+ }
250
+ const hasLifecycle = isOpsLifecycle(desc.lifecycle_config);
251
+ if (updates.length > 0) {
252
+ act(`set ${updates.map((u) => u.slice(2)).join(", ")} on ${bucketUrl}`);
253
+ if (!dryRun) {
254
+ const r = await run(["storage", "buckets", "update", bucketUrl, project, ...updates]);
255
+ if (!r.ok) return fail(`Updating ${bucketUrl}`, r);
256
+ }
257
+ }
258
+ if (!hasLifecycle) {
259
+ act(
260
+ `set the lifecycle of ${bucketUrl} to: delete non-current object versions after ${NONCURRENT_VERSION_DAYS} days (nothing else)`,
261
+ );
262
+ if (!dryRun) {
263
+ // A private directory (mkdtemp, mode 700): no other local user can
264
+ // swap the file between write and read.
265
+ const dir = mkdtempSync(join(tmpdir(), "caelo-ops-"));
266
+ const file = join(dir, "lifecycle.json");
267
+ try {
268
+ writeFileSync(file, JSON.stringify({ rule: [OPS_LIFECYCLE_RULE] }), { mode: 0o600 });
269
+ const r = await run([
270
+ "storage",
271
+ "buckets",
272
+ "update",
273
+ bucketUrl,
274
+ project,
275
+ `--lifecycle-file=${file}`,
276
+ ]);
277
+ if (!r.ok) return fail(`Setting the lifecycle rule on ${bucketUrl}`, r);
278
+ } finally {
279
+ rmSync(dir, { recursive: true, force: true });
280
+ }
281
+ }
282
+ }
283
+
284
+ // --- Bucket IAM -------------------------------------------------------
285
+ if (bucketExists) {
286
+ const got = await run([
287
+ "storage",
288
+ "buckets",
289
+ "get-iam-policy",
290
+ bucketUrl,
291
+ project,
292
+ "--format=json",
293
+ ]);
294
+ if (!got.ok) return fail(`Reading the IAM policy of ${bucketUrl}`, got);
295
+ const policy = parseJson<IamPolicy>(got.stdout, {});
296
+ const exposed = (policy.bindings ?? []).flatMap((b) =>
297
+ (b.members ?? []).filter((m) => PUBLIC_MEMBERS.has(m)).map((m) => `${m} (${b.role})`),
298
+ );
299
+ if (exposed.length > 0) {
300
+ return {
301
+ ok: false,
302
+ error: `${bucketUrl} grants ${exposed.join(", ")}. It holds the install's infrastructure state and must never be public. Remove those bindings (gcloud storage buckets remove-iam-policy-binding ${bucketUrl} --member=<member> --role=<role>) and re-run.`,
303
+ };
304
+ }
305
+ for (const member of members) {
306
+ if (hasBinding(policy, OPS_BUCKET_ROLE, member)) continue;
307
+ act(`grant ${member} ${OPS_BUCKET_ROLE} on ${bucketUrl}`);
308
+ if (dryRun) continue;
309
+ const r = await runWithRetry(run, sleep, [
310
+ "storage",
311
+ "buckets",
312
+ "add-iam-policy-binding",
313
+ bucketUrl,
314
+ project,
315
+ `--member=${member}`,
316
+ `--role=${OPS_BUCKET_ROLE}`,
317
+ ]);
318
+ if (!r.ok) return fail(`Granting ${member} access to ${bucketUrl}`, r);
319
+ }
320
+ for (const b of policy.bindings ?? []) {
321
+ for (const m of b.members ?? []) {
322
+ if (!/^project(Viewer|Editor):/.test(m) || !b.role) continue;
323
+ act(`remove ${m} ${b.role} from ${bucketUrl}`);
324
+ if (dryRun) continue;
325
+ const r = await run([
326
+ "storage",
327
+ "buckets",
328
+ "remove-iam-policy-binding",
329
+ bucketUrl,
330
+ project,
331
+ `--member=${m}`,
332
+ `--role=${b.role}`,
333
+ ]);
334
+ if (!r.ok) return fail(`Removing ${m} from ${bucketUrl}`, r);
335
+ }
336
+ }
337
+ } else {
338
+ for (const member of members) act(`grant ${member} ${OPS_BUCKET_ROLE} on ${bucketUrl}`);
339
+ }
340
+
341
+ // --- KMS key ring + key -------------------------------------------------
342
+ const kmsWhere = [`--location=${region}`, project];
343
+ const ring = await run([
344
+ "kms",
345
+ "keyrings",
346
+ "describe",
347
+ OPS_KEY_RING,
348
+ ...kmsWhere,
349
+ "--format=value(name)",
350
+ ]);
351
+ // In a dry run the KMS API may still be disabled; that reads as "not there yet".
352
+ const ringMissing = !ring.ok && (NOT_FOUND.test(ring.stderr) || dryRun);
353
+ if (!ring.ok && !ringMissing) return fail(`Reading key ring ${OPS_KEY_RING}`, ring);
354
+ if (ringMissing) {
355
+ act(`create KMS key ring ${OPS_KEY_RING} in ${region}`);
356
+ if (!dryRun) {
357
+ const r = await run(["kms", "keyrings", "create", OPS_KEY_RING, ...kmsWhere]);
358
+ if (!r.ok) return fail(`Creating key ring ${OPS_KEY_RING}`, r);
359
+ }
360
+ }
361
+ const keyWhere = [`--keyring=${OPS_KEY_RING}`, ...kmsWhere];
362
+ const key = ringMissing
363
+ ? { ok: false, stderr: "NOT_FOUND", stdout: "", exitCode: 1 }
364
+ : await run(["kms", "keys", "describe", OPS_STATE_KEY, ...keyWhere, "--format=value(name)"]);
365
+ const keyMissing = !key.ok && NOT_FOUND.test(key.stderr);
366
+ if (!key.ok && !keyMissing) return fail(`Reading key ${OPS_STATE_KEY}`, key);
367
+ if (keyMissing) {
368
+ act(`create KMS key ${OPS_KEY_RING}/${OPS_STATE_KEY}`);
369
+ if (!dryRun) {
370
+ const r = await run([
371
+ "kms",
372
+ "keys",
373
+ "create",
374
+ OPS_STATE_KEY,
375
+ ...keyWhere,
376
+ "--purpose=encryption",
377
+ "--protection-level=software",
378
+ ]);
379
+ if (!r.ok) return fail(`Creating key ${OPS_STATE_KEY}`, r);
380
+ }
381
+ }
382
+
383
+ // --- Key IAM ------------------------------------------------------------
384
+ let keyPolicy: IamPolicy = {};
385
+ if (!keyMissing) {
386
+ const got = await run([
387
+ "kms",
388
+ "keys",
389
+ "get-iam-policy",
390
+ OPS_STATE_KEY,
391
+ ...keyWhere,
392
+ "--format=json",
393
+ ]);
394
+ if (!got.ok) return fail(`Reading the IAM policy of key ${OPS_STATE_KEY}`, got);
395
+ keyPolicy = parseJson<IamPolicy>(got.stdout, {});
396
+ }
397
+ for (const member of members) {
398
+ if (hasBinding(keyPolicy, OPS_KEY_ROLE, member)) continue;
399
+ act(`grant ${member} ${OPS_KEY_ROLE} on ${OPS_KEY_RING}/${OPS_STATE_KEY}`);
400
+ if (dryRun) continue;
401
+ const r = await runWithRetry(run, sleep, [
402
+ "kms",
403
+ "keys",
404
+ "add-iam-policy-binding",
405
+ OPS_STATE_KEY,
406
+ ...keyWhere,
407
+ `--member=${member}`,
408
+ `--role=${OPS_KEY_ROLE}`,
409
+ ]);
410
+ if (!r.ok) return fail(`Granting ${member} use of key ${OPS_STATE_KEY}`, r);
411
+ }
412
+
413
+ return { ok: true, record: gcsBackendRecord(projectId, region), actions };
414
+ }