@caelo-cms/provisioning 0.11.11 → 0.11.12

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 (42) hide show
  1. package/dist/agent-db.js +171 -15
  2. package/dist/converge-job-gcp.d.ts +7 -0
  3. package/dist/converge-job-gcp.d.ts.map +1 -1
  4. package/dist/converge-job-gcp.js +33 -1
  5. package/dist/converge-job-gcp.js.map +1 -1
  6. package/dist/converge-job-preflight.d.ts +34 -2
  7. package/dist/converge-job-preflight.d.ts.map +1 -1
  8. package/dist/converge-job-preflight.js +68 -1
  9. package/dist/converge-job-preflight.js.map +1 -1
  10. package/dist/converge-job-setup.d.ts.map +1 -1
  11. package/dist/converge-job-setup.js +4 -1
  12. package/dist/converge-job-setup.js.map +1 -1
  13. package/dist/converge-job.d.ts +27 -3
  14. package/dist/converge-job.d.ts.map +1 -1
  15. package/dist/converge-job.js +93 -11
  16. package/dist/converge-job.js.map +1 -1
  17. package/dist/install-job.d.ts +6 -0
  18. package/dist/install-job.d.ts.map +1 -1
  19. package/dist/install-job.js +6 -0
  20. package/dist/install-job.js.map +1 -1
  21. package/dist/mcp-auth-proxy-setup.d.ts +3 -1
  22. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  23. package/dist/mcp-auth-proxy-setup.js +9 -4
  24. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  25. package/dist/upgrader-iam-preflight.d.ts +8 -6
  26. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  27. package/dist/upgrader-iam-preflight.js +5 -1
  28. package/dist/upgrader-iam-preflight.js.map +1 -1
  29. package/dist/wizards/gcp-cost.js +1 -1
  30. package/dist/wizards/gcp-cost.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/converge-job-gcp.ts +33 -1
  33. package/src/converge-job-preflight.test.ts +101 -0
  34. package/src/converge-job-preflight.ts +89 -3
  35. package/src/converge-job-setup.ts +4 -1
  36. package/src/converge-job.test.ts +143 -0
  37. package/src/converge-job.ts +143 -19
  38. package/src/install-job.ts +6 -0
  39. package/src/mcp-auth-proxy-setup.test.ts +33 -1
  40. package/src/mcp-auth-proxy-setup.ts +11 -4
  41. package/src/upgrader-iam-preflight.ts +15 -10
  42. package/src/wizards/gcp-cost.ts +1 -1
@@ -16,7 +16,9 @@
16
16
  * verifies its signature (release-manifest.ts), and decides
17
17
  * (upgrader-assess.ts, `route: "converge"`): no downgrade, nothing
18
18
  * below minFromVersion, no automatic converge unless the policy allows
19
- * it; then it checks its own IAM access (converge-job-preflight.ts).
19
+ * it; then it checks its own IAM access (converge-job-preflight.ts) and
20
+ * stops on anything missing — except a role of the self-grant
21
+ * allowlist, which it leaves to stage 2.
20
22
  * Then it verifies the target's ops image, points this job at it BY DIGEST and starts a new
21
23
  * execution, handing the request over to it. It changes nothing else:
22
24
  * the converge logic always comes from the release it installs.
@@ -25,7 +27,9 @@
25
27
  * version is the request's). It verifies the manifest again, checks
26
28
  * that the image it runs is the one the manifest signs, takes the
27
29
  * install's state lock (#815; the upgrader job and the terminal
28
- * `upgrade` take the same one, so they exclude each other), and runs
30
+ * `upgrade` take the same one, so they exclude each other), grants its
31
+ * own account the allowlisted roles the self-check found missing (each
32
+ * a timeline event; IAM is asked again before it goes on), and runs
29
33
  * the very upgrade the terminal runs (upgrade/upgrade.ts): backup →
30
34
  * converge + app-roll → deep health check, rollback on failure →
31
35
  * install.json + the infrastructure contract → the request's end.
@@ -46,10 +50,16 @@
46
50
  */
47
51
 
48
52
  import type { ConvergeCheckpoint } from "./converge-checkpoint.js";
