@caelo-cms/provisioning 0.10.55 → 0.10.58

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 (221) hide show
  1. package/dist/agent-db.js +97 -24
  2. package/dist/cli-routing.js +1 -1
  3. package/dist/cli-routing.js.map +1 -1
  4. package/dist/cli.js +4 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compose.js +5 -0
  7. package/dist/compose.js.map +1 -1
  8. package/dist/converge-checkpoint.d.ts +66 -0
  9. package/dist/converge-checkpoint.d.ts.map +1 -0
  10. package/dist/converge-checkpoint.js +61 -0
  11. package/dist/converge-checkpoint.js.map +1 -0
  12. package/dist/converge-job-gcp.d.ts +46 -0
  13. package/dist/converge-job-gcp.d.ts.map +1 -0
  14. package/dist/converge-job-gcp.js +368 -0
  15. package/dist/converge-job-gcp.js.map +1 -0
  16. package/dist/converge-job-setup.d.ts +62 -0
  17. package/dist/converge-job-setup.d.ts.map +1 -0
  18. package/dist/converge-job-setup.js +228 -0
  19. package/dist/converge-job-setup.js.map +1 -0
  20. package/dist/converge-job.d.ts +141 -0
  21. package/dist/converge-job.d.ts.map +1 -0
  22. package/dist/converge-job.js +325 -0
  23. package/dist/converge-job.js.map +1 -0
  24. package/dist/gcp-names.d.ts +9 -0
  25. package/dist/gcp-names.d.ts.map +1 -1
  26. package/dist/gcp-names.js +13 -0
  27. package/dist/gcp-names.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/infra-contract.d.ts +1 -1
  33. package/dist/infra-contract.d.ts.map +1 -1
  34. package/dist/infra-contract.js +33 -1
  35. package/dist/infra-contract.js.map +1 -1
  36. package/dist/install-job.d.ts +11 -3
  37. package/dist/install-job.d.ts.map +1 -1
  38. package/dist/install-job.js +22 -12
  39. package/dist/install-job.js.map +1 -1
  40. package/dist/lifecycle.d.ts +13 -1
  41. package/dist/lifecycle.d.ts.map +1 -1
  42. package/dist/lifecycle.js +115 -58
  43. package/dist/lifecycle.js.map +1 -1
  44. package/dist/operator-access.d.ts +4 -0
  45. package/dist/operator-access.d.ts.map +1 -1
  46. package/dist/operator-access.js +43 -7
  47. package/dist/operator-access.js.map +1 -1
  48. package/dist/release-contract.d.ts +3 -2
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +5 -2
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/release-manifest.d.ts +2 -0
  53. package/dist/release-manifest.d.ts.map +1 -1
  54. package/dist/release-manifest.js.map +1 -1
  55. package/dist/remote-mcp-edge.d.ts +114 -0
  56. package/dist/remote-mcp-edge.d.ts.map +1 -0
  57. package/dist/remote-mcp-edge.js +116 -0
  58. package/dist/remote-mcp-edge.js.map +1 -0
  59. package/dist/remote-mcp.d.ts +139 -0
  60. package/dist/remote-mcp.d.ts.map +1 -0
  61. package/dist/remote-mcp.js +434 -0
  62. package/dist/remote-mcp.js.map +1 -0
  63. package/dist/runtime-secrets.d.ts +12 -0
  64. package/dist/runtime-secrets.d.ts.map +1 -1
  65. package/dist/runtime-secrets.js +32 -4
  66. package/dist/runtime-secrets.js.map +1 -1
  67. package/dist/seal-key-rotation.d.ts +38 -0
  68. package/dist/seal-key-rotation.d.ts.map +1 -0
  69. package/dist/seal-key-rotation.js +206 -0
  70. package/dist/seal-key-rotation.js.map +1 -0
  71. package/dist/stack-contract.d.ts +206 -3
  72. package/dist/stack-contract.d.ts.map +1 -1
  73. package/dist/stack-contract.js +219 -4
  74. package/dist/stack-contract.js.map +1 -1
  75. package/dist/stack-converge.d.ts +53 -1
  76. package/dist/stack-converge.d.ts.map +1 -1
  77. package/dist/stack-converge.js +124 -4
  78. package/dist/stack-converge.js.map +1 -1
  79. package/dist/state-lock-lease.d.ts +142 -0
  80. package/dist/state-lock-lease.d.ts.map +1 -0
  81. package/dist/state-lock-lease.js +347 -0
  82. package/dist/state-lock-lease.js.map +1 -0
  83. package/dist/state-store.d.ts +66 -9
  84. package/dist/state-store.d.ts.map +1 -1
  85. package/dist/state-store.js +249 -30
  86. package/dist/state-store.js.map +1 -1
  87. package/dist/upgrade/app-roll.d.ts +36 -7
  88. package/dist/upgrade/app-roll.d.ts.map +1 -1
  89. package/dist/upgrade/app-roll.js +53 -19
  90. package/dist/upgrade/app-roll.js.map +1 -1
  91. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  92. package/dist/upgrade/audit-job-roll.js +3 -1
  93. package/dist/upgrade/audit-job-roll.js.map +1 -1
  94. package/dist/upgrade/converge.d.ts.map +1 -1
  95. package/dist/upgrade/converge.js +125 -12
  96. package/dist/upgrade/converge.js.map +1 -1
  97. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  98. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  99. package/dist/upgrade/gcp-lookup.js +46 -7
  100. package/dist/upgrade/gcp-lookup.js.map +1 -1
  101. package/dist/upgrade/record-rolled.d.ts +40 -0
  102. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  103. package/dist/upgrade/record-rolled.js +46 -0
  104. package/dist/upgrade/record-rolled.js.map +1 -0
  105. package/dist/upgrade/upgrade.d.ts +45 -1
  106. package/dist/upgrade/upgrade.d.ts.map +1 -1
  107. package/dist/upgrade/upgrade.js +60 -17
  108. package/dist/upgrade/upgrade.js.map +1 -1
  109. package/dist/upgrader-assess.d.ts +15 -1
  110. package/dist/upgrader-assess.d.ts.map +1 -1
  111. package/dist/upgrader-assess.js +36 -6
  112. package/dist/upgrader-assess.js.map +1 -1
  113. package/dist/upgrader-gcp.d.ts +15 -0
  114. package/dist/upgrader-gcp.d.ts.map +1 -1
  115. package/dist/upgrader-gcp.js +42 -7
  116. package/dist/upgrader-gcp.js.map +1 -1
  117. package/dist/upgrader-iam-preflight.d.ts +123 -0
  118. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  119. package/dist/upgrader-iam-preflight.js +414 -0
  120. package/dist/upgrader-iam-preflight.js.map +1 -0
  121. package/dist/upgrader-job.d.ts +33 -9
  122. package/dist/upgrader-job.d.ts.map +1 -1
  123. package/dist/upgrader-job.js +169 -27
  124. package/dist/upgrader-job.js.map +1 -1
  125. package/dist/upgrader-records.d.ts +58 -0
  126. package/dist/upgrader-records.d.ts.map +1 -0
  127. package/dist/upgrader-records.js +87 -0
  128. package/dist/upgrader-records.js.map +1 -0
  129. package/dist/upgrader-reporter.d.ts +3 -2
  130. package/dist/upgrader-reporter.d.ts.map +1 -1
  131. package/dist/upgrader-reporter.js +10 -2
  132. package/dist/upgrader-reporter.js.map +1 -1
  133. package/dist/upgrader-setup.d.ts +25 -3
  134. package/dist/upgrader-setup.d.ts.map +1 -1
  135. package/dist/upgrader-setup.js +123 -11
  136. package/dist/upgrader-setup.js.map +1 -1
  137. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  138. package/dist/wizards/gcp-cost.js +11 -3
  139. package/dist/wizards/gcp-cost.js.map +1 -1
  140. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  141. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  142. package/dist/wizards/gcp-pulumi.js +14 -2
  143. package/dist/wizards/gcp-pulumi.js.map +1 -1
  144. package/dist/wizards/gcp.d.ts.map +1 -1
  145. package/dist/wizards/gcp.js +106 -4
  146. package/dist/wizards/gcp.js.map +1 -1
  147. package/package.json +2 -2
  148. package/src/agent/run.test.ts +14 -1
  149. package/src/audit-job-setup.test.ts +7 -1
  150. package/src/cli-routing.ts +1 -1
  151. package/src/cli.ts +4 -1
  152. package/src/compose.ts +5 -0
  153. package/src/converge-checkpoint.ts +75 -0
  154. package/src/converge-entrypoint.test.ts +213 -0
  155. package/src/converge-job-gcp.test.ts +222 -0
  156. package/src/converge-job-gcp.ts +443 -0
  157. package/src/converge-job-setup.test.ts +152 -0
  158. package/src/converge-job-setup.ts +268 -0
  159. package/src/converge-job.test.ts +486 -0
  160. package/src/converge-job.ts +542 -0
  161. package/src/gateway-env-isolation.test.ts +8 -1
  162. package/src/gcp-names.ts +15 -0
  163. package/src/gcp-url-map.test.ts +9 -1
  164. package/src/index.ts +8 -0
  165. package/src/infra-contract.test.ts +8 -4
  166. package/src/infra-contract.ts +33 -1
  167. package/src/install-job.test.ts +3 -1
  168. package/src/install-job.ts +31 -14
  169. package/src/lifecycle.ts +137 -71
  170. package/src/operator-access.test.ts +24 -1
  171. package/src/operator-access.ts +44 -6
  172. package/src/release-contract.ts +5 -2
  173. package/src/release-manifest.test.ts +42 -0
  174. package/src/release-manifest.ts +2 -0
  175. package/src/remote-mcp-edge.ts +137 -0
  176. package/src/remote-mcp.test.ts +690 -0
  177. package/src/remote-mcp.ts +524 -0
  178. package/src/runtime-secrets.test.ts +31 -6
  179. package/src/runtime-secrets.ts +41 -3
  180. package/src/seal-key-rotation.test.ts +131 -0
  181. package/src/seal-key-rotation.ts +258 -0
  182. package/src/stack-contract-parity.test.ts +20 -3
  183. package/src/stack-contract.test.ts +32 -2
  184. package/src/stack-contract.ts +363 -4
  185. package/src/stack-converge-secrets.test.ts +4 -0
  186. package/src/stack-converge.test.ts +21 -5
  187. package/src/stack-converge.ts +162 -7
  188. package/src/state-attach.test.ts +7 -1
  189. package/src/state-lock-lease.test.ts +302 -0
  190. package/src/state-lock-lease.ts +423 -0
  191. package/src/state-lock.test.ts +568 -0
  192. package/src/state-store.ts +347 -27
  193. package/src/upgrade/app-roll.ts +102 -21
  194. package/src/upgrade/audit-job-roll.ts +3 -1
  195. package/src/upgrade/converge.ts +148 -10
  196. package/src/upgrade/gcp-lookup.test.ts +80 -0
  197. package/src/upgrade/gcp-lookup.ts +65 -8
  198. package/src/upgrade/record-rolled.test.ts +105 -0
  199. package/src/upgrade/record-rolled.ts +91 -0
  200. package/src/upgrade/upgrade.test.ts +203 -3
  201. package/src/upgrade/upgrade.ts +120 -24
  202. package/src/upgrader-assess.test.ts +92 -3
  203. package/src/upgrader-assess.ts +58 -9
  204. package/src/upgrader-gcp.ts +47 -8
  205. package/src/upgrader-iam-preflight.test.ts +393 -0
  206. package/src/upgrader-iam-preflight.ts +510 -0
  207. package/src/upgrader-job.test.ts +586 -15
  208. package/src/upgrader-job.ts +237 -53
  209. package/src/upgrader-records.ts +151 -0
  210. package/src/upgrader-reporter.test.ts +6 -0
  211. package/src/upgrader-reporter.ts +7 -2
  212. package/src/upgrader-setup.test.ts +3 -3
  213. package/src/upgrader-setup.ts +128 -11
  214. package/src/wizards/gcp-cost.ts +12 -3
  215. package/src/wizards/gcp-pulumi.test.ts +15 -0
  216. package/src/wizards/gcp-pulumi.ts +18 -2
  217. package/src/wizards/gcp.ts +134 -4
  218. package/stacks/aws/index.ts +10 -0
  219. package/stacks/azure/index.ts +9 -0
  220. package/stacks/gcp/index.ts +47 -7
  221. package/stacks/gcp-firebase/index.ts +38 -6
