@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
package/src/gcp-names.ts CHANGED
@@ -221,6 +221,31 @@ export function captureServiceAccountEmail(projectId: string, env: string): stri
221
221
  return `${captureServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
222
222
  }
223
223
 
224
+ /**
225
+ * #1140 — account id of the scheduler tick's service account: signs its own
226
+ * IAP credential and calls the admin's tick endpoint, nothing else
227
+ * (scheduler-setup.ts). `caelo-production-scheduler` is 26 of the 30
228
+ * characters an account id may have.
229
+ */
230
+ export function schedulerServiceAccountId(env: string): string {
231
+ return `${gcpNamePrefix(env)}-scheduler`;
232
+ }
233
+
234
+ /** Email of the scheduler tick's service account. */
235
+ export function schedulerServiceAccountEmail(projectId: string, env: string): string {
236
+ return `${schedulerServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
237
+ }
238
+
239
+ /** #1140 — the Cloud Workflows workflow one scheduler tick runs (scheduler-setup.ts). */
240
+ export function schedulerWorkflowName(env: string): string {
241
+ return `${gcpNamePrefix(env)}-scheduler-tick`;
242
+ }
243
+
244
+ /** #1140 — the Cloud Scheduler job that starts the tick workflow every N minutes. */
245
+ export function schedulerJobName(env: string): string {
246
+ return `${gcpNamePrefix(env)}-scheduler-tick`;
247
+ }
248
+
224
249
  /**
225
250
  * Account id of the upgrader job's own service account: may roll the two
226
251
  * Cloud Run services, run the migration job and back up Cloud SQL, never
package/src/index.test.ts CHANGED
@@ -152,7 +152,7 @@ describe("docker-compose generator", () => {
152
152
  const out = generateDockerCompose({
153
153
  domain: "example.com",
154
154
  postgresPassword: "supersecret",
155
- rolePasswords: { admin: "adminpw", public: "publicpw" },
155
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
156
156
  caeloSecretKek: "0".repeat(64),
157
157
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
158
158
  internalSecret: "i".repeat(48),
@@ -175,7 +175,7 @@ describe("docker-compose generator", () => {
175
175
  const out = generateDockerCompose({
176
176
  domain: "example.com",
177
177
  postgresPassword: "supersecret",
178
- rolePasswords: { admin: "adminpw", public: "publicpw" },
178
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
179
179
  caeloSecretKek: "0".repeat(64),
180
180
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
181
181
  internalSecret: "i".repeat(48),
@@ -201,7 +201,7 @@ describe("docker-compose generator", () => {
201
201
  const out = generateDockerCompose({
202
202
  domain: "example.com",
203
203
  postgresPassword: 'pa"ss',
204
- rolePasswords: { admin: "a", public: "b" },
204
+ rolePasswords: { admin: "a", public: "b", publicStaging: "ps" },
205
205
  caeloSecretKek: "0".repeat(64),
206
206
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
207
207
  internalSecret: "i".repeat(48),
package/src/index.ts CHANGED
@@ -46,12 +46,18 @@ export {
46
46
  selfHostedCdnCopy,
47
47
  } from "./cdn-copy.js";
48
48
  export { type ComposeSpec, generateDockerCompose, type RolePasswords } from "./compose.js";
49
+ export {
50
+ type ConvergeDatabaseTarget,
51
+ convergeDatabaseTarget,
52
+ serverIdentityProblem,
53
+ } from "./converge-db-trust.js";
49
54
  export {
50
55
  type ConvergeClaim,
51
56
  type ConvergeJobDeps,
52
57
  type ConvergeQueue,
53
58
  type ConvergeUpgradeOutcome,
54
59
  runConvergeJob,
60
+ type SecureDatabaseResult,
55
61
  } from "./converge-job.js";
56
62
  export { convergeExecutionNameError, gcpConvergeDeps } from "./converge-job-gcp.js";
57
63
  export { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
@@ -20,8 +20,8 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 16: 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)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(16);
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);
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");
@@ -36,6 +36,8 @@ describe("infrastructure contract steps", () => {
36
36
  expect(INFRA_CONTRACT_STEPS[14]).toContain("#1217");
37
37
  expect(INFRA_CONTRACT_STEPS[15]).toContain("#1338");
38
38
  expect(INFRA_CONTRACT_STEPS[16]).toContain("#1328");
39
+ expect(INFRA_CONTRACT_STEPS[17]).toContain("#1329");
40
+ expect(INFRA_CONTRACT_STEPS[18]).toContain("#1140");
39
41
  });
40
42
 
41
43
  it("lists the pending steps oldest first", () => {
@@ -46,6 +48,6 @@ describe("infrastructure contract steps", () => {
46
48
  });
47
49
 
48
50
  it("names a version a newer release added by number", () => {
49
- expect(infraStepsText(16, 17)).toBe("infrastructure step 17 (see that release's notes)");
51
+ expect(infraStepsText(18, 19)).toBe("infrastructure step 19 (see that release's notes)");
50
52
  });
51
53
  });
@@ -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 = 16;
25
+ export const INFRA_CONTRACT_VERSION = 18;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -131,6 +131,25 @@ export const INFRA_CONTRACT_VERSION = 16;
131
131
  // services, accounts, IAM and load-balancer resources — none of which
132
132
  // the in-cloud upgrader may create, and the app-roll rolls three
133
133
  // services from now on.
134
+ // 17 — #1329: staging's own database. The Cloud SQL database
135
+ // `cms_public_staging`, the Admin API user `public_staging_role` (its
136
+ // password the new CLI-generated secret `public-staging-role-password`)
137
+ // and its membership revoke; the admin's PUBLIC_STAGING_ADMIN_DATABASE_URL
138
+ // and the gateway's CAELO_GATEWAY_ENVIRONMENT. The in-cloud upgrader
139
+ // may create no database, user or secret, and the new admin and
140
+ // gateway refuse to start without them. Additive only (converge
141
+ // release 1 of the gateway hardening): nothing is served from the
142
+ // staging database yet, so rolling back stays harmless.
143
+ // 18 — #1140: the scheduler tick (scheduler-setup.ts). Its service account
144
+ // (token creator on itself, workflows.invoker, logWriter, IAP access to
145
+ // the admin), the Cloud Workflows workflow that signs its IAP JWT and
146
+ // calls the admin's /api/internal/scheduler/tick, the one Cloud
147
+ // Scheduler job that starts it every schedulerIntervalMinutes, the
148
+ // operator-access job's CAELO_OPERATOR_ACCESS_IAP_SERVICE_ACCOUNTS (so
149
+ // its sync keeps the account on IAP) and CAELO_SCHEDULER_SERVICE_ACCOUNT
150
+ // on the admin. The new admin runs its periodic work and plugin workers
151
+ // only on that tick, so an in-cloud upgrader must not roll an install
152
+ // onto it before the tick exists.
134
153
 
135
154
  /**
136
155
  * What each contract version adds, in operator terms — the upgrader's
@@ -154,6 +173,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
154
173
  14: "the capture job that takes server-side screenshots outside the admin, its service account, bucket and grants (#1217)",
155
174
  15: "verified TLS to the database: Cloud SQL's shared server CA, encrypted connections only, and a private DNS name the services dial (#1338)",
156
175
  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
+ 17: "the staging site's own database and its gateway login, kept apart from the live data (#1329)",
177
+ 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)",
157
178
  };
158
179
 
159
180
  /**
@@ -65,13 +65,22 @@ export const OPS_IMAGE_REPOSITORY =
65
65
  * Roles the provisioner needs on top of {@link PROVISIONER_ROLE_LIST} when it
66
66
  * runs the install job instead of the operator: the operator-access sync job
67
67
  * creates a project custom role and a Cloud Scheduler job (operator-access.ts),
68
- * and the job's own output has to reach Cloud Logging so the terminal can
68
+ * the scheduler tick deploys a Cloud Workflows workflow (scheduler-setup.ts,
69
+ * #1140; the converge job gets the same roles, converge-job-setup.ts), and
70
+ * the job's own output has to reach Cloud Logging so the terminal can
69
71
  * tail it. Granted only to installs that use the job. Not an escalation: the
70
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`).
71
79
  */
