@caelo-cms/provisioning 0.10.46 → 0.10.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/dist/adc-impersonation.d.ts.map +1 -1
  2. package/dist/adc-impersonation.js +23 -1
  3. package/dist/adc-impersonation.js.map +1 -1
  4. package/dist/agent/apply.d.ts +45 -0
  5. package/dist/agent/apply.d.ts.map +1 -0
  6. package/dist/agent/apply.js +83 -0
  7. package/dist/agent/apply.js.map +1 -0
  8. package/dist/agent/cli-provenance.d.ts +42 -0
  9. package/dist/agent/cli-provenance.d.ts.map +1 -0
  10. package/dist/agent/cli-provenance.js +138 -0
  11. package/dist/agent/cli-provenance.js.map +1 -0
  12. package/dist/agent/command.d.ts +79 -0
  13. package/dist/agent/command.d.ts.map +1 -0
  14. package/dist/agent/command.js +454 -0
  15. package/dist/agent/command.js.map +1 -0
  16. package/dist/agent/config.d.ts +30 -0
  17. package/dist/agent/config.d.ts.map +1 -0
  18. package/dist/agent/config.js +60 -0
  19. package/dist/agent/config.js.map +1 -0
  20. package/dist/agent/lock.d.ts +13 -0
  21. package/dist/agent/lock.d.ts.map +1 -0
  22. package/dist/agent/lock.js +100 -0
  23. package/dist/agent/lock.js.map +1 -0
  24. package/dist/agent/queue.d.ts +35 -0
  25. package/dist/agent/queue.d.ts.map +1 -0
  26. package/dist/agent/queue.js +50 -0
  27. package/dist/agent/queue.js.map +1 -0
  28. package/dist/agent/run.d.ts +97 -0
  29. package/dist/agent/run.d.ts.map +1 -0
  30. package/dist/agent/run.js +198 -0
  31. package/dist/agent/run.js.map +1 -0
  32. package/dist/agent/systemd.d.ts +55 -0
  33. package/dist/agent/systemd.d.ts.map +1 -0
  34. package/dist/agent/systemd.js +171 -0
  35. package/dist/agent/systemd.js.map +1 -0
  36. package/dist/agent/units.d.ts +44 -0
  37. package/dist/agent/units.d.ts.map +1 -0
  38. package/dist/agent/units.js +127 -0
  39. package/dist/agent/units.js.map +1 -0
  40. package/dist/agent-db.js +18362 -0
  41. package/dist/cli.js +122 -16
  42. package/dist/cli.js.map +1 -1
  43. package/dist/cloudsql-iam-user.d.ts +39 -0
  44. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  45. package/dist/cloudsql-iam-user.js +119 -0
  46. package/dist/cloudsql-iam-user.js.map +1 -0
  47. package/dist/compose.d.ts +16 -0
  48. package/dist/compose.d.ts.map +1 -1
  49. package/dist/compose.js +26 -1
  50. package/dist/compose.js.map +1 -1
  51. package/dist/dns/index.d.ts +2 -0
  52. package/dist/dns/index.d.ts.map +1 -1
  53. package/dist/dns/index.js +3 -2
  54. package/dist/dns/index.js.map +1 -1
  55. package/dist/dns/manual.d.ts +5 -0
  56. package/dist/dns/manual.d.ts.map +1 -1
  57. package/dist/dns/manual.js +28 -12
  58. package/dist/dns/manual.js.map +1 -1
  59. package/dist/gcloud.d.ts +2 -0
  60. package/dist/gcloud.d.ts.map +1 -1
  61. package/dist/gcloud.js +1 -1
  62. package/dist/gcloud.js.map +1 -1
  63. package/dist/gcp-names.d.ts +15 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +23 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/infra-contract.d.ts +1 -1
  72. package/dist/infra-contract.js +7 -1
  73. package/dist/infra-contract.js.map +1 -1
  74. package/dist/install-job-runner.d.ts +13 -0
  75. package/dist/install-job-runner.d.ts.map +1 -0
  76. package/dist/install-job-runner.js +155 -0
  77. package/dist/install-job-runner.js.map +1 -0
  78. package/dist/install-job.d.ts +210 -0
  79. package/dist/install-job.d.ts.map +1 -0
  80. package/dist/install-job.js +449 -0
  81. package/dist/install-job.js.map +1 -0
  82. package/dist/install-state.d.ts +19 -1
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +31 -3
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +24 -5
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/migration-runner.d.ts +5 -0
  90. package/dist/migration-runner.d.ts.map +1 -1
  91. package/dist/migration-runner.js +1 -1
  92. package/dist/migration-runner.js.map +1 -1
  93. package/dist/operator-access.d.ts +14 -8
  94. package/dist/operator-access.d.ts.map +1 -1
  95. package/dist/operator-access.js +16 -102
  96. package/dist/operator-access.js.map +1 -1
  97. package/dist/run-mode.d.ts +27 -0
  98. package/dist/run-mode.d.ts.map +1 -0
  99. package/dist/run-mode.js +22 -0
  100. package/dist/run-mode.js.map +1 -0
  101. package/dist/runtime-secrets.d.ts +19 -0
  102. package/dist/runtime-secrets.d.ts.map +1 -1
  103. package/dist/runtime-secrets.js +44 -0
  104. package/dist/runtime-secrets.js.map +1 -1
  105. package/dist/self-hosted-roles.d.ts +22 -1
  106. package/dist/self-hosted-roles.d.ts.map +1 -1
  107. package/dist/self-hosted-roles.js +81 -12
  108. package/dist/self-hosted-roles.js.map +1 -1
  109. package/dist/self-hosted-upgrade.d.ts +17 -0
  110. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  111. package/dist/self-hosted-upgrade.js +16 -1
  112. package/dist/self-hosted-upgrade.js.map +1 -1
  113. package/dist/stack-contract.d.ts +6 -0
  114. package/dist/stack-contract.d.ts.map +1 -1
  115. package/dist/stack-contract.js +10 -1
  116. package/dist/stack-contract.js.map +1 -1
  117. package/dist/state-attach.d.ts +23 -0
  118. package/dist/state-attach.d.ts.map +1 -0
  119. package/dist/state-attach.js +112 -0
  120. package/dist/state-attach.js.map +1 -0
  121. package/dist/state-backend.d.ts +11 -0
  122. package/dist/state-backend.d.ts.map +1 -1
  123. package/dist/state-backend.js +20 -0
  124. package/dist/state-backend.js.map +1 -1
  125. package/dist/state-command.d.ts +20 -0
  126. package/dist/state-command.d.ts.map +1 -0
  127. package/dist/state-command.js +237 -0
  128. package/dist/state-command.js.map +1 -0
  129. package/dist/state-migrate.d.ts +121 -0
  130. package/dist/state-migrate.d.ts.map +1 -0
  131. package/dist/state-migrate.js +290 -0
  132. package/dist/state-migrate.js.map +1 -0
  133. package/dist/state-pulumi.d.ts +17 -0
  134. package/dist/state-pulumi.d.ts.map +1 -0
  135. package/dist/state-pulumi.js +218 -0
  136. package/dist/state-pulumi.js.map +1 -0
  137. package/dist/state-store.d.ts +27 -0
  138. package/dist/state-store.d.ts.map +1 -1
  139. package/dist/state-store.js +65 -0
  140. package/dist/state-store.js.map +1 -1
  141. package/dist/upgrade/converge.d.ts +6 -0
  142. package/dist/upgrade/converge.d.ts.map +1 -1
  143. package/dist/upgrade/converge.js +59 -2
  144. package/dist/upgrade/converge.js.map +1 -1
  145. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  146. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  147. package/dist/upgrade/gcp-lookup.js +25 -0
  148. package/dist/upgrade/gcp-lookup.js.map +1 -1
  149. package/dist/upgrade/release-images.d.ts +12 -0
  150. package/dist/upgrade/release-images.d.ts.map +1 -1
  151. package/dist/upgrade/release-images.js +31 -0
  152. package/dist/upgrade/release-images.js.map +1 -1
  153. package/dist/upgrade/upgrade.d.ts +5 -0
  154. package/dist/upgrade/upgrade.d.ts.map +1 -1
  155. package/dist/upgrade/upgrade.js +28 -1
  156. package/dist/upgrade/upgrade.js.map +1 -1
  157. package/dist/upgrader-assess.d.ts +82 -0
  158. package/dist/upgrader-assess.d.ts.map +1 -0
  159. package/dist/upgrader-assess.js +116 -0
  160. package/dist/upgrader-assess.js.map +1 -0
  161. package/dist/upgrader-gcp.d.ts +17 -0
  162. package/dist/upgrader-gcp.d.ts.map +1 -0
  163. package/dist/upgrader-gcp.js +87 -0
  164. package/dist/upgrader-gcp.js.map +1 -0
  165. package/dist/upgrader-job.d.ts +131 -0
  166. package/dist/upgrader-job.d.ts.map +1 -0
  167. package/dist/upgrader-job.js +259 -0
  168. package/dist/upgrader-job.js.map +1 -0
  169. package/dist/upgrader-reporter.d.ts +35 -0
  170. package/dist/upgrader-reporter.d.ts.map +1 -0
  171. package/dist/upgrader-reporter.js +79 -0
  172. package/dist/upgrader-reporter.js.map +1 -0
  173. package/dist/upgrader-setup.d.ts +59 -0
  174. package/dist/upgrader-setup.d.ts.map +1 -0
  175. package/dist/upgrader-setup.js +308 -0
  176. package/dist/upgrader-setup.js.map +1 -0
  177. package/dist/wizard.d.ts +6 -0
  178. package/dist/wizard.d.ts.map +1 -1
  179. package/dist/wizard.js +32 -4
  180. package/dist/wizard.js.map +1 -1
  181. package/dist/wizards/gcp-firebase.d.ts +3 -0
  182. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  183. package/dist/wizards/gcp-firebase.js +1 -0
  184. package/dist/wizards/gcp-firebase.js.map +1 -1
  185. package/dist/wizards/gcp-install-job.d.ts +74 -0
  186. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  187. package/dist/wizards/gcp-install-job.js +398 -0
  188. package/dist/wizards/gcp-install-job.js.map +1 -0
  189. package/dist/wizards/gcp-prompts.d.ts +3 -0
  190. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  191. package/dist/wizards/gcp-prompts.js +25 -0
  192. package/dist/wizards/gcp-prompts.js.map +1 -0
  193. package/dist/wizards/gcp-pulumi.d.ts +9 -2
  194. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  195. package/dist/wizards/gcp-pulumi.js +8 -6
  196. package/dist/wizards/gcp-pulumi.js.map +1 -1
  197. package/dist/wizards/gcp.d.ts +40 -0
  198. package/dist/wizards/gcp.d.ts.map +1 -1
  199. package/dist/wizards/gcp.js +291 -83
  200. package/dist/wizards/gcp.js.map +1 -1
  201. package/package.json +6 -4
  202. package/src/adapter.ts +97 -0
  203. package/src/adc-impersonation.ts +176 -0
  204. package/src/admin-domain.test.ts +254 -0
  205. package/src/admin-domain.ts +245 -0
  206. package/src/admin-memory.test.ts +151 -0
  207. package/src/agent/apply.test.ts +185 -0
  208. package/src/agent/apply.ts +124 -0
  209. package/src/agent/cli-provenance.test.ts +159 -0
  210. package/src/agent/cli-provenance.ts +191 -0
  211. package/src/agent/command.test.ts +77 -0
  212. package/src/agent/command.ts +564 -0
  213. package/src/agent/config.ts +86 -0
  214. package/src/agent/lock-config.test.ts +97 -0
  215. package/src/agent/lock.ts +110 -0
  216. package/src/agent/queue.test.ts +10 -0
  217. package/src/agent/queue.ts +91 -0
  218. package/src/agent/run.test.ts +382 -0
  219. package/src/agent/run.ts +295 -0
  220. package/src/agent/systemd.ts +209 -0
  221. package/src/agent/units.test.ts +223 -0
  222. package/src/agent/units.ts +163 -0
  223. package/src/azure-front-door.test.ts +119 -0
  224. package/src/azure-front-door.ts +56 -0
  225. package/src/bootstrap-token.ts +20 -0
  226. package/src/caddy.ts +112 -0
  227. package/src/cdn-copy.ts +91 -0
  228. package/src/cert-wait.test.ts +194 -0
  229. package/src/cert-wait.ts +163 -0
  230. package/src/checkpoint-secrets.test.ts +380 -0
  231. package/src/checkpoint-secrets.ts +283 -0
  232. package/src/cli-routing.test.ts +45 -0
  233. package/src/cli-routing.ts +43 -0
  234. package/src/cli.ts +1491 -0
  235. package/src/cloudsql-iam-user.ts +147 -0
  236. package/src/compose.ts +334 -0
  237. package/src/default-firewall.test.ts +411 -0
  238. package/src/default-firewall.ts +342 -0
  239. package/src/deployed-release.test.ts +231 -0
  240. package/src/deployed-release.ts +220 -0
  241. package/src/dns/cloudflare.ts +182 -0
  242. package/src/dns/groups.test.ts +76 -0
  243. package/src/dns/groups.ts +86 -0
  244. package/src/dns/index.ts +56 -0
  245. package/src/dns/manual.ts +130 -0
  246. package/src/dns/types.ts +27 -0
  247. package/src/gateway-credentials.test.ts +180 -0
  248. package/src/gateway-credentials.ts +144 -0
  249. package/src/gcloud-components.test.ts +76 -0
  250. package/src/gcloud-components.ts +85 -0
  251. package/src/gcloud-retry.ts +41 -0
  252. package/src/gcloud-runner.test.ts +61 -0
  253. package/src/gcloud.test.ts +99 -0
  254. package/src/gcloud.ts +322 -0
  255. package/src/gcp-names.ts +135 -0
  256. package/src/index.test.ts +332 -0
  257. package/src/index.ts +114 -0
  258. package/src/infra-contract.ts +32 -0
  259. package/src/install-job-runner.ts +166 -0
  260. package/src/install-job.test.ts +566 -0
  261. package/src/install-job.ts +542 -0
  262. package/src/install-state.test.ts +239 -0
  263. package/src/install-state.ts +583 -0
  264. package/src/internal-token.ts +35 -0
  265. package/src/lifecycle.ts +900 -0
  266. package/src/mcp-iap-stacks.test.ts +58 -0
  267. package/src/mcp-iap.test.ts +149 -0
  268. package/src/mcp-iap.ts +131 -0
  269. package/src/media-volume.test.ts +296 -0
  270. package/src/migration-runner.ts +365 -0
  271. package/src/operator-access-stacks.test.ts +55 -0
  272. package/src/operator-access.test.ts +332 -0
  273. package/src/operator-access.ts +554 -0
  274. package/src/ops-backend.test.ts +320 -0
  275. package/src/ops-backend.ts +414 -0
  276. package/src/provisioner-identity.test.ts +482 -0
  277. package/src/provisioner-identity.ts +407 -0
  278. package/src/redirects-emit.ts +175 -0
  279. package/src/region-data.ts +428 -0
  280. package/src/regions.test.ts +277 -0
  281. package/src/regions.ts +370 -0
  282. package/src/release-contract.ts +57 -0
  283. package/src/release-manifest.test.ts +293 -0
  284. package/src/release-manifest.ts +430 -0
  285. package/src/run-mode.ts +40 -0
  286. package/src/runtime-secrets.test.ts +627 -0
  287. package/src/runtime-secrets.ts +754 -0
  288. package/src/self-command.test.ts +14 -0
  289. package/src/self-command.ts +21 -0
  290. package/src/self-hosted-compose.test.ts +177 -0
  291. package/src/self-hosted-release.ts +240 -0
  292. package/src/self-hosted-roles.integration.test.ts +161 -0
  293. package/src/self-hosted-roles.test.ts +124 -0
  294. package/src/self-hosted-roles.ts +203 -0
  295. package/src/self-hosted-upgrade.test.ts +394 -0
  296. package/src/self-hosted-upgrade.ts +548 -0
  297. package/src/stack-contract-parity.test.ts +199 -0
  298. package/src/stack-contract.test.ts +391 -0
  299. package/src/stack-contract.ts +888 -0
  300. package/src/stack-converge-secrets.test.ts +421 -0
  301. package/src/stack-converge.test.ts +833 -0
  302. package/src/stack-converge.ts +909 -0
  303. package/src/staging-admin.test.ts +246 -0
  304. package/src/staging-admin.ts +324 -0
  305. package/src/state-attach.test.ts +211 -0
  306. package/src/state-attach.ts +132 -0
  307. package/src/state-backend.test.ts +145 -0
  308. package/src/state-backend.ts +262 -0
  309. package/src/state-command.ts +293 -0
  310. package/src/state-migrate.test.ts +479 -0
  311. package/src/state-migrate.ts +434 -0
  312. package/src/state-pulumi.ts +289 -0
  313. package/src/state-store.test.ts +212 -0
  314. package/src/state-store.ts +243 -0
  315. package/src/upgrade/app-roll.ts +486 -0
  316. package/src/upgrade/backup.ts +109 -0
  317. package/src/upgrade/converge.ts +437 -0
  318. package/src/upgrade/gcp-lookup.ts +167 -0
  319. package/src/upgrade/health.test.ts +279 -0
  320. package/src/upgrade/health.ts +305 -0
  321. package/src/upgrade/release-images.test.ts +119 -0
  322. package/src/upgrade/release-images.ts +391 -0
  323. package/src/upgrade/reporter.ts +46 -0
  324. package/src/upgrade/upgrade.test.ts +626 -0
  325. package/src/upgrade/upgrade.ts +249 -0
  326. package/src/upgrader-assess.test.ts +265 -0
  327. package/src/upgrader-assess.ts +233 -0
  328. package/src/upgrader-gcp.ts +123 -0
  329. package/src/upgrader-job.test.ts +408 -0
  330. package/src/upgrader-job.ts +499 -0
  331. package/src/upgrader-reporter.test.ts +46 -0
  332. package/src/upgrader-reporter.ts +105 -0
  333. package/src/upgrader-setup.test.ts +169 -0
  334. package/src/upgrader-setup.ts +355 -0
  335. package/src/wizard.ts +425 -0
  336. package/src/wizards/gcp-cost.ts +188 -0
  337. package/src/wizards/gcp-firebase.ts +72 -0
  338. package/src/wizards/gcp-install-job.ts +506 -0
  339. package/src/wizards/gcp-prompts.ts +26 -0
  340. package/src/wizards/gcp-pulumi.test.ts +246 -0
  341. package/src/wizards/gcp-pulumi.ts +516 -0
  342. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,909 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Converge an existing GCP install to what its Pulumi stack would produce,
