@caelo-cms/provisioning 0.10.55 → 0.10.57

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 (209) hide show
  1. package/dist/agent-db.js +76 -19
  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 +22 -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.map +1 -1
  45. package/dist/operator-access.js +13 -3
  46. package/dist/operator-access.js.map +1 -1
  47. package/dist/release-contract.d.ts +3 -2
  48. package/dist/release-contract.d.ts.map +1 -1
  49. package/dist/release-contract.js +5 -2
  50. package/dist/release-contract.js.map +1 -1
  51. package/dist/release-manifest.d.ts +2 -0
  52. package/dist/release-manifest.d.ts.map +1 -1
  53. package/dist/release-manifest.js.map +1 -1
  54. package/dist/remote-mcp-edge.d.ts +114 -0
  55. package/dist/remote-mcp-edge.d.ts.map +1 -0
  56. package/dist/remote-mcp-edge.js +116 -0
  57. package/dist/remote-mcp-edge.js.map +1 -0
  58. package/dist/remote-mcp.d.ts +117 -0
  59. package/dist/remote-mcp.d.ts.map +1 -0
  60. package/dist/remote-mcp.js +379 -0
  61. package/dist/remote-mcp.js.map +1 -0
  62. package/dist/runtime-secrets.d.ts +12 -0
  63. package/dist/runtime-secrets.d.ts.map +1 -1
  64. package/dist/runtime-secrets.js +32 -4
  65. package/dist/runtime-secrets.js.map +1 -1
  66. package/dist/seal-key-rotation.d.ts +38 -0
  67. package/dist/seal-key-rotation.d.ts.map +1 -0
  68. package/dist/seal-key-rotation.js +206 -0
  69. package/dist/seal-key-rotation.js.map +1 -0
  70. package/dist/stack-contract.d.ts +167 -3
  71. package/dist/stack-contract.d.ts.map +1 -1
  72. package/dist/stack-contract.js +171 -3
  73. package/dist/stack-contract.js.map +1 -1
  74. package/dist/stack-converge.d.ts +38 -1
  75. package/dist/stack-converge.d.ts.map +1 -1
  76. package/dist/stack-converge.js +74 -4
  77. package/dist/stack-converge.js.map +1 -1
  78. package/dist/state-lock-lease.d.ts +142 -0
  79. package/dist/state-lock-lease.d.ts.map +1 -0
  80. package/dist/state-lock-lease.js +347 -0
  81. package/dist/state-lock-lease.js.map +1 -0
  82. package/dist/state-store.d.ts +66 -9
  83. package/dist/state-store.d.ts.map +1 -1
  84. package/dist/state-store.js +249 -30
  85. package/dist/state-store.js.map +1 -1
  86. package/dist/upgrade/app-roll.d.ts +36 -7
  87. package/dist/upgrade/app-roll.d.ts.map +1 -1
  88. package/dist/upgrade/app-roll.js +53 -19
  89. package/dist/upgrade/app-roll.js.map +1 -1
  90. package/dist/upgrade/converge.d.ts.map +1 -1
  91. package/dist/upgrade/converge.js +111 -9
  92. package/dist/upgrade/converge.js.map +1 -1
  93. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  94. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  95. package/dist/upgrade/gcp-lookup.js +46 -7
  96. package/dist/upgrade/gcp-lookup.js.map +1 -1
  97. package/dist/upgrade/record-rolled.d.ts +40 -0
  98. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  99. package/dist/upgrade/record-rolled.js +46 -0
  100. package/dist/upgrade/record-rolled.js.map +1 -0
  101. package/dist/upgrade/upgrade.d.ts +45 -1
  102. package/dist/upgrade/upgrade.d.ts.map +1 -1
  103. package/dist/upgrade/upgrade.js +60 -17
  104. package/dist/upgrade/upgrade.js.map +1 -1
  105. package/dist/upgrader-assess.d.ts +15 -1
  106. package/dist/upgrader-assess.d.ts.map +1 -1
  107. package/dist/upgrader-assess.js +36 -6
  108. package/dist/upgrader-assess.js.map +1 -1
  109. package/dist/upgrader-gcp.d.ts +15 -0
  110. package/dist/upgrader-gcp.d.ts.map +1 -1
  111. package/dist/upgrader-gcp.js +42 -7
  112. package/dist/upgrader-gcp.js.map +1 -1
  113. package/dist/upgrader-iam-preflight.d.ts +123 -0
  114. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  115. package/dist/upgrader-iam-preflight.js +414 -0
  116. package/dist/upgrader-iam-preflight.js.map +1 -0
  117. package/dist/upgrader-job.d.ts +33 -9
  118. package/dist/upgrader-job.d.ts.map +1 -1
  119. package/dist/upgrader-job.js +169 -27
  120. package/dist/upgrader-job.js.map +1 -1
  121. package/dist/upgrader-records.d.ts +58 -0
  122. package/dist/upgrader-records.d.ts.map +1 -0
  123. package/dist/upgrader-records.js +87 -0
  124. package/dist/upgrader-records.js.map +1 -0
  125. package/dist/upgrader-reporter.d.ts +3 -2
  126. package/dist/upgrader-reporter.d.ts.map +1 -1
  127. package/dist/upgrader-reporter.js +10 -2
  128. package/dist/upgrader-reporter.js.map +1 -1
  129. package/dist/upgrader-setup.d.ts +25 -3
  130. package/dist/upgrader-setup.d.ts.map +1 -1
  131. package/dist/upgrader-setup.js +123 -11
  132. package/dist/upgrader-setup.js.map +1 -1
  133. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  134. package/dist/wizards/gcp-cost.js +11 -3
  135. package/dist/wizards/gcp-cost.js.map +1 -1
  136. package/dist/wizards/gcp.d.ts.map +1 -1
  137. package/dist/wizards/gcp.js +99 -4
  138. package/dist/wizards/gcp.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/agent/run.test.ts +14 -1
  141. package/src/cli-routing.ts +1 -1
  142. package/src/cli.ts +4 -1
  143. package/src/compose.ts +5 -0
  144. package/src/converge-checkpoint.ts +75 -0
  145. package/src/converge-entrypoint.test.ts +213 -0
  146. package/src/converge-job-gcp.test.ts +222 -0
  147. package/src/converge-job-gcp.ts +443 -0
  148. package/src/converge-job-setup.test.ts +152 -0
  149. package/src/converge-job-setup.ts +268 -0
  150. package/src/converge-job.test.ts +486 -0
  151. package/src/converge-job.ts +542 -0
  152. package/src/gateway-env-isolation.test.ts +8 -1
  153. package/src/gcp-names.ts +15 -0
  154. package/src/gcp-url-map.test.ts +9 -1
  155. package/src/index.ts +8 -0
  156. package/src/infra-contract.test.ts +7 -4
  157. package/src/infra-contract.ts +22 -1
  158. package/src/install-job.test.ts +3 -1
  159. package/src/install-job.ts +31 -14
  160. package/src/lifecycle.ts +137 -71
  161. package/src/operator-access.test.ts +14 -0
  162. package/src/operator-access.ts +13 -3
  163. package/src/release-contract.ts +5 -2
  164. package/src/release-manifest.test.ts +42 -0
  165. package/src/release-manifest.ts +2 -0
  166. package/src/remote-mcp-edge.ts +137 -0
  167. package/src/remote-mcp.test.ts +517 -0
  168. package/src/remote-mcp.ts +460 -0
  169. package/src/runtime-secrets.test.ts +31 -6
  170. package/src/runtime-secrets.ts +41 -3
  171. package/src/seal-key-rotation.test.ts +131 -0
  172. package/src/seal-key-rotation.ts +258 -0
  173. package/src/stack-contract-parity.test.ts +20 -3
  174. package/src/stack-contract.test.ts +30 -2
  175. package/src/stack-contract.ts +291 -3
  176. package/src/stack-converge-secrets.test.ts +3 -0
  177. package/src/stack-converge.test.ts +21 -5
  178. package/src/stack-converge.ts +101 -7
  179. package/src/state-attach.test.ts +7 -1
  180. package/src/state-lock-lease.test.ts +302 -0
  181. package/src/state-lock-lease.ts +423 -0
  182. package/src/state-lock.test.ts +568 -0
  183. package/src/state-store.ts +347 -27
  184. package/src/upgrade/app-roll.ts +96 -21
  185. package/src/upgrade/converge.ts +130 -8
  186. package/src/upgrade/gcp-lookup.test.ts +80 -0
  187. package/src/upgrade/gcp-lookup.ts +65 -8
  188. package/src/upgrade/record-rolled.test.ts +105 -0
  189. package/src/upgrade/record-rolled.ts +91 -0
  190. package/src/upgrade/upgrade.test.ts +203 -3
  191. package/src/upgrade/upgrade.ts +120 -24
  192. package/src/upgrader-assess.test.ts +92 -3
  193. package/src/upgrader-assess.ts +58 -9
  194. package/src/upgrader-gcp.ts +47 -8
  195. package/src/upgrader-iam-preflight.test.ts +393 -0
  196. package/src/upgrader-iam-preflight.ts +510 -0
  197. package/src/upgrader-job.test.ts +586 -15
  198. package/src/upgrader-job.ts +237 -53
  199. package/src/upgrader-records.ts +151 -0
  200. package/src/upgrader-reporter.test.ts +6 -0
  201. package/src/upgrader-reporter.ts +7 -2
  202. package/src/upgrader-setup.test.ts +3 -3
  203. package/src/upgrader-setup.ts +128 -11
  204. package/src/wizards/gcp-cost.ts +12 -3
  205. package/src/wizards/gcp.ts +125 -4
  206. package/stacks/aws/index.ts +10 -0
  207. package/stacks/azure/index.ts +9 -0
  208. package/stacks/gcp/index.ts +46 -7
  209. package/stacks/gcp-firebase/index.ts +37 -6