49
- import { CONVERGE_PREFLIGHT_VOICE, convergeAccessChecks } from "./converge-job-preflight.js";
53
+ import {
54
+ CONVERGE_PREFLIGHT_VOICE,
55
+ convergeAccessChecks,
56
+ INSTALL_ROLE_USES,
57
+ selfGrantPlan,
58
+ } from "./converge-job-preflight.js";
50
59
  import { GCP_STACK_ENV } from "./gcp-names.js";
51
- import { decideInstallJob, type ExecutionState } from "./install-job.js";
60
+ import { decideInstallJob, type ExecutionState, type InstallJobRole } from "./install-job.js";
52
61
  import type { InstallMetadata } from "./install-state.js";
62
+ import { provisionerServiceAccountEmail } from "./provisioner-identity.js";
53
63
  import {
54
64
  compareSemver,
55
65
  type ReleaseManifest,
@@ -60,6 +70,7 @@ import type { UpgradeCheckpoint } from "./upgrade/upgrade.js";
60
70
  import { assessUpgradeRequest } from "./upgrader-assess.js";
61
71
  import {
62
72
  type IamTester,
73
+ missingAccessText,
63
74
  preflightErrorReason,
64
75
  preflightMissingReason,
65
76
  runIamPreflight,
@@ -116,6 +127,14 @@ export type ConvergeUpgradeOutcome =
116
127
  readonly unrecorded: boolean;
117
128
  };
118
129
 
130
+ /**
131
+ * How long stage 2 waits for IAM to confirm the roles it granted itself:
132
+ * Google documents that a policy change usually applies within two minutes
133
+ * and can take seven (cloud.google.com/iam/docs/access-change-propagation).
134
+ */
135
+ export const SELF_GRANT_RECHECK_INTERVAL_MS = 15_000;
136
+ export const SELF_GRANT_RECHECK_ATTEMPTS = 32;
137
+
119
138
  /** How {@link ConvergeJobDeps.secureDatabase} ended. */
120
139
  export type SecureDatabaseResult =
121
140
  | { readonly ok: true; readonly host: string }
@@ -138,6 +157,16 @@ export interface ConvergeJobDeps {
138
157
  readonly region: string;
139
158
  /** Asks IAM as this job's own identity (the IAM self-check, #884). */
140
159
  readonly iam: IamTester;
160
+ /**
161
+ * Grant this job's own account (the install's provisioner) `role` on the
162
+ * project. Stage 2 only, and only for roles of the self-grant allowlist
163
+ * (converge-job-preflight.ts); the real one refuses any other role.
164
+ */
165
+ readonly grantOwnRole: (
166
+ role: InstallJobRole,
167
+ ) => Promise<{ readonly ok: true } | { readonly ok: false; readonly error: string }>;
168
+ /** Waits between the IAM re-checks after a self-grant. */
169
+ readonly sleep: (ms: number) => Promise<void>;
141
170
  /** This Cloud Run execution; null outside Cloud Run. */
142
171
  readonly execution: string | null;
143
172
  /** The Caelo release this code belongs to (CAELO_VERSION of the image). */
@@ -326,18 +355,11 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
326
355
 
327
356
  // #884 — decided to run: ask IAM for what the rest needs before the
328
357
  // lock, the hand-over or the backup (converge-job-preflight.ts). Both
329
- // stages ask; stage 2's list is the target release's own.
330
- const access = await runIamPreflight(
331
- convergeAccessChecks({ projectId: deps.projectId, region: deps.region, env: GCP_STACK_ENV }),
332
- deps.iam,
333
- );
334
- if (access.kind === "missing") {
335
- return stopWith(
336
- "needs_cloud_shell",
337
- ["iam-preflight"],
338
- preflightMissingReason(access.missing, CONVERGE_PREFLIGHT_VOICE),
339
- );
340
- }
358
+ // stages ask; stage 2's list is the target release's own. A missing
359
+ // role of the self-grant allowlist does not stop the run: stage 2
360
+ // grants it once it has verified its own image and holds the lock.
361
+ const stage1 = compareSemver(deps.ownVersion, m.version) !== 0;
362
+ const access = await askIam();
341
363
  if (access.kind === "error") {
342
364
  return stopWith(
343
365
  "failed",
@@ -345,12 +367,36 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
345
367
  preflightErrorReason(access.errors, CONVERGE_PREFLIGHT_VOICE),
346
368
  );
347
369
  }
348
- r.info("The converge job has every permission this update needs.");
370
+ let toGrant: readonly InstallJobRole[] = [];
371
+ if (access.kind === "missing") {
372
+ const plan = selfGrantPlan(access.missing);
373
+ if (plan.blocking.length > 0) {
374
+ return stopWith(
375
+ "needs_cloud_shell",
376
+ ["iam-preflight"],
377
+ preflightMissingReason(access.missing, CONVERGE_PREFLIGHT_VOICE),
378
+ );
379
+ }
380
+ toGrant = plan.grant;
381
+ }
382
+ if (toGrant.length === 0) {
383
+ r.info("The converge job has every permission this update needs.");
384
+ } else {
385
+ r.info(
386
+ `The converge job's account lacks ${toGrant.join(", ")}, which this update needs. ${stage1 ? `The installer of ${m.version} grants them to it` : "It grants them to itself"} before it changes anything, once its own installer image is verified.`,
387
+ );
388
+ }
349
389
 
350
- if (compareSemver(deps.ownVersion, m.version) !== 0) {
390
+ if (stage1) {
351
391
  return await handOver(m);
352
392
  }
353
- return await convergeHere(m, fromVersion ?? (meta.releaseVersion as string), checkpoint, meta);
393
+ return await convergeHere(
394
+ m,
395
+ fromVersion ?? (meta.releaseVersion as string),
396
+ checkpoint,
397
+ meta,
398
+ toGrant,
399
+ );
354
400
  } catch (e) {
355
401
  return stopWith(
356
402
  "failed",
@@ -414,6 +460,7 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
414
460
  fromVersion: string,
415
461
  checkpoint: ConvergeCheckpoint | null,
416
462
  judged: InstallMetadata,
463
+ toGrant: readonly InstallJobRole[],
417
464
  ): Promise<UpgraderRunResult> {
418
465
  const installId = judged.installId;
419
466
  const own = await deps.ownImageDigest();
@@ -453,6 +500,13 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
453
500
  const stale = recordsChangedSince(judged, changed);
454
501
  if (stale) return stopWith(stale.status, stale.codes, stale.reason);
455
502
 
503
+ // After the image check and under the lock: only the target release's
504
+ // verified installer grants, and only roles of its own allowlist.
505
+ if (toGrant.length > 0) {
506
+ const granted = await grantOwnRoles(m, toGrant, changed);
507
+ if (granted) return stopWith(granted.status, granted.codes, granted.reason);
508
+ }
509
+
456
510
  const startedAt = deps.now().toISOString();
457
511
  let current: ConvergeCheckpoint = {
458
512
  statusVersion: 1,
@@ -550,6 +604,76 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
550
604
  );
551
605
  }
552
606
 
607
+ /** The converge self-check against IAM, as this job's identity. */
608
+ function askIam() {
609
+ return runIamPreflight(
610
+ convergeAccessChecks({ projectId: deps.projectId, region: deps.region, env: GCP_STACK_ENV }),
611
+ deps.iam,
612
+ );
613
+ }
614
+
615
+ /**
616
+ * Stage 2: grant this job's account each role in `roles` (the self-check
617
+ * found them missing; all are on the allowlist), then wait until IAM
618
+ * confirms the whole self-check. Null when it does; how the request ends
619
+ * otherwise.
620
+ */
621
+ async function grantOwnRoles(
622
+ m: ReleaseManifest,
623
+ roles: readonly InstallJobRole[],
624
+ changed: boolean,
625
+ ): Promise<{ status: UpdateEndStatus; codes: string[]; reason: string } | null> {
626
+ const account = provisionerServiceAccountEmail(deps.projectId);
627
+ const granted: InstallJobRole[] = [];
628
+ const before = () =>
629
+ changed
630
+ ? "This update already started changing the site; "
631
+ : granted.length > 0
632
+ ? `Nothing on the site was changed (the converge job's account got ${granted.join(", ")}): `
633
+ : "Nothing was changed: ";
634
+ for (const role of roles) {
635
+ const result = await deps.grantOwnRole(role);
636
+ if (!result.ok) {
637
+ return {
638
+ status: "needs_cloud_shell",
639
+ codes: ["iam-self-grant"],
640
+ reason: `${before()}the converge job could not grant its own account (${account}) ${role}, which Caelo ${m.version} needs to ${INSTALL_ROLE_USES[role].need} (${result.error}). ${CONVERGE_PREFLIGHT_VOICE.repair}`,
641
+ };
642
+ }
643
+ granted.push(role);
644
+ r.success(
645
+ `Granted ${role} to the converge job's account (${account}), needed by Caelo ${m.version} to ${INSTALL_ROLE_USES[role].need}.`,
646
+ );
647
+ }
648
+ await r.flush();
649
+ let last = await askIam();
650
+ for (let attempt = 1; last.kind !== "ok" && attempt < SELF_GRANT_RECHECK_ATTEMPTS; attempt++) {
651
+ await deps.sleep(SELF_GRANT_RECHECK_INTERVAL_MS);
652
+ last = await askIam();
653
+ }
654
+ if (last.kind === "ok") {
655
+ r.info(
656
+ "IAM confirms the granted roles; the converge job has every permission this update needs.",
657
+ );
658
+ return null;
659
+ }
660
+ const minutes = Math.round(
661
+ (SELF_GRANT_RECHECK_INTERVAL_MS * (SELF_GRANT_RECHECK_ATTEMPTS - 1)) / 60_000,
662
+ );
663
+ if (last.kind === "error") {
664
+ return {
665
+ status: "failed",
666
+ codes: ["iam-self-grant"],
667
+ reason: `${before()}after granting its own account ${granted.join(", ")}, the converge job could not check its access again (${last.errors.join("; ")}). Start the update again from Settings → Updates in a few minutes.`,
668
+ };
669
+ }
670
+ return {
671
+ status: "needs_cloud_shell",
672
+ codes: ["iam-self-grant"],
673
+ reason: `${before()}the converge job granted its own account ${granted.join(", ")}, but ${minutes} minutes later Google Cloud still reports permissions missing — ${missingAccessText(last.missing)}. ${CONVERGE_PREFLIGHT_VOICE.repair}`,
674
+ };
675
+ }
676
+
553
677
  /**
554
678
  * Whether install.json, re-read under the lock, is still what the run was
555
679
  * judged on; how the request ends otherwise.
@@ -70,6 +70,12 @@ export const OPS_IMAGE_REPOSITORY =
70
70
  * the job's own output has to reach Cloud Logging so the terminal can
71
71
  * tail it. Granted only to installs that use the job. Not an escalation: the
72
72
  * provisioner already holds `resourcemanager.projectIamAdmin`.
73
+ *
74
+ * This list is also the converge job's self-grant allowlist
75
+ * (converge-job-preflight.ts `CONVERGE_SELF_GRANTABLE_ROLES`): a role added
76
+ * here is granted by the converge job of the release that adds it to an
77
+ * install that lacks it, with no terminal run. Never add an ownership or
78
+ * IAM-policy role (the tests refuse `SELF_GRANT_FORBIDDEN_ROLES`).
73
79
  */
74
80
  export const INSTALL_JOB_ROLES = [
75
81
  "roles/iam.roleAdmin",
@@ -190,7 +190,10 @@ describe("ensureReleaseServices", () => {
190
190
  let cleaned = false;
191
191
  const { run, calls } = fakeGcloud({
192
192
  ...accountAndGrants,
193
- "run services describe caelo-production-data-gateway": fail("NOT_FOUND: could not be found"),
193
+ // gcloud's real wording: no NOT_FOUND code (searchVIU, 2026-11-03).
194
+ "run services describe caelo-production-data-gateway": fail(
195
+ "ERROR: (gcloud.run.services.describe) Cannot find service [caelo-production-data-gateway]",
196
+ ),
194
197
  "run services list": ok("caelo-production-gateway-bbb\n"),
195
198
  "run services describe caelo-production-gateway-bbb": ok(JSON.stringify(OLD_GATEWAY)),
196
199
  "run services replace": ok(),
@@ -293,6 +296,35 @@ describe("ensureReleaseServices", () => {
293
296
  expect(calls.some((c) => c.startsWith("run "))).toBe(false);
294
297
  });
295
298
 
299
+ it("retries a grant while the account it just created has not propagated", async () => {
300
+ const { run: base, calls } = fakeGcloud({
301
+ ...accountAndGrants,
302
+ "run services describe caelo-production-mcp-auth-proxy": ok(
303
+ "caelo-production-mcp-auth-proxy\n",
304
+ ),
305
+ });
306
+ let refusals = 2;
307
+ const run: GcloudRunner = async (args, opts) => {
308
+ if (args[0] === "secrets" && refusals > 0) {
309
+ refusals -= 1;
310
+ return fail(
311
+ `ERROR: (gcloud.secrets.add-iam-policy-binding) Status code: 400. Service account ${PROXY_SA} does not exist..`,
312
+ );
313
+ }
314
+ return base(args, opts);
315
+ };
316
+ const pauses: number[] = [];
317
+ const outcomes = await ensureReleaseServices(gcp, {
318
+ run,
319
+ sleep: async (ms) => {
320
+ pauses.push(ms);
321
+ },
322
+ });
323
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
324
+ expect(pauses.length).toBe(2);
325
+ expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toHaveLength(2);
326
+ });
327
+
296
328
  it("stops at once when the account cannot be read", async () => {
297
329
  const { run, calls } = fakeGcloud({
298
330
  [`iam service-accounts describe ${PROXY_SA}`]: fail("PERMISSION_DENIED"),
@@ -31,7 +31,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
31
  import { tmpdir } from "node:os";
32
32
  import { join } from "node:path";
33
33
  import { gcloud as defaultGcloud } from "./gcloud.js";
34
- import type { GcloudRunner } from "./gcloud-retry.js";
34
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
35
35
  import { gcpSecretId, mcpAuthProxyServiceAccountEmail } from "./gcp-names.js";
36
36
  import { type EnsureOutcome, ensureMcpAuthProxyServiceAccount } from "./runtime-secrets.js";
37
37
  import {
@@ -70,9 +70,13 @@ export interface ReleaseServicesDeps {
70
70
  readonly path: string;
71
71
  readonly cleanup: () => void;
72
72
  };
73
+ /** Waits between retries of a grant on the account created a moment ago. */
74
+ readonly sleep?: Sleep;
73
75
  }
74
76
 
75
- const NOT_FOUND = /NOT_FOUND|not found|does not exist|could not be found/i;
77
+ // gcloud words a missing Cloud Run service "Cannot find service [<name>]."
78
+ // with no NOT_FOUND code (searchVIU upgrade to v0.11.10, 2026-11-03).
79
+ const NOT_FOUND = /NOT_FOUND|not found|cannot find|does not exist|could not be found/i;
76
80
 
77
81
  /** Ensure everything {@link ReleaseServicesTarget} needs; outcomes in order, failures included. */
78
82
  export async function ensureReleaseServices(
@@ -84,7 +88,7 @@ export async function ensureReleaseServices(
84
88
  const account = await ensureMcpAuthProxyServiceAccount(target, { run });
85
89
  outcomes.push(account);
86
90
  if (account.status === "failed") return outcomes;
87
- outcomes.push(...(await ensureMcpAuthProxySecretAccess(target, run)));
91
+ outcomes.push(...(await ensureMcpAuthProxySecretAccess(target, run, deps.sleep ?? realSleep)));
88
92
  if (outcomes.some((o) => o.status === "failed")) return outcomes;
89
93
  outcomes.push(
90
94
  target.provider === "gcp-firebase"
@@ -103,12 +107,15 @@ export async function ensureReleaseServices(
103
107
  async function ensureMcpAuthProxySecretAccess(
104
108
  target: Pick<ReleaseServicesTarget, "projectId" | "env">,
105
109
  run: GcloudRunner,
110
+ sleep: Sleep,
106
111
  ): Promise<EnsureOutcome[]> {
107
112
  const member = `serviceAccount:${mcpAuthProxyServiceAccountEmail(target.projectId, target.env)}`;
108
113
  const outcomes: EnsureOutcome[] = [];
109
114
  for (const secret of serviceSecrets("mcp-auth-proxy")) {
110
115
  const id = `secretAccessor → MCP-Auth-Proxy on ${secret}`;
111
- const r = await run([
116
+ // The account was usually created a moment ago, and IAM answers "does
117
+ // not exist" until it has propagated: retried, not failed.
118
+ const r = await runWithRetry(run, sleep, [
112
119
  "secrets",
113
120
  "add-iam-policy-binding",
114
121
  gcpSecretId(target.env, secret),
@@ -92,9 +92,9 @@ export interface AccessCheck {
92
92
  readonly permissions: readonly string[];
93
93
  }
94
94
 
95
- /** A check with the permissions it lacks. */
96
- export interface MissingAccess {
97
- readonly check: AccessCheck;
95
+ /** A check with the permissions it lacks (`C`: the caller's own check type, handed back as given). */
96
+ export interface MissingAccess<C extends AccessCheck = AccessCheck> {
97
+ readonly check: C;
98
98
  readonly missing: readonly string[];
99
99
  /**
100
100
  * Permissions that could not be judged because another one is missing
@@ -114,9 +114,9 @@ export interface IamTester {
114
114
  }
115
115
 
116
116
  /** How the self-check came out. */
117
- export type PreflightOutcome =
117
+ export type PreflightOutcome<C extends AccessCheck = AccessCheck> =
118
118
  | { readonly kind: "ok" }
119
- | { readonly kind: "missing"; readonly missing: readonly MissingAccess[] }
119
+ | { readonly kind: "missing"; readonly missing: readonly MissingAccess<C>[] }
120
120
  /** IAM could not be asked about at least one resource; nothing was judged missing. */
121
121
  | { readonly kind: "error"; readonly errors: readonly string[] };
122
122
 
@@ -250,10 +250,10 @@ export function upgraderAccessChecks(target: {
250
250
  }
251
251
 
252
252
  /** Ask IAM about every check (one call per resource); never throws. */
253
- export async function runIamPreflight(
254
- checks: readonly AccessCheck[],
253
+ export async function runIamPreflight<C extends AccessCheck>(
254
+ checks: readonly C[],
255
255
  tester: IamTester,
256
- ): Promise<PreflightOutcome> {
256
+ ): Promise<PreflightOutcome<C>> {
257
257
  // One question per resource: the project alone carries four checks.
258
258
  const byResource = new Map<string, { resource: IamResource; permissions: Set<string> }>();
259
259
  for (const c of checks) {
@@ -300,7 +300,7 @@ export async function runIamPreflight(
300
300
  }),
301
301
  );
302
302
  if (errors.length > 0) return { kind: "error", errors: errors.sort() };
303
- const missing: MissingAccess[] = [];
303
+ const missing: MissingAccess<C>[] = [];
304
304
  for (const check of checks) {
305
305
  const key = resourceName(check.resource);
306
306
  const held = granted.get(key) ?? new Set<string>();
@@ -343,6 +343,11 @@ export function preflightMissingReason(
343
343
  missing: readonly MissingAccess[],
344
344
  voice: PreflightVoice,
345
345
  ): string {
346
+ return `Nothing was changed: ${voice.subject} checked its access before starting and is missing permissions this update needs — ${missingAccessText(missing)}. ${voice.repair}`;
347
+ }
348
+
349
+ /** Every missing permission with what it is for, as one clause list ("it cannot …; it cannot …"). */
350
+ export function missingAccessText(missing: readonly MissingAccess[]): string {
346
351
  const lines = missing.map((m) => {
347
352
  const where = resourceName(m.check.resource);
348
353
  const parts = [
@@ -355,7 +360,7 @@ export function preflightMissingReason(
355
360
  ];
356
361
  return `it ${m.missing.length > 0 ? "cannot" : "may not be able to"} ${m.check.need} (${parts.join("; ")})`;
357
362
  });
358
- return `Nothing was changed: ${voice.subject} checked its access before starting and is missing permissions this update needs — ${lines.join("; ")}. ${voice.repair}`;
363
+ return lines.join("; ");
359
364
  }
360
365
 
361
366
  /** The request's end reason when IAM could not be asked. */
@@ -192,7 +192,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
192
192
  name: "Quality checks (Cloud Run audit job)",
193
193
  monthlyUsd: 2,
194
194
  notes:
195
- "one container per Lighthouse run, all in parallel (up to 5 pages x 3 runs, ~90 s at 2 vCPU each): ~$0.05 per audited Stage, partly in the free tier; results bucket ~$0",
195
+ "one container per Lighthouse run, all in parallel (up to 5 pages x 3 runs, ~90 s at 2 vCPU each; a page whose runs disagree gets 2 more): ~$0.05 per audited Stage, at most ~$0.08, partly in the free tier; results bucket ~$0",
196
196
  },
197
197
  {
198
198
  name: "Screenshots (Cloud Run capture job)",