5
+ * through gcloud (`cms-provision upgrade` does not run Pulumi). The desired
6
+ * state comes from stack-contract.ts, which the stacks use too.
7
+ *
8
+ * - {@link planEnvUpdate}: the env-var flags for the `services update` that
9
+ * rolls the image, so env changes ride the same new revision — including
10
+ * moving a plain var to a Secret Manager reference.
11
+ * - {@link ensureStackInvariants}: IAM bindings + CDN settings. Each binding
12
+ * is checked against the resource's policy first and only added when
13
+ * missing, so an operator without IAM-admin rights can still upgrade an
14
+ * install that is already in shape.
15
+ * - {@link ensureStackInvariants} also deletes the firewall rules GCP
16
+ * pre-populates on the `default` network (SSH, RDP, ICMP, internal)
17
+ * (default-firewall.ts), the one thing it removes.
18
+ * - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
19
+ * Storage volume + mount), added in the same `services update` when
20
+ * missing.
21
+ */
22
+
23
+ import { removeDefaultFirewallRules } from "./default-firewall.js";
24
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
25
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
26
+ import { gcpBucketName, gcpSecretId } from "./gcp-names.js";
27
+ import { selfCommand } from "./self-command.js";
28
+ import {
29
+ ADMIN_MEMORY_DEFAULT,
30
+ type AdminEnvInputs,
31
+ adminEnvContract,
32
+ type CloudRunEnvVar,
33
+ type CloudRunSlug,
34
+ databaseUrls,
35
+ type GcpProvider,
36
+ gatewayEnvContract,
37
+ type IamInvariant,
38
+ type IamTarget,
39
+ iamMember,
40
+ type MediaVolumeContract,
41
+ memoryQuantityMiB,
42
+ RETIRED_IAM_BINDINGS,
43
+ RETIRED_SERVICE_ENV,
44
+ type RetiredIamBinding,
45
+ type RuntimeEnvInputs,
46
+ type ServiceEnvInputs,
47
+ STATIC_CDN_POLICY,
48
+ stackIamInvariants,
49
+ staticBackendBucketPrefix,
50
+ } from "./stack-contract.js";
51
+
52
+ // ===========================================================================
53
+ // Env vars
54
+ // ===========================================================================
55
+
56
+ /** An env var as a deployed Cloud Run service carries it. */
57
+ export type LiveEnvValue =
58
+ | { readonly kind: "value"; readonly value: string }
59
+ | { readonly kind: "secret"; readonly secret: string; readonly version: string };
60
+
61
+ /**
62
+ * Env vars of the first container in `gcloud run services describe
63
+ * --format=json` output (Knative `serving.knative.dev/v1` Service).
64
+ */
65
+ export function liveContainerEnv(serviceJson: string): Map<string, LiveEnvValue> {
66
+ const svc = JSON.parse(serviceJson) as {
67
+ spec?: {
68
+ template?: {
69
+ spec?: {
70
+ containers?: {
71
+ env?: {
72
+ name: string;
73
+ value?: string;
74
+ valueFrom?: { secretKeyRef?: { name: string; key: string } };
75
+ }[];
76
+ }[];
77
+ };
78
+ };
79
+ };
80
+ };
81
+ const env = svc.spec?.template?.spec?.containers?.[0]?.env ?? [];
82
+ const out = new Map<string, LiveEnvValue>();
83
+ for (const e of env) {
84
+ const ref = e.valueFrom?.secretKeyRef;
85
+ out.set(
86
+ e.name,
87
+ ref
88
+ ? { kind: "secret", secret: secretIdOf(ref.name), version: ref.key }
89
+ : { kind: "value", value: e.value ?? "" },
90
+ );
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** `projects/<p>/secrets/<id>` → `<id>`; a bare id stays as it is. */
96
+ function secretIdOf(name: string): string {
97
+ return name.slice(name.lastIndexOf("/") + 1);
98
+ }
99
+
100
+ /** The service account the service's revisions run as (`undefined`: the default compute SA). */
101
+ export function liveServiceAccount(serviceJson: string): string | undefined {
102
+ const svc = JSON.parse(serviceJson) as {
103
+ spec?: { template?: { spec?: { serviceAccountName?: string } } };
104
+ };
105
+ return svc.spec?.template?.spec?.serviceAccountName || undefined;
106
+ }
107
+
108
+ export interface EnvChange {
109
+ readonly name: string;
110
+ /** `undefined` when the service does not have the var yet. */
111
+ readonly from: string | undefined;
112
+ /** `undefined` when the var is removed. */
113
+ readonly to: string | undefined;
114
+ }
115
+
116
+ export type EnvUpdatePlan =
117
+ | { readonly ok: true; readonly changes: EnvChange[]; readonly flags: string[] }
118
+ | { readonly ok: false; readonly error: string };
119
+
120
+ /**
121
+ * A live value as upgrade prints it. Passwords in URLs are masked: before
122
+ * the move to Secret Manager the database URLs carried them inline.
123
+ */
124
+ function describeLive(v: LiveEnvValue): string {
125
+ return v.kind === "value" ? maskUrlPassword(v.value) : `secret:${v.secret}:${v.version}`;
126
+ }
127
+
128
+ /**
129
+ * Whether a service still carries a URL with an inline password in a plain
130
+ * var — the pre-Secret Manager shape. Such a password is in every revision
131
+ * Cloud Run keeps, so it should be rotated once the services moved off it.
132
+ */
133
+ export function liveEnvHasInlinePassword(liveEnv: ReadonlyMap<string, LiveEnvValue>): boolean {
134
+ return [...liveEnv.values()].some(
135
+ (v) => v.kind === "value" && maskUrlPassword(v.value) !== v.value,
136
+ );
137
+ }
138
+
139
+ function maskUrlPassword(value: string): string {
140
+ if (!value.includes("://")) return value;
141
+ try {
142
+ const url = new URL(value);
143
+ if (!url.password) return value;
144
+ url.password = "***";
145
+ return url.toString();
146
+ } catch {
147
+ return value;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * gcloud's list flags split on `,`; a value containing one needs the
153
+ * `^<delim>^` escape syntax (`gcloud topic escaping`).
154
+ */
155
+ function listFlag(flag: string, pairs: string[]): string {
156
+ const joined = pairs.join(",");
157
+ if (!pairs.some((p) => p.includes(","))) return `${flag}=${joined}`;
158
+ const delim = ["@", "|", "#", "~", ";"].find((d) => !pairs.some((p) => p.includes(d)));
159
+ if (!delim) throw new Error(`${flag}: no free delimiter for ${pairs.join(" ")}`);
160
+ return `${flag}=^${delim}^${pairs.join(delim)}`;
161
+ }
162
+
163
+ /**
164
+ * The `gcloud run services update` flags that bring `live` to `desired`.
165
+ * Only vars that differ are touched; vars the contract does not own
166
+ * (operator additions) are left alone, and the `retired` ones are removed.
167
+ *
168
+ * A plain var that becomes a Secret Manager reference moves in the same
169
+ * update: `--remove-env-vars=X --update-secrets=X=…`. gcloud applies all
170
+ * literal env changes (removals included) before the secret changes of one
171
+ * `services update` (googlecloudsdk/command_lib/run/flags.py
172
+ * `_GetConfigurationChanges`), so the new revision has X as a secret and no
173
+ * revision is ever without X. The reverse — a secret becoming a plain value
174
+ * — cannot be expressed in one update (the literal is set while X is still
175
+ * a secret, which gcloud rejects), so it is refused rather than attempted.
176
+ */
177
+ export function planEnvUpdate(
178
+ live: ReadonlyMap<string, LiveEnvValue>,
179
+ desired: readonly CloudRunEnvVar[],
180
+ retired: readonly string[] = [],
181
+ ): EnvUpdatePlan {
182
+ const changes: EnvChange[] = [];
183
+ const plain: string[] = [];
184
+ const secrets: string[] = [];
185
+ const removePlain: string[] = [];
186
+ const removeSecrets: string[] = [];
187
+ for (const entry of desired) {
188
+ const current = live.get(entry.name);
189
+ if ("value" in entry) {
190
+ if (current?.kind === "secret") {
191
+ return {
192
+ ok: false,
193
+ error: `${entry.name} is a Secret Manager reference on the service but a plain value in the contract`,
194
+ };
195
+ }
196
+ if (current?.value === entry.value) continue;
197
+ plain.push(`${entry.name}=${entry.value}`);
198
+ changes.push({
199
+ name: entry.name,
200
+ from: current ? describeLive(current) : undefined,
201
+ to: entry.value,
202
+ });
203
+ } else {
204
+ const { secret, version } = entry.valueSource.secretKeyRef;
205
+ if (current?.kind === "secret" && current.secret === secret && current.version === version) {
206
+ continue;
207
+ }
208
+ // Plain → secret: drop the literal in the same update (see above).
209
+ // The old literal may be a secret value, so it is never printed.
210
+ if (current?.kind === "value") removePlain.push(entry.name);
211
+ secrets.push(`${entry.name}=${secret}:${version}`);
212
+ changes.push({
213
+ name: entry.name,
214
+ from:
215
+ current === undefined
216
+ ? undefined
217
+ : current.kind === "value"
218
+ ? "(plain value)"
219
+ : describeLive(current),
220
+ to: `secret:${secret}:${version}`,
221
+ });
222
+ }
223
+ }
224
+ for (const name of retired) {
225
+ if (desired.some((e) => e.name === name)) {
226
+ return { ok: false, error: `${name} is both in the contract and retired` };
227
+ }
228
+ const current = live.get(name);
229
+ if (!current) continue;
230
+ (current.kind === "value" ? removePlain : removeSecrets).push(name);
231
+ // A retired var may hold a secret value (a plain KEK), so a literal is
232
+ // never printed.
233
+ changes.push({
234
+ name,
235
+ from: current.kind === "value" ? "(plain value)" : describeLive(current),
236
+ to: undefined,
237
+ });
238
+ }
239
+ const flags = [
240
+ ...(removePlain.length > 0 ? [listFlag("--remove-env-vars", removePlain)] : []),
241
+ ...(plain.length > 0 ? [listFlag("--update-env-vars", plain)] : []),
242
+ ...(removeSecrets.length > 0 ? [listFlag("--remove-secrets", removeSecrets)] : []),
243
+ ...(secrets.length > 0 ? [listFlag("--update-secrets", secrets)] : []),
244
+ ];
245
+ return { ok: true, changes, flags };
246
+ }
247
+
248
+ /** A deployed Cloud Run service as upgrade found it. */
249
+ export interface DeployedService {
250
+ /** Pulumi-suffixed service name. */
251
+ readonly serviceName: string;
252
+ readonly liveEnv: ReadonlyMap<string, LiveEnvValue>;
253
+ }
254
+
255
+ /**
256
+ * The Cloud SQL host the admin connects to, from its live `ADMIN_DATABASE_URL`
257
+ * (with or without an inline password — before and after the move to Secret
258
+ * Manager).
259
+ */
260
+ export function liveDatabaseHost(
261
+ liveEnv: ReadonlyMap<string, LiveEnvValue>,
262
+ ): { ok: true; host: string } | { ok: false; error: string } {
263
+ const url = liveEnv.get("ADMIN_DATABASE_URL");
264
+ if (url?.kind !== "value" || !url.value) {
265
+ return { ok: false, error: "it has no plain ADMIN_DATABASE_URL" };
266
+ }
267
+ try {
268
+ const host = new URL(url.value).hostname;
269
+ if (host) return { ok: true, host };
270
+ } catch {
271
+ // falls through to the error below
272
+ }
273
+ return { ok: false, error: "its ADMIN_DATABASE_URL is not a valid URL" };
274
+ }
275
+
276
+ /**
277
+ * The env changes that bring both deployed services to their env contract
278
+ * (stack-contract.ts — the contract the stacks deploy). Values Pulumi
279
+ * generated at install time (database host, Firebase site) are read from the
280
+ * live services. Fails instead of guessing when one can't be determined.
281
+ */
282
+ export function planContractEnv(
283
+ install: ServiceEnvInputs,
284
+ services: Readonly<Record<CloudRunSlug, DeployedService>>,
285
+ opts: {
286
+ /** Contract vars to leave exactly as the services have them. */
287
+ readonly leaveUntouched?: readonly string[];
288
+ } = {},
289
+ ):
290
+ | {
291
+ readonly ok: true;
292
+ readonly services: Record<CloudRunSlug, { flags: string[]; changes: EnvChange[] }>;
293
+ }
294
+ | { readonly ok: false; readonly error: string } {
295
+ const host = liveDatabaseHost(services.admin.liveEnv);
296
+ if (!host.ok) {
297
+ return {
298
+ ok: false,
299
+ error: `the Cloud SQL host is unknown: the admin service (${services.admin.serviceName}) ${host.error}`,
300
+ };
301
+ }
302
+ const runtime: RuntimeEnvInputs = { ...install, databaseUrls: databaseUrls(host.host) };
303
+ let inputs: AdminEnvInputs;
304
+ if (install.provider === "gcp-firebase") {
305
+ // The site id carries a random suffix Pulumi generated at install time.
306
+ const site = services.admin.liveEnv.get("CAELO_FIREBASE_SITE");
307
+ if (site?.kind !== "value" || !site.value) {
308
+ return {
309
+ ok: false,
310
+ error: `the admin service has no CAELO_FIREBASE_SITE, so the Firebase Hosting site is unknown. Set it (gcloud run services update ${services.admin.serviceName} --update-env-vars=CAELO_FIREBASE_SITE=<site-id>) and re-run.`,
311
+ };
312
+ }
313
+ inputs = {
314
+ ...runtime,
315
+ provider: "gcp-firebase",
316
+ firebaseSiteId: site.value,
317
+ gatewayService: services.gateway.serviceName,
318
+ };
319
+ } else {
320
+ inputs = { ...runtime, provider: "gcp" };
321
+ }
322
+ const skip = new Set(opts.leaveUntouched ?? []);
323
+ const owned = (contract: readonly CloudRunEnvVar[]) => contract.filter((e) => !skip.has(e.name));
324
+ const admin = planEnvUpdate(
325
+ services.admin.liveEnv,
326
+ owned(adminEnvContract(inputs)),
327
+ RETIRED_SERVICE_ENV.admin,
328
+ );
329
+ if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
330
+ const gateway = planEnvUpdate(
331
+ services.gateway.liveEnv,
332
+ owned(gatewayEnvContract(runtime)),
333
+ RETIRED_SERVICE_ENV.gateway,
334
+ );
335
+ if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
336
+ return {
337
+ ok: true,
338
+ services: {
339
+ admin: { flags: admin.flags, changes: admin.changes },
340
+ gateway: { flags: gateway.flags, changes: gateway.changes },
341
+ },
342
+ };
343
+ }
344
+
345
+ // ===========================================================================
346
+ // IAM + CDN invariants
347
+ // ===========================================================================
348
+
349
+ /** The deployed install `ensureStackInvariants` works on. */
350
+ export interface InstallTarget {
351
+ readonly provider: GcpProvider;
352
+ readonly projectId: string;
353
+ readonly region: string;
354
+ /** Pulumi stack name (`production`). */
355
+ readonly env: string;
356
+ /** Deployed (Pulumi-suffixed) Cloud Run service names. */
357
+ readonly services: Readonly<Record<CloudRunSlug, string>>;
358
+ }
359
+
360
+ export interface InvariantOutcome {
361
+ /** Human-readable identity, e.g. `roles/run.viewer → serviceAccount:… on gateway`. */
362
+ readonly id: string;
363
+ readonly status: "present" | "applied" | "failed";
364
+ /** What a failure means for the upgrade (from the invariant). */
365
+ readonly onFailure: "abort" | "warn";
366
+ readonly why: string;
367
+ readonly error?: string;
368
+ }
369
+
370
+ export interface InvariantsReport {
371
+ readonly outcomes: InvariantOutcome[];
372
+ /** True when a failed invariant is one the install cannot work without. */
373
+ readonly mustAbort: boolean;
374
+ }
375
+
376
+ function targetLabel(target: IamTarget): string {
377
+ switch (target.kind) {
378
+ case "project":
379
+ return "project";
380
+ case "secret":
381
+ return `secret ${target.name}`;
382
+ case "bucket":
383
+ return `bucket ${target.bucket}`;
384
+ case "run-service":
385
+ return `${target.service} service`;
386
+ }
387
+ }
388
+
389
+ /** gcloud argv (minus the trailing verb args) addressing an IAM target. */
390
+ function iamCommand(
391
+ target: IamTarget,
392
+ verb: "get-iam-policy" | "add-iam-policy-binding" | "remove-iam-policy-binding",
393
+ install: InstallTarget,
394
+ ): string[] {
395
+ const project = `--project=${install.projectId}`;
396
+ switch (target.kind) {
397
+ case "project":
398
+ return ["projects", verb, install.projectId];
399
+ case "secret":
400
+ return ["secrets", verb, gcpSecretId(install.env, target.name), project];
401
+ case "bucket":
402
+ return [
403
+ "storage",
404
+ "buckets",
405
+ verb,
406
+ `gs://${gcpBucketName(install.projectId, install.env, target.bucket)}`,
407
+ project,
408
+ ];
409
+ case "run-service":
410
+ return [
411
+ "run",
412
+ "services",
413
+ verb,
414
+ install.services[target.service],
415
+ `--region=${install.region}`,
416
+ project,
417
+ ];
418
+ }
419
+ }
420
+
421
+ /** Whether an IAM policy (JSON) grants `role` to `member` unconditionally. */
422
+ export function policyGrants(policyJson: string, role: string, member: string): boolean {
423
+ const policy = JSON.parse(policyJson) as {
424
+ bindings?: { role: string; members?: string[]; condition?: unknown }[];
425
+ };
426
+ return (policy.bindings ?? []).some(
427
+ (b) => b.role === role && !b.condition && (b.members ?? []).includes(member),
428
+ );
429
+ }
430
+
431
+ /** gcloud's error when the resource a binding targets does not exist. */
432
+ const RESOURCE_MISSING = /NOT_FOUND|not found|does not exist/i;
433
+
434
+ async function ensureIam(
435
+ invariants: readonly IamInvariant[],
436
+ install: InstallTarget,
437
+ projectNumber: string,
438
+ run: GcloudRunner,
439
+ sleep: Sleep,
440
+ ): Promise<InvariantOutcome[]> {
441
+ const outcomes: InvariantOutcome[] = [];
442
+ // One policy read per resource; several invariants share a resource.
443
+ const policies = new Map<
444
+ string,
445
+ Promise<{ ok: true; json: string } | { ok: false; error: string }>
446
+ >();
447
+ const readPolicy = (target: IamTarget) => {
448
+ const args = [...iamCommand(target, "get-iam-policy", install), "--format=json"];
449
+ const key = args.join(" ");
450
+ let p = policies.get(key);
451
+ if (!p) {
452
+ p = run(args).then((r) =>
453
+ r.ok
454
+ ? { ok: true as const, json: r.stdout }
455
+ : { ok: false as const, error: r.stderr.trim() },
456
+ );
457
+ policies.set(key, p);
458
+ }
459
+ return p;
460
+ };
461
+
462
+ for (const inv of invariants) {
463
+ const member = iamMember(inv.member, { ...install, projectNumber });
464
+ const id = `${inv.role} → ${member} on ${targetLabel(inv.target)}`;
465
+ const base = { id, onFailure: inv.onFailure, why: inv.why };
466
+ const policy = await readPolicy(inv.target);
467
+ if (policy.ok && policyGrants(policy.json, inv.role, member)) {
468
+ outcomes.push({ ...base, status: "present" });
469
+ continue;
470
+ }
471
+ // Policy unreadable or binding missing: add it. `--condition=None` only
472
+ // where gcloud prompts for it (project policies with conditional bindings).
473
+ const add = await runWithRetry(run, sleep, [
474
+ ...iamCommand(inv.target, "add-iam-policy-binding", install),
475
+ `--member=${member}`,
476
+ `--role=${inv.role}`,
477
+ ...(inv.target.kind === "project" ? ["--condition=None"] : []),
478
+ "--quiet",
479
+ "--format=none",
480
+ ]);
481
+ if (add.ok) {
482
+ outcomes.push({ ...base, status: "applied" });
483
+ continue;
484
+ }
485
+ const error = policy.ok ? add.stderr.trim() : `${policy.error}; ${add.stderr.trim()}`;
486
+ outcomes.push({
487
+ ...base,
488
+ status: "failed",
489
+ // upgrade only adds bindings; it never creates the resource itself.
490
+ error: RESOURCE_MISSING.test(error)
491
+ ? `${error}\n The ${targetLabel(inv.target)} or the account it grants to does not exist on this install (the install predates it). Re-run the installer (${selfCommand()}) to create it — it keeps the release this install runs — then re-run upgrade.`
492
+ : error,
493
+ });
494
+ }
495
+ return outcomes;
496
+ }
497
+
498
+ /** Bring the `gcp` stack's static backend bucket to {@link STATIC_CDN_POLICY}. */
499
+ async function ensureCdnPolicy(
500
+ install: InstallTarget,
501
+ run: GcloudRunner,
502
+ ): Promise<InvariantOutcome> {
503
+ const base = {
504
+ id: `Cloud CDN policy of ${staticBackendBucketPrefix(install.env)}*`,
505
+ onFailure: "warn" as const,
506
+ why: "immutable assets stay cached for a year instead of 1h (#555)",
507
+ };
508
+ const project = `--project=${install.projectId}`;
509
+ const list = await run([
510
+ "compute",
511
+ "backend-buckets",
512
+ "list",
513
+ project,
514
+ `--filter=name~^${staticBackendBucketPrefix(install.env)}`,
515
+ "--format=value(name)",
516
+ ]);
517
+ const name = list.ok ? list.stdout.trim().split("\n")[0]?.trim() : "";
518
+ if (!name) {
519
+ return {
520
+ ...base,
521
+ status: "failed",
522
+ error: list.ok ? "static backend bucket not found" : list.stderr.trim(),
523
+ };
524
+ }
525
+ const describe = await run([
526
+ "compute",
527
+ "backend-buckets",
528
+ "describe",
529
+ name,
530
+ project,
531
+ "--format=json",
532
+ ]);
533
+ if (describe.ok) {
534
+ const cdn = (JSON.parse(describe.stdout) as { cdnPolicy?: Record<string, unknown> }).cdnPolicy;
535
+ const inShape =
536
+ cdn !== undefined &&
537
+ (Object.keys(STATIC_CDN_POLICY) as (keyof typeof STATIC_CDN_POLICY)[]).every(
538
+ (k) => String(cdn[k]) === String(STATIC_CDN_POLICY[k]),
539
+ );
540
+ if (inShape) return { ...base, status: "present" };
541
+ }
542
+ const update = await run([
543
+ "compute",
544
+ "backend-buckets",
545
+ "update",
546
+ name,
547
+ project,
548
+ `--cache-mode=${STATIC_CDN_POLICY.cacheMode}`,
549
+ `--default-ttl=${STATIC_CDN_POLICY.defaultTtl}`,
550
+ `--max-ttl=${STATIC_CDN_POLICY.maxTtl}`,
551
+ `--client-ttl=${STATIC_CDN_POLICY.clientTtl}`,
552
+ "--quiet",
553
+ ]);
554
+ return update.ok
555
+ ? { ...base, status: "applied" }
556
+ : { ...base, status: "failed", error: update.stderr.trim() };
557
+ }
558
+
559
+ /**
560
+ * Idempotently ensure every IAM binding and CDN setting the install's stack
561
+ * declares (stack-contract.ts), and that the firewall rules GCP pre-populates
562
+ * on the default network are gone (ABSENT_DEFAULT_FIREWALL_RULES). Bindings
563
+ * are only added, never removed; those firewall rules are the only deletion. Never
564
+ * throws for a gcloud failure; the report says what failed and whether the
565
+ * upgrade must stop.
566
+ */
567
+ export async function ensureStackInvariants(
568
+ install: InstallTarget,
569
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
570
+ ): Promise<InvariantsReport> {
571
+ const run = deps.run ?? defaultGcloud;
572
+ const sleep = deps.sleep ?? realSleep;
573
+ const invariants = stackIamInvariants(install.provider);
574
+
575
+ const outcomes: InvariantOutcome[] = [];
576
+ let projectNumber = "";
577
+ if (invariants.some((i) => i.member === "iap-service-agent")) {
578
+ const r = await run([
579
+ "projects",
580
+ "describe",
581
+ install.projectId,
582
+ "--format=value(projectNumber)",
583
+ ]);
584
+ projectNumber = r.ok ? r.stdout.trim() : "";
585
+ if (!projectNumber) {
586
+ // Without the project number the IAP agent's binding can't be checked;
587
+ // report each of those invariants as failed instead of guessing.
588
+ for (const inv of invariants.filter((i) => i.member === "iap-service-agent")) {
589
+ outcomes.push({
590
+ id: `${inv.role} → IAP service agent on ${targetLabel(inv.target)}`,
591
+ status: "failed",
592
+ onFailure: inv.onFailure,
593
+ why: inv.why,
594
+ error: `read project number: ${r.stderr.trim() || "empty"}`,
595
+ });
596
+ }
597
+ }
598
+ }
599
+ const checkable = projectNumber
600
+ ? invariants
601
+ : invariants.filter((i) => i.member !== "iap-service-agent");
602
+ outcomes.push(...(await ensureIam(checkable, install, projectNumber, run, sleep)));
603
+ if (install.provider === "gcp") outcomes.push(await ensureCdnPolicy(install, run));
604
+ outcomes.push(...(await removeDefaultFirewallRules(install.projectId, { run })));
605
+
606
+ return {
607
+ outcomes,
608
+ mustAbort: outcomes.some((o) => o.status === "failed" && o.onFailure === "abort"),
609
+ };
610
+ }
611
+
612
+ /** What happened to one retired binding. */
613
+ export interface RetiredBindingOutcome {
614
+ readonly id: string;
615
+ /** `absent`: nothing to remove. */
616
+ readonly status: "absent" | "removed" | "failed";
617
+ readonly why: string;
618
+ readonly error?: string;
619
+ }
620
+
621
+ /**
622
+ * Remove the bindings older stacks declared and the contract retired
623
+ * ({@link RETIRED_IAM_BINDINGS}) — run by upgrade AFTER the services rolled
624
+ * off them. Checks the policy first, so an operator without IAM-admin
625
+ * rights can still upgrade an install that is already clean. Never throws
626
+ * for a gcloud failure; the caller decides how loudly to report it.
627
+ */
628
+ export async function removeRetiredIamBindings(
629
+ install: InstallTarget,
630
+ deps: { run?: GcloudRunner; sleep?: Sleep; retired?: readonly RetiredIamBinding[] } = {},
631
+ ): Promise<RetiredBindingOutcome[]> {
632
+ const run = deps.run ?? defaultGcloud;
633
+ const sleep = deps.sleep ?? realSleep;
634
+ const outcomes: RetiredBindingOutcome[] = [];
635
+ for (const b of deps.retired ?? RETIRED_IAM_BINDINGS) {
636
+ // Retired bindings never name the IAP agent, so no project number.
637
+ const member = iamMember(b.member, { ...install, projectNumber: "" });
638
+ const id = `${b.role} → ${member} on ${targetLabel(b.target)}`;
639
+ const policy = await run([...iamCommand(b.target, "get-iam-policy", install), "--format=json"]);
640
+ if (policy.ok && !policyGrants(policy.stdout, b.role, member)) {
641
+ outcomes.push({ id, status: "absent", why: b.why });
642
+ continue;
643
+ }
644
+ if (!policy.ok && RESOURCE_MISSING.test(policy.stderr)) {
645
+ outcomes.push({ id, status: "absent", why: b.why });
646
+ continue;
647
+ }
648
+ const remove = await runWithRetry(run, sleep, [
649
+ ...iamCommand(b.target, "remove-iam-policy-binding", install),
650
+ `--member=${member}`,
651
+ `--role=${b.role}`,
652
+ ...(b.target.kind === "project" ? ["--condition=None"] : []),
653
+ "--quiet",
654
+ "--format=none",
655
+ ]);
656
+ outcomes.push(
657
+ remove.ok
658
+ ? { id, status: "removed", why: b.why }
659
+ : { id, status: "failed", why: b.why, error: remove.stderr.trim() },
660
+ );
661
+ }
662
+ return outcomes;
663
+ }
664
+
665
+ /** The memory limit of the service's container (`512Mi`, `2Gi`), or null
666
+ * when the service declares none (Cloud Run then runs it with 512Mi). */
667
+ export function liveContainerMemory(serviceJson: string): string | null {
668
+ const svc = JSON.parse(serviceJson) as {
669
+ spec?: {
670
+ template?: {
671
+ spec?: { containers?: { resources?: { limits?: { memory?: string } } }[] };
672
+ };
673
+ };
674
+ };
675
+ return svc.spec?.template?.spec?.containers?.[0]?.resources?.limits?.memory ?? null;
676
+ }
677
+
678
+ /** Cloud Run's memory when a service declares no limit. */
679
+ const CLOUD_RUN_DEFAULT_MEMORY = "512Mi";
680
+
681
+ /**
682
+ * #553 — raise the admin's memory to the stack's default (it runs the
683
+ * Lighthouse quality audit). Upgrade only ever RAISES: an operator who set
684
+ * more keeps it, and a value this contract cannot parse is left alone and
685
+ * reported, never overwritten blindly.
686
+ */
687
+ export function planAdminMemory(
688
+ live: string | null,
689
+ desired: string = ADMIN_MEMORY_DEFAULT,
690
+ ):
691
+ | { readonly ok: true; readonly flags: readonly string[]; readonly from: string | null }
692
+ | { readonly ok: false; readonly error: string } {
693
+ const desiredMiB = memoryQuantityMiB(desired);
694
+ if (desiredMiB === null) return { ok: false, error: `invalid desired memory "${desired}"` };
695
+ const liveMiB = memoryQuantityMiB(live ?? CLOUD_RUN_DEFAULT_MEMORY);
696
+ if (liveMiB === null) {
697
+ return {
698
+ ok: false,
699
+ error: `the admin's memory "${live}" is not a quantity upgrade understands`,
700
+ };
701
+ }
702
+ if (liveMiB >= desiredMiB) return { ok: true, flags: [], from: live };
703
+ return { ok: true, flags: [`--memory=${desired}`], from: live };
704
+ }
705
+
706
+ // ===========================================================================
707
+ // Media volume
708
+ // ===========================================================================
709
+
710
+ /** The CSI driver Cloud Run reports for a Cloud Storage (gcsfuse) volume. */
711
+ const GCSFUSE_DRIVER = "gcsfuse.run.googleapis.com";
712
+ const EXECUTION_ENVIRONMENT_ANNOTATION = "run.googleapis.com/execution-environment";
713
+
714
+ interface KnativeVolume {
715
+ readonly name: string;
716
+ readonly csi?: {
717
+ readonly driver?: string;
718
+ readonly readOnly?: boolean;
719
+ readonly volumeAttributes?: { readonly bucketName?: string; readonly mountOptions?: string };
720
+ };
721
+ }
722
+
723
+ interface KnativeVolumeMount {
724
+ readonly name: string;
725
+ readonly mountPath: string;
726
+ }
727
+
728
+ /** The volume-related parts of a `gcloud run services describe --format=json` service. */
729
+ export interface LiveVolumes {
730
+ /** `gen1`, `gen2`, or null when the service leaves it to Cloud Run. */
731
+ readonly executionEnvironment: string | null;
732
+ readonly volumes: readonly KnativeVolume[];
733
+ /** Mounts of the first container. */
734
+ readonly mounts: readonly KnativeVolumeMount[];
735
+ }
736
+
737
+ /** Read the execution environment, volumes and mounts of a described service. */
738
+ export function liveVolumes(serviceJson: string): LiveVolumes {
739
+ const svc = JSON.parse(serviceJson) as {
740
+ spec?: {
741
+ template?: {
742
+ metadata?: { annotations?: Record<string, string> };
743
+ spec?: {
744
+ volumes?: KnativeVolume[];
745
+ containers?: { volumeMounts?: KnativeVolumeMount[] }[];
746
+ };
747
+ };
748
+ };
749
+ };
750
+ const template = svc.spec?.template;
751
+ return {
752
+ executionEnvironment:
753
+ template?.metadata?.annotations?.[EXECUTION_ENVIRONMENT_ANNOTATION] ?? null,
754
+ volumes: template?.spec?.volumes ?? [],
755
+ mounts: template?.spec?.containers?.[0]?.volumeMounts ?? [],
756
+ };
757
+ }
758
+
759
+ export type MediaVolumePlan =
760
+ | {
761
+ readonly ok: true;
762
+ /** Flags for the roll's `services update`; empty when converged. */
763
+ readonly flags: string[];
764
+ /** One line per change, for the upgrade log. */
765
+ readonly changes: string[];
766
+ }
767
+ | { readonly ok: false; readonly error: string };
768
+
769
+ function describeVolume(v: KnativeVolume): string {
770
+ if (v.csi?.driver === GCSFUSE_DRIVER) {
771
+ return `Cloud Storage volume "${v.name}" of bucket ${v.csi.volumeAttributes?.bucketName ?? "(none)"}${v.csi.readOnly ? " (read-only)" : ""}`;
772
+ }
773
+ return `volume "${v.name}" (not a Cloud Storage volume)`;
774
+ }
775
+
776
+ /**
777
+ * The flags that give the admin service its media volume
778
+ * ({@link adminMediaVolume} in stack-contract.ts), adding only what is
779
+ * missing: the gen2 execution environment, the volume, the mount. A service
780
+ * that already has exactly that volume and mount — from the stack, an
781
+ * earlier upgrade, or an operator's manual
782
+ * `gcloud run services update … --execution-environment gen2
783
+ * --add-volume name=media,type=cloud-storage,bucket=<bucket>
784
+ * --add-volume-mount volume=media,mount-path=<path>` — is converged and
785
+ * gets no volume flags. Mount options on that volume are left as they are.
786
+ *
787
+ * Something else at that name or path (another bucket, a read-only or
788
+ * non-Cloud-Storage volume, the media volume mounted elsewhere) is refused
789
+ * with what was found instead of being replaced: replacing it could point
790
+ * the admin at different media than it has been serving.
791
+ */
792
+ export function planMediaVolume(live: LiveVolumes, desired: MediaVolumeContract): MediaVolumePlan {
793
+ const flags: string[] = [];
794
+ const changes: string[] = [];
795
+ const fix = `Remove it (gcloud run services update <admin-service> --remove-volume-mount=${desired.mountPath} --remove-volume=${desired.volumeName}) only if it is not the media you mean to keep, then re-run upgrade.`;
796
+
797
+ const volume = live.volumes.find((v) => v.name === desired.volumeName);
798
+ if (volume) {
799
+ const inShape =
800
+ volume.csi?.driver === GCSFUSE_DRIVER &&
801
+ volume.csi.volumeAttributes?.bucketName === desired.bucket &&
802
+ volume.csi.readOnly !== true;
803
+ if (!inShape) {
804
+ return {
805
+ ok: false,
806
+ error: `the admin has ${describeVolume(volume)}, but media must be the read-write Cloud Storage volume of bucket ${desired.bucket}. ${fix}`,
807
+ };
808
+ }
809
+ }
810
+ const atPath = live.mounts.find((m) => m.mountPath === desired.mountPath);
811
+ if (atPath && atPath.name !== desired.volumeName) {
812
+ return {
813
+ ok: false,
814
+ error: `the admin mounts volume "${atPath.name}" at ${desired.mountPath}, where the media bucket belongs. ${fix}`,
815
+ };
816
+ }
817
+ const elsewhere = live.mounts.find(
818
+ (m) => m.name === desired.volumeName && m.mountPath !== desired.mountPath,
819
+ );
820
+ if (elsewhere && !atPath) {
821
+ return {
822
+ ok: false,
823
+ error: `the admin mounts the media volume at ${elsewhere.mountPath}, but the admin reads media from ${desired.mountPath}. ${fix}`,
824
+ };
825
+ }
826
+
827
+ if (live.executionEnvironment !== "gen2") {
828
+ flags.push("--execution-environment=gen2");
829
+ changes.push(
830
+ `execution environment: ${live.executionEnvironment ?? "(default)"} → gen2 (Cloud Storage volumes need it)`,
831
+ );
832
+ }
833
+ if (!volume) {
834
+ flags.push(
835
+ `--add-volume=name=${desired.volumeName},type=cloud-storage,bucket=${desired.bucket}`,
836
+ );
837
+ changes.push(`volume ${desired.volumeName}: (none) → Cloud Storage bucket ${desired.bucket}`);
838
+ }
839
+ if (!atPath) {
840
+ flags.push(`--add-volume-mount=volume=${desired.volumeName},mount-path=${desired.mountPath}`);
841
+ changes.push(`volume mount: (none) → ${desired.volumeName} at ${desired.mountPath}`);
842
+ }
843
+ return { ok: true, flags, changes };
844
+ }
845
+
846
+ /**
847
+ * The single `gcloud run services update` that rolls a service to a new
848
+ * image, its run SA and its env changes, so all land in one new revision.
849
+ */
850
+ export function serviceRollArgs(roll: {
851
+ readonly serviceName: string;
852
+ readonly region: string;
853
+ readonly projectId: string;
854
+ readonly imageRef: string;
855
+ /** The SA the new revision runs as (stack-contract.ts per-service SAs). */
856
+ readonly serviceAccount: string;
857
+ readonly envFlags: readonly string[];
858
+ /** Resource changes (`--memory=…`) that ride the same revision. */
859
+ readonly resourceFlags?: readonly string[];
860
+ /** The admin's media volume ({@link planMediaVolume}), in the same revision. */
861
+ readonly volumeFlags?: readonly string[];
862
+ }): string[] {
863
+ return [
864
+ "run",
865
+ "services",
866
+ "update",
867
+ roll.serviceName,
868
+ "--region",
869
+ roll.region,
870
+ "--project",
871
+ roll.projectId,
872
+ "--image",
873
+ roll.imageRef,
874
+ `--service-account=${roll.serviceAccount}`,
875
+ ...roll.envFlags,
876
+ ...(roll.resourceFlags ?? []),
877
+ ...(roll.volumeFlags ?? []),
878
+ "--quiet",
879
+ ];
880
+ }
881
+
882
+ /**
883
+ * Cloud Run checks at deploy time that the revision's SA can read every
884
+ * secret it references. A `secretAccessor` binding upgrade added seconds
885
+ * earlier (a new secret, the new gateway SA) can take a while to reach that
886
+ * check, so the deploy fails with a permission error that resolves itself.
887
+ */
888
+ const SECRET_ACCESS_PROPAGATING = /Permission denied on secret|secretmanager\.versions\.access/i;
889
+ const ROLL_RETRY_DELAYS_MS: readonly number[] = [10_000, 20_000, 30_000, 60_000];
890
+
891
+ /**
892
+ * Run a roll (`serviceRollArgs`), retrying while it fails only because a
893
+ * fresh secret binding has not propagated yet. Any other failure returns at
894
+ * once.
895
+ */
896
+ export async function rollService(
897
+ args: string[],
898
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
899
+ ): Promise<GcloudResult> {
900
+ const run = deps.run ?? defaultGcloud;
901
+ const sleep = deps.sleep ?? realSleep;
902
+ let r = await run(args);
903
+ for (const delay of ROLL_RETRY_DELAYS_MS) {
904
+ if (r.ok || !SECRET_ACCESS_PROPAGATING.test(r.stderr)) break;
905
+ await sleep(delay);
906
+ r = await run(args);
907
+ }
908
+ return r;
909
+ }