@caelo-cms/provisioning 0.10.55 → 0.10.58

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 (221) hide show
  1. package/dist/agent-db.js +97 -24
  2. package/dist/cli-routing.js +1 -1
  3. package/dist/cli-routing.js.map +1 -1
  4. package/dist/cli.js +4 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compose.js +5 -0
  7. package/dist/compose.js.map +1 -1
  8. package/dist/converge-checkpoint.d.ts +66 -0
  9. package/dist/converge-checkpoint.d.ts.map +1 -0
  10. package/dist/converge-checkpoint.js +61 -0
  11. package/dist/converge-checkpoint.js.map +1 -0
  12. package/dist/converge-job-gcp.d.ts +46 -0
  13. package/dist/converge-job-gcp.d.ts.map +1 -0
  14. package/dist/converge-job-gcp.js +368 -0
  15. package/dist/converge-job-gcp.js.map +1 -0
  16. package/dist/converge-job-setup.d.ts +62 -0
  17. package/dist/converge-job-setup.d.ts.map +1 -0
  18. package/dist/converge-job-setup.js +228 -0
  19. package/dist/converge-job-setup.js.map +1 -0
  20. package/dist/converge-job.d.ts +141 -0
  21. package/dist/converge-job.d.ts.map +1 -0
  22. package/dist/converge-job.js +325 -0
  23. package/dist/converge-job.js.map +1 -0
  24. package/dist/gcp-names.d.ts +9 -0
  25. package/dist/gcp-names.d.ts.map +1 -1
  26. package/dist/gcp-names.js +13 -0
  27. package/dist/gcp-names.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/infra-contract.d.ts +1 -1
  33. package/dist/infra-contract.d.ts.map +1 -1
  34. package/dist/infra-contract.js +33 -1
  35. package/dist/infra-contract.js.map +1 -1
  36. package/dist/install-job.d.ts +11 -3
  37. package/dist/install-job.d.ts.map +1 -1
  38. package/dist/install-job.js +22 -12
  39. package/dist/install-job.js.map +1 -1
  40. package/dist/lifecycle.d.ts +13 -1
  41. package/dist/lifecycle.d.ts.map +1 -1
  42. package/dist/lifecycle.js +115 -58
  43. package/dist/lifecycle.js.map +1 -1
  44. package/dist/operator-access.d.ts +4 -0
  45. package/dist/operator-access.d.ts.map +1 -1
  46. package/dist/operator-access.js +43 -7
  47. package/dist/operator-access.js.map +1 -1
  48. package/dist/release-contract.d.ts +3 -2
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +5 -2
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/release-manifest.d.ts +2 -0
  53. package/dist/release-manifest.d.ts.map +1 -1
  54. package/dist/release-manifest.js.map +1 -1
  55. package/dist/remote-mcp-edge.d.ts +114 -0
  56. package/dist/remote-mcp-edge.d.ts.map +1 -0
  57. package/dist/remote-mcp-edge.js +116 -0
  58. package/dist/remote-mcp-edge.js.map +1 -0
  59. package/dist/remote-mcp.d.ts +139 -0
  60. package/dist/remote-mcp.d.ts.map +1 -0
  61. package/dist/remote-mcp.js +434 -0
  62. package/dist/remote-mcp.js.map +1 -0
  63. package/dist/runtime-secrets.d.ts +12 -0
  64. package/dist/runtime-secrets.d.ts.map +1 -1
  65. package/dist/runtime-secrets.js +32 -4
  66. package/dist/runtime-secrets.js.map +1 -1
  67. package/dist/seal-key-rotation.d.ts +38 -0
  68. package/dist/seal-key-rotation.d.ts.map +1 -0
  69. package/dist/seal-key-rotation.js +206 -0
  70. package/dist/seal-key-rotation.js.map +1 -0
  71. package/dist/stack-contract.d.ts +206 -3
  72. package/dist/stack-contract.d.ts.map +1 -1
  73. package/dist/stack-contract.js +219 -4
  74. package/dist/stack-contract.js.map +1 -1
  75. package/dist/stack-converge.d.ts +53 -1
  76. package/dist/stack-converge.d.ts.map +1 -1
  77. package/dist/stack-converge.js +124 -4
  78. package/dist/stack-converge.js.map +1 -1
  79. package/dist/state-lock-lease.d.ts +142 -0
  80. package/dist/state-lock-lease.d.ts.map +1 -0
  81. package/dist/state-lock-lease.js +347 -0
  82. package/dist/state-lock-lease.js.map +1 -0
  83. package/dist/state-store.d.ts +66 -9
  84. package/dist/state-store.d.ts.map +1 -1
  85. package/dist/state-store.js +249 -30
  86. package/dist/state-store.js.map +1 -1
  87. package/dist/upgrade/app-roll.d.ts +36 -7
  88. package/dist/upgrade/app-roll.d.ts.map +1 -1
  89. package/dist/upgrade/app-roll.js +53 -19
  90. package/dist/upgrade/app-roll.js.map +1 -1
  91. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  92. package/dist/upgrade/audit-job-roll.js +3 -1
  93. package/dist/upgrade/audit-job-roll.js.map +1 -1
  94. package/dist/upgrade/converge.d.ts.map +1 -1
  95. package/dist/upgrade/converge.js +125 -12
  96. package/dist/upgrade/converge.js.map +1 -1
  97. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  98. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  99. package/dist/upgrade/gcp-lookup.js +46 -7
  100. package/dist/upgrade/gcp-lookup.js.map +1 -1
  101. package/dist/upgrade/record-rolled.d.ts +40 -0
  102. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  103. package/dist/upgrade/record-rolled.js +46 -0
  104. package/dist/upgrade/record-rolled.js.map +1 -0
  105. package/dist/upgrade/upgrade.d.ts +45 -1
  106. package/dist/upgrade/upgrade.d.ts.map +1 -1
  107. package/dist/upgrade/upgrade.js +60 -17
  108. package/dist/upgrade/upgrade.js.map +1 -1
  109. package/dist/upgrader-assess.d.ts +15 -1
  110. package/dist/upgrader-assess.d.ts.map +1 -1
  111. package/dist/upgrader-assess.js +36 -6
  112. package/dist/upgrader-assess.js.map +1 -1
  113. package/dist/upgrader-gcp.d.ts +15 -0
  114. package/dist/upgrader-gcp.d.ts.map +1 -1
  115. package/dist/upgrader-gcp.js +42 -7
  116. package/dist/upgrader-gcp.js.map +1 -1
  117. package/dist/upgrader-iam-preflight.d.ts +123 -0
  118. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  119. package/dist/upgrader-iam-preflight.js +414 -0
  120. package/dist/upgrader-iam-preflight.js.map +1 -0
  121. package/dist/upgrader-job.d.ts +33 -9
  122. package/dist/upgrader-job.d.ts.map +1 -1
  123. package/dist/upgrader-job.js +169 -27
  124. package/dist/upgrader-job.js.map +1 -1
  125. package/dist/upgrader-records.d.ts +58 -0
  126. package/dist/upgrader-records.d.ts.map +1 -0
  127. package/dist/upgrader-records.js +87 -0
  128. package/dist/upgrader-records.js.map +1 -0
  129. package/dist/upgrader-reporter.d.ts +3 -2
  130. package/dist/upgrader-reporter.d.ts.map +1 -1
  131. package/dist/upgrader-reporter.js +10 -2
  132. package/dist/upgrader-reporter.js.map +1 -1
  133. package/dist/upgrader-setup.d.ts +25 -3
  134. package/dist/upgrader-setup.d.ts.map +1 -1
  135. package/dist/upgrader-setup.js +123 -11
  136. package/dist/upgrader-setup.js.map +1 -1
  137. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  138. package/dist/wizards/gcp-cost.js +11 -3
  139. package/dist/wizards/gcp-cost.js.map +1 -1
  140. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  141. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  142. package/dist/wizards/gcp-pulumi.js +14 -2
  143. package/dist/wizards/gcp-pulumi.js.map +1 -1
  144. package/dist/wizards/gcp.d.ts.map +1 -1
  145. package/dist/wizards/gcp.js +106 -4
  146. package/dist/wizards/gcp.js.map +1 -1
  147. package/package.json +2 -2
  148. package/src/agent/run.test.ts +14 -1
  149. package/src/audit-job-setup.test.ts +7 -1
  150. package/src/cli-routing.ts +1 -1
  151. package/src/cli.ts +4 -1
  152. package/src/compose.ts +5 -0
  153. package/src/converge-checkpoint.ts +75 -0
  154. package/src/converge-entrypoint.test.ts +213 -0
  155. package/src/converge-job-gcp.test.ts +222 -0
  156. package/src/converge-job-gcp.ts +443 -0
  157. package/src/converge-job-setup.test.ts +152 -0
  158. package/src/converge-job-setup.ts +268 -0
  159. package/src/converge-job.test.ts +486 -0
  160. package/src/converge-job.ts +542 -0
  161. package/src/gateway-env-isolation.test.ts +8 -1
  162. package/src/gcp-names.ts +15 -0
  163. package/src/gcp-url-map.test.ts +9 -1
  164. package/src/index.ts +8 -0
  165. package/src/infra-contract.test.ts +8 -4
  166. package/src/infra-contract.ts +33 -1
  167. package/src/install-job.test.ts +3 -1
  168. package/src/install-job.ts +31 -14
  169. package/src/lifecycle.ts +137 -71
  170. package/src/operator-access.test.ts +24 -1
  171. package/src/operator-access.ts +44 -6
  172. package/src/release-contract.ts +5 -2
  173. package/src/release-manifest.test.ts +42 -0
  174. package/src/release-manifest.ts +2 -0
  175. package/src/remote-mcp-edge.ts +137 -0
  176. package/src/remote-mcp.test.ts +690 -0
  177. package/src/remote-mcp.ts +524 -0
  178. package/src/runtime-secrets.test.ts +31 -6
  179. package/src/runtime-secrets.ts +41 -3
  180. package/src/seal-key-rotation.test.ts +131 -0
  181. package/src/seal-key-rotation.ts +258 -0
  182. package/src/stack-contract-parity.test.ts +20 -3
  183. package/src/stack-contract.test.ts +32 -2
  184. package/src/stack-contract.ts +363 -4
  185. package/src/stack-converge-secrets.test.ts +4 -0
  186. package/src/stack-converge.test.ts +21 -5
  187. package/src/stack-converge.ts +162 -7
  188. package/src/state-attach.test.ts +7 -1
  189. package/src/state-lock-lease.test.ts +302 -0
  190. package/src/state-lock-lease.ts +423 -0
  191. package/src/state-lock.test.ts +568 -0
  192. package/src/state-store.ts +347 -27
  193. package/src/upgrade/app-roll.ts +102 -21
  194. package/src/upgrade/audit-job-roll.ts +3 -1
  195. package/src/upgrade/converge.ts +148 -10
  196. package/src/upgrade/gcp-lookup.test.ts +80 -0
  197. package/src/upgrade/gcp-lookup.ts +65 -8
  198. package/src/upgrade/record-rolled.test.ts +105 -0
  199. package/src/upgrade/record-rolled.ts +91 -0
  200. package/src/upgrade/upgrade.test.ts +203 -3
  201. package/src/upgrade/upgrade.ts +120 -24
  202. package/src/upgrader-assess.test.ts +92 -3
  203. package/src/upgrader-assess.ts +58 -9
  204. package/src/upgrader-gcp.ts +47 -8
  205. package/src/upgrader-iam-preflight.test.ts +393 -0
  206. package/src/upgrader-iam-preflight.ts +510 -0
  207. package/src/upgrader-job.test.ts +586 -15
  208. package/src/upgrader-job.ts +237 -53
  209. package/src/upgrader-records.ts +151 -0
  210. package/src/upgrader-reporter.test.ts +6 -0
  211. package/src/upgrader-reporter.ts +7 -2
  212. package/src/upgrader-setup.test.ts +3 -3
  213. package/src/upgrader-setup.ts +128 -11
  214. package/src/wizards/gcp-cost.ts +12 -3
  215. package/src/wizards/gcp-pulumi.test.ts +15 -0
  216. package/src/wizards/gcp-pulumi.ts +18 -2
  217. package/src/wizards/gcp.ts +134 -4
  218. package/stacks/aws/index.ts +10 -0
  219. package/stacks/azure/index.ts +9 -0
  220. package/stacks/gcp/index.ts +47 -7
  221. package/stacks/gcp-firebase/index.ts +38 -6