@@ -23,7 +23,12 @@ import { restoreCommand, takeSqlBackup } from "./backup.js";
23
23
  import { needsConverge, startConverge } from "./converge.js";
24
24
  import type { HealthOutcome, HealthProbe } from "./health.js";
25
25
  import type { UpgradeReporter } from "./reporter.js";
26
- import { type GcpUpgradeDeps, type GcpUpgradeTarget, upgradeGcpInstall } from "./upgrade.js";
26
+ import {
27
+ type GcpUpgradeDeps,
28
+ type GcpUpgradeTarget,
29
+ upgradeGcpInstall,
30
+ upgradeGcpInstallOutcome,
31
+ } from "./upgrade.js";
27
32
 
28
33
  const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
29
34
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
@@ -460,6 +465,142 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
460
465
  expect(calls.some((c) => c.includes("remove-iam-policy-binding"))).toBe(false);
461
466
  });
462
467
 
468
+ it("#883 review: reports rolledBack structurally — true only when every pin back succeeded", async () => {
469
+ const deep = { ok: false as const, error: "cms_admin is missing 1 migration(s)" };
470
+ const back = upgradeDeps([], { deep });
471
+ expect(await upgradeGcpInstallOutcome(TARGET, back.deps)).toEqual({
472
+ ok: false,
473
+ rolledBack: true,
474
+ });
475
+
476
+ // The gateway's pin back fails: the previous release does NOT serve.
477
+ const stuck = upgradeDeps([], {
478
+ deep,
479
+ overrides: [[new RegExp(`update-traffic ${GATEWAY} .*--to-revisions`), fail("503")]],
480
+ });
481
+ expect(await upgradeGcpInstallOutcome(TARGET, stuck.deps)).toEqual({
482
+ ok: false,
483
+ rolledBack: false,
484
+ });
485
+
486
+ // Nothing rolled (migrations failed): no rollback, so not rolled back.
487
+ const early = upgradeDeps([]);
488
+ expect(
489
+ await upgradeGcpInstallOutcome(TARGET, {
490
+ ...early.deps,
491
+ migrate: async () => ({ ok: false, error: "boom" }),
492
+ }),
493
+ ).toEqual({ ok: false, rolledBack: false });
494
+
495
+ expect(await upgradeGcpInstallOutcome(TARGET, upgradeDeps([]).deps)).toEqual({ ok: true });
496
+ });
497
+
498
+ it("sets up the converge job as the install's provisioner after the upgrader (#819)", async () => {
499
+ const events: string[] = [];
500
+ const { deps, lines } = upgradeDeps(events);
501
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
502
+ const upgrader = indexOf(events, /^gcloud run jobs deploy caelo-production-upgrader /);
503
+ const converge = indexOf(events, /^gcloud run jobs deploy caelo-production-converge /);
504
+ expect(upgrader).toBeLessThan(converge);
505
+ expect(events[converge]).toContain(
506
+ "--service-account=caelo-provisioner@acme.iam.gserviceaccount.com",
507
+ );
508
+ expect(events[converge]).toContain(`--image=registry/ops@sha256:${"c".repeat(64)}`);
509
+ expect(lines.some((l) => l.startsWith("done: Converge job ready"))).toBe(true);
510
+ });
511
+
512
+ it("a failed converge-job setup leaves the converge incomplete and CAELO_CONVERGE_JOB as it was", async () => {
513
+ const events: string[] = [];
514
+ const { deps, recorded, lines } = upgradeDeps(events, {
515
+ overrides: [[/^run jobs deploy caelo-production-converge /, fail("PERMISSION_DENIED")]],
516
+ });
517
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
518
+ expect(recorded.contract).toEqual([]);
519
+ expect(lines.some((l) => l.includes("Converge job not set up"))).toBe(true);
520
+ expect(
521
+ events.some((e) => /services update caelo-production-admin.*CAELO_CONVERGE_JOB=/.test(e)),
522
+ ).toBe(false);
523
+ });
524
+
525
+ it("#883 review: an install without caelo-provisioner still records the contract; its admin keeps the terminal path", async () => {
526
+ const events: string[] = [];
527
+ const { deps, recorded, lines } = upgradeDeps(events, {
528
+ overrides: [
529
+ [
530
+ /^iam service-accounts describe caelo-provisioner@/,
531
+ fail("ERROR: NOT_FOUND: Unknown service account"),
532
+ ],
533
+ // A project without the default network's open rules: nothing
534
+ // else keeps this converge incomplete.
535
+ [/^compute firewall-rules list/, ok("[]")],
536
+ ],
537
+ });
538
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
539
+ // Not blocked forever: the converge is complete without the job.
540
+ expect(recorded.contract).toEqual([TARGET.infraContractVersion]);
541
+ expect(lines.some((l) => l.includes("no caelo-provisioner"))).toBe(true);
542
+ expect(lines.some((l) => l.includes("Converge job not set up"))).toBe(false);
543
+ // No job, so no CAELO_CONVERGE_JOB: the admin routes such releases to the terminal.
544
+ expect(events.some((e) => /^gcloud run jobs deploy caelo-production-converge /.test(e))).toBe(
545
+ false,
546
+ );
547
+ expect(
548
+ events.some((e) => /services update caelo-production-admin.*CAELO_CONVERGE_JOB=/.test(e)),
549
+ ).toBe(false);
550
+ });
551
+
552
+ it("reports its backup and rollback targets before relying on them (#819 checkpoint)", async () => {
553
+ const events: string[] = [];
554
+ const { deps } = upgradeDeps(events);
555
+ const seen: string[] = [];
556
+ expect(
557
+ await upgradeGcpInstall(TARGET, {
558
+ ...deps,
559
+ onCheckpoint: (c) => {
560
+ if (c.priorRevisions) events.push(`checkpoint revisions ${c.priorRevisions.admin}`);
561
+ if (c.backup) events.push(`checkpoint backup ${c.backup.backupId}`);
562
+ seen.push(Object.keys(c).join(","));
563
+ },
564
+ }),
565
+ ).toBe(true);
566
+ expect(seen).toEqual(["priorRevisions", "backup"]);
567
+ const revisions = indexOf(events, /^checkpoint revisions caelo-production-admin-aaa-00001-old/);
568
+ const backup = indexOf(events, /^gcloud sql backups create/);
569
+ const saved = indexOf(events, new RegExp(`^checkpoint backup ${BACKUP_ID}`));
570
+ const migrate = indexOf(events, /^migrate /);
571
+ expect(revisions).toBeLessThan(backup);
572
+ expect(backup).toBeLessThan(saved);
573
+ expect(saved).toBeLessThan(migrate);
574
+ });
575
+
576
+ it("a resumed run keeps the interrupted run's backup and rolls back onto what served before it (#819)", async () => {
577
+ const events: string[] = [];
578
+ const { deps, lines } = upgradeDeps(events, {
579
+ deep: { ok: false, error: "unhealthy" },
580
+ });
581
+ const before = { admin: `${ADMIN}-00000-before`, gateway: `${GATEWAY}-00000-before` };
582
+ expect(
583
+ await upgradeGcpInstall(TARGET, {
584
+ ...deps,
585
+ resume: {
586
+ backup: { projectId: "acme", instance: SQL, backupId: "1759999999999" },
587
+ priorRevisions: before,
588
+ },
589
+ onCheckpoint: () => events.push("checkpoint"),
590
+ }),
591
+ ).toBe(false);
592
+ expect(events.some((e) => /sql backups create/.test(e))).toBe(false);
593
+ expect(events).not.toContain("checkpoint");
594
+ expect(lines).toContain(
595
+ "info: Continuing an interrupted update: keeping its pre-upgrade backup 1759999999999.",
596
+ );
597
+ const rollbacks = events.filter((e) => e.includes("--to-revisions"));
598
+ expect(rollbacks).toEqual([
599
+ expect.stringContaining(`--to-revisions ${before.gateway}=100`),
600
+ expect.stringContaining(`--to-revisions ${before.admin}=100`),
601
+ ]);
602
+ });
603
+
463
604
  it("revokes public_role's Cloud SQL role memberships before the migrations audit them (#633)", async () => {
464
605
  const events: string[] = [];
465
606
  const { deps, lines } = upgradeDeps(events);
@@ -527,6 +668,65 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
527
668
  expect(recorded.contract).toEqual([]);
528
669
  });
