@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,342 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Delete the firewall rules GCP auto-creates on a project's `default` network
5
+ * (`default-allow-ssh`, `-rdp`, `-icmp`, `-internal`;
6
+ * {@link ABSENT_DEFAULT_FIREWALL_RULES}).
7
+ *
8
+ * Why gcloud and not Pulumi: the rules are created by GCP when Compute Engine
9
+ * is enabled, not by our stacks, so Pulumi does not manage them. Pulumi can
10
+ * only delete a resource after importing it into the stack, and then a
11
+ * `pulumi destroy` or a resource rename would put nothing back, while every
12
+ * project that never had them (org policy `compute.skipDefaultNetworkCreation`,
13
+ * or rules already deleted) would fail the import. The removal is a one-way
14
+ * convergence, which is what the provisioner's gcloud steps are for: the
15
+ * wizard runs it on fresh installs and `upgrade` (via ensureStackInvariants)
16
+ * on existing ones.
17
+ *
18
+ * Only a rule in exactly the shape GCP creates is deleted. A rule with the
19
+ * same name but a different shape (narrowed source ranges, other ports,
20
+ * target tags, another network) is a deliberate change by someone, so it is
21
+ * reported with the next step instead of deleted.
22
+ *
23
+ * `default-allow-internal` carries traffic between VMs on the `default`
24
+ * network. Caelo puts nothing there (the stacks attach Cloud Run to their own
25
+ * VPC; default-firewall.test.ts pins that), but someone else may run VMs on
26
+ * it. So that rule is only deleted when the project is not a Shared VPC host
27
+ * (service projects' VMs could sit on its `default` network without showing
28
+ * up in this project) and `gcloud compute instances list` shows no instance
29
+ * on the `default` network; otherwise it is left in place and reported.
30
+ */
31
+
32
+ import { gcloud as defaultGcloud } from "./gcloud.js";
33
+ import type { GcloudRunner } from "./gcloud-retry.js";
34
+ import {
35
+ ABSENT_DEFAULT_FIREWALL_RULES,
36
+ type AbsentDefaultFirewallRule,
37
+ describeFirewallShape,
38
+ GCP_DEFAULT_NETWORK,
39
+ } from "./stack-contract.js";
40
+ import type { InvariantOutcome } from "./stack-converge.js";
41
+
42
+ /** A firewall rule as `gcloud compute firewall-rules list --format=json` returns it. */
43
+ export interface LiveFirewallRule {
44
+ readonly name: string;
45
+ readonly network?: string;
46
+ readonly direction?: string;
47
+ readonly sourceRanges?: readonly string[];
48
+ readonly sourceTags?: readonly string[];
49
+ readonly sourceServiceAccounts?: readonly string[];
50
+ readonly targetTags?: readonly string[];
51
+ readonly targetServiceAccounts?: readonly string[];
52
+ readonly destinationRanges?: readonly string[];
53
+ readonly allowed?: readonly { readonly IPProtocol: string; readonly ports?: readonly string[] }[];
54
+ readonly denied?: readonly unknown[];
55
+ }
56
+
57
+ /** A VM instance as `gcloud compute instances list --format=json` returns it. */
58
+ interface LiveInstance {
59
+ readonly name?: string;
60
+ readonly zone?: string;
61
+ readonly networkInterfaces?: readonly { readonly network?: string }[];
62
+ }
63
+
64
+ /**
65
+ * gcloud's error when Compute Engine is not enabled on the project. Without
66
+ * Compute Engine there is no `default` network, so there is nothing to remove.
67
+ */
68
+ const COMPUTE_DISABLED =
69
+ /SERVICE_DISABLED|accessNotConfigured|has not been used in project|API \[compute\.googleapis\.com\] not enabled|compute\.googleapis\.com.*(?:disabled|not enabled)/i;
70
+
71
+ /** gcloud's stderr when an aggregated list succeeded for only some zones. */
72
+ const PARTIAL_LIST = /did not succeed|unreachable|did not respond|could not fetch/i;
73
+
74
+ const empty = (xs: readonly unknown[] | undefined) => (xs ?? []).length === 0;
75
+
76
+ /** The last path segment of a network URL (`…/global/networks/default` → `default`). */
77
+ function networkName(network: string | undefined): string {
78
+ return (network ?? "").split("/").pop() ?? "";
79
+ }
80
+
81
+ /** Order-insensitive identity of an `allowed` list: `["icmp", "tcp:0-65535", …]`. */
82
+ function allowKeys(
83
+ allowed: readonly { readonly IPProtocol: string; readonly ports?: readonly string[] }[],
84
+ ): string[] {
85
+ return allowed
86
+ .map((a) => `${a.IPProtocol.toLowerCase()}${a.ports?.length ? `:${a.ports.join(",")}` : ""}`)
87
+ .sort();
88
+ }
89
+
90
+ function sameSet(a: readonly string[], b: readonly string[]): boolean {
91
+ const sortedB = [...b].sort();
92
+ return a.length === b.length && [...a].sort().every((x, i) => x === sortedB[i]);
93
+ }
94
+
95
+ /**
96
+ * Whether `live` is the rule exactly as GCP auto-creates it: ingress on the
97
+ * `default` network, from exactly the expected source ranges, allowing
98
+ * exactly the expected protocols and ports, applying to every instance.
99
+ * Priority, description, logging and the disabled flag are ignored —
100
+ * changing those does not narrow who can connect.
101
+ */
102
+ export function isStockDefaultRule(
103
+ live: LiveFirewallRule,
104
+ expected: AbsentDefaultFirewallRule,
105
+ ): boolean {
106
+ return (
107
+ live.name === expected.name &&
108
+ networkName(live.network) === GCP_DEFAULT_NETWORK &&
109
+ (live.direction ?? "INGRESS") === "INGRESS" &&
110
+ sameSet(live.sourceRanges ?? [], expected.sourceRanges) &&
111
+ empty(live.sourceTags) &&
112
+ empty(live.sourceServiceAccounts) &&
113
+ empty(live.targetTags) &&
114
+ empty(live.targetServiceAccounts) &&
115
+ empty(live.destinationRanges) &&
116
+ empty(live.denied) &&
117
+ sameSet(allowKeys(live.allowed ?? []), allowKeys(expected.allowed))
118
+ );
119
+ }
120
+
121
+ /** One-line description of a live rule, for the "left in place" report. */
122
+ function describeRule(live: LiveFirewallRule): string {
123
+ const parts = [
124
+ `network ${networkName(live.network) || "?"}`,
125
+ `${live.direction ?? "INGRESS"}`,
126
+ `allow ${describeFirewallShape(live.allowed ?? [], live.sourceRanges ?? [])}`,
127
+ ];
128
+ if (!empty(live.sourceTags)) parts.push(`source tags ${live.sourceTags?.join(",")}`);
129
+ if (!empty(live.targetTags)) parts.push(`target tags ${live.targetTags?.join(",")}`);
130
+ if (!empty(live.targetServiceAccounts)) {
131
+ parts.push(`target SAs ${live.targetServiceAccounts?.join(",")}`);
132
+ }
133
+ return parts.join(", ");
134
+ }
135
+
136
+ /**
137
+ * Whether the project is a Shared VPC host, from the Compute project's
138
+ * `xpnProjectStatus`. A host's networks can carry VMs of attached service
139
+ * projects that this project's instance list does not show.
140
+ */
141
+ async function isSharedVpcHost(
142
+ run: GcloudRunner,
143
+ project: string,
144
+ ): Promise<{ ok: true; host: boolean } | { ok: false; error: string }> {
145
+ const r = await run([
146
+ "compute",
147
+ "project-info",
148
+ "describe",
149
+ project,
150
+ "--format=value(xpnProjectStatus)",
151
+ ]);
152
+ if (!r.ok) return { ok: false, error: r.stderr.trim() };
153
+ return { ok: true, host: r.stdout.trim() === "HOST" };
154
+ }
155
+
156
+ /**
157
+ * The VM instances on the project's `default` network, as `zone/name`, or an
158
+ * error when they could not be listed. Compute Engine disabled means no VMs.
159
+ */
160
+ async function instancesOnDefaultNetwork(
161
+ run: GcloudRunner,
162
+ project: string,
163
+ ): Promise<{ ok: true; instances: string[] } | { ok: false; error: string }> {
164
+ const r = await run([
165
+ "compute",
166
+ "instances",
167
+ "list",
168
+ project,
169
+ "--format=json(name,zone,networkInterfaces[].network)",
170
+ ]);
171
+ if (!r.ok) {
172
+ if (COMPUTE_DISABLED.test(r.stderr)) return { ok: true, instances: [] };
173
+ return { ok: false, error: r.stderr.trim() };
174
+ }
175
+ // The list is aggregated across zones and may succeed with some zones
176
+ // missing (reported only as a stderr warning). An incomplete list cannot
177
+ // prove there are no VMs, so it counts as a failed check.
178
+ if (PARTIAL_LIST.test(r.stderr)) {
179
+ return { ok: false, error: `incomplete instance list: ${r.stderr.trim()}` };
180
+ }
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(r.stdout || "[]");
184
+ } catch {
185
+ parsed = null;
186
+ }
187
+ if (!Array.isArray(parsed)) {
188
+ return {
189
+ ok: false,
190
+ error: `unexpected output from gcloud compute instances list: ${r.stdout.slice(0, 200)}`,
191
+ };
192
+ }
193
+ const instances = (parsed as LiveInstance[])
194
+ .filter((i) =>
195
+ (i.networkInterfaces ?? []).some((n) => networkName(n.network) === GCP_DEFAULT_NETWORK),
196
+ )
197
+ .map((i) => `${networkName(i.zone) || "?"}/${i.name ?? "?"}`);
198
+ return { ok: true, instances };
199
+ }
200
+
201
+ /**
202
+ * Delete each {@link ABSENT_DEFAULT_FIREWALL_RULES} rule present on the
203
+ * project in its stock shape. Idempotent; never throws for a gcloud failure.
204
+ * One outcome per rule: `present` = the rule is absent (nothing to do, also
205
+ * when Compute Engine is disabled), `applied` = deleted now, `failed` = could
206
+ * not be read or deleted, exists in a customised shape, or (for
207
+ * `default-allow-internal`) VMs on the `default` network may still rely on
208
+ * it (VMs found, Shared VPC host, or either unknown) — and was left in place. Every failure is `warn`: the rules open nothing
209
+ * Caelo runs, so they never block an install or upgrade — but the report
210
+ * says what to do.
211
+ *
212
+ * @example
213
+ * const outcomes = await removeDefaultFirewallRules("acme");
214
+ * // → [{ id: "firewall rule default-allow-ssh (tcp:22 from 0.0.0.0/0)",
215
+ * // status: "applied", … }, { id: "… default-allow-rdp …", status: "present", … }, …]
216
+ */
217
+ export async function removeDefaultFirewallRules(
218
+ projectId: string,
219
+ deps: { run?: GcloudRunner } = {},
220
+ ): Promise<InvariantOutcome[]> {
221
+ const run = deps.run ?? defaultGcloud;
222
+ const project = `--project=${projectId}`;
223
+ const names = ABSENT_DEFAULT_FIREWALL_RULES.map((r) => r.name);
224
+ const outcomeBase = (rule: AbsentDefaultFirewallRule) => ({
225
+ id: `firewall rule ${rule.name} (${describeFirewallShape(rule.allowed, rule.sourceRanges)})`,
226
+ onFailure: "warn" as const,
227
+ why: rule.why,
228
+ });
229
+ const deleteCommand = (rule: AbsentDefaultFirewallRule) =>
230
+ `gcloud compute firewall-rules delete ${rule.name} ${project}`;
231
+
232
+ const list = await run([
233
+ "compute",
234
+ "firewall-rules",
235
+ "list",
236
+ project,
237
+ `--filter=name=(${names.join(" ")})`,
238
+ "--format=json",
239
+ ]);
240
+ if (!list.ok && COMPUTE_DISABLED.test(list.stderr)) {
241
+ return ABSENT_DEFAULT_FIREWALL_RULES.map((rule) => ({
242
+ ...outcomeBase(rule),
243
+ status: "present" as const,
244
+ }));
245
+ }
246
+ let live: LiveFirewallRule[] | null = null;
247
+ if (list.ok) {
248
+ try {
249
+ const parsed: unknown = JSON.parse(list.stdout || "[]");
250
+ if (Array.isArray(parsed)) live = parsed as LiveFirewallRule[];
251
+ } catch {
252
+ live = null;
253
+ }
254
+ }
255
+ if (live === null) {
256
+ const error = list.ok
257
+ ? `unexpected output from gcloud compute firewall-rules list: ${list.stdout.slice(0, 200)}`
258
+ : list.stderr.trim();
259
+ return ABSENT_DEFAULT_FIREWALL_RULES.map((rule) => ({
260
+ ...outcomeBase(rule),
261
+ status: "failed" as const,
262
+ error: `${error}\n Could not check for the rule. Re-run once the cause is fixed, or delete it yourself: ${deleteCommand(rule)}`,
263
+ }));
264
+ }
265
+
266
+ const outcomes: InvariantOutcome[] = [];
267
+ for (const rule of ABSENT_DEFAULT_FIREWALL_RULES) {
268
+ const base = outcomeBase(rule);
269
+ const found = live.find((r) => r.name === rule.name);
270
+ if (!found) {
271
+ outcomes.push({ ...base, status: "present" });
272
+ continue;
273
+ }
274
+ if (!isStockDefaultRule(found, rule)) {
275
+ outcomes.push({
276
+ ...base,
277
+ status: "failed",
278
+ error:
279
+ `${rule.name} exists but is not GCP's auto-created rule (${describeRule(found)}), so Caelo left it in place.\n` +
280
+ ` If nothing on this project needs it: ${deleteCommand(rule)}\n` +
281
+ " If it is intentional, rename it (create a copy under another name, then delete this one) so upgrades stop reporting it.",
282
+ });
283
+ continue;
284
+ }
285
+ if (rule.guardsInstanceTraffic) {
286
+ const xpn = await isSharedVpcHost(run, project);
287
+ if (!xpn.ok || xpn.host) {
288
+ outcomes.push({
289
+ ...base,
290
+ status: "failed",
291
+ error: xpn.ok
292
+ ? `This project is a Shared VPC host: VMs in attached service projects may use its default network and rely on ${rule.name}, so Caelo left it in place.\n` +
293
+ ` Check the service projects (gcloud compute shared-vpc list-associated-resources ${projectId}). If none of their VMs use the default network: ${deleteCommand(rule)}`
294
+ : `${xpn.error}\n Could not check whether this project is a Shared VPC host, so Caelo left ${rule.name} in place.\n` +
295
+ ` Re-run once the cause is fixed (needs compute.projects.get), or if no VM uses the default network: ${deleteCommand(rule)}`,
296
+ });
297
+ continue;
298
+ }
299
+ const vms = await instancesOnDefaultNetwork(run, project);
300
+ if (!vms.ok) {
301
+ outcomes.push({
302
+ ...base,
303
+ status: "failed",
304
+ error:
305
+ `${vms.error}\n Could not check whether VMs on the default network still use ${rule.name}, so Caelo left it in place.\n` +
306
+ ` Re-run once the cause is fixed (needs compute.instances.list), or if no VM uses the default network: ${deleteCommand(rule)}`,
307
+ });
308
+ continue;
309
+ }
310
+ if (vms.instances.length > 0) {
311
+ const shown = vms.instances.slice(0, 5).join(", ");
312
+ const more = vms.instances.length > 5 ? ` and ${vms.instances.length - 5} more` : "";
313
+ outcomes.push({
314
+ ...base,
315
+ status: "failed",
316
+ error:
317
+ `${vms.instances.length} VM instance(s) are on the default network (${shown}${more}); deleting ${rule.name} would cut the traffic between them, so Caelo left it in place.\n` +
318
+ " Caelo provisions nothing on the default network, so these belong to someone else on the project.\n" +
319
+ ` If they do not need to talk to each other: ${deleteCommand(rule)}\n` +
320
+ " If they do, replace it with a narrower rule under another name (only the ports and target tags they need), then delete this one so upgrades stop reporting it.",
321
+ });
322
+ continue;
323
+ }
324
+ }
325
+ const del = await run(["compute", "firewall-rules", "delete", rule.name, project, "--quiet"]);
326
+ if (del.ok) {
327
+ outcomes.push({ ...base, status: "applied" });
328
+ continue;
329
+ }
330
+ // Deleted concurrently between list and delete: the rule is gone either way.
331
+ if (/was not found|NOT_FOUND/i.test(del.stderr)) {
332
+ outcomes.push({ ...base, status: "present" });
333
+ continue;
334
+ }
335
+ outcomes.push({
336
+ ...base,
337
+ status: "failed",
338
+ error: `${del.stderr.trim()}\n Deleting needs compute.firewalls.delete on the project (roles/compute.securityAdmin). Re-run with an account that has it, or: ${deleteCommand(rule)}`,
339
+ });
340
+ }
341
+ return outcomes;
342
+ }
@@ -0,0 +1,231 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ checkDeployedRuntimeEnv,
6
+ chooseImageDigests,
7
+ digestFromImageRef,
8
+ readDeployedImageDigests,
9
+ readDeployedRegion,
10
+ } from "./deployed-release.js";
11
+ import type { GcloudResult } from "./gcloud.js";
12
+
13
+ const A = `sha256:${"a".repeat(64)}`;
14
+ const B = `sha256:${"b".repeat(64)}`;
15
+ const C = `sha256:${"c".repeat(64)}`;
16
+ const REG = "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images";
17
+
18
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
19
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
20
+
21
+ /** Fake gcloud answering by command prefix; records every call. */
22
+ function fakeGcloud(answers: Record<string, GcloudResult[]>) {
23
+ const calls: string[][] = [];
24
+ const run = async (args: string[]) => {
25
+ calls.push(args);
26
+ const key = Object.keys(answers).find((k) => args.join(" ").includes(k));
27
+ return (key && answers[key]?.shift()) || ok();
28
+ };
29
+ return { run, calls };
30
+ }
31
+
32
+ describe("digestFromImageRef", () => {
33
+ it("extracts the pinned digest", () => {
34
+ expect(digestFromImageRef(`${REG}/admin@${A}\n`)).toBe(A);
35
+ });
36
+ it("is null for a tag reference", () => {
37
+ expect(digestFromImageRef(`${REG}/admin:latest`)).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe("readDeployedImageDigests", () => {
42
+ it("reads both services' digests", async () => {
43
+ const { run, calls } = fakeGcloud({
44
+ "metadata.name~^caelo-production-admin": [ok("caelo-production-admin-abc\n")],
45
+ "metadata.name~^caelo-production-gateway": [ok("caelo-production-gateway-def\n")],
46
+ "describe caelo-production-admin-abc": [ok(`${REG}/admin@${A}`)],
47
+ "describe caelo-production-gateway-def": [ok(`${REG}/gateway@${B}`)],
48
+ });
49
+ const r = await readDeployedImageDigests({ projectId: "p", region: "europe-west1", run });
50
+ expect(r).toEqual({ ok: true, digests: { admin: A, gateway: B } });
51
+ expect(calls.every((c) => c.includes("--project=p"))).toBe(true);
52
+ });
53
+
54
+ it("refuses an ambiguous service match", async () => {
55
+ const { run } = fakeGcloud({
56
+ "metadata.name~^caelo-production-admin": [
57
+ ok("caelo-production-admin-a\ncaelo-production-admin-b\n"),
58
+ ],
59
+ });
60
+ const r = await readDeployedImageDigests({ projectId: "p", region: "r", run });
61
+ expect(r.ok).toBe(false);
62
+ if (!r.ok)
63
+ expect(r.error).toContain("found caelo-production-admin-a, caelo-production-admin-b");
64
+ });
65
+
66
+ it("refuses a service that runs a tag, not a digest", async () => {
67
+ const { run } = fakeGcloud({
68
+ "metadata.name~^caelo-production-admin": [ok("caelo-production-admin-abc\n")],
69
+ "describe caelo-production-admin-abc": [ok(`${REG}/admin:latest`)],
70
+ });
71
+ const r = await readDeployedImageDigests({ projectId: "p", region: "r", run });
72
+ expect(r.ok).toBe(false);
73
+ if (!r.ok) expect(r.error).toContain("not pinned to a digest");
74
+ });
75
+
76
+ it("surfaces a gcloud failure", async () => {
77
+ const { run } = fakeGcloud({ "services list": [fail("PERMISSION_DENIED")] });
78
+ const r = await readDeployedImageDigests({ projectId: "p", region: "r", run });
79
+ expect(r).toEqual({ ok: false, error: "list Cloud Run services: PERMISSION_DENIED" });
80
+ });
81
+ });
82
+
83
+ describe("checkDeployedRuntimeEnv", () => {
84
+ const svc = (env: unknown[]) =>
85
+ ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
86
+ const secret = (name: string, id = "postgres-password") => ({
87
+ name,
88
+ valueFrom: { secretKeyRef: { name: `caelo-production-${id}`, key: "latest" } },
89
+ });
90
+ const lists = () => ({
91
+ "metadata.name~^caelo-production-admin": [ok("caelo-production-admin-abc\n")],
92
+ "metadata.name~^caelo-production-gateway": [ok("caelo-production-gateway-def\n")],
93
+ });
94
+
95
+ it("passes once both services read the password from Secret Manager", async () => {
96
+ const { run } = fakeGcloud({
97
+ ...lists(),
98
+ "describe caelo-production-admin-abc": [
99
+ svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_ADMIN_DATABASE_PASSWORD")]),
100
+ ],
101
+ "describe caelo-production-gateway-def": [
102
+ svc([
103
+ secret("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
104
+ secret("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
105
+ ]),
106
+ ],
107
+ });
108
+ expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
109
+ ok: true,
110
+ });
111
+ });
112
+
113
+ it("stops a wizard re-run on a gateway from before #613 (it needs the admin_role pool)", async () => {
114
+ const { run } = fakeGcloud({
115
+ ...lists(),
116
+ "describe caelo-production-admin-abc": [
117
+ svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_ADMIN_DATABASE_PASSWORD")]),
118
+ ],
119
+ "describe caelo-production-gateway-def": [
120
+ svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_DATABASE_PASSWORD")]),
121
+ ],
122
+ });
123
+ const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
124
+ expect(r.ok).toBe(false);
125
+ if (!r.ok) {
126
+ expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
127
+ expect(r.error).toContain("Run `upgrade` first");
128
+ }
129
+ });
130
+
131
+ it("stops a wizard re-run on an install upgrade hasn't moved over (password in the URL)", async () => {
132
+ const { run } = fakeGcloud({
133
+ ...lists(),
134
+ "describe caelo-production-admin-abc": [
135
+ svc([
136
+ { name: "ADMIN_DATABASE_URL", value: "postgres://admin_role:pw@10.0.0.3:5432/cms_admin" },
137
+ ]),
138
+ ],
139
+ });
140
+ const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
141
+ expect(r.ok).toBe(false);
142
+ if (!r.ok) {
143
+ expect(r.error).toContain("Run `upgrade` first");
144
+ expect(r.error).not.toContain(":pw@");
145
+ }
146
+ });
147
+ });
148
+
149
+ describe("chooseImageDigests", () => {
150
+ const latest = { admin: C, gateway: C };
151
+ const never = () => {
152
+ throw new Error("must not be called");
153
+ };
154
+
155
+ it("keeps the recorded release", async () => {
156
+ const r = await chooseImageDigests({
157
+ recorded: { admin: A, gateway: B },
158
+ deployed: true,
159
+ readLive: never,
160
+ resolveLatest: never,
161
+ });
162
+ expect(r).toEqual({ ok: true, source: "recorded", digests: { admin: A, gateway: B } });
163
+ });
164
+
165
+ it("keeps what a provisioned install without a record runs (no :latest roll)", async () => {
166
+ const r = await chooseImageDigests({
167
+ recorded: null,
168
+ deployed: true,
169
+ readLive: async () => ({ ok: true, digests: { admin: A, gateway: B } }),
170
+ resolveLatest: never,
171
+ });
172
+ expect(r).toEqual({ ok: true, source: "live", digests: { admin: A, gateway: B } });
173
+ });
174
+
175
+ it("stops and points at upgrade when the running release can't be read", async () => {
176
+ const r = await chooseImageDigests({
177
+ recorded: null,
178
+ deployed: true,
179
+ readLive: async () => ({ ok: false, error: "PERMISSION_DENIED" }),
180
+ resolveLatest: never,
181
+ });
182
+ expect(r.ok).toBe(false);
183
+ if (!r.ok) expect(r.error).toMatch(/PERMISSION_DENIED.*Run `upgrade` once/);
184
+ });
185
+
186
+ it("resolves the newest release only for a new install", async () => {
187
+ const r = await chooseImageDigests({
188
+ recorded: null,
189
+ deployed: false,
190
+ readLive: never,
191
+ resolveLatest: async () => latest,
192
+ });
193
+ expect(r).toEqual({ ok: true, source: "latest", digests: latest });
194
+ });
195
+ });
196
+
197
+ describe("readDeployedRegion (#607)", () => {
198
+ it("reads the admin service's region across all regions", async () => {
199
+ const { run, calls } = fakeGcloud({
200
+ "metadata.name~^caelo-production-admin": [ok("europe-west1\n")],
201
+ });
202
+ expect(await readDeployedRegion({ projectId: "p", run })).toEqual({
203
+ ok: true,
204
+ region: "europe-west1",
205
+ });
206
+ expect(calls[0]?.some((a) => a.startsWith("--region"))).toBe(false);
207
+ });
208
+
209
+ it("is null when nothing is deployed", async () => {
210
+ const { run } = fakeGcloud({ "metadata.name~^caelo-production-admin": [ok("")] });
211
+ expect(await readDeployedRegion({ projectId: "p", run })).toEqual({ ok: true, region: null });
212
+ });
213
+
214
+ it("fails loudly when the services can't be listed", async () => {
215
+ const { run } = fakeGcloud({
216
+ "metadata.name~^caelo-production-admin": [fail("PERMISSION_DENIED")],
217
+ });
218
+ const r = await readDeployedRegion({ projectId: "p", run });
219
+ expect(r.ok).toBe(false);
220
+ if (!r.ok) expect(r.error).toContain("PERMISSION_DENIED");
221
+ });
222
+
223
+ it("refuses to guess between several regions", async () => {
224
+ const { run } = fakeGcloud({
225
+ "metadata.name~^caelo-production-admin": [ok("europe-west1\nus-central1\n")],
226
+ });
227
+ const r = await readDeployedRegion({ projectId: "p", run });
228
+ expect(r.ok).toBe(false);
229
+ if (!r.ok) expect(r.error).toContain("several regions");
230
+ });
231
+ });