@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,320 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The install's ops bucket + Pulumi state key (#696, ops-backend.ts) against
5
+ * a stateful fake gcloud. Load-bearing: a fresh project ends with a
6
+ * versioned, uniform-access, public-access-prevented bucket and a KMS key
7
+ * that only the operator and caelo-provisioner may use; a second run writes
8
+ * nothing; a public bucket is refused; a dry run only reads; nothing is
9
+ * ever deleted.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import type { GcloudResult } from "./gcloud.js";
14
+ import {
15
+ ensureOpsBackend,
16
+ isOpsLifecycle,
17
+ NONCURRENT_VERSION_DAYS,
18
+ OPS_BUCKET_ROLE,
19
+ OPS_KEY_ROLE,
20
+ } from "./ops-backend.js";
21
+ import { opsKmsKeyName } from "./state-backend.js";
22
+ import { estimateGcpCost } from "./wizards/gcp-cost.js";
23
+
24
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
25
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
26
+
27
+ type Binding = { role: string; members: string[] };
28
+
29
+ /** A tiny in-memory GCP: one bucket, one key ring, one key, their policies. */
30
+ function fakeGcp(initial: { bucket?: boolean; ring?: boolean; key?: boolean } = {}) {
31
+ const state = {
32
+ apis: new Set<string>(
33
+ initial.bucket ? ["storage.googleapis.com", "cloudkms.googleapis.com"] : [],
34
+ ),
35
+ bucket: initial.bucket
36
+ ? {
37
+ versioning_enabled: true,
38
+ uniform_bucket_level_access: true,
39
+ public_access_prevention: "enforced",
40
+ lifecycle_config: {
41
+ rule: [
42
+ {
43
+ action: { type: "Delete" },
44
+ condition: { daysSinceNoncurrentTime: 30, isLive: false },
45
+ },
46
+ ],
47
+ },
48
+ }
49
+ : null,
50
+ bucketPolicy: [] as Binding[],
51
+ /** The bucket name exists, but in someone else's project. */
52
+ foreignBucket: false,
53
+ ring: initial.ring ?? false,
54
+ key: initial.key ?? false,
55
+ keyPolicy: [] as Binding[],
56
+ };
57
+ const calls: string[] = [];
58
+ const flag = (args: string[], name: string) =>
59
+ args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
60
+ const bind = (policy: Binding[], role: string, member: string) => {
61
+ const b = policy.find((x) => x.role === role);
62
+ if (b) {
63
+ if (!b.members.includes(member)) b.members.push(member);
64
+ } else policy.push({ role, members: [member] });
65
+ };
66
+ const run = async (args: string[]): Promise<GcloudResult> => {
67
+ const line = args.join(" ");
68
+ calls.push(line);
69
+ if (line.startsWith("services list")) return ok([...state.apis].join("\n"));
70
+ if (line.startsWith("services enable")) {
71
+ for (const a of args.slice(2)) if (!a.startsWith("--")) state.apis.add(a);
72
+ return ok();
73
+ }
74
+ if (line.startsWith("storage buckets describe")) {
75
+ return state.bucket ? ok(JSON.stringify(state.bucket)) : fail("ERROR: NotFound: 404 bucket");
76
+ }
77
+ if (line.startsWith("storage buckets create")) {
78
+ state.bucket = {
79
+ versioning_enabled: false,
80
+ uniform_bucket_level_access: args.includes("--uniform-bucket-level-access"),
81
+ public_access_prevention: args.includes("--public-access-prevention")
82
+ ? "enforced"
83
+ : "inherited",
84
+ lifecycle_config: null as never,
85
+ };
86
+ // GCS adds the convenience bindings to every new bucket.
87
+ state.bucketPolicy.push(
88
+ {
89
+ role: "roles/storage.legacyBucketOwner",
90
+ members: ["projectOwner:acme", "projectEditor:acme"],
91
+ },
92
+ { role: "roles/storage.legacyBucketReader", members: ["projectViewer:acme"] },
93
+ );
94
+ return ok();
95
+ }
96
+ if (line.startsWith("storage buckets update")) {
97
+ if (!state.bucket) return fail("NotFound");
98
+ if (args.includes("--versioning")) state.bucket.versioning_enabled = true;
99
+ if (args.includes("--uniform-bucket-level-access"))
100
+ state.bucket.uniform_bucket_level_access = true;
101
+ if (args.includes("--public-access-prevention"))
102
+ state.bucket.public_access_prevention = "enforced";
103
+ if (flag(args, "lifecycle-file")) {
104
+ state.bucket.lifecycle_config = {
105
+ rule: [
106
+ {
107
+ action: { type: "Delete" },
108
+ condition: { daysSinceNoncurrentTime: NONCURRENT_VERSION_DAYS, isLive: false },
109
+ },
110
+ ],
111
+ } as never;
112
+ }
113
+ return ok();
114
+ }
115
+ if (line.startsWith("storage buckets list")) {
116
+ return ok(state.bucket && !state.foreignBucket ? "acme-caelo-ops\n" : "acme-media\n");
117
+ }
118
+ if (line.startsWith("storage buckets get-iam-policy")) {
119
+ return ok(JSON.stringify({ bindings: state.bucketPolicy }));
120
+ }
121
+ if (line.startsWith("storage buckets add-iam-policy-binding")) {
122
+ bind(state.bucketPolicy, flag(args, "role") ?? "", flag(args, "member") ?? "");
123
+ return ok();
124
+ }
125
+ if (line.startsWith("storage buckets remove-iam-policy-binding")) {
126
+ const b = state.bucketPolicy.find((x) => x.role === flag(args, "role"));
127
+ if (b) b.members = b.members.filter((m) => m !== flag(args, "member"));
128
+ return ok();
129
+ }
130
+ if (line.startsWith("kms keyrings describe")) {
131
+ return state.ring
132
+ ? ok("projects/acme/locations/europe-west1/keyRings/caelo")
133
+ : fail("NOT_FOUND: KeyRing");
134
+ }
135
+ if (line.startsWith("kms keyrings create")) {
136
+ state.ring = true;
137
+ return ok();
138
+ }
139
+ if (line.startsWith("kms keys describe")) {
140
+ return state.key ? ok(opsKmsKeyName("acme", "europe-west1")) : fail("NOT_FOUND: CryptoKey");
141
+ }
142
+ if (line.startsWith("kms keys create")) {
143
+ state.key = true;
144
+ return ok();
145
+ }
146
+ if (line.startsWith("kms keys get-iam-policy")) {
147
+ return ok(JSON.stringify({ bindings: state.keyPolicy }));
148
+ }
149
+ if (line.startsWith("kms keys add-iam-policy-binding")) {
150
+ bind(state.keyPolicy, flag(args, "role") ?? "", flag(args, "member") ?? "");
151
+ return ok();
152
+ }
153
+ return fail(`unexpected gcloud ${line}`);
154
+ };
155
+ const writes = () => calls.filter((c) => !/ (describe|get-iam-policy|list) /.test(` ${c} `));
156
+ return { run, calls, state, writes };
157
+ }
158
+
159
+ const TARGET = {
160
+ projectId: "acme",
161
+ region: "europe-west1",
162
+ operatorAccount: "Owner@Example.com",
163
+ provisionerEmail: "caelo-provisioner@acme.iam.gserviceaccount.com",
164
+ };
165
+ const OPERATOR = "user:owner@example.com";
166
+ const PROVISIONER = "serviceAccount:caelo-provisioner@acme.iam.gserviceaccount.com";
167
+ const noSleep = async () => {};
168
+
169
+ describe("ensureOpsBackend — new install", () => {
170
+ it("creates a hardened bucket + KMS key that only the operator and the provisioner can use", async () => {
171
+ const g = fakeGcp();
172
+ const r = await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
173
+ expect(r.ok).toBe(true);
174
+ if (!r.ok) return;
175
+ expect(r.record).toEqual({
176
+ kind: "gcs",
177
+ bucket: "acme-caelo-ops",
178
+ kmsKey: "projects/acme/locations/europe-west1/keyRings/caelo/cryptoKeys/pulumi-state",
179
+ });
180
+ expect(g.state.apis.has("cloudkms.googleapis.com")).toBe(true);
181
+ expect(g.state.bucket).toMatchObject({
182
+ versioning_enabled: true,
183
+ uniform_bucket_level_access: true,
184
+ public_access_prevention: "enforced",
185
+ });
186
+ expect(
187
+ g.calls.some((c) => c.includes("--location=europe-west1") && c.includes("buckets create")),
188
+ ).toBe(true);
189
+ // Bucket: exactly the operator + the provisioner hold object access; the
190
+ // viewer/editor convenience bindings are gone; nothing public.
191
+ const objectAdmins = g.state.bucketPolicy.find((b) => b.role === OPS_BUCKET_ROLE)?.members;
192
+ expect(objectAdmins?.sort()).toEqual([PROVISIONER, OPERATOR].sort());
193
+ const everyone = g.state.bucketPolicy.flatMap((b) => b.members);
194
+ expect(everyone.some((m) => /^project(Viewer|Editor):/.test(m))).toBe(false);
195
+ expect(everyone.some((m) => m === "allUsers" || m === "allAuthenticatedUsers")).toBe(false);
196
+ // Key: the same two principals, encrypt/decrypt only.
197
+ expect(g.state.ring && g.state.key).toBe(true);
198
+ expect(g.state.keyPolicy).toEqual([{ role: OPS_KEY_ROLE, members: [OPERATOR, PROVISIONER] }]);
199
+ // Never deletes anything.
200
+ expect(g.calls.some((c) => / delete /.test(` ${c} `))).toBe(false);
201
+ });
202
+
203
+ it("is idempotent: a second run reads and writes nothing", async () => {
204
+ const g = fakeGcp();
205
+ await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
206
+ const before = g.writes().length;
207
+ const again = await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
208
+ expect(again.ok && again.actions).toEqual([]);
209
+ expect(g.writes().length).toBe(before);
210
+ });
211
+
212
+ it("brings an existing bucket that lost its hardening back in line", async () => {
213
+ const g = fakeGcp({ bucket: true, ring: true, key: true });
214
+ if (g.state.bucket) {
215
+ g.state.bucket.versioning_enabled = false;
216
+ g.state.bucket.public_access_prevention = "inherited";
217
+ }
218
+ const r = await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
219
+ expect(r.ok).toBe(true);
220
+ expect(g.state.bucket).toMatchObject({
221
+ versioning_enabled: true,
222
+ public_access_prevention: "enforced",
223
+ });
224
+ expect(g.calls.some((c) => c.startsWith("storage buckets create"))).toBe(false);
225
+ expect(g.calls.some((c) => c.startsWith("kms keys create"))).toBe(false);
226
+ });
227
+
228
+ it("refuses a bucket that grants public access, naming the binding", async () => {
229
+ const g = fakeGcp({ bucket: true, ring: true, key: true });
230
+ g.state.bucketPolicy.push({ role: "roles/storage.objectViewer", members: ["allUsers"] });
231
+ const r = await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
232
+ expect(r.ok).toBe(false);
233
+ if (r.ok) return;
234
+ expect(r.error).toContain("allUsers (roles/storage.objectViewer)");
235
+ expect(r.error).toContain("must never be public");
236
+ });
237
+
238
+ it("stops on a bucket name it cannot read (taken by another project)", async () => {
239
+ const g = fakeGcp();
240
+ const run = async (args: string[]) =>
241
+ args.join(" ").startsWith("storage buckets describe")
242
+ ? fail("ERROR: 403 caller does not have storage.buckets.get access")
243
+ : g.run(args);
244
+ const r = await ensureOpsBackend(TARGET, { run, sleep: noSleep });
245
+ expect(r.ok).toBe(false);
246
+ expect(g.calls.some((c) => c.startsWith("storage buckets create"))).toBe(false);
247
+ });
248
+
249
+ it("refuses a same-named bucket in another project the operator can reach", async () => {
250
+ const g = fakeGcp({ bucket: true, ring: true, key: true });
251
+ g.state.foreignBucket = true;
252
+ const r = await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
253
+ expect(r.ok).toBe(false);
254
+ if (r.ok) return;
255
+ expect(r.error).toContain("does not belong to project acme");
256
+ expect(g.writes()).toEqual([]);
257
+ });
258
+
259
+ it("replaces a lifecycle that could cut the recovery window or delete live state", async () => {
260
+ for (const bad of [
261
+ {
262
+ rule: [
263
+ { action: { type: "Delete" }, condition: { daysSinceNoncurrentTime: 1, isLive: false } },
264
+ ],
265
+ },
266
+ {
267
+ rule: [
268
+ {
269
+ action: { type: "Delete" },
270
+ condition: { daysSinceNoncurrentTime: 30, isLive: false, matchesPrefix: ["x/"] },
271
+ },
272
+ ],
273
+ },
274
+ {
275
+ rule: [
276
+ { action: { type: "Delete" }, condition: { daysSinceNoncurrentTime: 30, isLive: false } },
277
+ { action: { type: "Delete" }, condition: { age: 7 } },
278
+ ],
279
+ },
280
+ ]) {
281
+ expect(isOpsLifecycle(bad)).toBe(false);
282
+ const g = fakeGcp({ bucket: true, ring: true, key: true });
283
+ if (g.state.bucket) g.state.bucket.lifecycle_config = bad as never;
284
+ await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep });
285
+ expect(isOpsLifecycle(g.state.bucket?.lifecycle_config)).toBe(true);
286
+ }
287
+ });
288
+
289
+ it("dry run lists what it would do and writes nothing", async () => {
290
+ const g = fakeGcp();
291
+ const r = await ensureOpsBackend(TARGET, { run: g.run, sleep: noSleep, dryRun: true });
292
+ expect(r.ok).toBe(true);
293
+ if (!r.ok) return;
294
+ expect(r.actions.every((a) => a.startsWith("would "))).toBe(true);
295
+ expect(r.actions.join("\n")).toContain("would create gs://acme-caelo-ops in europe-west1");
296
+ expect(r.actions.join("\n")).toContain("would create KMS key caelo/pulumi-state");
297
+ expect(g.writes()).toEqual([]);
298
+ expect(r.actions.join("\n")).toContain(
299
+ "would enable storage.googleapis.com, cloudkms.googleapis.com",
300
+ );
301
+ });
302
+ });
303
+
304
+ describe("cost table (CLAUDE.md §11.C)", () => {
305
+ it("lists the state bucket + KMS key at under $0.10/mo without float noise in the total", () => {
306
+ const est = estimateGcpCost({
307
+ cloudSqlTier: "db-f1-micro",
308
+ cloudSqlHa: false,
309
+ adminMinInstances: 0,
310
+ gatewayMinInstances: 0,
311
+ wafAdaptiveProtection: false,
312
+ provider: "gcp-firebase",
313
+ region: "europe-west1",
314
+ });
315
+ const line = est.lines.find((l) => l.name.includes("ops bucket"));
316
+ expect(line?.monthlyUsd).toBeLessThan(0.1);
317
+ expect(line?.notes).toContain("< $0.10/mo");
318
+ expect(String(est.totalUsd)).toMatch(/^\d+(\.\d{1,2})?$/);
319
+ });
320
+ });