@caelo-cms/provisioning 0.11.10 → 0.11.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adopt-existing.d.ts +6 -2
  4. package/dist/adopt-existing.d.ts.map +1 -1
  5. package/dist/adopt-existing.js +57 -0
  6. package/dist/adopt-existing.js.map +1 -1
  7. package/dist/agent/command.d.ts +5 -2
  8. package/dist/agent/command.d.ts.map +1 -1
  9. package/dist/agent/command.js +1 -1
  10. package/dist/agent/command.js.map +1 -1
  11. package/dist/agent-db.js +196 -23
  12. package/dist/cli-routing.js +1 -1
  13. package/dist/cli-routing.js.map +1 -1
  14. package/dist/cli.js +59 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/cloudsql-tls.d.ts +30 -0
  17. package/dist/cloudsql-tls.d.ts.map +1 -1
  18. package/dist/cloudsql-tls.js +7 -1
  19. package/dist/cloudsql-tls.js.map +1 -1
  20. package/dist/compose.d.ts +14 -0
  21. package/dist/compose.d.ts.map +1 -1
  22. package/dist/compose.js +54 -1
  23. package/dist/compose.js.map +1 -1
  24. package/dist/converge-db-trust.d.ts +44 -0
  25. package/dist/converge-db-trust.d.ts.map +1 -0
  26. package/dist/converge-db-trust.js +158 -0
  27. package/dist/converge-db-trust.js.map +1 -0
  28. package/dist/converge-job-gcp.d.ts +12 -2
  29. package/dist/converge-job-gcp.d.ts.map +1 -1
  30. package/dist/converge-job-gcp.js +42 -3
  31. package/dist/converge-job-gcp.js.map +1 -1
  32. package/dist/converge-job-preflight.d.ts +34 -2
  33. package/dist/converge-job-preflight.d.ts.map +1 -1
  34. package/dist/converge-job-preflight.js +76 -1
  35. package/dist/converge-job-preflight.js.map +1 -1
  36. package/dist/converge-job-setup.d.ts.map +1 -1
  37. package/dist/converge-job-setup.js +4 -1
  38. package/dist/converge-job-setup.js.map +1 -1
  39. package/dist/converge-job.d.ts +44 -3
  40. package/dist/converge-job.d.ts.map +1 -1
  41. package/dist/converge-job.js +105 -11
  42. package/dist/converge-job.js.map +1 -1
  43. package/dist/gcp-names.d.ts +13 -0
  44. package/dist/gcp-names.d.ts.map +1 -1
  45. package/dist/gcp-names.js +21 -0
  46. package/dist/gcp-names.js.map +1 -1
  47. package/dist/index.d.ts +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/infra-contract.d.ts +1 -1
  52. package/dist/infra-contract.d.ts.map +1 -1
  53. package/dist/infra-contract.js +22 -1
  54. package/dist/infra-contract.js.map +1 -1
  55. package/dist/install-job.d.ts +10 -2
  56. package/dist/install-job.d.ts.map +1 -1
  57. package/dist/install-job.js +10 -1
  58. package/dist/install-job.js.map +1 -1
  59. package/dist/install-state.d.ts +7 -0
  60. package/dist/install-state.d.ts.map +1 -1
  61. package/dist/install-state.js.map +1 -1
  62. package/dist/lifecycle.d.ts.map +1 -1
  63. package/dist/lifecycle.js +3 -0
  64. package/dist/lifecycle.js.map +1 -1
  65. package/dist/mcp-auth-proxy-setup.d.ts +3 -1
  66. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  67. package/dist/mcp-auth-proxy-setup.js +9 -4
  68. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  69. package/dist/migration-runner.d.ts.map +1 -1
  70. package/dist/migration-runner.js +8 -3
  71. package/dist/migration-runner.js.map +1 -1
  72. package/dist/migration-targets.d.ts +11 -0
  73. package/dist/migration-targets.d.ts.map +1 -0
  74. package/dist/migration-targets.js +12 -0
  75. package/dist/migration-targets.js.map +1 -0
  76. package/dist/operator-access.d.ts +10 -0
  77. package/dist/operator-access.d.ts.map +1 -1
  78. package/dist/operator-access.js +5 -0
  79. package/dist/operator-access.js.map +1 -1
  80. package/dist/pool-budget-stack.d.ts +13 -1
  81. package/dist/pool-budget-stack.d.ts.map +1 -1
  82. package/dist/pool-budget-stack.js +11 -1
  83. package/dist/pool-budget-stack.js.map +1 -1
  84. package/dist/public-role-isolation.d.ts +7 -3
  85. package/dist/public-role-isolation.d.ts.map +1 -1
  86. package/dist/public-role-isolation.js +25 -13
  87. package/dist/public-role-isolation.js.map +1 -1
  88. package/dist/release-contract.d.ts.map +1 -1
  89. package/dist/release-contract.js +3 -0
  90. package/dist/release-contract.js.map +1 -1
  91. package/dist/runtime-secrets.d.ts +2 -0
  92. package/dist/runtime-secrets.d.ts.map +1 -1
  93. package/dist/runtime-secrets.js +19 -15
  94. package/dist/runtime-secrets.js.map +1 -1
  95. package/dist/scheduler-setup.d.ts +75 -0
  96. package/dist/scheduler-setup.d.ts.map +1 -0
  97. package/dist/scheduler-setup.js +331 -0
  98. package/dist/scheduler-setup.js.map +1 -0
  99. package/dist/self-hosted-roles.d.ts +16 -9
  100. package/dist/self-hosted-roles.d.ts.map +1 -1
  101. package/dist/self-hosted-roles.js +49 -15
  102. package/dist/self-hosted-roles.js.map +1 -1
  103. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  104. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  105. package/dist/self-hosted-scheduler-interval.js +68 -0
  106. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  107. package/dist/self-hosted-upgrade.d.ts +7 -4
  108. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  109. package/dist/self-hosted-upgrade.js +9 -5
  110. package/dist/self-hosted-upgrade.js.map +1 -1
  111. package/dist/stack-contract.d.ts +67 -6
  112. package/dist/stack-contract.d.ts.map +1 -1
  113. package/dist/stack-contract.js +87 -6
  114. package/dist/stack-contract.js.map +1 -1
  115. package/dist/staging-database.d.ts +22 -0
  116. package/dist/staging-database.d.ts.map +1 -0
  117. package/dist/staging-database.js +117 -0
  118. package/dist/staging-database.js.map +1 -0
  119. package/dist/upgrade/converge.d.ts +5 -0
  120. package/dist/upgrade/converge.d.ts.map +1 -1
  121. package/dist/upgrade/converge.js +110 -10
  122. package/dist/upgrade/converge.js.map +1 -1
  123. package/dist/upgrade/upgrade.d.ts +6 -0
  124. package/dist/upgrade/upgrade.d.ts.map +1 -1
  125. package/dist/upgrade/upgrade.js +3 -0
  126. package/dist/upgrade/upgrade.js.map +1 -1
  127. package/dist/upgrader-gcp.d.ts +5 -4
  128. package/dist/upgrader-gcp.d.ts.map +1 -1
  129. package/dist/upgrader-gcp.js +5 -4
  130. package/dist/upgrader-gcp.js.map +1 -1
  131. package/dist/upgrader-iam-preflight.d.ts +8 -6
  132. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  133. package/dist/upgrader-iam-preflight.js +5 -1
  134. package/dist/upgrader-iam-preflight.js.map +1 -1
  135. package/dist/wizard.d.ts +7 -0
  136. package/dist/wizard.d.ts.map +1 -1
  137. package/dist/wizard.js +11 -0
  138. package/dist/wizard.js.map +1 -1
  139. package/dist/wizards/gcp-cost.d.ts +31 -0
  140. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  141. package/dist/wizards/gcp-cost.js +82 -12
  142. package/dist/wizards/gcp-cost.js.map +1 -1
  143. package/dist/wizards/gcp.d.ts.map +1 -1
  144. package/dist/wizards/gcp.js +87 -11
  145. package/dist/wizards/gcp.js.map +1 -1
  146. package/package.json +2 -2
  147. package/src/adapter.ts +6 -0
  148. package/src/admin-memory.test.ts +1 -1
  149. package/src/adopt-existing.test.ts +46 -0
  150. package/src/adopt-existing.ts +58 -2
  151. package/src/agent/command.ts +6 -3
  152. package/src/caddy-redirects.test.ts +1 -1
  153. package/src/cli-routing.test.ts +1 -1
  154. package/src/cli-routing.ts +1 -1
  155. package/src/cli.ts +63 -8
  156. package/src/cloudsql-tls.test.ts +30 -0
  157. package/src/cloudsql-tls.ts +10 -3
  158. package/src/compose.ts +68 -0
  159. package/src/converge-db-trust.test.ts +237 -0
  160. package/src/converge-db-trust.ts +212 -0
  161. package/src/converge-job-gcp.ts +42 -2
  162. package/src/converge-job-preflight.test.ts +106 -0
  163. package/src/converge-job-preflight.ts +97 -3
  164. package/src/converge-job-setup.ts +4 -1
  165. package/src/converge-job.test.ts +213 -0
  166. package/src/converge-job.ts +175 -19
  167. package/src/database-tls-builders.test.ts +3 -2
  168. package/src/deployed-release.test.ts +3 -0
  169. package/src/gateway-env-isolation.test.ts +40 -7
  170. package/src/gcp-names.ts +25 -0
  171. package/src/index.test.ts +3 -3
  172. package/src/index.ts +6 -0
  173. package/src/infra-contract.test.ts +5 -3
  174. package/src/infra-contract.ts +22 -1
  175. package/src/install-job.ts +10 -1
  176. package/src/install-state.ts +7 -0
  177. package/src/lifecycle.ts +3 -0
  178. package/src/mcp-auth-proxy-setup.test.ts +33 -1
  179. package/src/mcp-auth-proxy-setup.ts +11 -4
  180. package/src/migration-runner.ts +14 -9
  181. package/src/migration-targets.ts +12 -0
  182. package/src/operator-access.test.ts +15 -0
  183. package/src/operator-access.ts +14 -0
  184. package/src/pool-budget-stack.ts +15 -0
  185. package/src/pool-budget-stacks.test.ts +38 -13
  186. package/src/public-role-isolation.test.ts +18 -8
  187. package/src/public-role-isolation.ts +31 -17
  188. package/src/release-contract.ts +3 -0
  189. package/src/runtime-secrets.test.ts +18 -8
  190. package/src/runtime-secrets.ts +37 -18
  191. package/src/scheduler-setup.test.ts +201 -0
  192. package/src/scheduler-setup.ts +375 -0
  193. package/src/self-hosted-backup.test.ts +1 -0
  194. package/src/self-hosted-compose.test.ts +72 -1
  195. package/src/self-hosted-roles.integration.test.ts +2 -0
  196. package/src/self-hosted-roles.test.ts +49 -19
  197. package/src/self-hosted-roles.ts +60 -20
  198. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  199. package/src/self-hosted-scheduler-interval.ts +79 -0
  200. package/src/self-hosted-start.test.ts +1 -0
  201. package/src/self-hosted-upgrade.test.ts +2 -1
  202. package/src/self-hosted-upgrade.ts +14 -8
  203. package/src/stack-contract.test.ts +31 -3
  204. package/src/stack-contract.ts +113 -6
  205. package/src/stack-converge-secrets.test.ts +2 -0
  206. package/src/stack-converge.test.ts +6 -4
  207. package/src/staging-database.test.ts +114 -0
  208. package/src/staging-database.ts +148 -0
  209. package/src/upgrade/converge.ts +130 -9
  210. package/src/upgrade/upgrade.test.ts +85 -2
  211. package/src/upgrade/upgrade.ts +9 -0
  212. package/src/upgrader-gcp.ts +5 -4
  213. package/src/upgrader-iam-preflight.ts +15 -10
  214. package/src/wizard.ts +19 -0
  215. package/src/wizards/gcp-cost.test.ts +54 -1
  216. package/src/wizards/gcp-cost.ts +102 -12
  217. package/src/wizards/gcp.ts +109 -8
  218. package/stacks/aws/Pulumi.yaml +1 -1
  219. package/stacks/aws/index.ts +41 -1
  220. package/stacks/azure/index.ts +51 -3
  221. package/stacks/gcp/Pulumi.yaml +1 -1
  222. package/stacks/gcp/README.md +1 -0
  223. package/stacks/gcp/index.ts +50 -1
  224. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  225. package/stacks/gcp-firebase/index.ts +50 -1
  226. package/stacks/self-hosted/Pulumi.yaml +3 -0
  227. package/stacks/self-hosted/README.md +4 -0
  228. package/stacks/self-hosted/index.ts +29 -4