@@ -0,0 +1,510 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The upgrader job's IAM self-check (#818): before it takes the state lock
5
+ * or changes anything, the job asks Google Cloud whether its own service
6
+ * account holds every permission the run will need, and stops with ALL the
7
+ * missing ones named when it does not.
8
+ *
9
+ * Why: the first live in-app update died at its first bucket write (#799)
10
+ * because a grant that looked right could never match the call. Unit tests
11
+ * against a fake gcloud cannot catch that class of bug; asking IAM can, and
12
+ * asking first means a missing grant ends the run before the backup, the
13
+ * migrations or the roll, with a reason a non-technical Owner can pass on
14
+ * ("Run the upgrade once with Cloud Shell" re-converges the grants).
15
+ *
16
+ * How each permission is checked, without doing the action:
17
+ *
18
+ * - `testIamPermissions` on the resource the call acts on — the project,
19
+ * a service account, the upgrader's own job, the ops bucket, the release
20
+ * image repository. Google returns the subset the CALLER holds; no
21
+ * permission is needed to ask.
22
+ * - The `install/` objects in the ops bucket are the exception. The
23
+ * upgrader's object rights there are an IAM grant CONDITIONED on the
24
+ * object name (upgrader-setup.ts `installStateCondition`), and a bucket's
25
+ * testIamPermissions evaluates conditions against the bucket's own
26
+ * resource name, never an object's — it would report a grant that works
27
+ * as missing (the mirror image of #799). Cloud Storage has no object
28
+ * testIamPermissions on uniform-access buckets, so those three are
29
+ * probed with requests that can never change anything: a read of an
30
+ * object that does not exist, and an upload + a delete preconditioned
31
+ * on generation 1, which no object ever has. Cloud Storage authorizes a
32
+ * request before it evaluates its preconditions, so 403 means the
33
+ * permission is missing and 404/412 means it is held — with one
34
+ * exception: a read of an absent object is answered 403 whenever the
35
+ * caller lacks storage.objects.list, so objects.get is probed only once
36
+ * the bucket check confirmed list, and reported "unchecked" otherwise.
37
+ *
38
+ * Direct VPC egress (#818): the migration job and both services are
39
+ * deployed with `--network/--subnet`. Google documents the compute
40
+ * permissions for that (`compute.subnetworks.use`, …) on the Cloud Run
41
+ * SERVICE AGENT (roles/run.serviceAgent has them), not on the principal
42
+ * that deploys, so they are not checked here and the upgrader gets no
43
+ * compute role (UPGRADER_FORBIDDEN_ROLES). The real-GCP upgrader smoke test
44
+ * (.github/workflows/upgrader-smoke.yml) is where that shows on real GCP:
45
+ * each stable release, installed on the smoke install through Settings →
46
+ * Updates, must serve cleanly before the next tag ships.
47
+ *
48
+ * Everything here is pure except {@link restIamTester}, which takes its
49
+ * HTTP client and token source as arguments.
50
+ */
51
+
52
+ import { z } from "zod";
53
+ import {
54
+ auditServiceAccountEmail,
55
+ gatewayServiceAccountEmail,
56
+ mcpIapServiceAccountEmail,
57
+ runServiceAccountEmail,
58
+ upgraderJobName,
59
+ upgraderServiceAccountEmail,
60
+ } from "./gcp-names.js";
61
+ import type { HttpFetch } from "./runtime-secrets.js";
62
+ import { OPS_INSTALL_PREFIX, opsBucketName } from "./state-backend.js";
63
+ import { RELEASE_REGISTRY } from "./upgrade/release-images.js";
64
+
65
+ /** A resource IAM can be asked about. */
66
+ export type IamResource =
67
+ | { readonly kind: "project"; readonly projectId: string }
68
+ | { readonly kind: "service-account"; readonly projectId: string; readonly email: string }
69
+ | {
70
+ readonly kind: "run-job";
71
+ readonly projectId: string;
72
+ readonly region: string;
73
+ readonly name: string;
74
+ }
75
+ | { readonly kind: "bucket"; readonly bucket: string }
76
+ /** Objects under `prefix/` in `bucket`, behind a name-conditioned grant (probed). */
77
+ | { readonly kind: "bucket-objects"; readonly bucket: string; readonly prefix: string }
78
+ | {
79
+ readonly kind: "artifact-repository";
80
+ readonly projectId: string;
81
+ readonly location: string;
82
+ readonly repository: string;
83
+ };
84
+
85
+ /** One thing the run will do, the permissions it takes, and where. */
86
+ export interface AccessCheck {
87
+ /** What the upgrader could not do without them, in plain words. */
88
+ readonly need: string;
89
+ readonly resource: IamResource;
90
+ readonly permissions: readonly string[];
91
+ }
92
+
93
+ /** A check with the permissions it lacks. */
94
+ export interface MissingAccess {
95
+ readonly check: AccessCheck;
96
+ readonly missing: readonly string[];
97
+ /**
98
+ * Permissions that could not be judged because another one is missing
99
+ * first (storage.objects.get needs storage.objects.list to be testable).
100
+ */
101
+ readonly unchecked?: readonly string[];
102
+ }
103
+
104
+ /** The permissions IAM confirmed on one resource, or why it could not be asked. */
105
+ export type GrantedResult =
106
+ | { readonly ok: true; readonly granted: readonly string[] }
107
+ | { readonly ok: false; readonly error: string };
108
+
109
+ /** Asks IAM which of `permissions` the job's identity holds on `resource`. */
110
+ export interface IamTester {
111
+ granted(resource: IamResource, permissions: readonly string[]): Promise<GrantedResult>;
112
+ }
113
+
114
+ /** How the self-check came out. */
115
+ export type PreflightOutcome =
116
+ | { readonly kind: "ok" }
117
+ | { readonly kind: "missing"; readonly missing: readonly MissingAccess[] }
118
+ /** IAM could not be asked about at least one resource; nothing was judged missing. */
119
+ | { readonly kind: "error"; readonly errors: readonly string[] };
120
+
121
+ /** The object the `install/` probes name. It is never written: every probe fails its precondition. */
122
+ export const PREFLIGHT_PROBE_OBJECT = ".caelo-iam-preflight";
123
+
124
+ /**
125
+ * Every permission an upgrader run needs after its decision, grouped by
126
+ * what it is for (upgrade/app-roll.ts, migration-runner.ts, backup.ts,
127
+ * health.ts, state-store.ts, the self-update in upgrader-job.ts). Keep in
128
+ * step with the grants in upgrader-setup.ts: a permission listed here that
129
+ * setup does not grant stops every update.
130
+ */
131
+ export function upgraderAccessChecks(target: {
132
+ readonly projectId: string;
133
+ readonly region: string;
134
+ readonly env: string;
135
+ }): AccessCheck[] {
136
+ const { projectId, region, env } = target;
137
+ const project: IamResource = { kind: "project", projectId };
138
+ const sa = (email: string): IamResource => ({ kind: "service-account", projectId, email });
139
+ const bucket = opsBucketName(projectId);
140
+ return [
141
+ {
142
+ need: "read which release the site's admin and gateway serve",
143
+ resource: project,
144
+ permissions: [
145
+ "run.services.get",
146
+ "run.services.list",
147
+ "run.revisions.get",
148
+ "run.revisions.list",
149
+ ],
150
+ },
151
+ {
152
+ need: "deploy the new release to the admin and gateway and move their traffic",
153
+ resource: project,
154
+ permissions: ["run.services.update", "run.operations.get"],
155
+ },
156
+ {
157
+ need: "run the database migrations as a one-off job (and check whether an earlier run still holds the install's lock)",
158
+ resource: project,
159
+ permissions: [
160
+ "run.jobs.create",
161
+ "run.jobs.delete",
162
+ "run.jobs.get",
163
+ "run.jobs.run",
164
+ "run.executions.get",
165
+ ],
166
+ },
167
+ {
168
+ need: "back up the database before anything changes",
169
+ resource: project,
170
+ permissions: [
171
+ "cloudsql.backupRuns.create",
172
+ "cloudsql.backupRuns.get",
173
+ "cloudsql.backupRuns.list",
174
+ "cloudsql.instances.get",
175
+ "cloudsql.instances.list",
176
+ ],
177
+ },
178
+ {
179
+ need: "run the admin and the migration job as the admin's service account",
180
+ resource: sa(runServiceAccountEmail(projectId, env)),
181
+ permissions: ["iam.serviceAccounts.actAs"],
182
+ },
183
+ {
184
+ need: "run the gateway as its service account",
185
+ resource: sa(gatewayServiceAccountEmail(projectId, env)),
186
+ permissions: ["iam.serviceAccounts.actAs"],
187
+ },
188
+ {
189
+ // #875 — every app-roll moves the audit job onto the new admin image
190
+ // (upgrade/audit-job-roll.ts), and upgrader-setup.ts grants actAs on
191
+ // the job's account for exactly that.
192
+ need: "move the quality-audit job onto the new admin image",
193
+ resource: project,
194
+ permissions: ["run.jobs.update"],
195
+ },
196
+ {
197
+ need: "run the quality-audit job as its service account after moving it",
198
+ resource: sa(auditServiceAccountEmail(projectId, env)),
199
+ permissions: ["iam.serviceAccounts.actAs"],
200
+ },
201
+ {
202
+ need: "keep the upgrader job running as its own service account after it updates itself",
203
+ resource: sa(upgraderServiceAccountEmail(projectId, env)),
204
+ permissions: ["iam.serviceAccounts.actAs"],
205
+ },
206
+ {
207
+ need: "sign in to the admin (through its identity proxy) for the health check",
208
+ resource: sa(mcpIapServiceAccountEmail(projectId)),
209
+ permissions: ["iam.serviceAccounts.signJwt"],
210
+ },
211
+ {
212
+ need: "move the upgrader itself onto the new release",
213
+ resource: { kind: "run-job", projectId, region, name: upgraderJobName(env) },
214
+ permissions: ["run.jobs.get", "run.jobs.update"],
215
+ },
216
+ {
217
+ need: "list the install's ops bucket (every write there lists it first)",
218
+ resource: { kind: "bucket", bucket },
219
+ permissions: ["storage.objects.list"],
220
+ },
221
+ {
222
+ need: `read and write the install's records and its state lock (${OPS_INSTALL_PREFIX}/ in the ops bucket)`,
223
+ resource: { kind: "bucket-objects", bucket, prefix: OPS_INSTALL_PREFIX },
224
+ permissions: ["storage.objects.get", "storage.objects.create", "storage.objects.delete"],
225
+ },
226
+ {
227
+ need: "read Caelo's release images",
228
+ resource: {
229
+ kind: "artifact-repository",
230
+ projectId: RELEASE_REGISTRY.project,
231
+ location: RELEASE_REGISTRY.region,
232
+ repository: RELEASE_REGISTRY.repo,
233
+ },
234
+ permissions: ["artifactregistry.repositories.downloadArtifacts"],
235
+ },
236
+ ];
237
+ }
238
+
239
+ /** Ask IAM about every check (one call per resource); never throws. */
240
+ export async function runIamPreflight(
241
+ checks: readonly AccessCheck[],
242
+ tester: IamTester,
243
+ ): Promise<PreflightOutcome> {
244
+ // One question per resource: the project alone carries four checks.
245
+ const byResource = new Map<string, { resource: IamResource; permissions: Set<string> }>();
246
+ for (const c of checks) {
247
+ const key = resourceName(c.resource);
248
+ const entry = byResource.get(key) ?? { resource: c.resource, permissions: new Set<string>() };
249
+ for (const p of c.permissions) entry.permissions.add(p);
250
+ byResource.set(key, entry);
251
+ }
252
+ const granted = new Map<string, ReadonlySet<string>>();
253
+ /** Permissions that could not be judged (see the object probes below). */
254
+ const unverified = new Map<string, ReadonlySet<string>>();
255
+ const errors: string[] = [];
256
+ const ask = async (key: string, resource: IamResource, permissions: readonly string[]) => {
257
+ let r: GrantedResult;
258
+ try {
259
+ r = await tester.granted(resource, [...permissions].sort());
260
+ } catch (e) {
261
+ r = { ok: false, error: e instanceof Error ? e.message : String(e) };
262
+ }
263
+ if (r.ok) granted.set(key, new Set(r.granted));
264
+ else errors.push(`${key}: ${r.error}`);
265
+ };
266
+ const entries = [...byResource];
267
+ await Promise.all(
268
+ entries
269
+ .filter(([, e]) => e.resource.kind !== "bucket-objects")
270
+ .map(([key, e]) => ask(key, e.resource, [...e.permissions])),
271
+ );
272
+ // The object probes go second: Cloud Storage answers a read of an absent
273
+ // object with 403 — not 404 — when the caller lacks storage.objects.list
274
+ // on the bucket, so the objects.get probe means something only once list
275
+ // is confirmed. Without it, get is reported as unchecked, never missing.
276
+ await Promise.all(
277
+ entries
278
+ .filter(([, e]) => e.resource.kind === "bucket-objects")
279
+ .map(([key, e]) => {
280
+ const r = e.resource as Extract<IamResource, { kind: "bucket-objects" }>;
281
+ const listHeld =
282
+ granted.get(resourceName({ kind: "bucket", bucket: r.bucket }))?.has(OBJECT_LIST) ??
283
+ false;
284
+ const asked = [...e.permissions].filter((p) => listHeld || p !== OBJECT_GET);
285
+ if (asked.length < e.permissions.size) unverified.set(key, new Set([OBJECT_GET]));
286
+ return ask(key, e.resource, asked);
287
+ }),
288
+ );
289
+ if (errors.length > 0) return { kind: "error", errors: errors.sort() };
290
+ const missing: MissingAccess[] = [];
291
+ for (const check of checks) {
292
+ const key = resourceName(check.resource);
293
+ const held = granted.get(key) ?? new Set<string>();
294
+ const unknown = unverified.get(key) ?? new Set<string>();
295
+ const lacking = check.permissions.filter((p) => !held.has(p) && !unknown.has(p));
296
+ const unchecked = check.permissions.filter((p) => unknown.has(p));
297
+ if (lacking.length > 0 || unchecked.length > 0) {
298
+ missing.push({ check, missing: lacking, ...(unchecked.length > 0 ? { unchecked } : {}) });
299
+ }
300
+ }
301
+ return missing.length === 0 ? { kind: "ok" } : { kind: "missing", missing };
302
+ }
303
+
304
+ const OBJECT_LIST = "storage.objects.list";
305
+ const OBJECT_GET = "storage.objects.get";
306
+
307
+ /**
308
+ * The request's end reason for missing permissions: every one of them, each
309
+ * with what it is for, then the one next step.
310
+ */
311
+ export function preflightMissingReason(missing: readonly MissingAccess[]): string {
312
+ const lines = missing.map((m) => {
313
+ const where = resourceName(m.check.resource);
314
+ const parts = [
315
+ ...(m.missing.length > 0 ? [`missing ${m.missing.join(", ")} on ${where}`] : []),
316
+ ...(m.unchecked && m.unchecked.length > 0
317
+ ? [
318
+ `${m.unchecked.join(", ")} on ${where} could not be checked: without storage.objects.list Cloud Storage answers 403 for any absent object, held or not`,
319
+ ]
320
+ : []),
321
+ ];
322
+ return `it ${m.missing.length > 0 ? "cannot" : "may not be able to"} ${m.check.need} (${parts.join("; ")})`;
323
+ });
324
+ return `Nothing was changed: the upgrader checked its access before starting and is missing permissions this update needs — ${lines.join("; ")}. Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`;
325
+ }
326
+
327
+ /** The request's end reason when IAM could not be asked. */
328
+ export function preflightErrorReason(errors: readonly string[]): string {
329
+ return `Nothing was changed: the upgrader could not check its access before starting (${errors.join("; ")}). Try again in a few minutes; if it keeps failing, run the upgrade once with Cloud Shell.`;
330
+ }
331
+
332
+ /** A resource's full name, as IAM and the messages name it. */
333
+ export function resourceName(r: IamResource): string {
334
+ switch (r.kind) {
335
+ case "project":
336
+ return `projects/${r.projectId}`;
337
+ case "service-account":
338
+ return `projects/${r.projectId}/serviceAccounts/${r.email}`;
339
+ case "run-job":
340
+ return `projects/${r.projectId}/locations/${r.region}/jobs/${r.name}`;
341
+ case "bucket":
342
+ return `gs://${r.bucket}`;
343
+ case "bucket-objects":
344
+ return `gs://${r.bucket}/${r.prefix}/`;
345
+ case "artifact-repository":
346
+ return `projects/${r.projectId}/locations/${r.location}/repositories/${r.repository}`;
347
+ }
348
+ }
349
+
350
+ const testIamResponse = z.object({ permissions: z.array(z.string()).optional() });
351
+
352
+ /**
353
+ * The real {@link IamTester}: Google's REST APIs with the job's own access
354
+ * token. `token` is asked once per call (gcloud caches it).
355
+ *
356
+ * @example
357
+ * const tester = restIamTester({ http: fetch, token: async () => "<access token>" });
358
+ * await tester.granted({ kind: "project", projectId: "acme" }, ["run.services.get"]);
359
+ */
360
+ export function restIamTester(deps: {
361
+ readonly http: HttpFetch;
362
+ readonly token: () => Promise<string>;
363
+ }): IamTester {
364
+ return {
365
+ async granted(resource, permissions) {
366
+ const bearer = await deps.token();
367
+ if (!bearer) return { ok: false, error: "no access token for the upgrader's account" };
368
+ const auth = { Authorization: `Bearer ${bearer}` };
369
+ if (resource.kind === "bucket-objects") {
370
+ return probeObjects(deps.http, auth, resource, permissions);
371
+ }
372
+ const call = testIamCall(resource, permissions);
373
+ const res = await deps.http(call.url, {
374
+ method: call.method,
375
+ headers: { ...auth, ...(call.body ? { "Content-Type": "application/json" } : {}) },
376
+ ...(call.body ? { body: call.body } : {}),
377
+ });
378
+ const text = await res.text();
379
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${text.trim().slice(0, 300)}` };
380
+ let raw: unknown;
381
+ try {
382
+ raw = JSON.parse(text);
383
+ } catch {
384
+ return { ok: false, error: "testIamPermissions answered without JSON" };
385
+ }
386
+ const parsed = testIamResponse.safeParse(raw);
387
+ if (!parsed.success) {
388
+ return { ok: false, error: "testIamPermissions answered without a permission list" };
389
+ }
390
+ // An empty answer omits `permissions`: none of them is held.
391
+ return { ok: true, granted: parsed.data.permissions ?? [] };
392
+ },
393
+ };
394
+ }
395
+
396
+ /** The testIamPermissions request for a resource that has one. */
397
+ export function testIamCall(
398
+ resource: Exclude<IamResource, { kind: "bucket-objects" }>,
399
+ permissions: readonly string[],
400
+ ): { url: string; method: "GET" | "POST"; body?: string } {
401
+ const body = JSON.stringify({ permissions });
402
+ switch (resource.kind) {
403
+ case "project":
404
+ return {
405
+ url: `https://cloudresourcemanager.googleapis.com/v1/projects/${encodeURIComponent(resource.projectId)}:testIamPermissions`,
406
+ method: "POST",
407
+ body,
408
+ };
409
+ case "service-account":
410
+ return {
411
+ url: `https://iam.googleapis.com/v1/projects/${encodeURIComponent(resource.projectId)}/serviceAccounts/${encodeURIComponent(resource.email)}:testIamPermissions`,
412
+ method: "POST",
413
+ body,
414
+ };
415
+ case "run-job":
416
+ return {
417
+ url: `https://run.googleapis.com/v2/projects/${encodeURIComponent(resource.projectId)}/locations/${encodeURIComponent(resource.region)}/jobs/${encodeURIComponent(resource.name)}:testIamPermissions`,
418
+ method: "POST",
419
+ body,
420
+ };
421
+ case "bucket": {
422
+ const q = permissions.map((p) => `permissions=${encodeURIComponent(p)}`).join("&");
423
+ return {
424
+ url: `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(resource.bucket)}/iam/testPermissions?${q}`,
425
+ method: "GET",
426
+ };
427
+ }
428
+ case "artifact-repository":
429
+ return {
430
+ url: `https://artifactregistry.googleapis.com/v1/projects/${encodeURIComponent(resource.projectId)}/locations/${encodeURIComponent(resource.location)}/repositories/${encodeURIComponent(resource.repository)}:testIamPermissions`,
431
+ method: "POST",
432
+ body,
433
+ };
434
+ }
435
+ }
436
+
437
+ const OBJECT_PROBES: Record<
438
+ string,
439
+ { method: "GET" | "POST" | "DELETE"; held: readonly number[] }
440
+ > = {
441
+ // A read of an object that does not exist: 404 once authorized.
442
+ "storage.objects.get": { method: "GET", held: [404, 200] },
443
+ // An upload that must match generation 1: never true, so 412 once authorized.
444
+ "storage.objects.create": { method: "POST", held: [412] },
445
+ // A delete that must match generation 1: 404 (absent) or 412 once authorized.
446
+ "storage.objects.delete": { method: "DELETE", held: [404, 412] },
447
+ };
448
+
449
+ /** The precondition-guarded request that tests one object permission (see the file comment). */
450
+ export function objectProbeCall(
451
+ resource: Extract<IamResource, { kind: "bucket-objects" }>,
452
+ permission: string,
453
+ ): { url: string; method: "GET" | "POST" | "DELETE" } | null {
454
+ const probe = OBJECT_PROBES[permission];
455
+ if (!probe) return null;
456
+ const bucket = encodeURIComponent(resource.bucket);
457
+ const object = encodeURIComponent(`${resource.prefix}/${PREFLIGHT_PROBE_OBJECT}`);
458
+ switch (probe.method) {
459
+ case "GET":
460
+ return {
461
+ url: `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}`,
462
+ method: "GET",
463
+ };
464
+ case "POST":
465
+ return {
466
+ url: `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?uploadType=media&name=${object}&ifGenerationMatch=1`,
467
+ method: "POST",
468
+ };
469
+ case "DELETE":
470
+ return {
471
+ url: `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}?ifGenerationMatch=1`,
472
+ method: "DELETE",
473
+ };
474
+ }
475
+ }
476
+
477
+ async function probeObjects(
478
+ http: HttpFetch,
479
+ auth: Record<string, string>,
480
+ resource: Extract<IamResource, { kind: "bucket-objects" }>,
481
+ permissions: readonly string[],
482
+ ): Promise<GrantedResult> {
483
+ const granted: string[] = [];
484
+ for (const permission of permissions) {
485
+ const call = objectProbeCall(resource, permission);
486
+ const probe = OBJECT_PROBES[permission];
487
+ if (!call || !probe) {
488
+ return { ok: false, error: `no probe for ${permission} on ${resourceName(resource)}` };
489
+ }
490
+ const res = await http(call.url, {
491
+ method: call.method,
492
+ headers:
493
+ call.method === "POST" ? { ...auth, "Content-Type": "application/octet-stream" } : auth,
494
+ ...(call.method === "POST" ? { body: "" } : {}),
495
+ });
496
+ const text = await res.text();
497
+ if (probe.held.includes(res.status)) {
498
+ // 200 on the upload would mean an object was written; held[] for
499
+ // create has no 200, so that lands below as an error, loudly.
500
+ granted.push(permission);
501
+ } else if (res.status === 403) {
502
+ } else {
503
+ return {
504
+ ok: false,
505
+ error: `probing ${permission} on ${resourceName(resource)}: HTTP ${res.status} ${text.trim().slice(0, 300)}`,
506
+ };
507
+ }
508
+ }
509
+ return { ok: true, granted };
510
+ }