@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.
Files changed (64) hide show
  1. package/dist/admin-domain.d.ts +24 -0
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +43 -0
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/agent-db.js +171 -15
  6. package/dist/converge-job-gcp.d.ts +7 -0
  7. package/dist/converge-job-gcp.d.ts.map +1 -1
  8. package/dist/converge-job-gcp.js +33 -1
  9. package/dist/converge-job-gcp.js.map +1 -1
  10. package/dist/converge-job-preflight.d.ts +34 -2
  11. package/dist/converge-job-preflight.d.ts.map +1 -1
  12. package/dist/converge-job-preflight.js +68 -1
  13. package/dist/converge-job-preflight.js.map +1 -1
  14. package/dist/converge-job-setup.d.ts.map +1 -1
  15. package/dist/converge-job-setup.js +4 -1
  16. package/dist/converge-job-setup.js.map +1 -1
  17. package/dist/converge-job.d.ts +27 -3
  18. package/dist/converge-job.d.ts.map +1 -1
  19. package/dist/converge-job.js +93 -11
  20. package/dist/converge-job.js.map +1 -1
  21. package/dist/infra-contract.d.ts +1 -1
  22. package/dist/infra-contract.d.ts.map +1 -1
  23. package/dist/infra-contract.js +5 -1
  24. package/dist/infra-contract.js.map +1 -1
  25. package/dist/install-job.d.ts +6 -0
  26. package/dist/install-job.d.ts.map +1 -1
  27. package/dist/install-job.js +6 -0
  28. package/dist/install-job.js.map +1 -1
  29. package/dist/lifecycle.d.ts.map +1 -1
  30. package/dist/lifecycle.js +14 -4
  31. package/dist/lifecycle.js.map +1 -1
  32. package/dist/mcp-auth-proxy-setup.d.ts +3 -1
  33. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  34. package/dist/mcp-auth-proxy-setup.js +9 -4
  35. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  36. package/dist/scheduler-setup.d.ts +5 -0
  37. package/dist/scheduler-setup.d.ts.map +1 -1
  38. package/dist/scheduler-setup.js +7 -0
  39. package/dist/scheduler-setup.js.map +1 -1
  40. package/dist/upgrader-iam-preflight.d.ts +8 -6
  41. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  42. package/dist/upgrader-iam-preflight.js +5 -1
  43. package/dist/upgrader-iam-preflight.js.map +1 -1
  44. package/dist/wizards/gcp-cost.js +1 -1
  45. package/dist/wizards/gcp-cost.js.map +1 -1
  46. package/package.json +2 -2
  47. package/src/admin-domain.test.ts +65 -0
  48. package/src/admin-domain.ts +53 -0
  49. package/src/converge-job-gcp.ts +33 -1
  50. package/src/converge-job-preflight.test.ts +101 -0
  51. package/src/converge-job-preflight.ts +89 -3
  52. package/src/converge-job-setup.ts +4 -1
  53. package/src/converge-job.test.ts +143 -0
  54. package/src/converge-job.ts +143 -19
  55. package/src/infra-contract.test.ts +4 -3
  56. package/src/infra-contract.ts +5 -1
  57. package/src/install-job.ts +6 -0
  58. package/src/lifecycle.ts +16 -6
  59. package/src/mcp-auth-proxy-setup.test.ts +33 -1
  60. package/src/mcp-auth-proxy-setup.ts +11 -4
  61. package/src/scheduler-setup.test.ts +6 -0
  62. package/src/scheduler-setup.ts +7 -0
  63. package/src/upgrader-iam-preflight.ts +15 -10
  64. package/src/wizards/gcp-cost.ts +1 -1
@@ -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({
@@ -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.
@@ -20,8 +20,8 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 18: 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)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(18);
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(18, 19)).toBe("infrastructure step 19 (see that release's notes)");
52
+ expect(infraStepsText(19, 20)).toBe("infrastructure step 20 (see that release's notes)");
52
53
  });
53
54
  });
@@ -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 = 18;
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
  /**
@@ -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 now render the staging host; the gateway
690
- // serves it once it is mapped. The command is idempotent, so this
691
- // reminder is safe to repeat on every upgrade.
692
- log.info(
693
- `Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (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.`,
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
- "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),
@@ -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}`);
@@ -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: