@caelo-cms/provisioning 0.10.47 → 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 (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. 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
+ });