@@ -27,6 +27,8 @@
27
27
  * | target not newer than installed (downgrade/same) | refused |
28
28
  * | installed below the release's minFromVersion | refused |
29
29
  * | release raises the infrastructure contract | needs_cloud_shell |
30
+ * | … on the converge route (#819, the converge job) | proceed |
31
+ * | … converge route, automatic, autoConverge off | refused |
30
32
  * | contract (destructive) migrations, automatic run | refused |
31
33
  * | contract migrations, Owner's click | proceed + warning |
32
34
  * | automatic run the update policy does not allow | refused |
@@ -38,6 +40,13 @@
38
40
  * that rolls the images. So for them `installed.infraContractVersion` is
39
41
  * null and the infra-contract row never applies; every "needs_cloud_shell"
40
42
  * stop names the upgrade command on the server instead of Cloud Shell.
43
+ *
44
+ * The converge job (#819, converge-job.ts) asks with `route: "converge"`:
45
+ * it runs as the install's provisioner, so an infrastructure change is what
46
+ * it is for — every other row still applies. An automatic run on the
47
+ * converge route needs the Owner's `autoConverge` (#864) on top of
48
+ * everything an automatic install needs; the scheduler queues one only
49
+ * then, and the job re-checks it here against the policy it claimed.
41
50
  */
42
51
 
43
52
  import {
@@ -60,6 +69,7 @@ import { needsConverge } from "./upgrade/converge.js";
60
69
  /** Machine-readable reason codes (`platform_update_requests.refusal_codes`). */
61
70
  export type RefusalCode =
62
71
  | "registry"
72
+ | "manifest-missing"
63
73
  | "signature"
64
74
  | "manifest-unreadable"
65
75
  | "install-version-unknown"
@@ -134,6 +144,11 @@ export function assessUpgradeRequest(input: {
134
144
  readonly now: Date;
135
145
  /** Default `cloud-shell` (the GCP upgrader job). */
136
146
  readonly manualPath?: ManualUpgradePath;
147
+ /**
148
+ * Which job asks (#819). `converge` = the converge job: an infrastructure
149
+ * contract bump is installed, not handed to Cloud Shell. Default `app_roll`.
150
+ */
151
+ readonly route?: "app_roll" | "converge";
137
152
  }): RequestDecision {
138
153
  const { request, installed } = input;
139
154
  const by = manualRun(input.manualPath ?? "cloud-shell");
@@ -142,10 +157,20 @@ export function assessUpgradeRequest(input: {
142
157
  const e = input.manifest.error;
143
158
  switch (e.code) {
144
159
  case "registry":
160
+ // Only an outage is worth retrying: a manifest the registry says
161
+ // does not exist (404 / other 4xx) never appears, so retrying would
162
+ // loop on "try again later" forever.
163
+ if (e.transient) {
164
+ return stop(
165
+ "failed",
166
+ "registry",
167
+ `Could not reach the Caelo release registry to verify v${target} (${e.message}). Nothing was changed; try again later.`,
168
+ );
169
+ }
145
170
  return stop(
146
- "failed",
147
- "registry",
148
- `Could not reach the Caelo release registry to verify v${target} (${e.message}). Nothing was changed; try again later.`,
171
+ "refused",
172
+ "manifest-missing",
173
+ `v${target} has no signed release manifest in the Caelo release registry, so it cannot be verified and will not be installed. Nothing was changed; retrying will not help. (${e.message})`,
149
174
  );
150
175
  case "schema":
151
176
  return stop(
@@ -170,7 +195,18 @@ export function assessUpgradeRequest(input: {
170
195
  manifest,
171
196
  );
172
197
  }
173
- if (compareSemver(target, installed.version) <= 0) {
198
+ const order = compareSemver(target, installed.version);
199
+ if (order === 0) {
200
+ // Reached after a run that rolled but could not record it (#860): the
201
+ // next run re-records the serving release, which then is the target.
202
+ return stop(
203
+ "refused",
204
+ "not-newer",
205
+ `v${target} already serves on this install, so there is nothing to install. Nothing was changed.`,
206
+ manifest,
207
+ );
208
+ }
209
+ if (order < 0) {
174
210
  return stop(
175
211
  "refused",
176
212
  "not-newer",
@@ -188,20 +224,33 @@ export function assessUpgradeRequest(input: {
188
224
  }
189
225
  // Self-hosted: the target's own CLI renders the host setup in this run.
190
226
  const infraContractVersion = installed.infraContractVersion ?? manifest.infraContractVersion;
191
- if (
227
+ const converge = input.route === "converge";
228
+ const infraChange =
192
229
  installed.infraContractVersion !== null &&
193
- needsConverge({ infraContractVersion }, manifest.infraContractVersion)
194
- ) {
230
+ needsConverge({ infraContractVersion }, manifest.infraContractVersion);
231
+ if (infraChange && !converge) {
195
232
  return stop(
196
233
  "needs_cloud_shell",
197
234
  "infra-contract",
198
- `v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(infraContractVersion, manifest.infraContractVersion)}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
235
+ `v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(infraContractVersion, manifest.infraContractVersion)}). The upgrader may not change cloud permissions, so Caelo installs this release with its converge job: start it again from Settings → Updates. On an install without the converge job, install it once with your own Google account in Cloud Shell.`,
236
+ manifest,
237
+ );
238
+ }
239
+ if (converge && request.trigger === "auto" && !input.policy.autoConverge) {
240
+ return stop(
241
+ "refused",
242
+ "policy",
243
+ `Not installed automatically: v${target} changes this install's cloud setup, and the update policy does not install those automatically. Install it from Settings → Updates.`,
199
244
  manifest,
200
245
  );
201
246
  }
247
+ // On the converge route the contract bump is the job's work, not a
248
+ // reason for a manual install: only the migrations decide below.
202
249
  const assessment = assessAutoInstall(manifest, {
203
250
  version: installed.version,
204
- infraContractVersion,
251
+ infraContractVersion: converge
252
+ ? Math.max(infraContractVersion, manifest.infraContractVersion)
253
+ : infraContractVersion,
205
254
  });
206
255
  const destructive = assessment.mode === "manual";
207
256
  const warnings: string[] = [];
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * The real side effects of the upgrader job on Google Cloud (epic #696):
5
5
  * gcloud as the job's own service account, the install's ops bucket for
6
- * install.json, the fixed release registry, the migration job and the IAP
7
- * health probe. upgrader-job.ts takes them as {@link UpgraderJobDeps}; the
6
+ * install.json, the fixed release registry, the migration job, the IAP
7
+ * health probe and the IAM self-check (#818). upgrader-job.ts takes them as {@link UpgraderJobDeps}; the
8
8
  * database queue and the job log come from `@caelo-cms/platform-updater`,
9
9
  * which owns the Query API connection.
10
10
  *
@@ -24,12 +24,14 @@ import { readAdminConfig, runMigrationsViaCloudRunJob } from "./migration-runner
24
24
  import { fetchVerifiedReleaseManifest } from "./release-manifest.js";
25
25
  import type { HttpFetch } from "./runtime-secrets.js";
26
26
  import { opsBucketName } from "./state-backend.js";
27
- import { acquireBucketStateLock, GcsStateStore } from "./state-store.js";
27
+ import { acquireBucketStateLock, GcsStateStore, realSyncGcloud } from "./state-store.js";
28
28
  import type { AppRollPlan } from "./upgrade/app-roll.js";
29
29
  import { findServiceUrl } from "./upgrade/gcp-lookup.js";
30
30
  import { createIapHealthProbe } from "./upgrade/health.js";
31
+ import { restIamTester } from "./upgrader-iam-preflight.js";
31
32
  import type { UpdateQueue, UpgraderJobDeps } from "./upgrader-job.js";
32
33
  import type { JobLog } from "./upgrader-reporter.js";
34
+ import { UPGRADER_TASK_TIMEOUT_MINUTES } from "./upgrader-setup.js";
33
35
 
34
36
  /** Where the job runs, from the metadata server. */
35
37
  export interface UpgraderPlacement {
@@ -55,8 +57,11 @@ export async function upgraderDatabaseHost(
55
57
  return cfg.databaseHost;
56
58
  }
57
59
 
58
- /** The IAP-backed deep health probe for `plan`'s install. */
59
- async function healthProbeFor(run: GcloudRunner, http: HttpFetch, plan: AppRollPlan) {
60
+ /**
61
+ * The IAP-backed deep health probe for `plan`'s install (the upgrader job;
62
+ * the converge job, #819, too).
63
+ */
64
+ export async function healthProbeFor(run: GcloudRunner, http: HttpFetch, plan: AppRollPlan) {
60
65
  const { install } = plan;
61
66
  const urls: Partial<Record<"admin" | "gateway", string>> = {};
62
67
  for (const s of plan.services) {
@@ -102,12 +107,46 @@ export function gcpUpgraderDeps(
102
107
  region: placement.region,
103
108
  env: GCP_STACK_ENV,
104
109
  execution: placement.execution,
110
+ // #818 — the IAM self-check asks as the job's own account (gcloud's
111
+ // token from the metadata server), so it sees exactly the job's grants.
112
+ iam: restIamTester({
113
+ http,
114
+ token: async () => {
115
+ const t = await run(["auth", "print-access-token"]);
116
+ if (!t.ok) throw new Error(`gcloud auth print-access-token: ${t.stderr.trim()}`);
117
+ return t.stdout.trim();
118
+ },
119
+ }),
105
120
  // The default deps: the fixed registry and signer, never overridden.
106
121
  fetchManifest: (version) => fetchVerifiedReleaseManifest(version),
107
- lockState: () =>
108
- acquireBucketStateLock(opsBucketName(placement.projectId), "caelo upgrader job"),
122
+ // #815 — the lock names this execution, so a later run can break it
123
+ // once Cloud Run says this one has ended (killed at its task timeout,
124
+ // OOM). Outside Cloud Run there is no execution to vouch for it.
125
+ lockState: (onBreak) =>
126
+ acquireBucketStateLock(
127
+ opsBucketName(placement.projectId),
128
+ "caelo upgrader job",
129
+ realSyncGcloud,
130
+ {
131
+ onBreak,
132
+ ...(placement.execution
133
+ ? {
134
+ holder: {
135
+ execution: {
136
+ projectId: placement.projectId,
137
+ region: placement.region,
138
+ name: placement.execution,
139
+ },
140
+ maxRuntimeMs: UPGRADER_TASK_TIMEOUT_MINUTES * 60_000,
141
+ },
142
+ }
143
+ : {}),
144
+ },
145
+ ),
146
+ // Uncached on purpose: the job re-reads install.json once it holds the
147
+ // lock to see whether another writer changed it meanwhile (#815).
109
148
  readInstall: () => {
110
- const text = store.read("install.json");
149
+ const text = store.peek("install.json");
111
150
  return text === null ? null : (JSON.parse(text) as InstallMetadata);
112
151
  },
113
152
  writeInstall: (meta) => store.write("install.json", meta),
@@ -0,0 +1,393 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The upgrader's IAM self-check (#818): which permissions it asks for, how
5
+ * it groups and judges IAM's answers, the exact REST calls it makes, and
6
+ * that it never asks for a permission upgrader-setup.ts does not grant (a
7
+ * check the setup cannot pass would stop every update).
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import type { HttpFetch } from "./runtime-secrets.js";
12
+ import {
13
+ type IamResource,
14
+ type IamTester,
15
+ objectProbeCall,
16
+ PREFLIGHT_PROBE_OBJECT,
17
+ preflightMissingReason,
18
+ resourceName,
19
+ restIamTester,
20
+ runIamPreflight,
21
+ testIamCall,
22
+ upgraderAccessChecks,
23
+ } from "./upgrader-iam-preflight.js";
24
+ import {
25
+ UPGRADER_FORBIDDEN_ROLES,
26
+ UPGRADER_OPS_LIST_ROLE,
27
+ UPGRADER_PROJECT_ROLES,
28
+ UPGRADER_RUN_ROLE,
29
+ UPGRADER_SQL_BACKUP_ROLE,
30
+ } from "./upgrader-setup.js";
31
+
32
+ const TARGET = { projectId: "acme", region: "europe-west1", env: "production" };
33
+
34
+ /**
35
+ * The permissions of the predefined roles upgrader-setup.ts grants, as far
36
+ * as the self-check needs them (cloud.google.com/run/docs/reference/iam/roles,
37
+ * cloud.google.com/iam/docs/understanding-roles). Only the ones asked for
38
+ * are listed; a new check must find its permission here or in a custom role.
39
+ */
40
+ const PREDEFINED: Record<string, readonly string[]> = {
41
+ "roles/cloudsql.instanceUser": ["cloudsql.instances.get", "cloudsql.instances.login"],
42
+ "roles/logging.logWriter": ["logging.logEntries.create"],
43
+ "roles/iam.serviceAccountUser": ["iam.serviceAccounts.actAs"],
44
+ "roles/iam.serviceAccountTokenCreator": ["iam.serviceAccounts.signJwt"],
45
+ "roles/storage.objectUser": [
46
+ "storage.objects.get",
47
+ "storage.objects.create",
48
+ "storage.objects.delete",
49
+ "storage.objects.list",
50
+ ],
51
+ // The public release mirror grants allUsers roles/artifactregistry.reader.
52
+ "roles/artifactregistry.reader": ["artifactregistry.repositories.downloadArtifacts"],
53
+ };
54
+
55
+ /** What upgrader-setup.ts grants the upgrader's account, per resource name. */
56
+ function setupGrants(): Map<string, Set<string>> {
57
+ const p = TARGET.projectId;
58
+ const of = (...roles: string[]) => new Set(roles.flatMap((r) => PREDEFINED[r] ?? []));
59
+ const project = of(...UPGRADER_PROJECT_ROLES);
60
+ for (const perm of UPGRADER_SQL_BACKUP_ROLE.permissions) project.add(perm);
61
+ // #883 — the Cloud Run rights come from the custom caeloUpgraderRun role
62
+ // (no run.developer: it would hold runWithOverrides on the provisioner's jobs).
63
+ for (const perm of UPGRADER_RUN_ROLE.permissions) project.add(perm);
64
+ const bucketList = new Set(UPGRADER_OPS_LIST_ROLE.permissions);
65
+ return new Map([
66
+ [`projects/${p}`, project],
67
+ [
68
+ `projects/${p}/serviceAccounts/caelo-production-run-sa@${p}.iam.gserviceaccount.com`,
69
+ of("roles/iam.serviceAccountUser"),
70
+ ],
71
+ [
72
+ `projects/${p}/serviceAccounts/caelo-production-gateway-sa@${p}.iam.gserviceaccount.com`,
73
+ of("roles/iam.serviceAccountUser"),
74
+ ],
75
+ [
76
+ `projects/${p}/serviceAccounts/caelo-production-upgrader@${p}.iam.gserviceaccount.com`,
77
+ of("roles/iam.serviceAccountUser"),
78
+ ],
79
+ [
80
+ `projects/${p}/serviceAccounts/caelo-mcp@${p}.iam.gserviceaccount.com`,
81
+ of("roles/iam.serviceAccountTokenCreator"),
82
+ ],
83
+ [
84
+ `projects/${p}/serviceAccounts/caelo-production-audit@${p}.iam.gserviceaccount.com`,
85
+ of("roles/iam.serviceAccountUser"),
86
+ ],
87
+ // The project-level caeloUpgraderRun reaches the job too.
88
+ [`projects/${p}/locations/europe-west1/jobs/caelo-production-upgrader`, project],
89
+ [`gs://${p}-caelo-ops`, bucketList],
90
+ // The install/-conditioned roles/storage.objectUser grant.
91
+ [`gs://${p}-caelo-ops/install/`, of("roles/storage.objectUser")],
92
+ [
93
+ "projects/caelo-website/locations/europe-west1/repositories/caelo-cms-images",
94
+ of("roles/artifactregistry.reader"),
95
+ ],
96
+ ]);
97
+ }
98
+
99
+ /** A tester answering from `grants`, recording every question. */
100
+ function tester(grants: Map<string, Set<string>>, asked: string[] = []): IamTester {
101
+ return {
102
+ granted: async (resource, permissions) => {
103
+ asked.push(`${resourceName(resource)} ${permissions.join(",")}`);
104
+ const held = grants.get(resourceName(resource)) ?? new Set<string>();
105
+ return { ok: true, granted: permissions.filter((x) => held.has(x)) };
106
+ },
107
+ };
108
+ }
109
+
110
+ describe("upgraderAccessChecks", () => {
111
+ it("asks only for what upgrader-setup.ts grants: a fresh setup passes the self-check", async () => {
112
+ const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(setupGrants()));
113
+ expect(r).toEqual({ kind: "ok" });
114
+ });
115
+
116
+ it("#879 review: the audit job's account is checked, since every app-roll moves that job (#875)", async () => {
117
+ const grants = setupGrants();
118
+ grants.delete(
119
+ "projects/acme/serviceAccounts/caelo-production-audit@acme.iam.gserviceaccount.com",
120
+ );
121
+ const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants));
122
+ expect(r.kind).toBe("missing");
123
+ if (r.kind !== "missing") return;
124
+ expect(r.missing.map((m) => [resourceName(m.check.resource), m.missing])).toEqual([
125
+ [
126
+ "projects/acme/serviceAccounts/caelo-production-audit@acme.iam.gserviceaccount.com",
127
+ ["iam.serviceAccounts.actAs"],
128
+ ],
129
+ ]);
130
+ expect(preflightMissingReason(r.missing)).toContain("quality-audit job");
131
+ });
132
+
133
+ it("never needs a permission only a forbidden role would give (no compute, no IAM admin)", () => {
134
+ const all = upgraderAccessChecks(TARGET).flatMap((c) => c.permissions);
135
+ for (const p of all) {
136
+ expect(p.startsWith("compute.")).toBe(false);
137
+ expect(p.endsWith(".setIamPolicy")).toBe(false);
138
+ }
139
+ // Sanity: the forbidden list still bans compute (the Direct VPC decision).
140
+ expect(UPGRADER_FORBIDDEN_ROLES).toContain("roles/compute.");
141
+ });
142
+
143
+ it("covers every step the run takes after its decision", () => {
144
+ const all = new Set(upgraderAccessChecks(TARGET).flatMap((c) => c.permissions));
145
+ for (const p of [
146
+ "run.services.update", // the roll + traffic
147
+ "run.jobs.create", // the migration job
148
+ "run.jobs.run",
149
+ "run.executions.get", // execute --wait, the lock lease
150
+ "run.jobs.update", // the self-update
151
+ "cloudsql.backupRuns.create", // the backup
152
+ "iam.serviceAccounts.actAs",
153
+ "iam.serviceAccounts.signJwt", // the IAP health check
154
+ "storage.objects.list", // gcloud storage cp lists first (#799)
155
+ "storage.objects.create",
156
+ "storage.objects.delete", // overwrite + lock release
157
+ "artifactregistry.repositories.downloadArtifacts",
158
+ ]) {
159
+ expect(all.has(p)).toBe(true);
160
+ }
161
+ });
162
+ });
163
+
164
+ describe("runIamPreflight", () => {
165
+ it("asks once per resource, with every permission the checks on it need", async () => {
166
+ const asked: string[] = [];
167
+ await runIamPreflight(upgraderAccessChecks(TARGET), tester(setupGrants(), asked));
168
+ const names = asked.map((a) => a.split(" ")[0]);
169
+ expect(new Set(names).size).toBe(names.length);
170
+ const project = asked.find((a) => a.startsWith("projects/acme "));
171
+ expect(project).toContain("run.services.update");
172
+ expect(project).toContain("cloudsql.backupRuns.create");
173
+ });
174
+
175
+ it("names every missing permission, per need, in one outcome", async () => {
176
+ const grants = setupGrants();
177
+ grants.get("projects/acme")?.delete("run.jobs.run");
178
+ grants.get("projects/acme")?.delete("cloudsql.backupRuns.create");
179
+ grants.get("gs://acme-caelo-ops")?.delete("storage.objects.list");
180
+ const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants));
181
+ expect(r.kind).toBe("missing");
182
+ if (r.kind !== "missing") return;
183
+ expect(r.missing.flatMap((m) => m.missing).sort()).toEqual([
184
+ "cloudsql.backupRuns.create",
185
+ "run.jobs.run",
186
+ "storage.objects.list",
187
+ ]);
188
+ const reason = preflightMissingReason(r.missing);
189
+ expect(reason).toContain("run the database migrations");
190
+ expect(reason).toContain("missing storage.objects.list on gs://acme-caelo-ops");
191
+ expect(reason).toContain("Run the upgrade once with Cloud Shell");
192
+ });
193
+
194
+ it("probes objects.get only once objects.list is confirmed; otherwise names it unchecked, not missing", async () => {
195
+ const withList: string[] = [];
196
+ await runIamPreflight(upgraderAccessChecks(TARGET), tester(setupGrants(), withList));
197
+ expect(withList.find((a) => a.startsWith("gs://acme-caelo-ops/install/"))).toContain(
198
+ "storage.objects.get",
199
+ );
200
+
201
+ const grants = setupGrants();
202
+ grants.get("gs://acme-caelo-ops")?.delete("storage.objects.list");
203
+ const asked: string[] = [];
204
+ const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants, asked));
205
+ const objects = asked.find((a) => a.startsWith("gs://acme-caelo-ops/install/"));
206
+ expect(objects).toBeDefined();
207
+ expect(objects).not.toContain("storage.objects.get");
208
+ expect(r.kind).toBe("missing");
209
+ if (r.kind !== "missing") return;
210
+ expect(r.missing.flatMap((m) => m.missing)).not.toContain("storage.objects.get");
211
+ expect(r.missing.flatMap((m) => m.unchecked ?? [])).toEqual(["storage.objects.get"]);
212
+ expect(preflightMissingReason(r.missing)).toContain(
213
+ "storage.objects.get on gs://acme-caelo-ops/install/ could not be checked",
214
+ );
215
+ });
216
+
217
+ it("an unanswerable resource is an error, never 'nothing missing'", async () => {
218
+ const failing: IamTester = {
219
+ granted: async (resource) =>
220
+ resource.kind === "artifact-repository"
221
+ ? { ok: false, error: "HTTP 403 caller has no access" }
222
+ : { ok: true, granted: [] },
223
+ };
224
+ const r = await runIamPreflight(upgraderAccessChecks(TARGET), failing);
225
+ expect(r.kind).toBe("error");
226
+ if (r.kind === "error") expect(r.errors[0]).toContain("HTTP 403");
227
+ });
228
+
229
+ it("a tester that throws is an error too", async () => {
230
+ const throwing: IamTester = {
231
+ granted: async () => {
232
+ throw new Error("socket hang up");
233
+ },
234
+ };
235
+ const r = await runIamPreflight(
236
+ [{ need: "x", resource: { kind: "project", projectId: "p" }, permissions: ["a.b.c"] }],
237
+ throwing,
238
+ );
239
+ expect(r).toEqual({ kind: "error", errors: ["projects/p: socket hang up"] });
240
+ });
241
+ });
242
+
243
+ type Call = { url: string; method: string; headers: Record<string, string>; body?: string };
244
+
245
+ /** An HTTP client answering by method + URL prefix. */
246
+ function http(calls: Call[], answer: (c: Call) => { status: number; body: string }): HttpFetch {
247
+ return async (url, init) => {
248
+ const call = { url, ...init };
249
+ calls.push(call);
250
+ const a = answer(call);
251
+ return { ok: a.status >= 200 && a.status < 300, status: a.status, text: async () => a.body };
252
+ };
253
+ }
254
+
255
+ describe("restIamTester", () => {
256
+ it("calls each API's testIamPermissions with the job's token", async () => {
257
+ const calls: Call[] = [];
258
+ const t = restIamTester({
259
+ http: http(calls, () => ({ status: 200, body: JSON.stringify({ permissions: ["a.b.c"] }) })),
260
+ token: async () => "tok",
261
+ });
262
+ const resources: IamResource[] = [
263
+ { kind: "project", projectId: "acme" },
264
+ { kind: "service-account", projectId: "acme", email: "x@acme.iam.gserviceaccount.com" },
265
+ {
266
+ kind: "run-job",
267
+ projectId: "acme",
268
+ region: "europe-west1",
269
+ name: "caelo-production-upgrader",
270
+ },
271
+ { kind: "bucket", bucket: "acme-caelo-ops" },
272
+ {
273
+ kind: "artifact-repository",
274
+ projectId: "caelo-website",
275
+ location: "europe-west1",
276
+ repository: "caelo-cms-images",
277
+ },
278
+ ];
279
+ for (const res of resources) {
280
+ expect(await t.granted(res, ["a.b.c", "d.e.f"])).toEqual({ ok: true, granted: ["a.b.c"] });
281
+ }
282
+ expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
283
+ "POST https://cloudresourcemanager.googleapis.com/v1/projects/acme:testIamPermissions",
284
+ "POST https://iam.googleapis.com/v1/projects/acme/serviceAccounts/x%40acme.iam.gserviceaccount.com:testIamPermissions",
285
+ "POST https://run.googleapis.com/v2/projects/acme/locations/europe-west1/jobs/caelo-production-upgrader:testIamPermissions",
286
+ "GET https://storage.googleapis.com/storage/v1/b/acme-caelo-ops/iam/testPermissions?permissions=a.b.c&permissions=d.e.f",
287
+ "POST https://artifactregistry.googleapis.com/v1/projects/caelo-website/locations/europe-west1/repositories/caelo-cms-images:testIamPermissions",
288
+ ]);
289
+ for (const c of calls) expect(c.headers.Authorization).toBe("Bearer tok");
290
+ expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ permissions: ["a.b.c", "d.e.f"] });
291
+ expect(calls[3]?.body).toBeUndefined();
292
+ });
293
+
294
+ it("an answer without `permissions` means none is held", async () => {
295
+ const t = restIamTester({
296
+ http: http([], () => ({ status: 200, body: "{}" })),
297
+ token: async () => "tok",
298
+ });
299
+ expect(await t.granted({ kind: "project", projectId: "acme" }, ["a.b.c"])).toEqual({
300
+ ok: true,
301
+ granted: [],
302
+ });
303
+ });
304
+
305
+ it("an HTTP error or non-JSON answer is an error, not 'missing'", async () => {
306
+ const denied = restIamTester({
307
+ http: http([], () => ({ status: 403, body: "PERMISSION_DENIED" })),
308
+ token: async () => "tok",
309
+ });
310
+ const r = await denied.granted({ kind: "project", projectId: "acme" }, ["a.b.c"]);
311
+ expect(r).toEqual({ ok: false, error: "HTTP 403 PERMISSION_DENIED" });
312
+ const garbled = restIamTester({
313
+ http: http([], () => ({ status: 200, body: "<html>" })),
314
+ token: async () => "tok",
315
+ });
316
+ expect((await garbled.granted({ kind: "project", projectId: "acme" }, ["a"])).ok).toBe(false);
317
+ });
318
+
319
+ it("an empty token is an error before any call", async () => {
320
+ const calls: Call[] = [];
321
+ const t = restIamTester({
322
+ http: http(calls, () => ({ status: 200, body: "{}" })),
323
+ token: async () => "",
324
+ });
325
+ expect((await t.granted({ kind: "project", projectId: "acme" }, ["a"])).ok).toBe(false);
326
+ expect(calls).toEqual([]);
327
+ });
328
+ });
329
+
330
+ describe("install/ object probes", () => {
331
+ const objects: Extract<IamResource, { kind: "bucket-objects" }> = {
332
+ kind: "bucket-objects",
333
+ bucket: "acme-caelo-ops",
334
+ prefix: "install",
335
+ };
336
+ const PERMS = ["storage.objects.get", "storage.objects.create", "storage.objects.delete"];
337
+
338
+ it("every write probe is preconditioned on generation 1, so it can never change anything", () => {
339
+ const create = objectProbeCall(objects, "storage.objects.create");
340
+ const del = objectProbeCall(objects, "storage.objects.delete");
341
+ expect(create?.url).toContain("ifGenerationMatch=1");
342
+ expect(del?.url).toContain("ifGenerationMatch=1");
343
+ for (const c of [create, del, objectProbeCall(objects, "storage.objects.get")]) {
344
+ expect(c?.url).toContain(encodeURIComponent(`install/${PREFLIGHT_PROBE_OBJECT}`));
345
+ }
346
+ expect(objectProbeCall(objects, "storage.objects.list")).toBeNull();
347
+ });
348
+
349
+ it("404/412 is held, 403 is missing", async () => {
350
+ const calls: Call[] = [];
351
+ const t = restIamTester({
352
+ http: http(calls, (c) =>
353
+ c.method === "GET"
354
+ ? { status: 404, body: "No such object" }
355
+ : c.method === "POST"
356
+ ? { status: 412, body: "conditionNotMet" }
357
+ : { status: 403, body: "does not have storage.objects.delete access" },
358
+ ),
359
+ token: async () => "tok",
360
+ });
361
+ expect(await t.granted(objects, PERMS)).toEqual({
362
+ ok: true,
363
+ granted: ["storage.objects.get", "storage.objects.create"],
364
+ });
365
+ expect(calls.map((c) => c.method)).toEqual(["GET", "POST", "DELETE"]);
366
+ });
367
+
368
+ it("an upload that succeeded (never expected) is an error, loudly", async () => {
369
+ const t = restIamTester({
370
+ http: http([], (c) =>
371
+ c.method === "POST" ? { status: 200, body: "{}" } : { status: 404, body: "" },
372
+ ),
373
+ token: async () => "tok",
374
+ });
375
+ const r = await t.granted(objects, PERMS);
376
+ expect(r.ok).toBe(false);
377
+ });
378
+
379
+ it("a permission without a probe is an error", async () => {
380
+ const t = restIamTester({
381
+ http: http([], () => ({ status: 404, body: "" })),
382
+ token: async () => "tok",
383
+ });
384
+ expect((await t.granted(objects, ["storage.objects.update"])).ok).toBe(false);
385
+ });
386
+ });
387
+
388
+ describe("testIamCall", () => {
389
+ it("URL-encodes path parts", () => {
390
+ const c = testIamCall({ kind: "bucket", bucket: "a b" }, ["x"]);
391
+ expect(c.url).toContain("/b/a%20b/");
392
+ });
393
+ });