72
80
  export const INSTALL_JOB_ROLES = [
73
81
  "roles/iam.roleAdmin",
74
82
  "roles/cloudscheduler.admin",
83
+ "roles/workflows.editor",
75
84
  "roles/logging.logWriter",
76
85
  ] as const;
77
86
 
@@ -115,6 +115,13 @@ export interface InstallMetadata {
115
115
  * run while it is lower than the release's (upgrade/converge.ts).
116
116
  */
117
117
  infraContractVersion?: number;
118
+ /**
119
+ * #1140 — the `schedulerIntervalMinutes` knob: how often the one Cloud
120
+ * Scheduler job calls the admin's tick (1–15 minutes; `--scheduler-
121
+ * interval-minutes` on the wizard). Absent: the default (5,
122
+ * stack-contract.ts SCHEDULER_INTERVAL_MINUTES).
123
+ */
124
+ schedulerIntervalMinutes?: number;
118
125
  }
119
126
 
120
127
  /** The install's staging host: its recorded setting, else the default. */
package/src/lifecycle.ts CHANGED
@@ -641,6 +641,9 @@ async function upgradeLockedInstall(
641
641
  domain: meta.domain,
642
642
  stagingHost,
643
643
  ownerEmail: meta.ownerEmail,
644
+ ...(meta.schedulerIntervalMinutes !== undefined
645
+ ? { schedulerIntervalMinutes: meta.schedulerIntervalMinutes }
646
+ : {}),
644
647
  targetTag,
645
648
  infraContractVersion: INFRA_CONTRACT_VERSION,
646
649
  skipVerify: opts.skipVerify === true,
@@ -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),
@@ -24,6 +24,7 @@ import { green, red } from "kleur/colors";
24
24
  import { gcloud as defaultGcloud } from "./gcloud.js";