@@ -19,6 +19,8 @@ import {
19
19
  type ConvergeQueue,
20
20
  type ConvergeUpgradeOutcome,
21
21
  runConvergeJob,
22
+ SELF_GRANT_RECHECK_ATTEMPTS,
23
+ type SecureDatabaseResult,
22
24
  } from "./converge-job.js";
23
25
  import type { ExecutionState } from "./install-job.js";
24
26
  import type { InstallMetadata } from "./install-state.js";
@@ -147,6 +149,10 @@ function harness(
147
149
  imageSigned?: boolean;
148
150
  /** What IAM answers the self-check; every permission asked for by default. */
149
151
  iam?: IamTester;
152
+ /** #1373 — the database TLS step's answer; null: the queue dials the DNS name already. */
153
+ secureDatabase?: SecureDatabaseResult | null;
154
+ /** What a self-grant answers; ok by default. */
155
+ grant?: (role: string) => { ok: true } | { ok: false; error: string };
150
156
  } = {},
151
157
  ): Harness {
152
158
  const calls: string[] = [];
@@ -156,11 +162,25 @@ function harness(
156
162
  let stored = opts.checkpoint ?? null;
157
163
  const deps: ConvergeJobDeps = {
158
164
  queue: q.queue,
165
+ secureDatabase:
166
+ opts.secureDatabase === undefined || opts.secureDatabase === null
167
+ ? null
168
+ : async () => {
169
+ calls.push("secure database");
170
+ return opts.secureDatabase as SecureDatabaseResult;
171
+ },
159
172
  projectId: "acme",
160
173
  region: "europe-west1",
161
174
  iam: opts.iam ?? {
162
175
  granted: async (_resource, permissions) => ({ ok: true, granted: permissions }),
163
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
+ },
164
184
  execution: "caelo-production-converge-run1",
165
185
  ownVersion: opts.ownVersion ?? "0.11.0",
166
186
  fetchManifest: async (v) => {
@@ -455,6 +475,139 @@ describe("runConvergeJob — stage 2 (the target release's own installer)", () =
455
475
  });
456
476
  });
457
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
+
458
611
  describe("runConvergeJob — resume after a killed run", () => {
459
612
  it("a run that died is adopted once Cloud Run says it ended, and resumes from the checkpoint", async () => {
460
613
  const h = harness({
@@ -556,6 +709,66 @@ describe("runConvergeJob — resume after a killed run", () => {
556
709
  });
557
710
  });
558
711
 
712
+ describe("runConvergeJob — the database TLS step (#1373)", () => {
713
+ const DNS = "abc.p-label.europe-west1.sql-psa.goog";
714
+
715
+ it("a failed TLS step reaches the timeline and ends the request, before any roll", async () => {
716
+ const h = harness({
717
+ secureDatabase: {
718
+ ok: false,
719
+ error:
720
+ "database TLS could not be set up: caelo-production-pg-1 reports no PRIVATE_SERVICES_ACCESS DNS name",
721
+ },
722
+ });
723
+ const r = await runConvergeJob(h.deps);
724
+ expect(r.exitCode).toBe(1);
725
+ expect(r.status).toBe("failed");
726
+ expect(h.upgrades).toEqual([]);
727
+ // Under the lock, after the resumable checkpoint, released at the end.
728
+ expect(h.calls).toEqual([
729
+ "manifest 0.11.0",
730
+ "lock",
731
+ "checkpoint running",
732
+ "secure database",
733
+ "checkpoint failed",
734
+ "unlock",
735
+ ]);
736
+ const end = h.q.ends[0];
737
+ expect(end?.codes).toEqual(["database-tls"]);
738
+ expect(end?.reason).toContain("reports no PRIVATE_SERVICES_ACCESS DNS name");
739
+ // The step's start and its error are timeline events of the request.
740
+ const converge = h.q.events.filter((e) => e.phase === "converge");
741
+ expect(converge[0]?.message).toStartWith("Setting up verified encryption");
742
+ const error = h.q.events.find((e) => e.level === "error");
743
+ expect(error?.message).toContain("reports no PRIVATE_SERVICES_ACCESS DNS name");
744
+ });
745
+
746
+ it("a successful TLS step runs right before the upgrade and names the verified host", async () => {
747
+ const h = harness({ secureDatabase: { ok: true, host: DNS } });
748
+ const r = await runConvergeJob(h.deps);
749
+ expect(r.status).toBe("succeeded");
750
+ const at = h.calls.indexOf("secure database");
751
+ expect(at).toBeGreaterThan(h.calls.indexOf("lock"));
752
+ expect(h.calls[at + 1]).toBe("upgrade");
753
+ expect(h.q.events.some((e) => e.level === "success" && e.message.includes(DNS))).toBe(true);
754
+ });
755
+
756
+ it("stage 1 never runs it: the release's own installer changes the database", async () => {
757
+ const h = harness({ ownVersion: "0.10.53", secureDatabase: { ok: true, host: DNS } });
758
+ await runConvergeJob(h.deps);
759
+ expect(h.calls.includes("secure database")).toBe(false);
760
+ });
761
+
762
+ it("a stop before the lock leaves the database alone", async () => {
763
+ const h = harness({
764
+ secureDatabase: { ok: true, host: DNS },
765
+ manifest: { ok: false, error: { code: "signature", message: "no valid signature" } },
766
+ });
767
+ await runConvergeJob(h.deps);
768
+ expect(h.calls.includes("secure database")).toBe(false);
769
+ });
770
+ });
771
+
559
772
  describe("runConvergeJob — nothing to do", () => {
560
773
  it("exits cleanly without a converge request", async () => {
561
774
  const h = harness({ claim: null });
@@ -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,14 +127,46 @@ 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
+
138
+ /** How {@link ConvergeJobDeps.secureDatabase} ended. */
139
+ export type SecureDatabaseResult =
140
+ | { readonly ok: true; readonly host: string }
141
+ | { readonly ok: false; readonly error: string };
142
+
119
143
  export interface ConvergeJobDeps {
120
144
  readonly queue: ConvergeQueue;
145
+ /**
146
+ * #1373 — the install's database TLS step (#1338, cloudsql-tls.ts), and
147
+ * then {@link queue} moved onto the database's DNS name with verify-full.
148
+ * Null when the queue already dials that name. Until it ran the queue
149
+ * dials the instance's IP (converge-db-trust.ts), so a failure of the
150
+ * step reaches the timeline and the request's end like any other step;
151
+ * when it fails the queue is reopened on whatever the install dials then.
152
+ */
153
+ readonly secureDatabase: (() => Promise<SecureDatabaseResult>) | null;
121
154
  /** From the metadata server — never from env or argv. */
122
155
  readonly projectId: string;
123
156
  /** The region this job runs in (metadata server). */
124
157
  readonly region: string;
125
158
  /** Asks IAM as this job's own identity (the IAM self-check, #884). */
126
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>;
127
170
  /** This Cloud Run execution; null outside Cloud Run. */
128
171
  readonly execution: string | null;
129
172
  /** The Caelo release this code belongs to (CAELO_VERSION of the image). */
@@ -312,18 +355,11 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
312
355
 
313
356
  // #884 — decided to run: ask IAM for what the rest needs before the
314
357
  // lock, the hand-over or the backup (converge-job-preflight.ts). Both
315
- // stages ask; stage 2's list is the target release's own.
316
- const access = await runIamPreflight(
317
- convergeAccessChecks({ projectId: deps.projectId, region: deps.region, env: GCP_STACK_ENV }),
318
- deps.iam,
319
- );
320
- if (access.kind === "missing") {
321
- return stopWith(
322
- "needs_cloud_shell",
323
- ["iam-preflight"],
324
- preflightMissingReason(access.missing, CONVERGE_PREFLIGHT_VOICE),
325
- );
326
- }
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();
327
363
  if (access.kind === "error") {
328
364
  return stopWith(
329
365
  "failed",
@@ -331,12 +367,36 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
331
367
  preflightErrorReason(access.errors, CONVERGE_PREFLIGHT_VOICE),
332
368
  );
333
369
  }
334
- 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
+ }
335
389
 
336
- if (compareSemver(deps.ownVersion, m.version) !== 0) {
390
+ if (stage1) {
337
391
  return await handOver(m);
338
392
  }
339
- 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
+ );
340
400
  } catch (e) {
341
401
  return stopWith(
342
402
  "failed",
@@ -400,6 +460,7 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
400
460
  fromVersion: string,
401
461
  checkpoint: ConvergeCheckpoint | null,
402
462
  judged: InstallMetadata,
463
+ toGrant: readonly InstallJobRole[],
403
464
  ): Promise<UpgraderRunResult> {
404
465
  const installId = judged.installId;
405
466
  const own = await deps.ownImageDigest();
@@ -439,6 +500,13 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
439
500
  const stale = recordsChangedSince(judged, changed);
440
501
  if (stale) return stopWith(stale.status, stale.codes, stale.reason);
441
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
+
442
510
  const startedAt = deps.now().toISOString();
443
511
  let current: ConvergeCheckpoint = {
444
512
  statusVersion: 1,
@@ -463,6 +531,24 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
463
531
  save({});
464
532
 
465
533
  r.phase("converge");
534
+ // #1373 — stage 2 only, under the lock and after every check: it
535
+ // changes the database instance, its DNS and the services' URLs.
536
+ if (deps.secureDatabase) {
537
+ r.info(
538
+ "Setting up verified encryption for the database connection (the database's certificate and its private DNS name).",
539
+ );
540
+ await r.flush();
541
+ const secured = await deps.secureDatabase();
542
+ if (!secured.ok) {
543
+ save({ state: "failed", phase: "stopped" });
544
+ return stopWith(
545
+ "failed",
546
+ ["database-tls"],
547
+ `The update to ${m.version} stopped at the database's TLS step, before it rolled anything: ${secured.error}. The previous release keeps serving. Fix what it names and start the update again; it continues where it stopped.`,
548
+ );
549
+ }
550
+ r.success(`The database connection is verified (${secured.host}).`);
551
+ }
466
552
  const outcome = await deps.upgrade({
467
553
  manifest: m,
468
554
  reporter: r,
@@ -518,6 +604,76 @@ export async function runConvergeJob(deps: ConvergeJobDeps): Promise<UpgraderRun
518
604
  );
519
605
  }
520
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
+
521
677
  /**
522
678
  * Whether install.json, re-read under the lock, is still what the run was
523
679
  * judged on; how the request ends otherwise.
@@ -28,7 +28,7 @@ describe("database URL builders (#1338)", () => {
28
28
  const yaml = generateDockerCompose({
29
29
  domain: "example.com",
30
30
  postgresPassword: "superpw",
31
- rolePasswords: { admin: "adminpw", public: "publicpw" },
31
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
32
32
  caeloSecretKek: "0".repeat(64),
33
33
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
34
34
  internalSecret: "i".repeat(48),
@@ -42,7 +42,8 @@ describe("database URL builders (#1338)", () => {
42
42
  project: "caelo-example-com",
43
43
  });
44
44
  const urls = [...yaml.matchAll(/_DATABASE_URL: "([^"]+)"/g)].map((m) => m[1] as string);
45
- expect(urls).toHaveLength(3);
45
+ // admin: cms_admin, cms_public, cms_public_staging (#1329); gateway: cms_public.
46
+ expect(urls).toHaveLength(4);
46
47
  for (const url of urls) expect(databaseTlsProblem(url)).toBeNull();
47
48
  });
48
49
 
@@ -128,6 +128,9 @@ describe("checkDeployedRuntimeEnv", () => {
128
128
  const adminOk = svc([
129
129
  secret("ADMIN_DATABASE_PASSWORD"),
130
130
  secret("PUBLIC_ADMIN_DATABASE_PASSWORD"),
131
+ // #1329 — the staging database's pool: a release from before it is
132
+ // refused here too and goes through `upgrade` first.
133
+ secret("PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD"),
131
134
  ]);
132
135
  const gatewayOk = svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]);
133
136
  const proxyOk = svc([secret("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]);
@@ -16,6 +16,12 @@
16
16
  * self-hosted the generated compose gateway service: env, secrets,
17
17
  * mounts
18
18
  *
19
+ * Issue #1329 — the same crosswise between the two public databases: the
20
+ * live gateway's env names nothing of the staging database
21
+ * (cms_public_staging, public_staging_role, its password secret), and its
22
+ * identity cannot read the staging login's password, on every adapter. The
23
+ * staging gateway (#1330) gets the mirror-image checks with its service.
24
+ *
19
25
  * The gateway itself refuses to boot on such an env
20
26
  * (apps/api-gateway `adminDatabaseAccessInEnv`); this catches it at review
21
27
  * time, before a release can ship it.
@@ -42,6 +48,12 @@ const ADMIN_ACCESS = /cms_admin|admin_role|gateway_role|ADMIN_DATABASE|GATEWAY_D
42
48
  /** Secrets that unlock a cms_admin login. */
43
49
  const ADMIN_SECRETS = ["postgres-password", "gateway-role-password"];
44
50
 
51
+ /** #1329 — anything in the live gateway's env that reaches the staging database. */
52
+ const STAGING_ACCESS = /cms_public_staging|public_staging_role|PUBLIC_STAGING/;
53
+
54
+ /** #1329 — the secret that unlocks the staging gateway's login. */
55
+ const STAGING_SECRETS = ["public-staging-role-password"];
56
+
45
57
  /** The secrets the gateway reads on GCP: its own login and the staging gate key. */
46
58
  const GCP_GATEWAY_SECRETS = [
47
59
  "public-role-password",
@@ -68,11 +80,19 @@ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
68
80
  databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
69
81
  });
70
82
 
71
- it("carries no cms_admin URL, role or credential name", () => {
83
+ it("carries no cms_admin URL, role or credential name, and nothing of the staging database (#1329)", () => {
72
84
  for (const v of env) {
73
85
  expect(v.name).not.toMatch(ADMIN_ACCESS);
74
- if ("value" in v && typeof v.value === "string") expect(v.value).not.toMatch(ADMIN_ACCESS);
86
+ expect(v.name).not.toMatch(STAGING_ACCESS);
87
+ if ("value" in v && typeof v.value === "string") {
88
+ expect(v.value).not.toMatch(ADMIN_ACCESS);
89
+ expect(v.value).not.toMatch(STAGING_ACCESS);
90
+ }
75
91
  }
92
+ expect(env.find((v) => v.name === "CAELO_GATEWAY_ENVIRONMENT")).toEqual({
93
+ name: "CAELO_GATEWAY_ENVIRONMENT",
94
+ value: "live",
95
+ });
76
96
  expect(env.find((v) => v.name === "PUBLIC_DATABASE_URL")).toEqual({
77
97
  name: "PUBLIC_DATABASE_URL",
78
98
  value:
@@ -80,12 +100,16 @@ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
80
100
  });
81
101
  });
82
102
 
83
- it("references no secret that unlocks a cms_admin login", () => {
103
+ it("references no secret that unlocks a cms_admin or the staging login", () => {
84
104
  for (const secret of Object.values(SERVICE_SECRET_ENV.gateway)) {
85
105
  expect(ADMIN_SECRETS).not.toContain(secret);
106
+ expect(STAGING_SECRETS).not.toContain(secret);
86
107
  }
87
108
  const gatewayBindings = runtimeSecretBindings().filter((b) => b.service === "gateway");
88
- for (const b of gatewayBindings) expect(ADMIN_SECRETS).not.toContain(b.secret);
109
+ for (const b of gatewayBindings) {
110
+ expect(ADMIN_SECRETS).not.toContain(b.secret);
111
+ expect(STAGING_SECRETS).not.toContain(b.secret);
112
+ }
89
113
  });
90
114
 
91
115
  for (const provider of ["gcp", "gcp-firebase"] as const) {
@@ -134,7 +158,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
134
158
  const out = generateDockerCompose({
135
159
  domain: "example.com",
136
160
  postgresPassword: "superpw",
137
- rolePasswords: { admin: "adminpw", public: "publicpw" },
161
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "stagingpw" },
138
162
  caeloSecretKek: "0".repeat(64),
139
163
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
140
164
  internalSecret: "i".repeat(48),
@@ -152,7 +176,9 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
152
176
  const gateway = out.slice(start, end === -1 ? undefined : end);
153
177
  expect(gateway).toContain("PUBLIC_DATABASE_URL:");
154
178
  expect(gateway).not.toMatch(ADMIN_ACCESS);
155
- expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw/);
179
+ expect(gateway).not.toMatch(STAGING_ACCESS);
180
+ expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw|stagingpw/);
181
+ expect(gateway).toContain('CAELO_GATEWAY_ENVIRONMENT: "live"');
156
182
  // No other way in: no Compose secrets, no env file, and exactly one
157
183
  // mount — the database CA certificate (#1338), public, no credential.
158
184
  expect(gateway).not.toMatch(/^ {4}(secrets|env_file):/m);
@@ -164,6 +190,8 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
164
190
  expect(keys.sort()).toEqual([
165
191
  // #1254 — its cms_public pool size (pool-budget-stack.ts).
166
192
  "CAELO_DB_PUBLIC_POOL_MAX",
193
+ // #1329 — the live site's gateway.
194
+ "CAELO_GATEWAY_ENVIRONMENT",
167
195
  "CAELO_TRUSTED_PROXY_HOPS",
168
196
  "GATEWAY_PORT",
169
197
  "NODE_ENV",
@@ -181,6 +209,9 @@ describe("azure: the gateway container app (#635)", () => {
181
209
  const block = src.slice(start, src.indexOf("});", start));
182
210
  expect(block).toContain('{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }');
183
211
  expect(block).not.toMatch(/adminDatabase|gatewayDatabase|ADMIN_DATABASE|GATEWAY_DATABASE/);
212
+ // #1329 — nothing of the staging database, and it says it is the live one.
213
+ expect(block).not.toMatch(/publicStaging|PUBLIC_STAGING/);
214
+ expect(block).toContain('{ name: GATEWAY_ENVIRONMENT_ENV, value: "live" }');
184
215
  });
185
216
 
186
217
  it("builds no cms_admin URL for any role but the admin's", () => {
@@ -206,7 +237,9 @@ describe("aws: the stack (#635)", () => {
206
237
 
207
238
  it("the gateway's task role reads only the public_role password (review finding)", () => {
208
239
  expect(AWS_SERVICE_SECRETS.gateway).toEqual(["public-role-password"]);
209
- for (const secret of ADMIN_SECRETS) expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
240
+ for (const secret of [...ADMIN_SECRETS, ...STAGING_SECRETS]) {
241
+ expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
242
+ }
210
243
  });
211
244
 
212
245
  it("each service gets its own task role whose policy is built from that table", () => {