529
670
 
671
+ it("#806 — the rolls carry remote MCP: identity env, the gateway's client file and timeout", async () => {
672
+ const events: string[] = [];
673
+ const { deps, calls } = upgradeDeps(events);
674
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
675
+ const rolls = calls.filter((c) =>
676
+ /^run services update \S+ --region europe-west1 .*--image/.test(c),
677
+ );
678
+ const admin = rolls.find((c) => c.startsWith(`run services update ${ADMIN} `)) ?? "";
679
+ const gateway = rolls.find((c) => c.startsWith(`run services update ${GATEWAY} `)) ?? "";
680
+ expect(admin).toContain(
681
+ `CAELO_IAP_AUDIENCE=/projects/42/locations/europe-west1/services/${ADMIN}`,
682
+ );
683
+ expect(admin).toContain(`CAELO_MCP_PUBLIC_URL=https://${GATEWAY}-xyz.a.run.app`);
684
+ expect(gateway).toContain(`CAELO_ADMIN_URL=https://${ADMIN}-xyz.a.run.app`);
685
+ expect(gateway).toContain(`CAELO_MCP_PUBLIC_URL=https://${GATEWAY}-xyz.a.run.app`);
686
+ expect(gateway).toContain(
687
+ "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
688
+ );
689
+ expect(gateway).toContain("--timeout=3600");
690
+ expect(admin).not.toContain("--timeout");
691
+ // No extra revision: everything rides the image rolls.
692
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
693
+ });
694
+
695
+ it("#806 — on gcp, routes the discovery paths and adds the Cloud Armor rule before the roll", async () => {
696
+ const events: string[] = [];
697
+ const urlMap = {
698
+ name: "caelo-production-url-map-1",
699
+ hostRules: [{ hosts: ["acme.com"], pathMatcher: "public" }],
700
+ pathMatchers: [{ name: "public", pathRules: [{ paths: ["/api/*"], service: "gw" }] }],
701
+ };
702
+ const puts: string[] = [];
703
+ const { deps, lines } = upgradeDeps(events, {
704
+ overrides: [
705
+ [/^compute url-maps list/, ok(JSON.stringify([urlMap]))],
706
+ [/^compute security-policies list/, ok("caelo-production-waf-1\n")],
707
+ [/^compute security-policies describe/, ok(JSON.stringify({ rules: [] }))],
708
+ ],
709
+ });
710
+ const recordingHttp: HttpFetch = async (url, init) => {
711
+ if (init.method === "PUT" && url.includes("/urlMaps/"))
712
+ puts.push(`${url} ${init.body ?? ""}`);
713
+ return http(url, init);
714
+ };
715
+ expect(await upgradeGcpInstall(GCP_TARGET, { ...deps, http: recordingHttp })).toBe(true);
716
+ expect(events).toContainEqual(
717
+ expect.stringMatching(
718
+ /^gcloud compute security-policies rules create 900 --security-policy=caelo-production-waf-1 /,
719
+ ),
720
+ );
721
+ expect(puts).toHaveLength(1);
722
+ expect(puts[0]).toContain("/global/urlMaps/caelo-production-url-map-1");
723
+ expect(puts[0]).toContain("/.well-known/oauth-authorization-server/*");
724
+ expect(lines).toContain("done: Remote-MCP load-balancer rules ready");
725
+ expect(indexOf(events, /security-policies rules create/)).toBeLessThan(
726
+ indexOf(events, /^migrate /),
727
+ );
728
+ });
729
+
530
730
  it("on gcp-firebase there is no URL map: no redirects role", async () => {
531
731
  const events: string[] = [];
532
732
  const { deps, calls } = upgradeDeps(events);
@@ -691,7 +891,7 @@ describe("app-roll and converge stay on their side", () => {
691
891
  },
692
892
  },
693
893
  );
694
- expect(result).toEqual({ ok: false, phase: "roll", migrated: true });
894
+ expect(result).toEqual({ ok: false, phase: "roll", migrated: true, rolledBack: true });
695
895
  expect(calls).toContain(
696
896
  `run services update-traffic ${ADMIN} --region europe-west1 --project acme --to-revisions ${ADMIN}-00001-old=100 --quiet`,
697
897
  );
@@ -715,7 +915,7 @@ describe("app-roll and converge stay on their side", () => {
715
915
  recordDigests: async () => {},
716
916
  },
717
917
  );
718
- expect(result).toEqual({ ok: false, phase: "migrations", migrated: true });
918
+ expect(result).toEqual({ ok: false, phase: "migrations", migrated: true, rolledBack: false });
719
919
  expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
720
920
  expect(
721
921
  lines.some((l) => l.includes("contract-type") && l.includes(restoreCommand(BACKUP))),
@@ -17,7 +17,14 @@
17
17
  * install's infrastructure contract version
18
18
  *
19
19
  * Every side effect goes through {@link GcpUpgradeDeps}, so the sequence runs
20
- * against a fake gcloud in tests. lifecycle.ts wires the real ones.
20
+ * against a fake gcloud in tests. lifecycle.ts wires the real ones for the
21
+ * terminal; converge-job-gcp.ts wires them for the converge job (#819),
22
+ * which runs this same sequence from inside the install's project. The job
23
+ * may be killed mid-way and started again, so the sequence reports its two
24
+ * facts a resumed run must not recompute — the pre-upgrade backup and the
25
+ * revisions that served before ({@link UpgradeCheckpoint}) — and accepts
26
+ * them back: a resumed run neither backs up the half-migrated database
27
+ * again nor rolls back onto a revision the interrupted run created.
21
28
  */
22
29
 
23
30
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
@@ -31,7 +38,7 @@ import {
31
38
  type RollSlug,
32
39
  reportPlan,
33
40
  } from "./app-roll.js";
34
- import { backupFailureHelp, restoreCommand, takeSqlBackup } from "./backup.js";
41
+ import { backupFailureHelp, restoreCommand, type SqlBackup, takeSqlBackup } from "./backup.js";
35
42
  import { startConverge } from "./converge.js";
36
43
  import type { HealthProbe } from "./health.js";
37
44
  import type { UpgradeReporter } from "./reporter.js";
@@ -51,6 +58,21 @@ export interface GcpUpgradeTarget {
51
58
  readonly infraContractVersion: number;
52
59
  readonly skipVerify: boolean;
53
60
  readonly skipHealthCheck: boolean;
61
+ /**
62
+ * Whether the release's migrations are contract-type, from the signed
63
+ * release manifest (the converge job knows it; the terminal does not).
64
+ */
65
+ readonly migrationsContract?: boolean | null;
66
+ }
67
+
68
+ /**
69
+ * What an upgrade run has fixed that a resumed run must reuse (#819): the
70
+ * backup taken before anything changed, and the revisions that served
71
+ * before — the rollback targets.
72
+ */
73
+ export interface UpgradeCheckpoint {
74
+ readonly backup?: SqlBackup;
75
+ readonly priorRevisions?: Readonly<Record<RollSlug, string>>;
54
76
  }
55
77
 
56
78
  export interface GcpUpgradeDeps {
@@ -86,13 +108,43 @@ export interface GcpUpgradeDeps {
86
108
  readonly provisionerIdentity?: () => Promise<boolean>;
87
109
  /** Today, for the backup's description. */
88
110
  readonly now?: () => Date;
111
+ /** #819 — what an interrupted run of this same upgrade already fixed. */
112
+ readonly resume?: UpgradeCheckpoint;
113
+ /** #819 — called once each checkpoint fact is known (before it is relied on). */
114
+ readonly onCheckpoint?: (checkpoint: UpgradeCheckpoint) => void;
89
115
  }
90
116
 
117
+ /** How {@link upgradeGcpInstallOutcome} ended. */
118
+ export type GcpUpgradeOutcome =
119
+ | { readonly ok: true }
120
+ | {
121
+ readonly ok: false;
122
+ /**
123
+ * The roll failed AND every rolled service was pinned back to the
124
+ * revision that served before (app-roll.ts AppRollResult): the
125
+ * previous release serves again. Structural, never read from text.
126
+ */
127
+ readonly rolledBack: boolean;
128
+ };
129
+
91
130
  /** Upgrade one install; true on success. See the file comment for the order. */
92
131
  export async function upgradeGcpInstall(
93
132
  target: GcpUpgradeTarget,
94
133
  deps: GcpUpgradeDeps,
95
134
  ): Promise<boolean> {
135
+ return (await upgradeGcpInstallOutcome(target, deps)).ok;
136
+ }
137
+
138
+ /**
139
+ * {@link upgradeGcpInstall} with how a failure ended: whether the previous
140
+ * release serves again after a rollback (the converge job reports
141
+ * rolled_back only then, #819 review).
142
+ */
143
+ export async function upgradeGcpInstallOutcome(
144
+ target: GcpUpgradeTarget,
145
+ deps: GcpUpgradeDeps,
146
+ ): Promise<GcpUpgradeOutcome> {
147
+ const failed: GcpUpgradeOutcome = { ok: false, rolledBack: false };
96
148
  const { reporter: r, run } = deps;
97
149
  const { projectId } = target;
98
150
 
@@ -102,7 +154,7 @@ export async function upgradeGcpInstall(
102
154
  if (!images.ok) {
103
155
  sPre.failed("Couldn't resolve the release images");
104
156
  r.error(images.error);
105
- return false;
157
+ return failed;
106
158
  }
107
159
  const planned = await planAppRoll(
108
160
  {
@@ -119,34 +171,46 @@ export async function upgradeGcpInstall(
119
171
  if (!planned.ok) {
120
172
  sPre.failed("Pre-flight failed");
121
173
  r.error(planned.error);
122
- return false;
174
+ return failed;
123
175
  }
124
- const plan = planned.plan;
176
+ const plan = withPriorRevisions(planned.plan, deps, r);
125
177
  sPre.done(`Pre-flight ok — ${plan.services.length} services planned`);
126
178
  reportPlan(plan, r);
127
179
 
128
180
  // 2. P21 ship 4 — signatures against the Caelo release workflow's
129
181
  // identity: refuses a compromised registry, a typosquat or a fork's image.
130
182
  if (!target.skipVerify) {
131
- if (!(await deps.verifyImages(plan.services))) return false;
183
+ if (!(await deps.verifyImages(plan.services))) return failed;
132
184
  } else {
133
185
  r.warn("--skip-verify set — image signatures NOT verified.");
134
186
  }
135
187
 
136
188
  // 3. The first change: a failure leaves the install exactly as it was.
137
- const sBak = r.step("Taking a pre-upgrade Cloud SQL backup...");
138
- const backup = await takeSqlBackup(
139
- run,
140
- { projectId, env: target.env },
141
- `pre-upgrade to ${target.targetTag}`,
142
- deps.now,
143
- );
144
- if (!backup.ok) {
145
- sBak.failed(`Pre-upgrade backup failed: ${backup.error}`);
146
- r.warn(backupFailureHelp({ projectId }));
147
- return false;
189
+ // A resumed run keeps the interrupted run's backup: a new one could
190
+ // already hold the half-migrated schema.
191
+ let backup: { readonly ok: true; readonly backup: SqlBackup };
192
+ if (deps.resume?.backup) {
193
+ backup = { ok: true, backup: deps.resume.backup };
194
+ r.info(
195
+ `Continuing an interrupted update: keeping its pre-upgrade backup ${backup.backup.backupId}.`,
196
+ );
197
+ } else {
198
+ const sBak = r.step("Taking a pre-upgrade Cloud SQL backup...");
199
+ const taken = await takeSqlBackup(
200
+ run,
201
+ { projectId, env: target.env },
202
+ `pre-upgrade to ${target.targetTag}`,
203
+ deps.now,
204
+ );
205
+ if (!taken.ok) {
206
+ sBak.failed(`Pre-upgrade backup failed: ${taken.error}`);
207
+ r.warn(backupFailureHelp({ projectId }));
208
+ return failed;
209
+ }
210
+ sBak.done(`Pre-upgrade backup ${taken.backup.backupId} of ${taken.backup.instance} taken`);
211
+ backup = taken;
212
+ deps.onCheckpoint?.({ backup: taken.backup });
148
213
  }
149
- sBak.done(`Pre-upgrade backup ${backup.backup.backupId} of ${backup.backup.instance} taken`);
150
214
 
151
215
  // 4. Converge, the part before the roll. The upgrader job runs the ops
152
216
  // image with rights over the whole app, so it is deployed only when its
@@ -160,7 +224,7 @@ export async function upgradeGcpInstall(
160
224
  ...(deps.http ? { http: deps.http } : {}),
161
225
  ...(deps.provisionerIdentity ? { provisionerIdentity: deps.provisionerIdentity } : {}),
162
226
  });
163
- if (!(await converge.beforeAppRoll())) return false;
227
+ if (!(await converge.beforeAppRoll())) return failed;
164
228
 
165
229
  // 5. App-roll, with the converge steps that sit between its own.
166
230
  let health: HealthProbe | null = null;
@@ -169,7 +233,7 @@ export async function upgradeGcpInstall(
169
233
  if (!probe.ok) {
170
234
  r.error(probe.error);
171
235
  r.warn("No migrations ran and no traffic moved.");
172
- return false;
236
+ return failed;
173
237
  }
174
238
  health = probe.probe;
175
239
  }
@@ -178,8 +242,8 @@ export async function upgradeGcpInstall(
178
242
  plan,
179
243
  backup: backup.backup,
180
244
  targetVersion: /^\d+\.\d+\.\d+/.test(target.targetTag) ? target.targetTag : null,
181
- // Unknown until the signed release manifest names it (epic #696).
182
- migrationsContract: null,
245
+ // Unknown on the terminal path until the signed release manifest names it.
246
+ migrationsContract: target.migrationsContract ?? null,
183
247
  },
184
248
  converge.hooks,
185
249
  {
@@ -191,7 +255,7 @@ export async function upgradeGcpInstall(
191
255
  recordDigests: deps.recordDigests,
192
256
  },
193
257
  );
194
- if (!rolled.ok) return false;
258
+ if (!rolled.ok) return { ok: false, rolledBack: rolled.rolledBack };
195
259
  r.success(`Upgrade to ${target.targetTag} complete (admin + gateway revisions serving).`);
196
260
  r.info(
197
261
  `Pre-upgrade backup: ${backup.backup.backupId} (restore: ${restoreCommand(backup.backup)})`,
@@ -212,7 +276,39 @@ export async function upgradeGcpInstall(
212
276
  "Some converge steps did not succeed (see above); the install is not marked as converged, so in-product updates stay unavailable until a re-run of upgrade completes them.",
213
277
  );
214
278
  }
215
- return true;
279
+ return { ok: true };
280
+ }
281
+
282
+ /**
283
+ * The plan with the rollback targets an interrupted run recorded (#819): it
284
+ * may have rolled a service already, so the revision serving NOW would be
285
+ * the new release's. A fresh run records the planned ones instead.
286
+ */
287
+ function withPriorRevisions(
288
+ plan: AppRollPlan,
289
+ deps: GcpUpgradeDeps,
290
+ r: UpgradeReporter,
291
+ ): AppRollPlan {
292
+ const recorded = deps.resume?.priorRevisions;
293
+ if (!recorded) {
294
+ const prior = Object.fromEntries(plan.services.map((s) => [s.slug, s.priorRevision])) as Record<
295
+ RollSlug,
296
+ string
297
+ >;
298
+ deps.onCheckpoint?.({ priorRevisions: prior });
299
+ return plan;
300
+ }
301
+ return {
302
+ ...plan,
303
+ services: plan.services.map((s) => {
304
+ const before = recorded[s.slug];
305
+ if (before === s.priorRevision) return s;
306
+ r.info(
307
+ `Continuing an interrupted update: ${s.slug} goes back to ${before} (what served before it started) if the new release fails its checks.`,
308
+ );
309
+ return { ...s, priorRevision: before };
310
+ }),
311
+ };
216
312
  }
217
313
 
218
314
  /**
@@ -54,7 +54,11 @@ const verified = (m: Partial<ReleaseManifest> = {}): ReleaseManifestResult => ({
54
54
  });
55
55
  const broken = (
56
56
  code: "registry" | "signature" | "schema" | "blob-digest" | "version-mismatch" | "artifact-shape",
57
- ): ReleaseManifestResult => ({ ok: false, error: { code, message: `${code} problem` } });
57
+ transient?: true,
58
+ ): ReleaseManifestResult => ({
59
+ ok: false,
60
+ error: { code, message: `${code} problem`, ...(transient ? { transient } : {}) },
61
+ });
58
62
 
59
63
  interface Row {
60
64
  readonly name: string;
@@ -73,9 +77,16 @@ const ROWS: readonly Row[] = [
73
77
  { name: "a signed, newer, app-only release", expect: "proceed" },
74
78
  {
75
79
  name: "registry unreachable",
76
- manifest: broken("registry"),
80
+ manifest: broken("registry", true),
77
81
  expect: "failed/registry",
78
- reasonContains: "Nothing was changed",
82
+ reasonContains: "try again later",
83
+ },
84
+ {
85
+ // Regression #865: a 404 never heals, so it must not end retryable.
86
+ name: "no signed manifest for the target (404)",
87
+ manifest: broken("registry"),
88
+ expect: "refused/manifest-missing",
89
+ reasonContains: "has no signed release manifest",
79
90
  },
80
91
  { name: "bad signature", manifest: broken("signature"), expect: "refused/signature" },
81
92
  { name: "tampered blob", manifest: broken("blob-digest"), expect: "refused/signature" },
@@ -95,6 +106,7 @@ const ROWS: readonly Row[] = [
95
106
  name: "same version",
96
107
  installed: { version: "0.11.0", infraContractVersion: 2 },
97
108
  expect: "refused/not-newer",
109
+ reasonContains: "v0.11.0 already serves on this install",
98
110
  },
99
111
  {
100
112
  name: "downgrade",
@@ -327,3 +339,80 @@ describe("assessUpgradeRequest — self-hosted (the host agent, #696 Phase 4c)",
327
339
  ).toBe("refused/contract-migrations");
328
340
  });
329
341
  });
342
+
343
+ describe("assessUpgradeRequest — the converge route (#819)", () => {
344
+ const assess = (row: {
345
+ manifest?: ReleaseManifestResult;
346
+ installed?: InstalledState;
347
+ trigger?: "manual" | "auto";
348
+ policy?: Partial<StoredUpdatePolicy>;
349
+ }) =>
350
+ assessUpgradeRequest({
351
+ request: { targetVersion: "0.11.0", trigger: row.trigger ?? "manual" },
352
+ manifest: row.manifest ?? verified({ infraContractVersion: 3 }),
353
+ installed: row.installed ?? INSTALLED,
354
+ policy: { ...POLICY, ...row.policy },
355
+ now: NOW,
356
+ route: "converge",
357
+ });
358
+ const code = (d: ReturnType<typeof assess>) =>
359
+ d.kind === "stop" ? `${d.status}/${d.codes.join(",")}` : d.kind;
360
+
361
+ it("installs a release that raises the infrastructure contract: that is its job", () => {
362
+ expect(code(assess({}))).toBe("proceed");
363
+ });
364
+
365
+ it("the contract bump is no reason to warn about destructive migrations", () => {
366
+ const d = assess({});
367
+ expect(d.kind === "proceed" ? d.warnings : ["stopped"]).toEqual([]);
368
+ });
369
+
370
+ it("converges automatically only with the Owner's autoConverge (#864), default off", () => {
371
+ expect(code(assess({ trigger: "auto" }))).toBe("refused/policy");
372
+ const off = assess({ trigger: "auto" });
373
+ expect(off.kind === "stop" ? off.reason : "").toContain("Settings → Updates");
374
+ expect(code(assess({ trigger: "auto", policy: { autoConverge: true } }))).toBe("proceed");
375
+ });
376
+
377
+ it("autoConverge never widens the rest of the policy: kinds, mode and pause still decide", () => {
378
+ const on = { autoConverge: true };
379
+ // 0.10.45 → 0.11.0 is a minor release.
380
+ expect(code(assess({ trigger: "auto", policy: { ...on, autoMinor: false } }))).toBe(
381
+ "refused/policy",
382
+ );
383
+ expect(code(assess({ trigger: "auto", policy: { ...on, mode: "notify" } }))).toBe(
384
+ "refused/policy",
385
+ );
386
+ expect(code(assess({ trigger: "auto", policy: { ...on, paused: true } }))).toBe(
387
+ "refused/policy",
388
+ );
389
+ // The Owner's click needs no autoConverge.
390
+ expect(code(assess({ trigger: "manual" }))).toBe("proceed");
391
+ });
392
+
393
+ it("keeps every other refusal: signature, downgrade, minFromVersion, unknown version", () => {
394
+ expect(code(assess({ manifest: broken("signature") }))).toBe("refused/signature");
395
+ expect(code(assess({ manifest: broken("blob-digest") }))).toBe("refused/signature");
396
+ expect(code(assess({ installed: { version: "0.11.0", infraContractVersion: 2 } }))).toBe(
397
+ "refused/not-newer",
398
+ );
399
+ expect(
400
+ code(assess({ manifest: verified({ infraContractVersion: 3, minFromVersion: "0.10.50" }) })),
401
+ ).toBe("refused/min-from-version");
402
+ expect(code(assess({ installed: { version: null, infraContractVersion: 2 } }))).toBe(
403
+ "needs_cloud_shell/install-version-unknown",
404
+ );
405
+ });
406
+
407
+ it("the upgrader's own route still hands the contract bump on, naming the converge job first", () => {
408
+ const d = assessUpgradeRequest({
409
+ request: { targetVersion: "0.11.0", trigger: "manual" },
410
+ manifest: verified({ infraContractVersion: 3 }),
411
+ installed: INSTALLED,
412
+ policy: POLICY,
413
+ now: NOW,
414
+ });
415
+ expect(code(d)).toBe("needs_cloud_shell/infra-contract");
416
+ expect(d.kind === "stop" ? d.reason : "").toContain("converge job");
417
+ });
418
+ });