25
25
  import type { GcloudRunner } from "./gcloud-retry.js";
26
26
  import { GCP_STACK_ENV, gcpSecretId, runServiceAccountEmail } from "./gcp-names.js";
27
+ import { MIGRATION_TARGETS } from "./migration-targets.js";
27
28
  import { databaseUrls, SERVICE_SECRET_ENV } from "./stack-contract.js";
28
29
  import { liveContainerEnv, liveDatabaseHost } from "./stack-converge.js";
29
30
 
@@ -58,15 +59,19 @@ const NEEDS_UPGRADE =
58
59
  */
59
60
  export function migrationJobEnvArgs(databaseHost: string, env = GCP_STACK_ENV): string[] {
60
61
  const urls = databaseUrls(databaseHost);
61
- const passwords = (["ADMIN_DATABASE_PASSWORD", "PUBLIC_ADMIN_DATABASE_PASSWORD"] as const).map(
62
- (name) => {
63
- const secret = SERVICE_SECRET_ENV.admin[name];
64
- if (!secret) throw new Error(`the admin env contract has no ${name}`);
65
- return `${name}=${gcpSecretId(env, secret)}:latest`;
66
- },
67
- );
62
+ const passwords = (
63
+ [
64
+ "ADMIN_DATABASE_PASSWORD",
65
+ "PUBLIC_ADMIN_DATABASE_PASSWORD",
66
+ "PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD",
67
+ ] as const
68
+ ).map((name) => {
69
+ const secret = SERVICE_SECRET_ENV.admin[name];
70
+ if (!secret) throw new Error(`the admin env contract has no ${name}`);
71
+ return `${name}=${gcpSecretId(env, secret)}:latest`;
72
+ });
68
73
  return [
69
- `--set-env-vars=ADMIN_DATABASE_URL=${urls.admin},PUBLIC_ADMIN_DATABASE_URL=${urls.publicAdmin}`,
74
+ `--set-env-vars=ADMIN_DATABASE_URL=${urls.admin},PUBLIC_ADMIN_DATABASE_URL=${urls.publicAdmin},PUBLIC_STAGING_ADMIN_DATABASE_URL=${urls.publicStagingAdmin}`,
70
75
  `--set-secrets=${passwords.join(",")}`,
71
76
  ];
72
77
  }
