@caelo-cms/provisioning 0.10.61 → 0.10.64

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 (281) hide show
  1. package/caddy/Caddyfile.production +7 -0
  2. package/caddy/Caddyfile.staging +7 -0
  3. package/dist/admin-internal-answer.d.ts +12 -0
  4. package/dist/admin-internal-answer.d.ts.map +1 -0
  5. package/dist/admin-internal-answer.js +32 -0
  6. package/dist/admin-internal-answer.js.map +1 -0
  7. package/dist/admin-internal-google.d.ts +4 -0
  8. package/dist/admin-internal-google.d.ts.map +1 -0
  9. package/dist/admin-internal-google.js +161 -0
  10. package/dist/admin-internal-google.js.map +1 -0
  11. package/dist/admin-internal-self-hosted.d.ts +30 -0
  12. package/dist/admin-internal-self-hosted.d.ts.map +1 -0
  13. package/dist/admin-internal-self-hosted.js +92 -0
  14. package/dist/admin-internal-self-hosted.js.map +1 -0
  15. package/dist/admin-internal.d.ts +33 -12
  16. package/dist/admin-internal.d.ts.map +1 -1
  17. package/dist/admin-internal.js +27 -159
  18. package/dist/admin-internal.js.map +1 -1
  19. package/dist/agent/command.d.ts +7 -0
  20. package/dist/agent/command.d.ts.map +1 -1
  21. package/dist/agent/command.js +1 -0
  22. package/dist/agent/command.js.map +1 -1
  23. package/dist/agent/lock.d.ts +13 -1
  24. package/dist/agent/lock.d.ts.map +1 -1
  25. package/dist/agent/lock.js +62 -5
  26. package/dist/agent/lock.js.map +1 -1
  27. package/dist/agent/run.d.ts +5 -1
  28. package/dist/agent/run.d.ts.map +1 -1
  29. package/dist/agent/run.js +8 -1
  30. package/dist/agent/run.js.map +1 -1
  31. package/dist/agent-db.js +192 -90
  32. package/dist/caddy.d.ts.map +1 -1
  33. package/dist/caddy.js +11 -1
  34. package/dist/caddy.js.map +1 -1
  35. package/dist/cli-routing.js +1 -1
  36. package/dist/cli-routing.js.map +1 -1
  37. package/dist/cli.js +95 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/compose.d.ts +7 -0
  40. package/dist/compose.d.ts.map +1 -1
  41. package/dist/compose.js +5 -0
  42. package/dist/compose.js.map +1 -1
  43. package/dist/converge-job-gcp.d.ts.map +1 -1
  44. package/dist/converge-job-gcp.js +10 -7
  45. package/dist/converge-job-gcp.js.map +1 -1
  46. package/dist/converge-job-preflight.d.ts +21 -0
  47. package/dist/converge-job-preflight.d.ts.map +1 -0
  48. package/dist/converge-job-preflight.js +111 -0
  49. package/dist/converge-job-preflight.js.map +1 -0
  50. package/dist/converge-job.d.ts +8 -2
  51. package/dist/converge-job.d.ts.map +1 -1
  52. package/dist/converge-job.js +14 -0
  53. package/dist/converge-job.js.map +1 -1
  54. package/dist/gateway-credentials.d.ts +5 -3
  55. package/dist/gateway-credentials.d.ts.map +1 -1
  56. package/dist/gateway-credentials.js +5 -8
  57. package/dist/gateway-credentials.js.map +1 -1
  58. package/dist/gcloud.d.ts +29 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +66 -0
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/infra-contract.d.ts +1 -1
  63. package/dist/infra-contract.d.ts.map +1 -1
  64. package/dist/infra-contract.js +10 -1
  65. package/dist/infra-contract.js.map +1 -1
  66. package/dist/install-job.d.ts +3 -1
  67. package/dist/install-job.d.ts.map +1 -1
  68. package/dist/install-job.js.map +1 -1
  69. package/dist/install-state.d.ts +10 -0
  70. package/dist/install-state.d.ts.map +1 -1
  71. package/dist/install-state.js +12 -0
  72. package/dist/install-state.js.map +1 -1
  73. package/dist/lifecycle.d.ts +12 -0
  74. package/dist/lifecycle.d.ts.map +1 -1
  75. package/dist/lifecycle.js +113 -28
  76. package/dist/lifecycle.js.map +1 -1
  77. package/dist/mcp-iap.d.ts +2 -0
  78. package/dist/mcp-iap.d.ts.map +1 -1
  79. package/dist/mcp-iap.js +50 -17
  80. package/dist/mcp-iap.js.map +1 -1
  81. package/dist/owner-reset.d.ts +16 -19
  82. package/dist/owner-reset.d.ts.map +1 -1
  83. package/dist/owner-reset.js +92 -19
  84. package/dist/owner-reset.js.map +1 -1
  85. package/dist/preview-plan.d.ts +71 -0
  86. package/dist/preview-plan.d.ts.map +1 -0
  87. package/dist/preview-plan.js +168 -0
  88. package/dist/preview-plan.js.map +1 -0
  89. package/dist/public-role-isolation.d.ts +2 -0
  90. package/dist/public-role-isolation.d.ts.map +1 -1
  91. package/dist/public-role-isolation.js +9 -3
  92. package/dist/public-role-isolation.js.map +1 -1
  93. package/dist/release-contract.d.ts.map +1 -1
  94. package/dist/release-contract.js +4 -0
  95. package/dist/release-contract.js.map +1 -1
  96. package/dist/self-hosted-internal-secret.d.ts +27 -0
  97. package/dist/self-hosted-internal-secret.d.ts.map +1 -0
  98. package/dist/self-hosted-internal-secret.js +69 -0
  99. package/dist/self-hosted-internal-secret.js.map +1 -0
  100. package/dist/self-hosted-release.d.ts +3 -1
  101. package/dist/self-hosted-release.d.ts.map +1 -1
  102. package/dist/self-hosted-release.js +6 -2
  103. package/dist/self-hosted-release.js.map +1 -1
  104. package/dist/self-hosted-upgrade.d.ts +13 -1
  105. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  106. package/dist/self-hosted-upgrade.js +26 -2
  107. package/dist/self-hosted-upgrade.js.map +1 -1
  108. package/dist/stack-converge.d.ts +5 -2
  109. package/dist/stack-converge.d.ts.map +1 -1
  110. package/dist/stack-converge.js +73 -8
  111. package/dist/stack-converge.js.map +1 -1
  112. package/dist/staging-admin.d.ts +4 -1
  113. package/dist/staging-admin.d.ts.map +1 -1
  114. package/dist/staging-admin.js.map +1 -1
  115. package/dist/state-backend.d.ts +3 -2
  116. package/dist/state-backend.d.ts.map +1 -1
  117. package/dist/state-backend.js +3 -2
  118. package/dist/state-backend.js.map +1 -1
  119. package/dist/state-command.d.ts +17 -0
  120. package/dist/state-command.d.ts.map +1 -1
  121. package/dist/state-command.js +34 -5
  122. package/dist/state-command.js.map +1 -1
  123. package/dist/state-migrate.d.ts +14 -3
  124. package/dist/state-migrate.d.ts.map +1 -1
  125. package/dist/state-migrate.js +31 -7
  126. package/dist/state-migrate.js.map +1 -1
  127. package/dist/state-pulumi.d.ts +0 -2
  128. package/dist/state-pulumi.d.ts.map +1 -1
  129. package/dist/state-pulumi.js +19 -20
  130. package/dist/state-pulumi.js.map +1 -1
  131. package/dist/static-security-headers.d.ts +86 -0
  132. package/dist/static-security-headers.d.ts.map +1 -0
  133. package/dist/static-security-headers.js +126 -0
  134. package/dist/static-security-headers.js.map +1 -0
  135. package/dist/static-svg-gcp.d.ts +28 -0
  136. package/dist/static-svg-gcp.d.ts.map +1 -0
  137. package/dist/static-svg-gcp.js +106 -0
  138. package/dist/static-svg-gcp.js.map +1 -0
  139. package/dist/upgrade/app-roll.d.ts +3 -0
  140. package/dist/upgrade/app-roll.d.ts.map +1 -1
  141. package/dist/upgrade/app-roll.js +12 -5
  142. package/dist/upgrade/app-roll.js.map +1 -1
  143. package/dist/upgrade/backup.d.ts +2 -0
  144. package/dist/upgrade/backup.d.ts.map +1 -1
  145. package/dist/upgrade/backup.js +5 -4
  146. package/dist/upgrade/backup.js.map +1 -1
  147. package/dist/upgrade/converge.d.ts +7 -0
  148. package/dist/upgrade/converge.d.ts.map +1 -1
  149. package/dist/upgrade/converge.js +19 -7
  150. package/dist/upgrade/converge.js.map +1 -1
  151. package/dist/upgrade/gcp-lookup.d.ts +38 -3
  152. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  153. package/dist/upgrade/gcp-lookup.js +31 -9
  154. package/dist/upgrade/gcp-lookup.js.map +1 -1
  155. package/dist/upgrade/health.d.ts +31 -5
  156. package/dist/upgrade/health.d.ts.map +1 -1
  157. package/dist/upgrade/health.js +114 -25
  158. package/dist/upgrade/health.js.map +1 -1
  159. package/dist/upgrade/release-images.d.ts +17 -0
  160. package/dist/upgrade/release-images.d.ts.map +1 -1
  161. package/dist/upgrade/release-images.js +23 -12
  162. package/dist/upgrade/release-images.js.map +1 -1
  163. package/dist/upgrade/reporter.d.ts +2 -0
  164. package/dist/upgrade/reporter.d.ts.map +1 -1
  165. package/dist/upgrade/reporter.js +1 -0
  166. package/dist/upgrade/reporter.js.map +1 -1
  167. package/dist/upgrade/upgrade.d.ts +11 -1
  168. package/dist/upgrade/upgrade.d.ts.map +1 -1
  169. package/dist/upgrade/upgrade.js +5 -2
  170. package/dist/upgrade/upgrade.js.map +1 -1
  171. package/dist/upgrader-gcp.d.ts +12 -2
  172. package/dist/upgrader-gcp.d.ts.map +1 -1
  173. package/dist/upgrader-gcp.js +22 -13
  174. package/dist/upgrader-gcp.js.map +1 -1
  175. package/dist/upgrader-iam-preflight.d.ts +16 -3
  176. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  177. package/dist/upgrader-iam-preflight.js +9 -4
  178. package/dist/upgrader-iam-preflight.js.map +1 -1
  179. package/dist/upgrader-job.d.ts.map +1 -1
  180. package/dist/upgrader-job.js +10 -8
  181. package/dist/upgrader-job.js.map +1 -1
  182. package/dist/upgrader-reporter.d.ts.map +1 -1
  183. package/dist/upgrader-reporter.js +1 -0
  184. package/dist/upgrader-reporter.js.map +1 -1
  185. package/dist/wizards/gcp-pulumi.d.ts +70 -23
  186. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  187. package/dist/wizards/gcp-pulumi.js +118 -39
  188. package/dist/wizards/gcp-pulumi.js.map +1 -1
  189. package/dist/wizards/gcp.d.ts.map +1 -1
  190. package/dist/wizards/gcp.js +67 -11
  191. package/dist/wizards/gcp.js.map +1 -1
  192. package/package.json +2 -2
  193. package/src/admin-internal-answer.ts +43 -0
  194. package/src/admin-internal-google.ts +187 -0
  195. package/src/admin-internal-self-hosted.test.ts +70 -0
  196. package/src/admin-internal-self-hosted.ts +128 -0
  197. package/src/admin-internal.ts +59 -174
  198. package/src/admin-memory.test.ts +1 -0
  199. package/src/agent/command.ts +3 -0
  200. package/src/agent/lock-config.test.ts +57 -2
  201. package/src/agent/lock.ts +68 -5
  202. package/src/agent/run.test.ts +18 -1
  203. package/src/agent/run.ts +17 -6
  204. package/src/caddy-redirects.test.ts +1 -0
  205. package/src/caddy.ts +12 -0
  206. package/src/cli-routing.ts +1 -1
  207. package/src/cli.ts +102 -44
  208. package/src/compose.ts +12 -0
  209. package/src/converge-job-gcp.ts +16 -11
  210. package/src/converge-job-preflight.test.ts +108 -0
  211. package/src/converge-job-preflight.ts +124 -0
  212. package/src/converge-job.test.ts +50 -0
  213. package/src/converge-job.ts +38 -2
  214. package/src/gateway-credentials.test.ts +4 -3
  215. package/src/gateway-credentials.ts +6 -10
  216. package/src/gateway-env-isolation.test.ts +1 -0
  217. package/src/gcloud.test.ts +93 -1
  218. package/src/gcloud.ts +82 -0
  219. package/src/index.test.ts +38 -1
  220. package/src/infra-contract.test.ts +4 -3
  221. package/src/infra-contract.ts +10 -1
  222. package/src/install-job.ts +5 -2
  223. package/src/install-state.ts +13 -0
  224. package/src/lifecycle.ts +134 -28
  225. package/src/mcp-iap.test.ts +66 -5
  226. package/src/mcp-iap.ts +55 -17
  227. package/src/owner-reset-providers.test.ts +370 -0
  228. package/src/owner-reset.test.ts +2 -2
  229. package/src/owner-reset.ts +108 -19
  230. package/src/preview-plan.test.ts +233 -0
  231. package/src/preview-plan.ts +226 -0
  232. package/src/public-role-isolation.test.ts +13 -2
  233. package/src/public-role-isolation.ts +14 -4
  234. package/src/release-contract.ts +4 -0
  235. package/src/self-hosted-compose.test.ts +25 -0
  236. package/src/self-hosted-internal-secret.test.ts +61 -0
  237. package/src/self-hosted-internal-secret.ts +80 -0
  238. package/src/self-hosted-release.ts +10 -3
  239. package/src/self-hosted-upgrade.test.ts +35 -1
  240. package/src/self-hosted-upgrade.ts +40 -3
  241. package/src/stack-contract.test.ts +1 -0
  242. package/src/stack-converge.test.ts +89 -5
  243. package/src/stack-converge.ts +81 -8
  244. package/src/staging-admin.test.ts +1 -1
  245. package/src/staging-admin.ts +2 -1
  246. package/src/state-backend.ts +3 -2
  247. package/src/state-command.test.ts +31 -0
  248. package/src/state-command.ts +35 -8
  249. package/src/state-migrate.test.ts +58 -7
  250. package/src/state-migrate.ts +48 -10
  251. package/src/state-pulumi.ts +20 -21
  252. package/src/static-security-headers.test.ts +124 -0
  253. package/src/static-security-headers.ts +154 -0
  254. package/src/static-svg-gcp.test.ts +133 -0
  255. package/src/static-svg-gcp.ts +140 -0
  256. package/src/upgrade/app-roll.ts +18 -7
  257. package/src/upgrade/backup.ts +14 -5
  258. package/src/upgrade/converge.ts +25 -7
  259. package/src/upgrade/gcp-lookup.test.ts +80 -1
  260. package/src/upgrade/gcp-lookup.ts +61 -11
  261. package/src/upgrade/health.test.ts +132 -8
  262. package/src/upgrade/health.ts +169 -31
  263. package/src/upgrade/release-images.test.ts +17 -0
  264. package/src/upgrade/release-images.ts +26 -14
  265. package/src/upgrade/reporter.ts +3 -0
  266. package/src/upgrade/upgrade.test.ts +137 -8
  267. package/src/upgrade/upgrade.ts +14 -2
  268. package/src/upgrader-gcp.ts +28 -13
  269. package/src/upgrader-iam-preflight.test.ts +6 -3
  270. package/src/upgrader-iam-preflight.ts +26 -5
  271. package/src/upgrader-job.ts +14 -6
  272. package/src/upgrader-reporter.ts +1 -0
  273. package/src/wizards/gcp-pulumi.test.ts +1 -1
  274. package/src/wizards/gcp-pulumi.ts +147 -38
  275. package/src/wizards/gcp.ts +89 -9
  276. package/src/wizards/stack-work-dir.test.ts +175 -0
  277. package/stacks/aws/index.ts +40 -0
  278. package/stacks/azure/index.ts +22 -1
  279. package/stacks/gcp/README.md +4 -0
  280. package/stacks/gcp/index.ts +6 -2
  281. package/stacks/gcp/Pulumi.production.yaml +0 -16
