@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
@@ -5,7 +5,8 @@
5
5
  * service accounts, secrets or other cloud resources so the install matches
6
6
  * what the release's stacks declare — the gateway's run SA and the
7
7
  * CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
8
- * MCP access through IAP, public_role's Cloud SQL role memberships (#633),
8
+ * MCP access through IAP, the staging database and its gateway login
9
+ * (#1329), the gateway logins' Cloud SQL role memberships (#633),
9
10
  * the gateway's and public_role's database logins, the operator-access sync
10
11
  * job, the quality-audit job (#875), the remote-MCP load-balancer rules and
11
12
  * identity env (#806), and dropping bindings nothing reads any more.
@@ -39,7 +40,7 @@ import {
39
40
  import { ensureConvergeJob } from "../converge-job-setup.js";
40
41
  import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
41
42
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
42
- import { upgraderServiceAccountEmail } from "../gcp-names.js";
43
+ import { schedulerServiceAccountEmail, upgraderServiceAccountEmail } from "../gcp-names.js";
43
44
  import type { InstallMetadata } from "../install-state.js";
44
45
  import { ensureMcpIapAccess, type IapResource } from "../mcp-iap.js";
45
46
  import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "../operator-access.js";
@@ -52,6 +53,7 @@ import {
52
53
  plainGeneratedSecretSeed,
53
54
  readSecretReplication,
54
55
  } from "../runtime-secrets.js";
56
+ import { ensureSchedulerTick } from "../scheduler-setup.js";
55
57
  import { selfCommand } from "../self-command.js";
56
58
  import {
57
59
  AUDIT_JOB_ENV_VAR,
@@ -64,7 +66,9 @@ import {
64
66
  INTERIM_FORWARD_ORIGIN_ENV,
65
67
  MCP_ENV_VAR,
66
68
  OPERATOR_ACCESS_JOB_ENV_VAR,
69
+ parseSchedulerIntervalMinutes,
67
70
  remoteMcpIdentityEnv,
71
+ SCHEDULER_SERVICE_ACCOUNT_ENV_VAR,
68
72
  UPGRADER_JOB_ENV_VAR,
69
73
  } from "../stack-contract.js";
70
74
  import {
@@ -73,6 +77,7 @@ import {
73
77
  liveEnvHasInlinePassword,
74
78
  removeRetiredIamBindings,
75
79
  } from "../stack-converge.js";
80
+ import { ensureStagingDatabase } from "../staging-database.js";
76
81
  import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
77
82
  import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
78
83
  import type { AppRollHooks, AppRollPlan, RollPlan, RollSlug } from "./app-roll.js";
@@ -110,6 +115,11 @@ export interface ConvergeDeps {
110
115
  * then fails and the converge stays incomplete.
111
116
  */
112
117
  readonly opsImageRef?: string | null;
118
+ /**
119
+ * #1140 — the install's `schedulerIntervalMinutes` knob (install.json);
120
+ * absent: the default. The scheduler step refuses an out-of-range value.
121
+ */
122
+ readonly schedulerIntervalMinutes?: number;
113
123
  }
114
124
 
115
125
  export interface ConvergeSession {
@@ -234,22 +244,48 @@ export function startConverge(
234
244
  return true;
235
245
  }
236
246
 
247
+ async function stagingDatabase(): Promise<boolean> {
248
+ // #1329 — staging's own database and its gateway login, on an install
249
+ // from before them. Before migrations: the cms_public_staging run and
250
+ // migration 0530 need both, and the membership revoke below needs the
251
+ // user. A failure aborts — the new admin refuses to start without the
252
+ // staging database.
253
+ const s = r.step("Ensuring the staging database (cms_public_staging) and its gateway login...");
254
+ const outcomes = await ensureStagingDatabase(
255
+ { projectId: install.projectId, env: install.env, credentials: install.credentials },
256
+ { ...runDeps, ...(deps.http ? { http: deps.http } : {}) },
257
+ );
258
+ const failed = outcomes.filter((o) => o.status === "failed");
259
+ if (failed.length > 0) {
260
+ s.failed("The staging database could not be set up. Aborting upgrade.");
261
+ for (const o of failed) r.error(` FAILED: ${o.id}\n ${o.error ?? ""}`);
262
+ r.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
263
+ return false;
264
+ }
265
+ s.done("Staging database and its gateway login ready");
266
+ for (const o of outcomes.filter((o) => o.status === "applied")) {
267
+ r.info(` ${o.id}: ${o.detail ?? "created"}`);
268
+ }
269
+ return true;
270
+ }
271
+
237
272
  async function publicRoleIsolation(): Promise<boolean> {
238
- // #633 — the Admin API made public_role a member of cloudsqlsuperuser.
239
- // Revoked here, before migrations, because the migration job's role
240
- // audit is what verifies it and fails the upgrade while it remains.
241
- const s = r.step("Removing public_role from the Cloud SQL superuser group...");
273
+ // #633 — the Admin API made public_role (and, #1329, public_staging_role)
274
+ // a member of cloudsqlsuperuser. Revoked here, before migrations,
275
+ // because the migration job's role audit is what verifies it and fails
276
+ // the upgrade while it remains.
277
+ const s = r.step("Removing the gateway logins from the Cloud SQL superuser group...");
242
278
  const outcome = await revokePublicRoleMemberships(
243
279
  { projectId: install.projectId, env: install.env, credentials: install.credentials },
244
280
  runDeps,
245
281
  );
246
282
  if (outcome.status === "failed") {
247
- s.failed("public_role's role memberships could not be revoked. Aborting upgrade.");
283
+ s.failed("The gateway logins' role memberships could not be revoked. Aborting upgrade.");
248
284
  r.error(` ${outcome.error ?? ""}`);
249
285
  r.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
250
286
  return false;
251
287
  }
252
- s.done(`public_role holds no database roles (${outcome.detail ?? "revoked"})`);
288
+ s.done(`The gateway logins hold no database roles (${outcome.detail ?? "revoked"})`);
253
289
  return true;
254
290
  }
255
291
 
@@ -427,6 +463,9 @@ export function startConverge(
427
463
  return [{ name: INTERIM_FORWARD_ORIGIN_ENV, value: new URL(url).origin }];
428
464
  }
429
465
 
466
+ /** The admin's origin behind IAP, once {@link remoteMcpIdentity} read it. */
467
+ let resolvedAdminUrl: string | null = null;
468
+
430
469
  async function remoteMcpIdentity(): Promise<Record<RollSlug, CloudRunEnvVar[]> | null> {
431
470
  // #806 — CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL / CAELO_IAP_AUDIENCE
432
471
  // name identities the env contract cannot (stack-contract.ts
@@ -449,6 +488,7 @@ export function startConverge(
449
488
  runDeps,
450
489
  );
451
490
  if (resolved.ok) {
491
+ resolvedAdminUrl = resolved.identity.adminUrl;
452
492
  s.done(
453
493
  `Google sign-in and remote MCP can be set up under Settings → Google sign-in (${resolved.identity.mcpPublicUrl})`,
454
494
  );
@@ -672,6 +712,77 @@ export function startConverge(
672
712
  leaveUntouched.push(CONVERGE_JOB_ENV_VAR);
673
713
  }
674
714
 
715
+ let schedulerReady = false;
716
+ /** The scheduler's SA exists from an earlier run though this one failed. */
717
+ let schedulerAccountExists = false;
718
+
719
+ async function setUpSchedulerTick(): Promise<
720
+ | { ok: true; done: string[]; intervalMinutes: number }
721
+ | { ok: false; done: string[]; error: string }
722
+ > {
723
+ let intervalMinutes: number;
724
+ try {
725
+ intervalMinutes = parseSchedulerIntervalMinutes(
726
+ deps.schedulerIntervalMinutes,
727
+ "install.json schedulerIntervalMinutes",
728
+ );
729
+ } catch (e) {
730
+ return { ok: false, done: [], error: (e as Error).message };
731
+ }
732
+ const resource = await adminIapResource();
733
+ if (!resource) return { ok: false, done: [], error: "the admin's IAP resource was not found" };
734
+ if (!resolvedAdminUrl)
735
+ return { ok: false, done: [], error: "the admin's URL could not be read" };
736
+ const st = await ensureSchedulerTick(
737
+ {
738
+ projectId: install.projectId,
739
+ region: install.region,
740
+ env: install.env,
741
+ adminUrl: resolvedAdminUrl,
742
+ resource,
743
+ intervalMinutes,
744
+ },
745
+ runDeps,
746
+ );
747
+ return st.ok ? { ok: true, done: st.done, intervalMinutes } : st;
748
+ }
749
+
750
+ async function schedulerTick(): Promise<void> {
751
+ // #1140 — the one Cloud Scheduler job that calls the admin's tick
752
+ // (scheduler-setup.ts). After migrations and before the operator-access
753
+ // step, which must keep the scheduler's account on IAP. The schedule
754
+ // starts right away: until the roll below finishes, a tick reaches the
755
+ // previous admin, which has no such endpoint (404) — that execution
756
+ // fails and nothing runs; the first tick after the roll does the work.
757
+ // A failure costs the periodic work (plugin workers, GC, release check),
758
+ // not the site, so it warns and the admin keeps its current env.
759
+ const s = r.step("Ensuring the scheduler tick (periodic work and plugin workers)...");
760
+ const st = await setUpSchedulerTick();
761
+ for (const step of st.done) r.info(` ${step}`);
762
+ if (st.ok) {
763
+ schedulerReady = true;
764
+ s.done(`Scheduler tick ready (every ${st.intervalMinutes} min)`);
765
+ return;
766
+ }
767
+ s.warned(`Scheduler tick not set up: ${st.error}`);
768
+ // An account set up by an earlier run keeps its IAP access, or the next
769
+ // sync would cut off a tick that still works.
770
+ const exists = await run([
771
+ "iam",
772
+ "service-accounts",
773
+ "describe",
774
+ schedulerServiceAccountEmail(install.projectId, install.env),
775
+ `--project=${install.projectId}`,
776
+ "--format=value(email)",
777
+ ]);
778
+ schedulerAccountExists = exists.ok;
779
+ r.warn(
780
+ "The upgrade continues; plugin workers, update checks and clean-up run late or not at all until this succeeds. Re-run upgrade once the cause is fixed.",
781
+ );
782
+ incomplete = true;
783
+ leaveUntouched.push(SCHEDULER_SERVICE_ACCOUNT_ENV_VAR);
784
+ }
785
+
675
786
  async function operatorAccess(): Promise<void> {
676
787
  // The job that keeps IAP in step with the user list (operator-access.ts).
677
788
  // After migrations: its database user gets the role migration 0239
@@ -699,6 +810,13 @@ export function startConverge(
699
810
  ],
700
811
  }
701
812
  : {}),
813
+ ...(schedulerReady || schedulerAccountExists
814
+ ? {
815
+ iapServiceAccounts: [
816
+ schedulerServiceAccountEmail(install.projectId, install.env),
817
+ ],
818
+ }
819
+ : {}),
702
820
  },
703
821
  runDeps,
704
822
  )
@@ -721,6 +839,7 @@ export function startConverge(
721
839
  if (deps.provisionerIdentity && !(await deps.provisionerIdentity())) return false;
722
840
  if (!(await runtimeIdentities())) return false;
723
841
  if (!(await stackInvariants())) return false;
842
+ if (!(await stagingDatabase())) return false;
724
843
  if (!(await publicRoleIsolation())) return false;
725
844
  if (!(await databaseTls())) return false;
726
845
  await mcpAccess();
@@ -734,10 +853,12 @@ export function startConverge(
734
853
  hooks: {
735
854
  async afterMigrations() {
736
855
  if (!(await databaseLogins())) return { ok: false };
856
+ // Read-only, first: the scheduler tick needs the admin's URL.
857
+ const identity = await remoteMcpIdentity();
737
858
  await upgraderJob();
738
859
  await convergeJob();
860
+ await schedulerTick();
739
861
  await operatorAccess();
740
- const identity = await remoteMcpIdentity();
741
862
  let forward: CloudRunEnvVar[];
742
863
  try {
743
864
  forward = await interimForwardEnv();
@@ -423,6 +423,66 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
423
423
  ).toBe(false);
424
424
  });
425
425
 
426
+ it("#1140 — sets up the scheduler tick before operator access keeps its account on IAP, and tells the admin whom to trust", async () => {
427
+ const events: string[] = [];
428
+ const { deps, lines } = upgradeDeps(events);
429
+ expect(await upgradeGcpInstall({ ...TARGET, schedulerIntervalMinutes: 2 }, deps)).toBe(true);
430
+ const workflow = indexOf(events, /^gcloud workflows deploy caelo-production-scheduler-tick /);
431
+ const schedule = indexOf(
432
+ events,
433
+ /^gcloud scheduler jobs (create|update) http caelo-production-scheduler-tick /,
434
+ );
435
+ const opAccess = indexOf(
436
+ events,
437
+ /^gcloud run jobs deploy caelo-production-operator-access-sync /,
438
+ );
439
+ const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
440
+ expect(workflow < schedule && schedule < opAccess && opAccess < adminRoll).toBe(true);
441
+ expect(events[schedule]).toContain("--schedule=*/2 * * * *");
442
+ expect(events[schedule]).toContain(
443
+ "--oauth-service-account-email=caelo-production-scheduler@acme.iam.gserviceaccount.com",
444
+ );
445
+ expect(events[opAccess]).toContain(
446
+ "CAELO_OPERATOR_ACCESS_IAP_SERVICE_ACCOUNTS=caelo-production-scheduler@acme.iam.gserviceaccount.com",
447
+ );
448
+ expect(events[adminRoll]).toContain(
449
+ "CAELO_SCHEDULER_SERVICE_ACCOUNT=caelo-production-scheduler@acme.iam.gserviceaccount.com",
450
+ );
451
+ expect(lines).toContain("done: Scheduler tick ready (every 2 min)");
452
+ });
453
+
454
+ it("#1140 — a failed scheduler setup leaves the converge incomplete and the admin's trust env untouched", async () => {
455
+ const events: string[] = [];
456
+ const { deps, recorded, lines } = upgradeDeps(events, {
457
+ overrides: [
458
+ [/^workflows deploy caelo-production-scheduler-tick/, fail("PERMISSION_DENIED")],
459
+ [/^iam service-accounts describe caelo-production-scheduler@/, fail("NOT_FOUND")],
460
+ ],
461
+ });
462
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
463
+ expect(recorded.contract).toEqual([]);
464
+ expect(lines.some((l) => l.includes("Scheduler tick not set up"))).toBe(true);
465
+ expect(
466
+ events.some((e) =>
467
+ /services update caelo-production-admin.*CAELO_SCHEDULER_SERVICE_ACCOUNT=/.test(e),
468
+ ),
469
+ ).toBe(false);
470
+ expect(
471
+ events.some((e) =>
472
+ /^gcloud scheduler jobs (create|update) http caelo-production-scheduler-tick/.test(e),
473
+ ),
474
+ ).toBe(false);
475
+ });
476
+
477
+ it("#1140 — refuses an out-of-range interval in install.json without touching the scheduler", async () => {
478
+ const events: string[] = [];
479
+ const { deps, recorded, lines } = upgradeDeps(events);
480
+ expect(await upgradeGcpInstall({ ...TARGET, schedulerIntervalMinutes: 60 }, deps)).toBe(true);
481
+ expect(recorded.contract).toEqual([]);
482
+ expect(lines.some((l) => l.includes("between 1 and 15"))).toBe(true);
483
+ expect(events.some((e) => /^gcloud workflows deploy/.test(e))).toBe(false);
484
+ });
485
+
426
486
  it("an unreadable audit job is reported as an error, not as a missing job, and is not moved", async () => {
427
487
  const events: string[] = [];
428
488
  const { deps, lines } = upgradeDeps(events, {
@@ -748,10 +808,33 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
748
808
  expect(indexOf(events, /^gcloud sql backups create/)).toBeLessThan(revoke);
749
809
  expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
750
810
  expect(lines).toContain(
751
- "done: public_role holds no database roles (no database roles left on caelo-production-pg-abc)",
811
+ "done: The gateway logins hold no database roles (public_role and public_staging_role hold no database roles on caelo-production-pg-abc)",
752
812
  );
753
813
  });
754
814
 
815
+ it("creates the staging database and its login before the revoke and the migrations (#1329)", async () => {
816
+ const events: string[] = [];
817
+ const { deps, lines } = upgradeDeps(events);
818
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
819
+ const created = indexOf(events, /^gcloud sql databases (list|create)/);
820
+ const revoke = indexOf(events, /^gcloud sql users assign-roles public_staging_role/);
821
+ expect(created).toBeGreaterThan(-1);
822
+ expect(created).toBeLessThan(revoke);
823
+ expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
824
+ expect(lines).toContain("done: Staging database and its gateway login ready");
825
+ });
826
+
827
+ it("a staging database that cannot be created stops the upgrade before migrations and any roll (#1329)", async () => {
828
+ const events: string[] = [];
829
+ const { deps, calls, lines } = upgradeDeps(events, {
830
+ overrides: [[/^sql databases list/, fail("PERMISSION_DENIED: no")]],
831
+ });
832
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
833
+ expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
834
+ expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
835
+ expect(lines).toContain("failed: The staging database could not be set up. Aborting upgrade.");
836
+ });
837
+
755
838
  it("a refused public_role revoke stops the upgrade before migrations and any roll (#633)", async () => {
756
839
  const events: string[] = [];
757
840
  const { deps, calls, lines } = upgradeDeps(events, {
@@ -761,7 +844,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
761
844
  expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
762
845
  expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
763
846
  expect(lines).toContain(
764
- "failed: public_role's role memberships could not be revoked. Aborting upgrade.",
847
+ "failed: The gateway logins' role memberships could not be revoked. Aborting upgrade.",
765
848
  );
766
849
  });
767
850
 
@@ -57,6 +57,12 @@ export interface GcpUpgradeTarget {
57
57
  readonly domain: string;
58
58
  readonly stagingHost: string;
59
59
  readonly ownerEmail: string;
60
+ /**
61
+ * #1140 — install.json's `schedulerIntervalMinutes` (absent: the default).
62
+ * Bounded to 1–15 by the converge, so a tampered record can change only
63
+ * how soon due work runs.
64
+ */
65
+ readonly schedulerIntervalMinutes?: number;
60
66
  /** The tag rolled to: a version (`0.11.0`) or a channel tag (`latest`, `rc`). */
61
67
  readonly targetTag: string;
62
68
  /** The infrastructure contract version this CLI converges to (infra-contract.ts). */
@@ -276,6 +282,9 @@ export async function upgradeGcpInstallOutcome(
276
282
  const opsImageRef = await verifiedOpsImage(target, deps);
277
283
  const converge = startConverge(plan, target.ownerEmail, {
278
284
  opsImageRef,
285
+ ...(target.schedulerIntervalMinutes !== undefined
286
+ ? { schedulerIntervalMinutes: target.schedulerIntervalMinutes }
287
+ : {}),
279
288
  run,
280
289
  reporter: r,
281
290
  ...(deps.sleep ? { sleep: deps.sleep } : {}),
@@ -73,11 +73,12 @@ export async function upgraderDatabaseHost(
73
73
 
74
74
  /**
75
75
  * #1338 — the database host for the converge job (#819), after making sure
76
- * a verify-full connection to it can work. The job opens its database
77
- * queue before it converges anything, with this release's code, which
78
- * connects with verify-full only; on an install whose services still dial
79
- * the instance's IP it first runs the converge's database TLS step
76
+ * a verify-full connection to it can work. On an install whose services
77
+ * still dial the instance's IP it runs the converge's database TLS step
80
78
  * (cloudsql-tls.ts: shared CA, private DNS name, services moved onto it).
79
+ * Stage 2 of the job runs it as a timeline step, over a queue opened on
80
+ * the IP (converge-db-trust.ts, #1373), then reconnects on the host this
81
+ * returns.
81
82
  * The job runs as the install's `caelo-provisioner`, which holds
82
83
  * `roles/cloudsql.admin` and `roles/dns.admin` (gcloud.ts PROVISIONER_ROLES).
83
84
  * Idempotent: the converge's own step finds everything in shape.
@@ -92,9 +92,9 @@ export interface AccessCheck {
92
92
  readonly permissions: readonly string[];
93
93
  }
94
94
 
95
- /** A check with the permissions it lacks. */
96
- export interface MissingAccess {
97
- readonly check: AccessCheck;
95
+ /** A check with the permissions it lacks (`C`: the caller's own check type, handed back as given). */
96
+ export interface MissingAccess<C extends AccessCheck = AccessCheck> {
97
+ readonly check: C;
98
98
  readonly missing: readonly string[];
99
99
  /**
100
100
  * Permissions that could not be judged because another one is missing
@@ -114,9 +114,9 @@ export interface IamTester {
114
114
  }
115
115
 
116
116
  /** How the self-check came out. */
117
- export type PreflightOutcome =
117
+ export type PreflightOutcome<C extends AccessCheck = AccessCheck> =
118
118
  | { readonly kind: "ok" }
119
- | { readonly kind: "missing"; readonly missing: readonly MissingAccess[] }
119
+ | { readonly kind: "missing"; readonly missing: readonly MissingAccess<C>[] }
120
120
  /** IAM could not be asked about at least one resource; nothing was judged missing. */
121
121
  | { readonly kind: "error"; readonly errors: readonly string[] };
122
122
 
@@ -250,10 +250,10 @@ export function upgraderAccessChecks(target: {
250
250
  }
251
251
 
252
252
  /** Ask IAM about every check (one call per resource); never throws. */
253
- export async function runIamPreflight(
254
- checks: readonly AccessCheck[],
253
+ export async function runIamPreflight<C extends AccessCheck>(
254
+ checks: readonly C[],
255
255
  tester: IamTester,
256
- ): Promise<PreflightOutcome> {
256
+ ): Promise<PreflightOutcome<C>> {
257
257
  // One question per resource: the project alone carries four checks.
258
258
  const byResource = new Map<string, { resource: IamResource; permissions: Set<string> }>();
259
259
  for (const c of checks) {
@@ -300,7 +300,7 @@ export async function runIamPreflight(
300
300
  }),
301
301
  );
302
302
  if (errors.length > 0) return { kind: "error", errors: errors.sort() };
303
- const missing: MissingAccess[] = [];
303
+ const missing: MissingAccess<C>[] = [];
304
304
  for (const check of checks) {
305
305
  const key = resourceName(check.resource);
306
306
  const held = granted.get(key) ?? new Set<string>();
@@ -343,6 +343,11 @@ export function preflightMissingReason(
343
343
  missing: readonly MissingAccess[],
344
344
  voice: PreflightVoice,
345
345
  ): string {
346
+ return `Nothing was changed: ${voice.subject} checked its access before starting and is missing permissions this update needs — ${missingAccessText(missing)}. ${voice.repair}`;
347
+ }
348
+
349
+ /** Every missing permission with what it is for, as one clause list ("it cannot …; it cannot …"). */
350
+ export function missingAccessText(missing: readonly MissingAccess[]): string {
346
351
  const lines = missing.map((m) => {
347
352
  const where = resourceName(m.check.resource);
348
353
  const parts = [
@@ -355,7 +360,7 @@ export function preflightMissingReason(
355
360
  ];
356
361
  return `it ${m.missing.length > 0 ? "cannot" : "may not be able to"} ${m.check.need} (${parts.join("; ")})`;
357
362
  });
358
- return `Nothing was changed: ${voice.subject} checked its access before starting and is missing permissions this update needs — ${lines.join("; ")}. ${voice.repair}`;
363
+ return lines.join("; ");
359
364
  }
360
365
 
361
366
  /** The request's end reason when IAM could not be asked. */
package/src/wizard.ts CHANGED
@@ -72,6 +72,13 @@ export interface WizardOptions {
72
72
  * install a different value is refused.
73
73
  */
74
74
  region?: string;
75
+ /**
76
+ * #1140 — `--scheduler-interval-minutes` (1–15, already validated): how
77
+ * often the scheduler tick calls the admin. Recorded in install.json, so
78
+ * every later `upgrade` keeps it; omitted keeps what is recorded (or the
79
+ * default, 5).
80
+ */
81
+ schedulerIntervalMinutes?: number;
75
82
  /**
76
83
  * #696 — where `pulumi up` and the rest of a GCP install run
77
84
  * (run-mode.ts): `job` (default in Cloud Shell) or `local`.
@@ -192,6 +199,18 @@ export async function runWizard(opts: WizardOptions = {}): Promise<void> {
192
199
  }
193
200
  }
194
201
 
202
+ // #1140 — the scheduler knob, on a new install and on a resumed one alike
203
+ // (every later `upgrade` reads it from install.json).
204
+ if (opts.schedulerIntervalMinutes !== undefined) {
205
+ const recorded = readMetadata(installId);
206
+ if (recorded) {
207
+ writeMetadata(installId, {
208
+ ...recorded,
209
+ schedulerIntervalMinutes: opts.schedulerIntervalMinutes,
210
+ });
211
+ }
212
+ }
213
+
195
214
  // 3.5. #656 — a re-run of an install an earlier CLI set up: drop the
196
215
  // secrets that CLI checkpointed (database passwords, the setup link)
197
216
  // before any step writes progress.json again, and say what to rotate.
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, it } from "bun:test";
10
- import { type CostEstimateInputs, estimateGcpCost } from "./gcp-cost.js";
10
+ import { SERVICE_CPU_IDLE } from "../stack-contract.js";
11
+ import {
12
+ adminKeptWarmByTicks,
13
+ alwaysOnAdminInstanceUsd,
14
+ type CostEstimateInputs,
15
+ estimateGcpCost,
16
+ schedulerTickMonthlyUsd,
17
+ } from "./gcp-cost.js";
11
18
 
12
19
  const inputs: CostEstimateInputs = {
13
20
  cloudSqlTier: "db-f1-micro",
@@ -48,3 +55,49 @@ describe("cost table: MCP-Auth-Proxy (#1328)", () => {
48
55
  expect(names("gcp")).toContain("Cloud Run gateway");
49
56
  });
50
57
  });
58
+
59
+ describe("cost table: scheduler tick (#1140)", () => {
60
+ it("lists the tick at the default 5 minutes: well under a dollar", () => {
61
+ const line = estimateGcpCost(inputs).lines.find((l) => l.name.startsWith("Scheduler tick"));
62
+ expect(line?.name).toBe("Scheduler tick (Cloud Scheduler + Workflows, every 5 min)");
63
+ // 8,640 ticks: 25,920 internal steps (20,920 billed) + 8,640 external
64
+ // (6,640 billed) + the scheduler job.
65
+ expect(line?.monthlyUsd).toBe(0.48);
66
+ });
67
+
68
+ it("follows the knob: one minute stays a few dollars, fifteen little more than the job", () => {
69
+ expect(schedulerTickMonthlyUsd(1)).toBe(2.38);
70
+ expect(schedulerTickMonthlyUsd(15)).toBe(0.16);
71
+ const fast = estimateGcpCost({ ...inputs, schedulerIntervalMinutes: 1 }).lines.find((l) =>
72
+ l.name.startsWith("Scheduler tick"),
73
+ );
74
+ expect(fast?.name).toContain("every 1 min");
75
+ });
76
+
77
+ it("prices the admin kept up around the clock while its CPU is always allocated (#1361)", () => {
78
+ // The tick (at most every 15 minutes) beats Cloud Run's 15-minute idle
79
+ // shutdown, so a scale-to-zero admin never stops.
80
+ expect(SERVICE_CPU_IDLE.admin).toBe(false);
81
+ expect(adminKeptWarmByTicks(0)).toBe(true);
82
+ // 2,592,000 s x (1 vCPU x $0.000018 + 2 GiB x $0.000002).
83
+ expect(alwaysOnAdminInstanceUsd(2)).toBe(57);
84
+ const admin = estimateGcpCost(inputs).lines.find((l) => l.name.startsWith("Cloud Run admin"));
85
+ expect(admin?.monthlyUsd).toBe(57);
86
+ expect(admin?.notes).toContain("around the clock");
87
+ expect(admin?.notes).toContain("#1361");
88
+ });
89
+
90
+ it("adds nothing on the admin with a min instance, which is up anyway", () => {
91
+ expect(adminKeptWarmByTicks(1)).toBe(false);
92
+ const admin = estimateGcpCost({ ...inputs, adminMinInstances: 1 }).lines.find((l) =>
93
+ l.name.startsWith("Cloud Run admin"),
94
+ );
95
+ expect(admin?.notes).toContain("the scheduler tick adds nothing");
96
+ });
97
+
98
+ it("refuses an interval outside 1-15 minutes", () => {
99
+ expect(() => estimateGcpCost({ ...inputs, schedulerIntervalMinutes: 20 })).toThrow(
100
+ "between 1 and 15",
101
+ );
102
+ });
103
+ });