@@ -300,7 +305,7 @@ export async function runMigrationsViaCloudRunJob(
300
305
  sAdmin.stop(green(`Admin config resolved (${migrationImage.slice(-19)})`));
301
306
  }
302
307
 
303
- for (const target of ["admin", "public"] as const) {
308
+ for (const target of MIGRATION_TARGETS) {
304
309
  const s = spinner();
305
310
  s.start(`Applying ${target} migrations via one-shot Cloud Run Job...`);
306
311
  // Delete-then-create so each invocation runs with the freshest
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The targets of `packages/migrations/src/migrate.ts`, in the order every
5
+ * runner applies them — the GCP migration job (migration-runner.ts), the
6
+ * self-hosted install and upgrade (self-hosted-start.ts,
7
+ * self-hosted-upgrade.ts) and the self-hosted stack: cms_admin first (its
8
+ * role audit covers the whole cluster), then the two public databases,
9
+ * which run the same cms_public files (#1329: cms_public_staging is the
10
+ * staging site's twin, with its own gateway login).
11
+ */
12
+ export const MIGRATION_TARGETS = ["admin", "public", "public-staging"] as const;
@@ -205,6 +205,21 @@ describe("ensureOperatorAccessSync", () => {
205
205
  );
206
206
  });
207
207
 
208
+ it("#1140 — tells the job which service accounts keep IAP access (the scheduler tick)", async () => {
209
+ const { run, calls } = freshInstall();
210
+ await ensureOperatorAccessSync(
211
+ { ...target, iapServiceAccounts: ["caelo-production-scheduler@p.iam.gserviceaccount.com"] },
212
+ { run, sleep: async () => {} },
213
+ );
214
+ const deploy = calls.find((c) => c.slice(0, 3).join(" ") === "run jobs deploy") ?? [];
215
+ const env = deploy.find((a) => a.startsWith("--set-env-vars=")) ?? "";
216
+ expect(env).toContain(
217
+ "|CAELO_OPERATOR_ACCESS_IAP_SERVICE_ACCOUNTS=caelo-production-scheduler@p.iam.gserviceaccount.com",
218
+ );
219
+ // The static members stay the install's allowlist (converge-job-gcp.ts compares them).
220
+ expect(env).toContain("|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|");
221
+ });
222
+
208
223
  it("#914 — lets the job read exactly the Google sign-in client's secret, nothing else", async () => {
209
224
  const { run, lines } = freshInstall();
210
225
  await ensureOperatorAccessSync(target, { run, sleep: async () => {} });
@@ -218,11 +218,22 @@ export interface OperatorAccessSyncTarget {
218
218
  * The job reconciles that binding, so anyone missing here loses it.
219
219
  */
220
220
  readonly mcpSigners?: readonly string[];
221
+ /**
222
+ * #1140 — service accounts (besides caelo-mcp) that keep
223
+ * `roles/iap.httpsResourceAccessor` on the admin: the scheduler tick's
224
+ * account (scheduler-setup.ts) calls the admin through IAP. The job
225
+ * reconciles that binding to exactly its members, so anyone missing here
226
+ * loses it on the next run. Emails, not `serviceAccount:` members.
227
+ */
228
+ readonly iapServiceAccounts?: readonly string[];
221
229
  }
222
230
 
223
231
  /** Env var of the sync job listing {@link OperatorAccessSyncTarget.mcpSigners}. */
224
232
  export const MCP_SIGNERS_ENV = "CAELO_OPERATOR_ACCESS_MCP_SIGNERS";
225
233
 
234
+ /** #1140 — env var of the sync job listing {@link OperatorAccessSyncTarget.iapServiceAccounts}. */
235
+ export const IAP_SERVICE_ACCOUNTS_ENV = "CAELO_OPERATOR_ACCESS_IAP_SERVICE_ACCOUNTS";
236
+
226
237
  /** #914 — env var of the sync job naming the Google sign-in client's secret. */
227
238
  export const OPERATOR_OAUTH_CLIENT_SECRET_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_SECRET";
228
239
 
@@ -424,6 +435,9 @@ export async function ensureOperatorAccessSync(
424
435
  ...(target.mcpSigners && target.mcpSigners.length > 0
425
436
  ? [`${MCP_SIGNERS_ENV}=${target.mcpSigners.join(",")}`]
426
437
  : []),
438
+ ...(target.iapServiceAccounts && target.iapServiceAccounts.length > 0
439
+ ? [`${IAP_SERVICE_ACCOUNTS_ENV}=${target.iapServiceAccounts.join(",")}`]
440
+ : []),
427
441
  ];
428
442
  const deploy = await run([
429
443
  "run",
@@ -14,11 +14,14 @@
14
14
 
15
15
  import {
16
16
  computePoolBudget,
17
+ DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
17
18
  type PoolBudget,
18
19
  type PoolBudgetInput,
19
20
  poolBudgetEnv,
20
21
  } from "@caelo-cms/shared";
21
22
 
23
+ export { DEFAULT_STAGING_GATEWAY_MAX_INSTANCES };
24
+
22
25
  /** The adapters that provision a Postgres server. */
23
26
  export type BudgetProvider = "gcp" | "gcp-firebase" | "aws" | "azure" | "self-hosted";
24
27
 
@@ -84,12 +87,24 @@ export const DEFAULT_MAX_CONNECTIONS: Readonly<Record<BudgetProvider, number>> =
84
87
  */
85
88
  export const DEFAULT_GATEWAY_MAX_INSTANCES = 10;
86
89
 
90
+ /**
91
+ * Admin instances a GCP or AWS stack lets start (`adminMaxInstances` knob).
92
+ * Five before #1329; the staging database added the admin's
93
+ * cms_public_staging pool and the staging gateway to the budget, and on the
94
+ * default server (100 connections) five instances no longer get the
95
+ * minimum cms_admin pool. Raising `max_connections` instead was ruled out
96
+ * for db-f1-micro (docs/dev/connection-budget.md, "#1329").
97
+ */
98
+ export const DEFAULT_ADMIN_MAX_INSTANCES = 4;
99
+
87
100
  /** The server and scale-out a stack sizes its pools for. */
88
101
  export interface StackScale {
89
102
  readonly maxConnections: number;
90
103
  /** Every instance that opens the admin's pools (admin-side services included). */
91
104
  readonly adminMaxInstances: number;
92
105
  readonly gatewayMaxInstances: number;
106
+ /** #1329 — the staging gateway's scale-out ({@link DEFAULT_STAGING_GATEWAY_MAX_INSTANCES}). */
107
+ readonly stagingGatewayMaxInstances: number;
93
108
  }
94
109
 
95
110
  /** A stack's budget plus the env each service sets from it. */
@@ -12,8 +12,10 @@ import { resolve } from "node:path";
12
12
  import { COMPOSE_POOL_BUDGET } from "./compose.js";
13
13
  import {
14
14
  type BudgetProvider,
15
+ DEFAULT_ADMIN_MAX_INSTANCES,
15
16
  DEFAULT_GATEWAY_MAX_INSTANCES,
16
17
  DEFAULT_MAX_CONNECTIONS,
18
+ DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
17
19
  stackPoolBudget,
18
20
  } from "./pool-budget-stack.js";
19
21
 
@@ -21,22 +23,39 @@ const stackSource = (provider: string): string =>
21
23
  readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
22
24
 
23
25
  /** Each stack's default scale-out, as its index.ts reads it. */
24
- const DEFAULT_SCALE: Record<BudgetProvider, { admin: number; gateway: number }> = {
25
- gcp: { admin: 5, gateway: DEFAULT_GATEWAY_MAX_INSTANCES },
26
- "gcp-firebase": { admin: 5, gateway: DEFAULT_GATEWAY_MAX_INSTANCES },
27
- aws: { admin: 5, gateway: DEFAULT_GATEWAY_MAX_INSTANCES },
26
+ const DEFAULT_SCALE: Record<BudgetProvider, { admin: number; gateway: number; staging: number }> = {
27
+ gcp: {
28
+ admin: DEFAULT_ADMIN_MAX_INSTANCES,
29
+ gateway: DEFAULT_GATEWAY_MAX_INSTANCES,
30
+ staging: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
31
+ },
32
+ "gcp-firebase": {
33
+ admin: DEFAULT_ADMIN_MAX_INSTANCES,
34
+ gateway: DEFAULT_GATEWAY_MAX_INSTANCES,
35
+ staging: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
36
+ },
37
+ aws: {
38
+ admin: DEFAULT_ADMIN_MAX_INSTANCES,
39
+ gateway: DEFAULT_GATEWAY_MAX_INSTANCES,
40
+ staging: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
41
+ },
28
42
  // admin + orchestrator + runner apps, 10 replicas each.
29
- azure: { admin: 30, gateway: 10 },
30
- "self-hosted": { admin: 1, gateway: 1 },
43
+ azure: { admin: 30, gateway: 10, staging: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES },
44
+ "self-hosted": { admin: 1, gateway: 1, staging: 1 },
31
45
  };
32
46
 
33
- /** [cms_admin pool per admin instance, connections left unassigned]. */
47
+ /**
48
+ * [cms_admin pool per admin instance, connections left unassigned]. #1329
49
+ * added the staging gateway and the admin's cms_public_staging pool; the
50
+ * GCP and AWS stacks start four admin instances instead of five so the
51
+ * cms_admin pool stays at five (docs/dev/connection-budget.md).
52
+ */
34
53
  const EXPECTED: Record<BudgetProvider, [number, number]> = {
35
- gcp: [5, 1],
36
- "gcp-firebase": [5, 1],
37
- aws: [5, 3],
38
- azure: [7, 12],
39
- "self-hosted": [77, 0],
54
+ gcp: [5, 0],
55
+ "gcp-firebase": [5, 0],
56
+ aws: [5, 2],
57
+ azure: [5, 9],
58
+ "self-hosted": [72, 0],
40
59
  };
41
60
 
42
61
  describe("stack pool budgets at the default config", () => {
@@ -46,7 +65,10 @@ describe("stack pool budgets at the default config", () => {
46
65
  maxConnections: DEFAULT_MAX_CONNECTIONS[provider],
47
66
  adminMaxInstances: DEFAULT_SCALE[provider].admin,
48
67
  gatewayMaxInstances: DEFAULT_SCALE[provider].gateway,
68
+ stagingGatewayMaxInstances: DEFAULT_SCALE[provider].staging,
49
69
  });
70
+ expect(budget.reserves.map((r) => r.name)).toContain("staging-gateway");
71
+ expect(budget.reserves.map((r) => r.name)).toContain("admin-public-staging");
50
72
  expect([budget.adminPoolMax, budget.unassigned]).toEqual(EXPECTED[provider]);
51
73
  expect(env.admin).toEqual([
52
74
  { name: "CAELO_DB_POOL_MAX", value: String(EXPECTED[provider][0]) },
@@ -60,8 +82,9 @@ describe("stack pool budgets at the default config", () => {
60
82
  expect(() =>
61
83
  stackPoolBudget("gcp", {
62
84
  maxConnections: 100,
63
- adminMaxInstances: 5,
85
+ adminMaxInstances: 4,
64
86
  gatewayMaxInstances: 100,
87
+ stagingGatewayMaxInstances: 1,
65
88
  }),
66
89
  ).toThrow(/CONNECTION LIMIT, #1338/);
67
90
  });
@@ -75,6 +98,8 @@ describe("stacks set the budget on their services", () => {
75
98
  expect(src).toContain("...poolBudget.env.admin");
76
99
  expect(src).toContain("...poolBudget.env.gateway");
77
100
  expect(src).toContain("maxInstances: gatewayMaxInstances");
101
+ expect(src).toContain("stagingGatewayMaxInstances: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES");
102
+ expect(src).toContain('cfg.get("adminMaxInstances") ?? String(DEFAULT_ADMIN_MAX_INSTANCES)');
78
103
  expect(src).not.toContain("maxInstances: 100");
79
104
  });
80
105
  }
@@ -25,7 +25,11 @@ function fake(answers: (line: string) => GcloudResult) {
25
25
  describe("revokePublicRoleMembershipsArgs (#633)", () => {
26
26
  it("revokes every database role of the built-in public_role user", () => {
27
27
  expect(
28
- revokePublicRoleMembershipsArgs({ projectId: "acme", sqlInstance: "caelo-production-pg-1" }),
28
+ revokePublicRoleMembershipsArgs({
29
+ projectId: "acme",
30
+ sqlInstance: "caelo-production-pg-1",
31
+ role: "public_role",
32
+ }),
29
33
  ).toEqual([
30
34
  "sql",
31
35
  "users",
@@ -40,7 +44,12 @@ describe("revokePublicRoleMembershipsArgs (#633)", () => {
40
44
  });
41
45
 
42
46
  it("grants nothing in exchange (no --database-roles)", () => {
43
- const args = revokePublicRoleMembershipsArgs({ projectId: "p", sqlInstance: "i" });
47
+ const args = revokePublicRoleMembershipsArgs({
48
+ projectId: "p",
49
+ sqlInstance: "i",
50
+ role: "public_staging_role",
51
+ });
52
+ expect(args[3]).toBe("public_staging_role");
44
53
  expect(args.some((a) => a.startsWith("--database-roles"))).toBe(false);
45
54
  });
46
55
  });
@@ -48,17 +57,18 @@ describe("revokePublicRoleMembershipsArgs (#633)", () => {
48
57
  describe("revokePublicRoleMemberships", () => {
49
58
  const install = { projectId: "acme", env: "production", credentials: "operator" } as const;
50
59
 
51
- it("finds the instance, then revokes", async () => {
60
+ it("finds the instance, then revokes for both gateway logins (#1329)", async () => {
52
61
  const { run, calls } = fake((l) => (l.startsWith("sql instances list") ? ok("pg-1\n") : ok()));
53
62
  const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
54
63
  expect(r).toEqual({
55
- id: "public_role database role memberships",
64
+ id: "gateway logins' database role memberships",
56
65
  status: "applied",
57
- detail: "no database roles left on pg-1",
66
+ detail: "public_role and public_staging_role hold no database roles on pg-1",
58
67
  });
59
- expect(calls[1]).toBe(
68
+ expect(calls.slice(1)).toEqual([
60
69
  "sql users assign-roles public_role --instance=pg-1 --type=BUILT_IN --revoke-existing-roles --project=acme --quiet",
61
- );
70
+ "sql users assign-roles public_staging_role --instance=pg-1 --type=BUILT_IN --revoke-existing-roles --project=acme --quiet",
71
+ ]);
62
72
  });
63
73
 
64
74
  it("is idempotent: a second run issues the same revoke and succeeds", async () => {
@@ -66,7 +76,7 @@ describe("revokePublicRoleMemberships", () => {
66
76
  await revokePublicRoleMemberships(install, { run, sleep: noSleep });
67
77
  const again = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
68
78
  expect(again.status).toBe("applied");
69
- expect(calls[1]).toBe(calls[3] as string);
79
+ expect(calls.slice(4)).toEqual(calls.slice(1, 3));
70
80
  });
71
81
 
72
82
  it("fails without an instance and never calls the revoke", async () => {