@caelo-cms/provisioning 0.11.11 → 0.11.13
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/admin-domain.d.ts +24 -0
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +43 -0
- package/dist/admin-domain.js.map +1 -1
- 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/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +5 -1
- package/dist/infra-contract.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/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +14 -4
- package/dist/lifecycle.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/scheduler-setup.d.ts +5 -0
- package/dist/scheduler-setup.d.ts.map +1 -1
- package/dist/scheduler-setup.js +7 -0
- package/dist/scheduler-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/admin-domain.test.ts +65 -0
- package/src/admin-domain.ts +53 -0
- 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/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +5 -1
- package/src/install-job.ts +6 -0
- package/src/lifecycle.ts +16 -6
- package/src/mcp-auth-proxy-setup.test.ts +33 -1
- package/src/mcp-auth-proxy-setup.ts +11 -4
- package/src/scheduler-setup.test.ts +6 -0
- package/src/scheduler-setup.ts +7 -0
- package/src/upgrader-iam-preflight.ts +15 -10
- package/src/wizards/gcp-cost.ts +1 -1
package/src/converge-job.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ConvergeQueue,
|
|
20
20
|
type ConvergeUpgradeOutcome,
|
|
21
21
|
runConvergeJob,
|
|
22
|
+
SELF_GRANT_RECHECK_ATTEMPTS,
|
|
22
23
|
type SecureDatabaseResult,
|
|
23
24
|
} from "./converge-job.js";
|
|
24
25
|
import type { ExecutionState } from "./install-job.js";
|
|
@@ -150,6 +151,8 @@ function harness(
|
|
|
150
151
|
iam?: IamTester;
|
|
151
152
|
/** #1373 — the database TLS step's answer; null: the queue dials the DNS name already. */
|
|
152
153
|
secureDatabase?: SecureDatabaseResult | null;
|
|
154
|
+
/** What a self-grant answers; ok by default. */
|
|
155
|
+
grant?: (role: string) => { ok: true } | { ok: false; error: string };
|
|
153
156
|
} = {},
|
|
154
157
|
): Harness {
|
|
155
158
|
const calls: string[] = [];
|
|
@@ -171,6 +174,13 @@ function harness(
|
|
|
171
174
|
iam: opts.iam ?? {
|
|
172
175
|
granted: async (_resource, permissions) => ({ ok: true, granted: permissions }),
|
|
173
176
|
},
|
|
177
|
+
grantOwnRole: async (role) => {
|
|
178
|
+
calls.push(`grant ${role}`);
|
|
179
|
+
return opts.grant ? opts.grant(role) : { ok: true };
|
|
180
|
+
},
|
|
181
|
+
sleep: async () => {
|
|
182
|
+
calls.push("sleep");
|
|
183
|
+
},
|
|
174
184
|
execution: "caelo-production-converge-run1",
|
|
175
185
|
ownVersion: opts.ownVersion ?? "0.11.0",
|
|
176
186
|
fetchManifest: async (v) => {
|
|
@@ -465,6 +475,139 @@ describe("runConvergeJob — stage 2 (the target release's own installer)", () =
|
|
|
465
475
|
});
|
|
466
476
|
});
|
|
467
477
|
|
|
478
|
+
/**
|
|
479
|
+
* An IAM that lacks the workflows.* permissions (an install set up before
|
|
480
|
+
* 0.11.11 added roles/workflows.editor, the searchviu-website case) until
|
|
481
|
+
* the job granted the role, and confirms it `lag` asks later.
|
|
482
|
+
*/
|
|
483
|
+
function lacksWorkflows(lag = 0, extraMissing: (p: string) => boolean = () => false) {
|
|
484
|
+
const state = { granted: false, asksSinceGrant: 0 };
|
|
485
|
+
const iam: IamTester = {
|
|
486
|
+
granted: async (_resource, permissions) => {
|
|
487
|
+
if (state.granted) state.asksSinceGrant += 1;
|
|
488
|
+
const workflowsHeld = state.granted && state.asksSinceGrant > lag;
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
granted: permissions.filter(
|
|
492
|
+
(p) => !extraMissing(p) && (workflowsHeld || !p.startsWith("workflows.")),
|
|
493
|
+
),
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
const grant = (role: string) => {
|
|
498
|
+
if (role === "roles/workflows.editor") state.granted = true;
|
|
499
|
+
return { ok: true } as const;
|
|
500
|
+
};
|
|
501
|
+
return { iam, grant };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
describe("runConvergeJob — the job grants itself the roles its release names (2026-11-03)", () => {
|
|
505
|
+
it("stage 1 leaves a missing allowlisted role to stage 2 and hands over without granting", async () => {
|
|
506
|
+
const { iam, grant } = lacksWorkflows();
|
|
507
|
+
const h = harness({ ownVersion: "0.10.53", iam, grant });
|
|
508
|
+
const r = await runConvergeJob(h.deps);
|
|
509
|
+
expect(r.status).toBeNull();
|
|
510
|
+
expect(h.calls.some((c) => c.startsWith("grant"))).toBe(false);
|
|
511
|
+
expect(h.calls).toContain("start");
|
|
512
|
+
expect(h.q.ends).toEqual([]);
|
|
513
|
+
expect(
|
|
514
|
+
h.q.events.some(
|
|
515
|
+
(e) =>
|
|
516
|
+
e.message.includes("lacks roles/workflows.editor") &&
|
|
517
|
+
e.message.includes("The installer of 0.11.0 grants them"),
|
|
518
|
+
),
|
|
519
|
+
).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("stage 2 grants the role after the image check and under the lock, records it, re-checks, converges", async () => {
|
|
523
|
+
const { iam, grant } = lacksWorkflows();
|
|
524
|
+
const h = harness({ iam, grant });
|
|
525
|
+
const r = await runConvergeJob(h.deps);
|
|
526
|
+
expect(r.status).toBe("succeeded");
|
|
527
|
+
const g = h.calls.indexOf("grant roles/workflows.editor");
|
|
528
|
+
expect(g).toBeGreaterThan(h.calls.indexOf("lock"));
|
|
529
|
+
expect(g).toBeLessThan(h.calls.indexOf("checkpoint running"));
|
|
530
|
+
expect(g).toBeLessThan(h.calls.indexOf("upgrade"));
|
|
531
|
+
// Only the missing role, nothing else of the list.
|
|
532
|
+
expect(h.calls.filter((c) => c.startsWith("grant"))).toEqual(["grant roles/workflows.editor"]);
|
|
533
|
+
const event = h.q.events.find((e) => e.message.startsWith("Granted roles/workflows.editor"));
|
|
534
|
+
expect(event?.level).toBe("success");
|
|
535
|
+
expect(event?.message).toBe(
|
|
536
|
+
"Granted roles/workflows.editor to the converge job's account (caelo-provisioner@acme.iam.gserviceaccount.com), needed by Caelo 0.11.0 to deploy and update the workflow the scheduler tick runs (#1140).",
|
|
537
|
+
);
|
|
538
|
+
expect(h.q.events.some((e) => e.message.startsWith("IAM confirms the granted roles"))).toBe(
|
|
539
|
+
true,
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("waits for IAM to confirm a fresh grant (propagation) before it goes on", async () => {
|
|
544
|
+
const { iam, grant } = lacksWorkflows(3);
|
|
545
|
+
const h = harness({ iam, grant });
|
|
546
|
+
const r = await runConvergeJob(h.deps);
|
|
547
|
+
expect(r.status).toBe("succeeded");
|
|
548
|
+
const sleeps = h.calls.filter((c) => c === "sleep").length;
|
|
549
|
+
expect(sleeps).toBeGreaterThan(0);
|
|
550
|
+
expect(h.calls.lastIndexOf("sleep")).toBeLessThan(h.calls.indexOf("upgrade"));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("a grant that fails (org policy) ends the request with the Cloud Shell step, before any change", async () => {
|
|
554
|
+
const { iam } = lacksWorkflows();
|
|
555
|
+
const h = harness({
|
|
556
|
+
iam,
|
|
557
|
+
grant: () => ({
|
|
558
|
+
ok: false,
|
|
559
|
+
error: "ERROR: (gcloud.projects.add-iam-policy-binding) FAILED_PRECONDITION: org policy",
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
const r = await runConvergeJob(h.deps);
|
|
563
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
564
|
+
expect(h.q.ends[0]?.codes).toEqual(["iam-self-grant"]);
|
|
565
|
+
const reason = h.q.ends[0]?.reason ?? "";
|
|
566
|
+
expect(reason).toStartWith(
|
|
567
|
+
"Nothing was changed: the converge job could not grant its own account",
|
|
568
|
+
);
|
|
569
|
+
expect(reason).toContain("roles/workflows.editor");
|
|
570
|
+
expect(reason).toContain("org policy");
|
|
571
|
+
expect(reason).toContain("Run the update once from Cloud Shell");
|
|
572
|
+
expect(h.upgrades).toEqual([]);
|
|
573
|
+
expect(h.calls.some((c) => c.startsWith("checkpoint"))).toBe(false);
|
|
574
|
+
expect(h.calls.at(-1)).toBe("unlock");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("a grant IAM never confirms ends with the Cloud Shell step after the wait", async () => {
|
|
578
|
+
const { iam, grant } = lacksWorkflows(10_000);
|
|
579
|
+
const h = harness({ iam, grant });
|
|
580
|
+
const r = await runConvergeJob(h.deps);
|
|
581
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
582
|
+
expect(h.q.ends[0]?.codes).toEqual(["iam-self-grant"]);
|
|
583
|
+
expect(h.q.ends[0]?.reason).toContain("workflows.workflows.create");
|
|
584
|
+
expect(h.calls.filter((c) => c === "sleep").length).toBe(SELF_GRANT_RECHECK_ATTEMPTS - 1);
|
|
585
|
+
expect(h.upgrades).toEqual([]);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("a missing permission no allowlisted role carries stops either stage without granting anything", async () => {
|
|
589
|
+
// workflows.* is repairable, actAs on its own account is not.
|
|
590
|
+
const { iam, grant } = lacksWorkflows(0, (p) => p === "iam.serviceAccounts.actAs");
|
|
591
|
+
for (const stage of ["0.10.53", "0.11.0"]) {
|
|
592
|
+
const h = harness({ ownVersion: stage, iam, grant });
|
|
593
|
+
const r = await runConvergeJob(h.deps);
|
|
594
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
595
|
+
expect(h.q.ends[0]?.codes).toEqual(["iam-preflight"]);
|
|
596
|
+
expect(h.calls.some((c) => /^(grant|lock|point|start|upgrade)/.test(c))).toBe(false);
|
|
597
|
+
expect(h.q.ends[0]?.reason).toContain("workflows.workflows.create");
|
|
598
|
+
expect(h.q.ends[0]?.reason).toContain("iam.serviceAccounts.actAs");
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("stage 2 refuses an image the manifest does not sign before it grants anything", async () => {
|
|
603
|
+
const { iam, grant } = lacksWorkflows();
|
|
604
|
+
const h = harness({ iam, grant, ownDigest: D("9") });
|
|
605
|
+
const r = await runConvergeJob(h.deps);
|
|
606
|
+
expect(r.status).toBe("refused");
|
|
607
|
+
expect(h.calls.some((c) => c.startsWith("grant"))).toBe(false);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
468
611
|
describe("runConvergeJob — resume after a killed run", () => {
|
|
469
612
|
it("a run that died is adopted once Cloud Run says it ended, and resumes from the checkpoint", async () => {
|
|
470
613
|
const h = harness({
|
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.
|
|
@@ -20,8 +20,8 @@ describe("infrastructure contract steps", () => {
|
|
|
20
20
|
);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("is at
|
|
24
|
-
expect(INFRA_CONTRACT_VERSION).toBe(
|
|
23
|
+
it("is at 19: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8), converge job (9), remote MCP (10), Google sign-in (11), project-level sign-in client (12), edge security headers (13), capture job (14), database verify-full TLS (15), MCP-Auth-Proxy (16), staging database (17), scheduler tick (18), scheduler tick as JSON (19)", () => {
|
|
24
|
+
expect(INFRA_CONTRACT_VERSION).toBe(19);
|
|
25
25
|
expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
|
|
26
26
|
expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
|
|
27
27
|
expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
|
|
@@ -38,6 +38,7 @@ describe("infrastructure contract steps", () => {
|
|
|
38
38
|
expect(INFRA_CONTRACT_STEPS[16]).toContain("#1328");
|
|
39
39
|
expect(INFRA_CONTRACT_STEPS[17]).toContain("#1329");
|
|
40
40
|
expect(INFRA_CONTRACT_STEPS[18]).toContain("#1140");
|
|
41
|
+
expect(INFRA_CONTRACT_STEPS[19]).toContain("JSON");
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
it("lists the pending steps oldest first", () => {
|
|
@@ -48,6 +49,6 @@ describe("infrastructure contract steps", () => {
|
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
it("names a version a newer release added by number", () => {
|
|
51
|
-
expect(infraStepsText(
|
|
52
|
+
expect(infraStepsText(19, 20)).toBe("infrastructure step 20 (see that release's notes)");
|
|
52
53
|
});
|
|
53
54
|
});
|
package/src/infra-contract.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* whose app-roll references infrastructure an install does not have yet fails
|
|
23
23
|
* at roll time, after migrations ran.
|
|
24
24
|
*/
|
|
25
|
-
export const INFRA_CONTRACT_VERSION =
|
|
25
|
+
export const INFRA_CONTRACT_VERSION = 19;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -150,6 +150,9 @@ export const INFRA_CONTRACT_VERSION = 18;
|
|
|
150
150
|
// on the admin. The new admin runs its periodic work and plugin workers
|
|
151
151
|
// only on that tick, so an in-cloud upgrader must not roll an install
|
|
152
152
|
// onto it before the tick exists.
|
|
153
|
+
// 19 — the scheduler workflow's tick is a JSON POST. Without a content type
|
|
154
|
+
// SvelteKit refused every tick of contract 18 with 403, so no periodic
|
|
155
|
+
// work ran. The workflow source is only deployed by a converge.
|
|
153
156
|
|
|
154
157
|
/**
|
|
155
158
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -175,6 +178,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
175
178
|
16: "the MCP sign-in service as its own Cloud Run service with its own account (no database access), the visitor gateway as its own service on Firebase installs, and on Google Cloud load-balancer installs the sign-in service's backend and Cloud Armor rule (#1328)",
|
|
176
179
|
17: "the staging site's own database and its gateway login, kept apart from the live data (#1329)",
|
|
177
180
|
18: "the scheduler tick that runs the admin's periodic work and plugin workers every few minutes: its service account, workflow and Cloud Scheduler job, and its IAP access (#1140)",
|
|
181
|
+
19: "the scheduler tick sent as a JSON request, because the admin refused every tick before (#1140)",
|
|
178
182
|
};
|
|
179
183
|
|
|
180
184
|
/**
|
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",
|
package/src/lifecycle.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import { provisioningUpgradeCommand } from "@caelo-cms/shared";
|
|
21
21
|
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
22
22
|
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
23
|
+
import { stagingDomainState } from "./admin-domain.js";
|
|
23
24
|
import { activeAccount, checkGcloudAuth, gcloud } from "./gcloud.js";
|
|
24
25
|
import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
|
|
25
26
|
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
@@ -686,12 +687,21 @@ async function upgradeLockedInstall(
|
|
|
686
687
|
const stateNotice = localStateNotice(meta);
|
|
687
688
|
if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
|
|
688
689
|
if (ok && provider === "gcp-firebase") {
|
|
689
|
-
// #650 — staging builds
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
690
|
+
// #650 — staging builds render the staging host; the gateway serves it
|
|
691
|
+
// once it is mapped. Only an install without the mapping is told to run
|
|
692
|
+
// `staging-domain enable`.
|
|
693
|
+
const staging = await stagingDomainState({ projectId, region, stagingHost, run });
|
|
694
|
+
if (staging.status === "mapped") {
|
|
695
|
+
log.info(`Protected staging site: https://${stagingHost}/`);
|
|
696
|
+
} else {
|
|
697
|
+
const unknown =
|
|
698
|
+
staging.status === "unknown"
|
|
699
|
+
? ` (could not check whether ${bold(stagingHost)} is mapped: ${staging.reason})`
|
|
700
|
+
: "";
|
|
701
|
+
log.info(
|
|
702
|
+
`Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host${unknown} (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
695
705
|
}
|
|
696
706
|
return ok;
|
|
697
707
|
}
|
|
@@ -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),
|
|
@@ -177,6 +177,12 @@ describe("schedulerWorkflowSource", () => {
|
|
|
177
177
|
expect(src).toContain(`exp: \${now + 600}`);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
it("declares the tick as JSON, or SvelteKit refuses it as a cross-site form post", () => {
|
|
181
|
+
const tick = src.slice(src.indexOf("- tick:"), src.indexOf("- done:"));
|
|
182
|
+
expect(tick).toContain("Content-Type: application/json");
|
|
183
|
+
expect(tick).toContain("body: {}");
|
|
184
|
+
});
|
|
185
|
+
|
|
180
186
|
it("posts the tick with the signed JWT and a bounded timeout", () => {
|
|
181
187
|
expect(src).toContain('url: "https://admin.acme.com/api/internal/scheduler/tick"');
|
|
182
188
|
expect(src).toContain(`Authorization: \${"Bearer " + signed.body.signedJwt}`);
|
package/src/scheduler-setup.ts
CHANGED
|
@@ -165,6 +165,11 @@ export function tickOrigin(
|
|
|
165
165
|
* Credentials API, authenticated with the workflow's own OAuth token), then
|
|
166
166
|
* POST the tick with it. A refused or failed tick (HTTP ≥ 400) fails the
|
|
167
167
|
* execution, so it shows in the workflow's execution history and logs.
|
|
168
|
+
*
|
|
169
|
+
* The tick carries `Content-Type: application/json` and a JSON body, like the
|
|
170
|
+
* self-hosted clock (admin-core scheduler/clock.ts), because the admin's
|
|
171
|
+
* SvelteKit treats a POST with no content type and no `Origin` as a
|
|
172
|
+
* cross-site form submission: it answers 403 before the route runs.
|
|
168
173
|
*/
|
|
169
174
|
export function schedulerWorkflowSource(opts: {
|
|
170
175
|
readonly serviceAccount: string;
|
|
@@ -198,6 +203,8 @@ main:
|
|
|
198
203
|
url: "${origin}${SCHEDULER_TICK_PATH}"
|
|
199
204
|
headers:
|
|
200
205
|
Authorization: \${"Bearer " + signed.body.signedJwt}
|
|
206
|
+
Content-Type: application/json
|
|
207
|
+
body: {}
|
|
201
208
|
timeout: ${SCHEDULER_TICK_TIMEOUT_SECONDS}
|
|
202
209
|
result: answer
|
|
203
210
|
- done:
|