@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.
- package/dist/agent-db.js +171 -15
- package/dist/converge-job-gcp.d.ts +7 -0
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +33 -1
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +34 -2
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +68 -1
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job-setup.d.ts.map +1 -1
- package/dist/converge-job-setup.js +4 -1
- package/dist/converge-job-setup.js.map +1 -1
- package/dist/converge-job.d.ts +27 -3
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +93 -11
- package/dist/converge-job.js.map +1 -1
- package/dist/install-job.d.ts +6 -0
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +6 -0
- package/dist/install-job.js.map +1 -1
- package/dist/mcp-auth-proxy-setup.d.ts +3 -1
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
- package/dist/mcp-auth-proxy-setup.js +9 -4
- package/dist/mcp-auth-proxy-setup.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +8 -6
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +5 -1
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/package.json +2 -2
- package/src/converge-job-gcp.ts +33 -1
- package/src/converge-job-preflight.test.ts +101 -0
- package/src/converge-job-preflight.ts +89 -3
- package/src/converge-job-setup.ts +4 -1
- package/src/converge-job.test.ts +143 -0
- package/src/converge-job.ts +143 -19
- package/src/install-job.ts +6 -0
- package/src/mcp-auth-proxy-setup.test.ts +33 -1
- package/src/mcp-auth-proxy-setup.ts +11 -4
- package/src/upgrader-iam-preflight.ts +15 -10
- package/src/wizards/gcp-cost.ts +1 -1
package/src/converge-job.ts
CHANGED
|
@@ -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),
|
|
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 {
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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 (
|
|
390
|
+
if (stage1) {
|
|
351
391
|
return await handOver(m);
|
|
352
392
|
}
|
|
353
|
-
return await convergeHere(
|
|
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.
|
package/src/install-job.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
363
|
+
return lines.join("; ");
|
|
359
364
|
}
|
|
360
365
|
|
|
361
366
|
/** The request's end reason when IAM could not be asked. */
|
package/src/wizards/gcp-cost.ts
CHANGED
|
@@ -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)",
|