@@ -24,9 +24,18 @@
24
24
  * that account). The gateway is public by design and gets no credential.
25
25
  *
26
26
  * Before anything changes, {@link HealthProbe.baseline} proves the probe can
27
- * reach both services at all. Otherwise a DNS, certificate or IAP problem
28
- * would only show after migrations, as a "failed" health check that rolls a
29
- * healthy release back.
27
+ * reach both services at all — each answering with its own JSON, so a
28
+ * Google front-end 404 for a gateway whose ingress refuses the request
29
+ * counts as unreachable (#733). Otherwise a DNS, certificate, ingress or IAP
30
+ * problem would only show after migrations, as a "failed" health check that
31
+ * rolls a healthy release back.
32
+ *
33
+ * IAM is eventually consistent (Google: typically under 2 minutes, up to
34
+ * 7). When the converge step that ran just before changed the access the
35
+ * probe relies on — the operator's right to sign as `caelo-mcp@`, that
36
+ * account's IAP access, the gateway's ingress — the baseline keeps retrying
37
+ * a refusal for {@link IAM_PROPAGATION_WINDOW_MS} instead of failing the
38
+ * upgrade on a grant that has not taken effect yet (#734).
30
39
  */
31
40
 
32
41
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
@@ -38,8 +47,13 @@ export type HealthOutcome =
38
47
  | { readonly ok: false; readonly error: string };
39
48
 
40
49
  export interface HealthProbe {
41
- /** Reach both services as they serve now (before any change). */
42
- baseline(): Promise<HealthOutcome>;
50
+ /**
51
+ * Reach both services as they serve now (before any change).
52
+ *
53
+ * @param onWait told why the baseline waits, each time it waits for
54
+ * access the converge step just granted to take effect
55
+ */
56
+ baseline(onWait?: (reason: string) => void): Promise<HealthOutcome>;
43
57
  /**
44
58
  * Deep-check both services after the roll, retrying while the new
45
59
  * revisions warm up. `version` is the release the roll targets (null when
@@ -169,10 +183,23 @@ export function healthOrigins(target: HealthTarget): { admin: string; gateway: s
169
183
  };
170
184
  }
171
185
 
186
+ /**
187
+ * How long the baseline treats a refusal as access still propagating: IAM's
188
+ * documented worst case for a new binding to take effect.
189
+ */
190
+ export const IAM_PROPAGATION_WINDOW_MS = 7 * 60_000;
191
+
172
192
  export interface HealthProbeDeps {
173
193
  readonly run: GcloudRunner;
174
194
  readonly http: HttpFetch;
175
195
  readonly sleep: Sleep;
196
+ /**
197
+ * Whether the converge step that ran just before changed the access the
198
+ * probe relies on (IAM grants, the gateway's ingress). Only then does the
199
+ * baseline wait for a refusal to clear: a grant that existed before this
200
+ * run and still refuses is missing, and waiting would only delay saying so.
201
+ */
202
+ readonly accessJustChanged: boolean;
176
203
  /** Deep-check attempts while the new revisions warm up (default 12). */
177
204
  readonly attempts?: number;
178
205
  /** Pause between attempts in ms (default 5000). */
@@ -181,7 +208,21 @@ export interface HealthProbeDeps {
181
208
  readonly now?: () => number;
182
209
  }
183
210
 
211
+ /**
212
+ * Why the baseline could not reach the install. The refusals other than
213
+ * `other` are what fresh access looks like before it applies: the baseline
214
+ * waits them out after a converge that just changed access.
215
+ */
216
+ type BaselineRefusal = "sign-denied" | "iap-refused" | "gateway-not-caelo" | "other";
217
+
218
+ type BaselineOutcome =
219
+ | { readonly ok: true; readonly detail: string }
220
+ | { readonly ok: false; readonly error: string; readonly refusal: BaselineRefusal };
221
+
184
222
  const JWT_LIFETIME_S = 600;
223
+ /** First pause between baseline attempts while access propagates; doubles up to the cap. */
224
+ const SETTLE_FIRST_DELAY_MS = 5_000;
225
+ const SETTLE_MAX_DELAY_MS = 30_000;
185
226
  const IAM_CREDENTIALS = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts";
186
227
 
187
228
  /** The IAP-backed probe of a gcp / gcp-firebase install. */
@@ -197,24 +238,35 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
197
238
  * account. Never throws: a transport failure is a failed outcome, so the
198
239
  * retries and the app-roll's rollback still run.
199
240
  */
200
- async function iapToken(): Promise<{ ok: true; jwt: string } | { ok: false; error: string }> {
241
+ async function iapToken(): Promise<SignOutcome> {
201
242
  try {
202
243
  return await signIapToken();
203
244
  } catch (e) {
204
245
  return {
205
246
  ok: false,
206
247
  error: `could not sign an IAP credential as ${signer}: ${e instanceof Error ? e.message : String(e)}`,
248
+ denied: false,
207
249
  };
208
250
  }
209
251
  }
210
252
 
211
- async function signIapToken(): Promise<{ ok: true; jwt: string } | { ok: false; error: string }> {
253
+ type SignOutcome =
254
+ | { ok: true; jwt: string }
255
+ | {
256
+ ok: false;
257
+ error: string;
258
+ /** IAM refused the signing (HTTP 403): the token-creator grant is missing or not in effect yet. */
259
+ denied: boolean;
260
+ };
261
+
262
+ async function signIapToken(): Promise<SignOutcome> {
212
263
  const token = await deps.run(["auth", "print-access-token"]);
213
264
  const bearer = token.ok ? token.stdout.trim() : "";
214
265
  if (!bearer) {
215
266
  return {
216
267
  ok: false,
217
268
  error: `gcloud auth print-access-token: ${token.stderr.trim() || "empty"}`,
269
+ denied: false,
218
270
  };
219
271
  }
220
272
  const iat = now();
@@ -232,14 +284,43 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
232
284
  }),
233
285
  });
234
286
  const text = await res.text();
287
+ if (res.status === 403) {
288
+ return {
289
+ ok: false,
290
+ error: `could not sign an IAP credential as ${signer} (HTTP 403): ${text.slice(0, 300)}`,
291
+ denied: true,
292
+ };
293
+ }
235
294
  if (!res.ok) {
236
295
  return {
237
296
  ok: false,
238
- error: `could not sign an IAP credential as ${signer} (HTTP ${res.status}). Your account needs roles/iam.serviceAccountTokenCreator on it — upgrade's converge step grants it to every IAP-allowlisted user. ${text.slice(0, 300)}`,
297
+ error: `could not sign an IAP credential as ${signer} (HTTP ${res.status}): ${text.slice(0, 300)}`,
298
+ denied: false,
239
299
  };
240
300
  }
241
301
  const jwt = parseJson<{ signedJwt?: string }>(text)?.signedJwt;
242
- return jwt ? { ok: true, jwt } : { ok: false, error: "signJwt returned no signedJwt" };
302
+ return jwt
303
+ ? { ok: true, jwt }
304
+ : { ok: false, error: "signJwt returned no signedJwt", denied: false };
305
+ }
306
+
307
+ /**
308
+ * What to do about a refused signJwt, depending on whether this run's
309
+ * converge just granted the access or it was there before.
310
+ */
311
+ function signDeniedHelp(waitedMs: number): string {
312
+ const role = "roles/iam.serviceAccountTokenCreator";
313
+ if (deps.accessJustChanged) {
314
+ return `The upgrade's converge step changed who may sign as ${signer} just now (it grants ${role} to every user on the admin's IAP allowlist), and Google IAM had not applied it after ${Math.round(waitedMs / 60_000)} minutes of waiting. The grant exists; nothing was changed: re-run upgrade in a few minutes.`;
315
+ }
316
+ return `Your account may not sign as ${signer}: it lacks ${role} on it. Upgrade's converge step grants that role to every user on the admin's IAP allowlist, so run upgrade as an allowlisted account (gcloud auth login), or add yours to the allowlist. If an upgrade run in the last few minutes granted it, IAM may still be applying it: re-run upgrade in a few minutes.`;
317
+ }
318
+
319
+ /** Why the gateway's answer is not the gateway's own, and what fixes it. */
320
+ function gatewayRoutingHelp(): string {
321
+ return target.provider === "gcp-firebase"
322
+ ? `Requests to its run.app URL do not reach the Caelo gateway: its Cloud Run ingress likely refuses them. The gcp-firebase stack declares ingress "all"; upgrade's converge step sets it. Check it with: gcloud run services describe <gateway service> --region=<region> --project=${target.projectId} --format='value(metadata.annotations."run.googleapis.com/ingress")'`
323
+ : `Requests to ${origins.gateway}/api/ do not reach the Caelo gateway: the load balancer's URL map must route /api/* to the gateway backend. Check the install's load balancer, then re-run upgrade.`;
243
324
  }
244
325
 
245
326
  async function get(url: string, headers: Record<string, string>) {
@@ -251,30 +332,73 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
251
332
  }
252
333
  }
253
334
 
335
+ async function baselineOnce(): Promise<BaselineOutcome> {
336
+ const token = await iapToken();
337
+ if (!token.ok) {
338
+ return { ok: false, error: token.error, refusal: token.denied ? "sign-denied" : "other" };
339
+ }
340
+ const auth = { Authorization: `Bearer ${token.jwt}` };
341
+ const admin = await get(`${origins.admin}/_caelo/health`, auth);
342
+ if (!admin.reached) {
343
+ return {
344
+ ok: false,
345
+ error: `admin unreachable at ${origins.admin}: ${admin.error}`,
346
+ refusal: "other",
347
+ };
348
+ }
349
+ // The serving (old) admin answers its shallow health JSON. Anything
350
+ // else — IAP's login redirect, a 401/403, an HTML error page — means
351
+ // the probe would not see the new revision either.
352
+ const body = parseJson<{ version?: string }>(admin.text);
353
+ if (!body?.version) {
354
+ return {
355
+ ok: false,
356
+ error: `admin at ${origins.admin} did not answer its health check (HTTP ${admin.status}); IAP may not admit ${signer}`,
357
+ // IAP refuses with 401/403 until a fresh grant to caelo-mcp applies.
358
+ refusal: admin.status === 401 || admin.status === 403 ? "iap-refused" : "other",
359
+ };
360
+ }
361
+ const gateway = await get(`${origins.gateway}/api/_caelo/health`, {});
362
+ if (!gateway.reached) {
363
+ return {
364
+ ok: false,
365
+ error: `gateway unreachable at ${origins.gateway}: ${gateway.error}`,
366
+ refusal: "other",
367
+ };
368
+ }
369
+ // Any JSON counts, whatever the status: a gateway older than its health
370
+ // route answers its own JSON 404. An HTML or empty answer comes from
371
+ // Google's front end, not from Caelo (#733).
372
+ if (parseJson<unknown>(gateway.text) === null) {
373
+ return {
374
+ ok: false,
375
+ error: `gateway at ${origins.gateway} answered HTTP ${gateway.status} without JSON. ${gatewayRoutingHelp()}`,
376
+ // A just-opened ingress takes a moment, like an IAM grant.
377
+ refusal: "gateway-not-caelo",
378
+ };
379
+ }
380
+ return { ok: true, detail: `admin ${body.version} and gateway reachable` };
381
+ }
382
+
254
383
  return {
255
- async baseline() {
256
- const token = await iapToken();
257
- if (!token.ok) return token;
258
- const auth = { Authorization: `Bearer ${token.jwt}` };
259
- const admin = await get(`${origins.admin}/_caelo/health`, auth);
260
- if (!admin.reached) {
261
- return { ok: false, error: `admin unreachable at ${origins.admin}: ${admin.error}` };
384
+ async baseline(onWait) {
385
+ const windowMs = deps.accessJustChanged ? IAM_PROPAGATION_WINDOW_MS : 0;
386
+ let waited = 0;
387
+ let delay = SETTLE_FIRST_DELAY_MS;
388
+ for (;;) {
389
+ const outcome = await baselineOnce();
390
+ if (outcome.ok) return outcome;
391
+ if (outcome.refusal === "other" || waited >= windowMs) {
392
+ return { ok: false, error: explainRefusal(outcome, waited) };
393
+ }
394
+ onWait?.(
395
+ `Waiting for the access the converge step just granted to take effect (${Math.round(waited / 1000)}s of up to ${windowMs / 60_000} min): ${outcome.error.slice(0, 200)}`,
396
+ );
397
+ const pause = Math.min(delay, windowMs - waited);
398
+ await deps.sleep(pause);
399
+ waited += pause;
400
+ delay = Math.min(delay * 2, SETTLE_MAX_DELAY_MS);
262
401
  }
263
- // The serving (old) admin answers its shallow health JSON. Anything
264
- // else — IAP's login redirect, a 401/403, an HTML error page — means
265
- // the probe would not see the new revision either.
266
- const body = parseJson<{ version?: string }>(admin.text);
267
- if (!body?.version) {
268
- return {
269
- ok: false,
270
- error: `admin at ${origins.admin} did not answer its health check (HTTP ${admin.status}); IAP may not admit ${signer}`,
271
- };
272
- }
273
- const gateway = await get(`${origins.gateway}/api/_caelo/health`, {});
274
- if (!gateway.reached) {
275
- return { ok: false, error: `gateway unreachable at ${origins.gateway}: ${gateway.error}` };
276
- }
277
- return { ok: true, detail: `admin ${body.version} and gateway reachable` };
278
402
  },
279
403
 
280
404
  async deep(expect) {
@@ -288,6 +412,20 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
288
412
  },
289
413
  };
290
414
 
415
+ /**
416
+ * The baseline's final error: a refusal that outlived the wait says the
417
+ * access was granted and has not applied yet (re-run later), apart from
418
+ * a refusal with no fresh grant behind it (missing access).
419
+ */
420
+ function explainRefusal(
421
+ outcome: Extract<BaselineOutcome, { ok: false }>,
422
+ waitedMs: number,
423
+ ): string {
424
+ if (outcome.refusal === "sign-denied") return `${outcome.error}\n${signDeniedHelp(waitedMs)}`;
425
+ if (outcome.refusal === "other" || !deps.accessJustChanged) return outcome.error;
426
+ return `${outcome.error}\nThe upgrade's converge step changed this access just now and it had not applied after ${Math.round(waitedMs / 60_000)} minutes of waiting. Nothing was changed: re-run upgrade in a few minutes.`;
427
+ }
428
+
291
429
  async function deepOnce(expectedVersion: string | null): Promise<HealthOutcome> {
292
430
  const token = await iapToken();
293
431
  if (!token.ok) return token;
@@ -4,6 +4,7 @@ import { describe, expect, it } from "bun:test";
4
4
  import {
5
5
  canonicalDigest,
6
6
  canonicalReleaseTag,
7
+ cosignInstallHint,
7
8
  cosignVerifyArgv,
8
9
  parseBearerChallenge,
9
10
  RELEASE_SIGNER_IDENTITY_REGEXP,
@@ -117,3 +118,19 @@ describe("canonical values from the network", () => {
117
118
  expect(canonicalReleaseTag("latest")).toBeNull();
118
119
  });
119
120
  });
121
+
122
+ describe("cosignInstallHint (#816)", () => {
123
+ it("never offers --skip-verify to the Owner", () => {
124
+ for (const platform of ["darwin", "linux", "win32"] as const) {
125
+ expect(cosignInstallHint(platform)).not.toContain("skip-verify");
126
+ }
127
+ });
128
+
129
+ it("is not brew-only: Cloud Shell and Linux get a verified path", () => {
130
+ const linux = cosignInstallHint("linux");
131
+ expect(linux).not.toContain("brew");
132
+ expect(linux).toContain("./run.sh upgrade");
133
+ expect(linux).toContain("docs.sigstore.dev");
134
+ expect(cosignInstallHint("darwin")).toContain("brew install cosign");
135
+ });
136
+ });
@@ -300,23 +300,40 @@ function probeBinary(argv: string[]): boolean {
300
300
  }
301
301
 
302
302
  /**
303
- * Ensure cosign is on PATH. If not — and the operator is on macOS with
304
- * brew available — offer to `brew install cosign` interactively. Falls
305
- * back to printing the platform install URL + the --skip-verify
306
- * escape hatch when auto-install isn't possible.
303
+ * How to get cosign onto PATH on `platform`. Owner-facing, so it names
304
+ * only verified installs and never the unverified escape hatch: an Owner
305
+ * who cannot install cosign must not be steered to roll unsigned images
306
+ * (#816). Cloud Shell (Linux) is where the break-glass upgrade runs; its
307
+ * `./run.sh upgrade` installs a checksum-verified cosign itself.
308
+ */
309
+ export function cosignInstallHint(platform: NodeJS.Platform): string {
310
+ const lines = ["Install cosign, then run this command again:"];
311
+ if (platform === "darwin") lines.push(" • brew install cosign");
312
+ lines.push(
313
+ " • Cloud Shell: run the upgrade with `./run.sh upgrade` from https://github.com/caelo-cms/install — it installs a verified cosign first",
314
+ " • Linux / Windows: the signed release binary, https://docs.sigstore.dev/cosign/system_config/installation/",
315
+ );
316
+ return lines.join("\n");
317
+ }
318
+
319
+ /**
320
+ * Ensure cosign is on PATH; the upgrade's pre-flight runs this before it
321
+ * plans or changes anything, so a missing cosign never stops an upgrade
322
+ * half-way (#816). On macOS with brew it offers `brew install cosign`;
323
+ * elsewhere it prints {@link cosignInstallHint}.
307
324
  *
308
- * Returns true if cosign is callable after this function returns.
325
+ * @returns whether cosign is callable afterwards
309
326
  */
310
- async function ensureCosignAvailable(): Promise<boolean> {
327
+ export async function ensureCosignAvailable(): Promise<boolean> {
311
328
  if (probeBinary(["cosign", "version"])) return true;
312
329
 
313
- log.error(red("cosign not found on PATH — required for image-signature verification."));
330
+ log.error(red("cosign not found on PATH — required to verify the release images' signatures."));
314
331
 
315
332
  // macOS auto-install offer. Only when `brew` is itself on PATH —
316
333
  // otherwise the operator is on a stripped-down install where
317
334
  // homebrew was never set up, and the manual instructions are more
318
335
  // useful than a failing brew shell-out.
319
- if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
336
+ if (process.platform === "darwin" && process.stdin.isTTY && probeBinary(["brew", "--version"])) {
320
337
  const proceed = await confirm({
321
338
  message: "Install cosign via brew now? (one-shot: brew install cosign)",
322
339
  initialValue: true,
@@ -338,12 +355,7 @@ async function ensureCosignAvailable(): Promise<boolean> {
338
355
  }
339
356
  }
340
357
 
341
- log.warn(
342
- "Install cosign:\n" +
343
- " • brew install cosign (macOS)\n" +
344
- " • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
345
- "Or pass --skip-verify to roll without signature checks (NOT recommended for production).",
346
- );
358
+ log.warn(cosignInstallHint(process.platform));
347
359
  return false;
348
360
  }
349
361
 
@@ -16,6 +16,8 @@ export interface ReportStep {
16
16
  failed(message: string): void;
17
17
  /** Ended without success, but the upgrade continues. */
18
18
  warned(message: string): void;
19
+ /** Still running: say what it is waiting for now. */
20
+ progress(message: string): void;
19
21
  }
20
22
 
21
23
  export interface UpgradeReporter {
@@ -36,6 +38,7 @@ export function clackReporter(): UpgradeReporter {
36
38
  done: (m) => s.stop(green(m)),
37
39
  failed: (m) => s.stop(red(m)),
38
40
  warned: (m) => s.stop(yellow(m)),
41
+ progress: (m) => s.message(m),
39
42
  };
40
43
  },
41
44
  info: (m) => log.info(m),
@@ -21,7 +21,7 @@ import {
21
21
  } from "./app-roll.js";
22
22
  import { restoreCommand, takeSqlBackup } from "./backup.js";
23
23
  import { needsConverge, startConverge } from "./converge.js";
24
- import type { HealthOutcome, HealthProbe } from "./health.js";
24
+ import { createIapHealthProbe, type HealthOutcome, type HealthProbe } from "./health.js";
25
25
  import type { UpgradeReporter } from "./reporter.js";
26
26
  import {
27
27
  type GcpUpgradeDeps,
@@ -48,9 +48,13 @@ const secretRef = (name: string, secret: string) => ({
48
48
  valueFrom: { secretKeyRef: { name: `caelo-production-${secret}`, key: "latest" } },
49
49
  });
50
50
 
51
- /** A described service, as `gcloud run services describe --format=json` returns it. */
52
- const serviceJson = (image: string, env: unknown[]) =>
51
+ /**
52
+ * A described service, as `gcloud run services describe --format=json`
53
+ * returns it. `ingress`: the service-level setting (#732).
54
+ */
55
+ const serviceJson = (image: string, env: unknown[], ingress = "all") =>
53
56
  JSON.stringify({
57
+ metadata: { annotations: { "run.googleapis.com/ingress": ingress } },
54
58
  spec: {
55
59
  template: {
56
60
  metadata: {
@@ -150,6 +154,7 @@ function recordingReporter() {
150
154
  done: (m) => lines.push(`done: ${m}`),
151
155
  failed: (m) => lines.push(`failed: ${m}`),
152
156
  warned: (m) => lines.push(`warned: ${m}`),
157
+ progress: (m) => lines.push(`progress: ${m}`),
153
158
  };
154
159
  },
155
160
  info: (m) => lines.push(`info: ${m}`),
@@ -188,6 +193,7 @@ const TARGET: GcpUpgradeTarget = {
188
193
  infraContractVersion: 1,
189
194
  skipVerify: false,
190
195
  skipHealthCheck: false,
196
+ credentials: "operator",
191
197
  };
192
198
 
193
199
  /** The same install on the `gcp` provider (load balancer + URL map). */
@@ -788,6 +794,88 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
788
794
  expect(calls.some((c) => APP_ROLL_WRITE.test(c))).toBe(false);
789
795
  });
790
796
 
797
+ it("#732 — opens a load-balancer-only gcp-firebase gateway in converge, before the baseline and migrations", async () => {
798
+ const events: string[] = [];
799
+ const internalGateway = serviceJson(
800
+ "registry/gateway@sha256:old",
801
+ JSON.parse(GATEWAY_JSON).spec.template.spec.containers[0].env,
802
+ "internal-and-cloud-load-balancing",
803
+ );
804
+ const { deps, lines } = upgradeDeps(events, {
805
+ overrides: [[new RegExp(`describe ${GATEWAY} .*--format=json`), ok(internalGateway)]],
806
+ });
807
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
808
+ const ingress = indexOf(
809
+ events,
810
+ new RegExp(`^gcloud run services update ${GATEWAY} .*--ingress=all`),
811
+ );
812
+ expect(ingress).toBeLessThan(indexOf(events, /^health baseline/));
813
+ expect(ingress).toBeLessThan(indexOf(events, /^migrate /));
814
+ // Its own update: the image roll does not carry it (no new revision).
815
+ expect(events.filter((e) => e.includes("--ingress=all"))).toHaveLength(1);
816
+ expect(lines).toContain(
817
+ `info: applied: ingress internal-and-cloud-load-balancing → all on ${GATEWAY} (the gcp-firebase stack's gateway ingress (#650); the health check, the staging domain and remote MCP reach its run.app URL)`,
818
+ );
819
+ });
820
+
821
+ it("#732 — leaves a gateway that is already open alone, and never touches the gcp gateway's ingress", async () => {
822
+ for (const target of [TARGET, GCP_TARGET]) {
823
+ const events: string[] = [];
824
+ const { deps } = upgradeDeps(events);
825
+ await upgradeGcpInstall(target, deps);
826
+ expect(events.some((e) => e.includes("--ingress"))).toBe(false);
827
+ }
828
+ });
829
+
830
+ it("#734 — tells the health probe when converge just changed access, so its baseline waits", async () => {
831
+ const seen: boolean[] = [];
832
+ const events: string[] = [];
833
+ const { deps } = upgradeDeps(events);
834
+ await upgradeGcpInstall(TARGET, {
835
+ ...deps,
836
+ healthProbe: async (_plan, context) => {
837
+ seen.push(context.accessJustChanged);
838
+ return { ok: true, probe: fakeHealth(events) };
839
+ },
840
+ });
841
+ // The fake install's empty IAM policies make converge add every binding.
842
+ expect(seen).toEqual([true]);
843
+ });
844
+
845
+ it("#733 — a gateway answering Google's HTML 404 stops the upgrade before migrations and any roll", async () => {
846
+ const events: string[] = [];
847
+ const { deps, calls, lines } = upgradeDeps(events);
848
+ const htmlGateway: HttpFetch = async (url) => {
849
+ const [status, text] = url.includes(":signJwt")
850
+ ? [200, JSON.stringify({ signedJwt: "signed.jwt" })]
851
+ : url.includes("admin")
852
+ ? [200, JSON.stringify({ ok: true, version: "0.10.20" })]
853
+ : [404, "<html><title>Error 404 (Not Found)!!1</title></html>"];
854
+ return { ok: status < 400, status, text: async () => text };
855
+ };
856
+ expect(
857
+ await upgradeGcpInstall(TARGET, {
858
+ ...deps,
859
+ healthProbe: async () => ({
860
+ ok: true,
861
+ probe: createIapHealthProbe(
862
+ {
863
+ provider: "gcp-firebase",
864
+ projectId: "acme",
865
+ domain: "acme.com",
866
+ adminServiceUrl: `https://${ADMIN}-xyz.a.run.app`,
867
+ gatewayServiceUrl: `https://${GATEWAY}-xyz.a.run.app`,
868
+ },
869
+ { run: deps.run, http: htmlGateway, sleep: noSleep, accessJustChanged: false },
870
+ ),
871
+ }),
872
+ }),
873
+ ).toBe(false);
874
+ expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
875
+ expect(calls.some((c) => APP_ROLL_WRITE.test(c))).toBe(false);
876
+ expect(lines.some((l) => l.includes("answered HTTP 404 without JSON"))).toBe(true);
877
+ });
878
+
791
879
  it("--skip-health-check rolls without a probe", async () => {
792
880
  const events: string[] = [];
793
881
  const { deps } = upgradeDeps(events);
@@ -814,6 +902,7 @@ const INSTALL: AppRollInstall = {
814
902
  env: "production",
815
903
  domain: "acme.com",
816
904
  stagingHost: "staging.acme.com",
905
+ credentials: "operator",
817
906
  };
818
907
 
819
908
  async function plannedRoll(run: GcloudRunner): Promise<AppRollPlan> {
@@ -942,7 +1031,7 @@ describe("takeSqlBackup", () => {
942
1031
  const { run, calls } = fakeInstall(events);
943
1032
  const r = await takeSqlBackup(
944
1033
  run,
945
- { projectId: "acme", env: "production" },
1034
+ { projectId: "acme", env: "production", credentials: "operator" },
946
1035
  "backup",
947
1036
  () => new Date("2026-10-13T10:00:00Z"),
948
1037
  );
@@ -959,7 +1048,11 @@ describe("takeSqlBackup", () => {
959
1048
 
960
1049
  it("fails loudly when the backup's id can't be found", async () => {
961
1050
  const { run } = fakeInstall([], [[/^sql backups list/, ok("")]]);
962
- const r = await takeSqlBackup(run, { projectId: "acme", env: "production" }, "backup");
1051
+ const r = await takeSqlBackup(
1052
+ run,
1053
+ { projectId: "acme", env: "production", credentials: "operator" },
1054
+ "backup",
1055
+ );
963
1056
  expect(r.ok).toBe(false);
964
1057
  if (!r.ok)
965
1058
  expect(r.error).toContain("gcloud sql backups list --instance=caelo-production-pg-abc");
@@ -967,11 +1060,47 @@ describe("takeSqlBackup", () => {
967
1060
 
968
1061
  it("fails when the install has no Cloud SQL instance", async () => {
969
1062
  const { run } = fakeInstall([], [[/^sql instances list/, ok("")]]);
970
- const r = await takeSqlBackup(run, { projectId: "acme", env: "production" }, "backup");
1063
+ const r = await takeSqlBackup(
1064
+ run,
1065
+ { projectId: "acme", env: "production", credentials: "operator" },
1066
+ "backup",
1067
+ );
971
1068
  expect(r).toEqual({
972
1069
  ok: false,
973
- error:
974
- "no caelo-production-pg* Cloud SQL instance in project acme (or listing it was denied)",
1070
+ error: "Could not find the caelo-production-pg* Cloud SQL instance in project acme",
975
1071
  });
976
1072
  });
1073
+
1074
+ it("reports expired credentials instead of a missing instance (#630)", async () => {
1075
+ const { run } = fakeInstall(
1076
+ [],
1077
+ [
1078
+ [
1079
+ /^sql instances list/,
1080
+ fail(
1081
+ "ERROR: (gcloud.sql.instances.list) Reauthentication failed. cannot prompt during non-interactive execution.",
1082
+ ),
1083
+ ],
1084
+ ],
1085
+ );
1086
+ const operator = await takeSqlBackup(
1087
+ run,
1088
+ { projectId: "acme", env: "production", credentials: "operator" },
1089
+ "backup",
1090
+ );
1091
+ expect(operator.ok).toBe(false);
1092
+ if (operator.ok) return;
1093
+ expect(operator.error).toContain("gcloud credentials expired");
1094
+ expect(operator.error).toContain("gcloud auth login");
1095
+ expect(operator.error).not.toContain("Could not find");
1096
+ const job = await takeSqlBackup(
1097
+ run,
1098
+ { projectId: "acme", env: "production", credentials: "job" },
1099
+ "backup",
1100
+ );
1101
+ expect(job.ok).toBe(false);
1102
+ if (job.ok) return;
1103
+ expect(job.error).toContain("rejected this job's credentials");
1104
+ expect(job.error).not.toContain("gcloud auth login");
1105
+ });
977
1106
  });
@@ -40,6 +40,7 @@ import {
40
40
  } from "./app-roll.js";
41
41
  import { backupFailureHelp, restoreCommand, type SqlBackup, takeSqlBackup } from "./backup.js";
42
42
  import { startConverge } from "./converge.js";
43
+ import type { GcloudCredentials } from "./gcp-lookup.js";
43
44
  import type { HealthProbe } from "./health.js";
44
45
  import type { UpgradeReporter } from "./reporter.js";
45
46
 
@@ -63,6 +64,8 @@ export interface GcpUpgradeTarget {
63
64
  * release manifest (the converge job knows it; the terminal does not).
64
65
  */
65
66
  readonly migrationsContract?: boolean | null;
67
+ /** Whose gcloud credentials run the sequence: the Owner's, or the converge job's (#630). */
68
+ readonly credentials: GcloudCredentials;
66
69
  }
67
70
 
68
71
  /**
@@ -91,8 +94,14 @@ export interface GcpUpgradeDeps {
91
94
  readonly migrate: (
92
95
  imageRef: string,
93
96
  ) => Promise<{ readonly ok: boolean; readonly error?: string }>;
97
+ /**
98
+ * The health probe for `plan`'s install. `accessJustChanged`: the
99
+ * converge before it changed access the probe relies on, so its baseline
100
+ * waits for that to take effect (health.ts, #734).
101
+ */
94
102
  readonly healthProbe: (
95
103
  plan: AppRollPlan,
104
+ context: { readonly accessJustChanged: boolean },
96
105
  ) => Promise<
97
106
  | { readonly ok: true; readonly probe: HealthProbe }
98
107
  | { readonly ok: false; readonly error: string }
@@ -164,6 +173,7 @@ export async function upgradeGcpInstallOutcome(
164
173
  env: target.env,
165
174
  domain: target.domain,
166
175
  stagingHost: target.stagingHost,
176
+ credentials: target.credentials,
167
177
  },
168
178
  images.images,
169
179
  run,
@@ -198,7 +208,7 @@ export async function upgradeGcpInstallOutcome(
198
208
  const sBak = r.step("Taking a pre-upgrade Cloud SQL backup...");
199
209
  const taken = await takeSqlBackup(
200
210
  run,
201
- { projectId, env: target.env },
211
+ { projectId, env: target.env, credentials: target.credentials },
202
212
  `pre-upgrade to ${target.targetTag}`,
203
213
  deps.now,
204
214
  );
@@ -229,7 +239,9 @@ export async function upgradeGcpInstallOutcome(
229
239
  // 5. App-roll, with the converge steps that sit between its own.
230
240
  let health: HealthProbe | null = null;
231
241
  if (!target.skipHealthCheck) {
232
- const probe = await deps.healthProbe(plan);
242
+ const probe = await deps.healthProbe(plan, {
243
+ accessJustChanged: converge.accessJustChanged(),
244
+ });
233
245
  if (!probe.ok) {
234
246
  r.error(probe.error);
235
247
  r.warn("No migrations ran and no traffic moved.");