@caelo-cms/provisioning 0.11.10 → 0.11.11

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 (212) 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 +25 -8
  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 +5 -2
  29. package/dist/converge-job-gcp.d.ts.map +1 -1
  30. package/dist/converge-job-gcp.js +9 -2
  31. package/dist/converge-job-gcp.js.map +1 -1
  32. package/dist/converge-job-preflight.d.ts.map +1 -1
  33. package/dist/converge-job-preflight.js +8 -0
  34. package/dist/converge-job-preflight.js.map +1 -1
  35. package/dist/converge-job.d.ts +17 -0
  36. package/dist/converge-job.d.ts.map +1 -1
  37. package/dist/converge-job.js +12 -0
  38. package/dist/converge-job.js.map +1 -1
  39. package/dist/gcp-names.d.ts +13 -0
  40. package/dist/gcp-names.d.ts.map +1 -1
  41. package/dist/gcp-names.js +21 -0
  42. package/dist/gcp-names.js.map +1 -1
  43. package/dist/index.d.ts +2 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/infra-contract.d.ts +1 -1
  48. package/dist/infra-contract.d.ts.map +1 -1
  49. package/dist/infra-contract.js +22 -1
  50. package/dist/infra-contract.js.map +1 -1
  51. package/dist/install-job.d.ts +4 -2
  52. package/dist/install-job.d.ts.map +1 -1
  53. package/dist/install-job.js +4 -1
  54. package/dist/install-job.js.map +1 -1
  55. package/dist/install-state.d.ts +7 -0
  56. package/dist/install-state.d.ts.map +1 -1
  57. package/dist/install-state.js.map +1 -1
  58. package/dist/lifecycle.d.ts.map +1 -1
  59. package/dist/lifecycle.js +3 -0
  60. package/dist/lifecycle.js.map +1 -1
  61. package/dist/migration-runner.d.ts.map +1 -1
  62. package/dist/migration-runner.js +8 -3
  63. package/dist/migration-runner.js.map +1 -1
  64. package/dist/migration-targets.d.ts +11 -0
  65. package/dist/migration-targets.d.ts.map +1 -0
  66. package/dist/migration-targets.js +12 -0
  67. package/dist/migration-targets.js.map +1 -0
  68. package/dist/operator-access.d.ts +10 -0
  69. package/dist/operator-access.d.ts.map +1 -1
  70. package/dist/operator-access.js +5 -0
  71. package/dist/operator-access.js.map +1 -1
  72. package/dist/pool-budget-stack.d.ts +13 -1
  73. package/dist/pool-budget-stack.d.ts.map +1 -1
  74. package/dist/pool-budget-stack.js +11 -1
  75. package/dist/pool-budget-stack.js.map +1 -1
  76. package/dist/public-role-isolation.d.ts +7 -3
  77. package/dist/public-role-isolation.d.ts.map +1 -1
  78. package/dist/public-role-isolation.js +25 -13
  79. package/dist/public-role-isolation.js.map +1 -1
  80. package/dist/release-contract.d.ts.map +1 -1
  81. package/dist/release-contract.js +3 -0
  82. package/dist/release-contract.js.map +1 -1
  83. package/dist/runtime-secrets.d.ts +2 -0
  84. package/dist/runtime-secrets.d.ts.map +1 -1
  85. package/dist/runtime-secrets.js +19 -15
  86. package/dist/runtime-secrets.js.map +1 -1
  87. package/dist/scheduler-setup.d.ts +75 -0
  88. package/dist/scheduler-setup.d.ts.map +1 -0
  89. package/dist/scheduler-setup.js +331 -0
  90. package/dist/scheduler-setup.js.map +1 -0
  91. package/dist/self-hosted-roles.d.ts +16 -9
  92. package/dist/self-hosted-roles.d.ts.map +1 -1
  93. package/dist/self-hosted-roles.js +49 -15
  94. package/dist/self-hosted-roles.js.map +1 -1
  95. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  96. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  97. package/dist/self-hosted-scheduler-interval.js +68 -0
  98. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  99. package/dist/self-hosted-upgrade.d.ts +7 -4
  100. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  101. package/dist/self-hosted-upgrade.js +9 -5
  102. package/dist/self-hosted-upgrade.js.map +1 -1
  103. package/dist/stack-contract.d.ts +67 -6
  104. package/dist/stack-contract.d.ts.map +1 -1
  105. package/dist/stack-contract.js +87 -6
  106. package/dist/stack-contract.js.map +1 -1
  107. package/dist/staging-database.d.ts +22 -0
  108. package/dist/staging-database.d.ts.map +1 -0
  109. package/dist/staging-database.js +117 -0
  110. package/dist/staging-database.js.map +1 -0
  111. package/dist/upgrade/converge.d.ts +5 -0
  112. package/dist/upgrade/converge.d.ts.map +1 -1
  113. package/dist/upgrade/converge.js +110 -10
  114. package/dist/upgrade/converge.js.map +1 -1
  115. package/dist/upgrade/upgrade.d.ts +6 -0
  116. package/dist/upgrade/upgrade.d.ts.map +1 -1
  117. package/dist/upgrade/upgrade.js +3 -0
  118. package/dist/upgrade/upgrade.js.map +1 -1
  119. package/dist/upgrader-gcp.d.ts +5 -4
  120. package/dist/upgrader-gcp.d.ts.map +1 -1
  121. package/dist/upgrader-gcp.js +5 -4
  122. package/dist/upgrader-gcp.js.map +1 -1
  123. package/dist/wizard.d.ts +7 -0
  124. package/dist/wizard.d.ts.map +1 -1
  125. package/dist/wizard.js +11 -0
  126. package/dist/wizard.js.map +1 -1
  127. package/dist/wizards/gcp-cost.d.ts +31 -0
  128. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  129. package/dist/wizards/gcp-cost.js +81 -11
  130. package/dist/wizards/gcp-cost.js.map +1 -1
  131. package/dist/wizards/gcp.d.ts.map +1 -1
  132. package/dist/wizards/gcp.js +87 -11
  133. package/dist/wizards/gcp.js.map +1 -1
  134. package/package.json +2 -2
  135. package/src/adapter.ts +6 -0
  136. package/src/admin-memory.test.ts +1 -1
  137. package/src/adopt-existing.test.ts +46 -0
  138. package/src/adopt-existing.ts +58 -2
  139. package/src/agent/command.ts +6 -3
  140. package/src/caddy-redirects.test.ts +1 -1
  141. package/src/cli-routing.test.ts +1 -1
  142. package/src/cli-routing.ts +1 -1
  143. package/src/cli.ts +63 -8
  144. package/src/cloudsql-tls.test.ts +30 -0
  145. package/src/cloudsql-tls.ts +10 -3
  146. package/src/compose.ts +68 -0
  147. package/src/converge-db-trust.test.ts +237 -0
  148. package/src/converge-db-trust.ts +212 -0
  149. package/src/converge-job-gcp.ts +9 -1
  150. package/src/converge-job-preflight.test.ts +5 -0
  151. package/src/converge-job-preflight.ts +8 -0
  152. package/src/converge-job.test.ts +70 -0
  153. package/src/converge-job.ts +32 -0
  154. package/src/database-tls-builders.test.ts +3 -2
  155. package/src/deployed-release.test.ts +3 -0
  156. package/src/gateway-env-isolation.test.ts +40 -7
  157. package/src/gcp-names.ts +25 -0
  158. package/src/index.test.ts +3 -3
  159. package/src/index.ts +6 -0
  160. package/src/infra-contract.test.ts +5 -3
  161. package/src/infra-contract.ts +22 -1
  162. package/src/install-job.ts +4 -1
  163. package/src/install-state.ts +7 -0
  164. package/src/lifecycle.ts +3 -0
  165. package/src/migration-runner.ts +14 -9
  166. package/src/migration-targets.ts +12 -0
  167. package/src/operator-access.test.ts +15 -0
  168. package/src/operator-access.ts +14 -0
  169. package/src/pool-budget-stack.ts +15 -0
  170. package/src/pool-budget-stacks.test.ts +38 -13
  171. package/src/public-role-isolation.test.ts +18 -8
  172. package/src/public-role-isolation.ts +31 -17
  173. package/src/release-contract.ts +3 -0
  174. package/src/runtime-secrets.test.ts +18 -8
  175. package/src/runtime-secrets.ts +37 -18
  176. package/src/scheduler-setup.test.ts +201 -0
  177. package/src/scheduler-setup.ts +375 -0
  178. package/src/self-hosted-backup.test.ts +1 -0
  179. package/src/self-hosted-compose.test.ts +72 -1
  180. package/src/self-hosted-roles.integration.test.ts +2 -0
  181. package/src/self-hosted-roles.test.ts +49 -19
  182. package/src/self-hosted-roles.ts +60 -20
  183. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  184. package/src/self-hosted-scheduler-interval.ts +79 -0
  185. package/src/self-hosted-start.test.ts +1 -0
  186. package/src/self-hosted-upgrade.test.ts +2 -1
  187. package/src/self-hosted-upgrade.ts +14 -8
  188. package/src/stack-contract.test.ts +31 -3
  189. package/src/stack-contract.ts +113 -6
  190. package/src/stack-converge-secrets.test.ts +2 -0
  191. package/src/stack-converge.test.ts +6 -4
  192. package/src/staging-database.test.ts +114 -0
  193. package/src/staging-database.ts +148 -0
  194. package/src/upgrade/converge.ts +130 -9
  195. package/src/upgrade/upgrade.test.ts +85 -2
  196. package/src/upgrade/upgrade.ts +9 -0
  197. package/src/upgrader-gcp.ts +5 -4
  198. package/src/wizard.ts +19 -0
  199. package/src/wizards/gcp-cost.test.ts +54 -1
  200. package/src/wizards/gcp-cost.ts +101 -11
  201. package/src/wizards/gcp.ts +109 -8
  202. package/stacks/aws/Pulumi.yaml +1 -1
  203. package/stacks/aws/index.ts +41 -1
  204. package/stacks/azure/index.ts +51 -3
  205. package/stacks/gcp/Pulumi.yaml +1 -1
  206. package/stacks/gcp/README.md +1 -0
  207. package/stacks/gcp/index.ts +50 -1
  208. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  209. package/stacks/gcp-firebase/index.ts +50 -1
  210. package/stacks/self-hosted/Pulumi.yaml +3 -0
  211. package/stacks/self-hosted/README.md +4 -0
  212. package/stacks/self-hosted/index.ts +29 -4
package/src/cli.ts CHANGED
@@ -86,11 +86,12 @@ import {
86
86
  resolveVerifiedRelease,
87
87
  } from "./self-hosted-release.js";
88
88
  import {
89
- applyPublicRoleLimits,
89
+ applyPreMigrationRoles,
90
90
  convergeSelfHostedRoles,
91
91
  decideUp,
92
92
  selfHostedBootstrapScript,
93
93
  } from "./self-hosted-roles.js";
94
+ import { parseSchedulerIntervalMinutes } from "./stack-contract.js";
94
95
 
95
96
  interface CaeloConfig {
96
97
  domain: string;
@@ -153,6 +154,12 @@ interface CaeloConfig {
153
154
  * carry it).
154
155
  */
155
156
  composeProject?: string;
157
+ /**
158
+ * #1140 — the `schedulerIntervalMinutes` knob (`init
159
+ * --scheduler-interval-minutes`, 1–15): how often the scheduler container
160
+ * calls the admin's tick. Absent: the default (5).
161
+ */
162
+ schedulerIntervalMinutes?: number;
156
163
  }
157
164
 
158
165
  const REPO_ROOT = resolve(import.meta.dir, "../../..");
@@ -255,6 +262,15 @@ function loadConfig(): CaeloConfig | null {
255
262
  cfg.composeProject ??= "caelo";
256
263
  saveConfig(cfg);
257
264
  }
265
+ // #1329 — the staging gateway's login came with the staging database. An
266
+ // install from before gets its own password now; the next `up` or
267
+ // upgrade creates the role with it (self-hosted-roles.ts) before anything
268
+ // uses it, so the roles count as not converged until then.
269
+ if (cfg.rolePasswords && !cfg.rolePasswords.publicStaging) {
270
+ cfg.rolePasswords = { ...cfg.rolePasswords, publicStaging: randomSecret(32) };
271
+ cfg.rolesConverged = false;
272
+ saveConfig(cfg);
273
+ }
258
274
  return cfg;
259
275
  }
260
276
 
@@ -269,9 +285,9 @@ function arg(name: string): string | undefined {
269
285
  return process.argv[idx + 1];
270
286
  }
271
287
 
272
- /** Fresh passwords for the two application roles. */
288
+ /** Fresh passwords for the application roles. */
273
289
  function newRolePasswords(): RolePasswords {
274
- return { admin: randomSecret(32), public: randomSecret(32) };
290
+ return { admin: randomSecret(32), public: randomSecret(32), publicStaging: randomSecret(32) };
275
291
  }
276
292
 
277
293
  function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): void {
@@ -369,6 +385,9 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
369
385
  images,
370
386
  ports: cfg.ports,
371
387
  project: cfg.composeProject,
388
+ ...(cfg.schedulerIntervalMinutes !== undefined
389
+ ? { schedulerIntervalMinutes: cfg.schedulerIntervalMinutes }
390
+ : {}),
372
391
  ...(activeAgent(cfg) ? { agent: activeAgent(cfg) } : {}),
373
392
  });
374
393
  }
@@ -405,8 +424,20 @@ function agentContext(cfg: CaeloConfig): AgentCliContext {
405
424
  ...(activeAgent(cfg) ? { agentPassword: activeAgent(cfg)?.dbPassword } : {}),
406
425
  });
407
426
  },
408
- applyPublicRoleLimits: () =>
409
- applyPublicRoleLimits({ probe: postgresIsUp, psql: superuserPsql }),
427
+ applyPreMigrationRoles: () => {
428
+ if (!cfg.rolePasswords) {
429
+ return Promise.resolve({
430
+ ok: false as const,
431
+ reachable: true,
432
+ error: "config.json has no role passwords — run `cms-provision up` first",
433
+ });
434
+ }
435
+ return applyPreMigrationRoles({
436
+ probe: postgresIsUp,
437
+ psql: superuserPsql,
438
+ stagingPassword: cfg.rolePasswords.publicStaging,
439
+ });
440
+ },
410
441
  renderCompose: (images) => renderCompose(cfg, images),
411
442
  refreshCaddy: () => refreshCaddyfile(cfg),
412
443
  randomSecret: () => randomSecret(32),
@@ -562,7 +593,7 @@ async function init(): Promise<void> {
562
593
  }
563
594
  if (!domain || !ownerEmail) {
564
595
  console.error(
565
- "Usage: cms-provision init [--provider self-hosted] --domain example.com [--staging-domain staging.example.com] --owner-email me@example.com [--version x.y.z] [--skip-verify] [--dev] [--tls acme|internal] [--http-port 80] [--https-port 443]\n" +
596
+ "Usage: cms-provision init [--provider self-hosted] --domain example.com [--staging-domain staging.example.com] --owner-email me@example.com [--version x.y.z] [--skip-verify] [--dev] [--tls acme|internal] [--http-port 80] [--https-port 443] [--scheduler-interval-minutes 5]\n" +
566
597
  "Cloud providers (gcp | gcp-firebase | aws | azure) run the wizard: cms-provision --provider <name>",
567
598
  );
568
599
  process.exit(2);
@@ -577,6 +608,17 @@ async function init(): Promise<void> {
577
608
  console.error("--http-port and --https-port must differ");
578
609
  process.exit(2);
579
610
  }
611
+ const schedulerArg = arg("scheduler-interval-minutes");
612
+ let schedulerIntervalMinutes: number | undefined;
613
+ try {
614
+ schedulerIntervalMinutes =
615
+ schedulerArg === undefined
616
+ ? undefined
617
+ : parseSchedulerIntervalMinutes(schedulerArg, "--scheduler-interval-minutes");
618
+ } catch (e) {
619
+ console.error((e as Error).message);
620
+ process.exit(2);
621
+ }
580
622
  const stagingArg = arg("staging-domain");
581
623
  const stagingProblem = stagingArg
582
624
  ? stagingHostProblem(stagingArg, {
@@ -645,6 +687,7 @@ async function init(): Promise<void> {
645
687
  tls,
646
688
  ports,
647
689
  composeProject: composeProjectName(domain),
690
+ ...(schedulerIntervalMinutes !== undefined ? { schedulerIntervalMinutes } : {}),
648
691
  };
649
692
  saveConfig(cfg);
650
693
  // P15.1 — the shared HMAC secret for /api/internal/* endpoints, read by
@@ -1057,7 +1100,7 @@ async function upgradeSelfHostedCommand(opts: {
1057
1100
  runner: bunHostRunner,
1058
1101
  renderCompose: (images) => renderCompose(cfg, images),
1059
1102
  convergeRoles: agentContext(cfg).convergeRoles,
1060
- applyPublicRoleLimits: agentContext(cfg).applyPublicRoleLimits,
1103
+ applyPreMigrationRoles: agentContext(cfg).applyPreMigrationRoles,
1061
1104
  refreshCaddy: () => refreshCaddyfile(cfg),
1062
1105
  log: {
1063
1106
  step: (m) => console.log(`\n==> ${m}`),
@@ -1625,13 +1668,25 @@ async function waitForDomainMappingCert(target: {
1625
1668
  * §11.C — interactive wizard. Loaded lazily so the bare-CLI startup
1626
1669
  * cost stays small (clack + kleur are pulled in only when needed).
1627
1670
  * Reads `--provider`, `--domain`, `--owner-email`, `--project-id`, `--region`,
1628
- * `--non-interactive` and the run mode (`--local` / `--install-job`, default:
1671
+ * `--scheduler-interval-minutes` (#1140), `--non-interactive` and the run mode (`--local` / `--install-job`, default:
1629
1672
  * the install job in Cloud Shell, local elsewhere — run-mode.ts) from argv.
1630
1673
  */
1631
1674
  async function wizardCommand(): Promise<void> {
1632
1675
  const { runWizard } = await import("./wizard.js");
1633
1676
  const { chooseRunMode } = await import("./run-mode.js");
1677
+ const schedulerArg = arg("scheduler-interval-minutes");
1678
+ let schedulerIntervalMinutes: number | undefined;
1679
+ try {
1680
+ schedulerIntervalMinutes =
1681
+ schedulerArg === undefined
1682
+ ? undefined
1683
+ : parseSchedulerIntervalMinutes(schedulerArg, "--scheduler-interval-minutes");
1684
+ } catch (e) {
1685
+ console.error((e as Error).message);
1686
+ process.exit(2);
1687
+ }
1634
1688
  await runWizard({
1689
+ ...(schedulerIntervalMinutes !== undefined ? { schedulerIntervalMinutes } : {}),
1635
1690
  runMode: chooseRunMode(process.argv, process.env),
1636
1691
  nonInteractive: process.argv.includes("--non-interactive"),
1637
1692
  provider: arg("provider") as Provider | undefined,
@@ -94,6 +94,36 @@ describe("ensureCloudSqlVerifyFull", () => {
94
94
  ]);
95
95
  });
96
96
 
97
+ it("reads the DNS name after the move when the shared CA's certificate is active at once", async () => {
98
+ // searchviu-website, v0.11.10: the patch made the shared CA's certificate
99
+ // the active one, so no rotation re-read the instance, and the
100
+ // description from before the patch named no DNS host.
101
+ let described = 0;
102
+ const g = fake((a) => {
103
+ const line = a.join(" ");
104
+ if (line.startsWith("sql instances describe")) {
105
+ described += 1;
106
+ return ok(
107
+ instance(
108
+ described === 1
109
+ ? { caMode: "GOOGLE_MANAGED_INTERNAL_CA", sslMode: "ENCRYPTED_ONLY", dns: false }
110
+ : { caMode: "GOOGLE_MANAGED_CAS_CA", sslMode: "ENCRYPTED_ONLY", dns: true },
111
+ ),
112
+ );
113
+ }
114
+ if (line.startsWith("auth print-access-token")) return ok("token");
115
+ if (line.includes(" describe ")) return notFound();
116
+ return ok();
117
+ });
118
+ const r = await ensureCloudSqlVerifyFull(TARGET, {
119
+ run: g.run,
120
+ http: certs({ activeVersion: "f1", serverCerts: [{ sha1Fingerprint: "f1" }] }),
121
+ sleep: async () => {},
122
+ });
123
+ expect(r).toMatchObject({ ok: true, host: DNS });
124
+ expect(g.calls.filter((c) => c.startsWith("sql ssl"))).toEqual([]);
125
+ });
126
+
97
127
  it("changes nothing on an instance already in shape", async () => {
98
128
  const g = fake((a) => {
99
129
  const line = a.join(" ");
@@ -72,8 +72,10 @@ export type CloudSqlTlsResult =
72
72
  | { readonly ok: true; readonly host: string; readonly done: string[] }
73
73
  | { readonly ok: false; readonly error: string; readonly done: string[] };
74
74
 
75
- /** The parts of `gcloud sql instances describe --format=json` this reads. */
76
- interface InstanceDescription {
75
+ /** The parts of `gcloud sql instances describe --format=json` this module and converge-db-trust.ts read. */
76
+ export interface InstanceDescription {
77
+ /** The CA of the instance's current server certificate (PEM in `cert`). */
78
+ readonly serverCaCert?: { readonly cert?: string };
77
79
  readonly settings?: {
78
80
  readonly ipConfiguration?: {
79
81
  readonly serverCaMode?: string;
@@ -88,7 +90,8 @@ interface InstanceDescription {
88
90
  const NOT_FOUND = /NOT_FOUND|not found|does not exist|404/i;
89
91
  const SQL_ADMIN = "https://sqladmin.googleapis.com/v1";
90
92
 
91
- async function describeInstance(
93
+ /** `gcloud sql instances describe` of the install's instance, parsed. */
94
+ export async function describeInstance(
92
95
  t: CloudSqlTlsTarget,
93
96
  run: GcloudRunner,
94
97
  ): Promise<{ ok: true; d: InstanceDescription } | { ok: false; error: string }> {
@@ -170,6 +173,10 @@ export async function ensureCloudSqlVerifyFull(
170
173
  const r = await run(["sql", "instances", "patch", t.sqlInstance, project, ...patch, "--quiet"]);
171
174
  if (!r.ok) return fail(`patch ${t.sqlInstance} (${patch.join(" ")}): ${r.stderr.trim()}`);
172
175
  done.push(`Cloud SQL ${t.sqlInstance}: ${patch.join(" ")}`);
176
+ // The instance reports its DNS name only once it uses the shared CA, so
177
+ // the description from before the patch cannot name the host.
178
+ described = await describeInstance(t, run);
179
+ if (!described.ok) return fail(described.error);
173
180
  }
174
181
 
175
182
  // The active server certificate must come from the shared CA. Before the
package/src/compose.ts CHANGED
@@ -37,9 +37,11 @@ import {
37
37
  } from "./self-hosted-db-tls.js";
38
38
  import {
39
39
  ADMIN_MEMORY_DEFAULT,
40
+ GATEWAY_ENVIRONMENT_ENV,
40
41
  GATEWAY_TRUSTED_PROXY_HOPS,
41
42
  MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS,
42
43
  memoryQuantityMiB,
44
+ parseSchedulerIntervalMinutes,
43
45
  publicSiteUrl,
44
46
  REMOTE_MCP_MODE,
45
47
  REMOTE_MCP_MODE_ENV,
@@ -84,6 +86,8 @@ export const COMPOSE_POOL_BUDGET = stackPoolBudget("self-hosted", {
84
86
  maxConnections: DEFAULT_MAX_CONNECTIONS["self-hosted"],
85
87
  adminMaxInstances: 1,
86
88
  gatewayMaxInstances: 1,
89
+ // #1329 — one staging gateway container (P3b-2) next to the live one.
90
+ stagingGatewayMaxInstances: 1,
87
91
  });
88
92
 
89
93
  /** The budget's env as Compose `environment:` lines for `service`. */
@@ -216,6 +220,8 @@ export function ghcrImageDigest(service: ReleaseService, ref: string): string |
216
220
  export interface RolePasswords {
217
221
  readonly admin: string;
218
222
  readonly public: string;
223
+ /** #1329 — public_staging_role's, the staging gateway's login on cms_public_staging. */
224
+ readonly publicStaging: string;
219
225
  }
220
226
 
221
227
  export interface ComposeSpec {
@@ -258,6 +264,12 @@ export interface ComposeSpec {
258
264
  readonly adminMemory?: string;
259
265
  /** Which Caelo images run (see {@link ComposeImages}). */
260
266
  readonly images: ComposeImages;
267
+ /**
268
+ * #1140 — the `schedulerIntervalMinutes` knob: how often the scheduler
269
+ * container calls the admin's tick (1–15, default 5; stack-contract.ts
270
+ * SCHEDULER_INTERVAL_MINUTES).
271
+ */
272
+ readonly schedulerIntervalMinutes?: number;
261
273
  /**
262
274
  * The host ports Caddy listens on (`80` / `443` for a public install —
263
275
  * ACME's HTTP and TLS-ALPN challenges need exactly those).
@@ -336,6 +348,7 @@ services:
336
348
  POSTGRES_DB: caelo
337
349
  ADMIN_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.admin)}"
338
350
  PUBLIC_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.public)}"
351
+ PUBLIC_STAGING_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.publicStaging)}"
339
352
  # Recreates Postgres when its certificate is renewed (see the spec).
340
353
  CAELO_DB_TLS_SERVER_CERT_SHA256: "${spec.dbTlsServerCertSha256}"
341
354
  ${postgresAgent(spec.agent)} volumes:
@@ -366,6 +379,7 @@ ${adminService(spec, adminMemoryMiB, env)}
366
379
 
367
380
  ${gatewayService(spec)}
368
381
  ${mcpAuthProxyService(spec.images)}
382
+ ${schedulerService(spec)}
369
383
  volumes:
370
384
  caelo-pg:
371
385
  caelo-caddy-data:
@@ -457,6 +471,8 @@ ${serviceRuntime(
457
471
  environment:
458
472
  ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
459
473
  PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
474
+ # #1329 — the staging database's plugin schemas and projection.
475
+ PUBLIC_STAGING_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public_staging")}"
460
476
  CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
461
477
  CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
462
478
  CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
@@ -480,6 +496,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
480
496
  # public_role on cms_public is its only database login (#613, #635,
481
497
  # CLAUDE.md §2: the public side has no path to the authoring database).
482
498
  PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
499
+ # #1329 — the live site's gateway: it refuses anything of the staging data.
500
+ ${GATEWAY_ENVIRONMENT_ENV}: "live"
483
501
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
484
502
  # Caddy is the one proxy in front (client-ip.ts in the gateway).
485
503
  ${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
@@ -523,6 +541,56 @@ ${serviceRuntime(images, "mcp-auth-proxy", "bun run apps/mcp-auth-proxy/src/serv
523
541
  `;
524
542
  }
525
543
 
544
+ /** #1140 — the Compose service that calls the admin's scheduler tick. */
545
+ export const COMPOSE_SCHEDULER_SERVICE = "caelo-scheduler";
546
+
547
+ /** Where the admin image (and the dev checkout) ships the scheduler clock. */
548
+ export const SCHEDULER_CLOCK_SCRIPT = "packages/admin-core/src/scheduler/clock.ts";
549
+
550
+ /** The admin's tick endpoint on the Compose network. */
551
+ export const COMPOSE_SCHEDULER_TICK_URL = `http://${COMPOSE_ADMIN_UPSTREAM}/api/internal/scheduler/tick`;
552
+
553
+ /**
554
+ * #1140 — the scheduler: the self-hosted counterpart of the Cloud Scheduler
555
+ * job on Google Cloud. Every `schedulerIntervalMinutes` it POSTs the admin's
556
+ * `/api/internal/scheduler/tick` with an internal token (scope
557
+ * `scheduler.tick`); the admin runs whatever its due-table says is due. The
558
+ * clock only calls, it does no work itself, so the admin's background work
559
+ * runs inside a request on every provider.
560
+ *
561
+ * It runs the admin image with another entry point (dependency-free, node
562
+ * built-ins only) and holds only the internal secret it signs with: no
563
+ * database URL, no KEK, no mount, read-only root filesystem, every
564
+ * capability dropped. A rollback to a release from before #1140 renders it
565
+ * too; that admin image has no clock script, so the container exits and
566
+ * Docker restarts it with back-off — that release runs its work in-process.
567
+ */
568
+ function schedulerService(spec: ComposeSpec): string {
569
+ const minutes = parseSchedulerIntervalMinutes(spec.schedulerIntervalMinutes);
570
+ const runtime =
571
+ spec.images.mode === "dev"
572
+ ? serviceRuntime(spec.images, "admin", `bun run ${SCHEDULER_CLOCK_SCRIPT}`)
573
+ : [
574
+ serviceRuntime(spec.images, "admin", ""),
575
+ ` command: ["bun", "--bun", "/app/${SCHEDULER_CLOCK_SCRIPT}"]`,
576
+ ].join("\n");
577
+ return `
578
+ ${COMPOSE_SCHEDULER_SERVICE}:
579
+ ${runtime}
580
+ restart: unless-stopped
581
+ read_only: true
582
+ tmpfs: ["/tmp"]
583
+ cap_drop: ["ALL"]
584
+ security_opt: ["no-new-privileges:true"]
585
+ depends_on: [caelo-admin]
586
+ environment:
587
+ CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
588
+ CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"
589
+ CAELO_SCHEDULER_INTERVAL_MINUTES: "${minutes}"
590
+ NODE_ENV: production
591
+ `;
592
+ }
593
+
526
594
  /**
527
595
  * A compose-network database URL, password URL-encoded and YAML-escaped.
528
596
  * #1338 — verify-full against the install's own CA (self-hosted-db-tls.ts),
@@ -0,0 +1,237 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * converge-db-trust.ts (#1373) against a fake gcloud and a fake Cloud SQL
5
+ * Admin API: which host the converge job dials before the database TLS
6
+ * step, and whose certificate it accepts there.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ convergeDatabaseTarget,
12
+ normalizedSha1,
13
+ serverIdentityProblem,
14
+ } from "./converge-db-trust.js";
15
+ import type { GcloudResult } from "./gcloud.js";
16
+ import type { HttpFetch } from "./runtime-secrets.js";
17
+
18
+ const DNS = "abc123.p-label.europe-west1.sql-psa.goog";
19
+ const IP = "10.20.0.3";
20
+ const PLACEMENT = { projectId: "p", region: "europe-west1" };
21
+ const OWN_CA = "-----BEGIN CERTIFICATE-----\nper-instance\n-----END CERTIFICATE-----";
22
+ const SHARED_BUNDLE = "-----BEGIN CERTIFICATE-----\nshared\n-----END CERTIFICATE-----";
23
+ const ACTIVE = "a212b6cf51b3736b34a5d4a3194b200db6b3532a";
24
+ const OLD = "0123456789abcdef0123456789abcdef01234567";
25
+
26
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
27
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
28
+
29
+ /** The admin service as `gcloud run services describe --format=json` prints it. */
30
+ function adminService(host: string): string {
31
+ return JSON.stringify({
32
+ spec: {
33
+ template: {
34
+ metadata: {
35
+ annotations: {
36
+ "run.googleapis.com/network-interfaces": JSON.stringify([
37
+ { network: "net", subnetwork: "sub" },
38
+ ]),
39
+ },
40
+ },
41
+ spec: {
42
+ containers: [
43
+ {
44
+ image: "img@sha256:x",
45
+ env: [
46
+ {
47
+ name: "ADMIN_DATABASE_URL",
48
+ value: `postgresql://admin_role@${host}:5432/cms_admin?sslmode=require`,
49
+ },
50
+ {
51
+ name: "ADMIN_DATABASE_PASSWORD",
52
+ valueFrom: { secretKeyRef: { name: "pw", key: "latest" } },
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ },
60
+ });
61
+ }
62
+
63
+ function gcloud(opts: { host: string; instance?: object; describeFails?: boolean }) {
64
+ const calls: string[] = [];
65
+ const run = async (args: string[]) => {
66
+ const line = args.join(" ");
67
+ calls.push(line);
68
+ if (line.startsWith("run services list")) return ok("caelo-production-admin-abc1234\n");
69
+ if (line.startsWith("run services describe")) return ok(adminService(opts.host));
70
+ if (line.startsWith("sql instances list")) return ok("caelo-production-pg-1\n");
71
+ if (line.startsWith("sql instances describe")) {
72
+ return opts.describeFails ? fail("PERMISSION_DENIED") : ok(JSON.stringify(opts.instance));
73
+ }
74
+ if (line.startsWith("auth print-access-token")) return ok("ya29.token\n");
75
+ return fail(`unexpected: ${line}`);
76
+ };
77
+ return { run, calls };
78
+ }
79
+
80
+ function api(body: object, status = 200): HttpFetch {
81
+ return async () => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
82
+ }
83
+
84
+ const perInstance = {
85
+ serverCaCert: { cert: OWN_CA },
86
+ settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
87
+ };
88
+ const shared = {
89
+ serverCaCert: { cert: OWN_CA },
90
+ settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_CAS_CA" } },
91
+ };
92
+
93
+ describe("convergeDatabaseTarget", () => {
94
+ it("an install on the DNS name connects with verify-full and asks Cloud SQL nothing", async () => {
95
+ const g = gcloud({ host: DNS });
96
+ expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
97
+ kind: "dns",
98
+ host: DNS,
99
+ });
100
+ expect(g.calls.some((c) => c.startsWith("sql "))).toBe(false);
101
+ });
102
+
103
+ it("on the IP with the per-instance CA it trusts exactly that CA", async () => {
104
+ const g = gcloud({ host: IP, instance: perInstance });
105
+ const target = await convergeDatabaseTarget(PLACEMENT, {
106
+ run: g.run,
107
+ http: async () => {
108
+ throw new Error("the per-instance CA needs no certificate list");
109
+ },
110
+ });
111
+ expect(target).toEqual({ kind: "ip", host: IP, caPem: OWN_CA, pinnedSha1: null });
112
+ });
113
+
114
+ it("on the IP with the shared CA active it trusts the shared bundle and pins the instance's certificates", async () => {
115
+ const g = gcloud({ host: IP, instance: shared });
116
+ const target = await convergeDatabaseTarget(PLACEMENT, {
117
+ run: g.run,
118
+ http: api({ activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: ACTIVE }] }),
119
+ readSharedCaBundle: () => SHARED_BUNDLE,
120
+ });
121
+ expect(target).toEqual({ kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] });
122
+ });
123
+
124
+ it("moved to the shared CA but not rotated in: pinned to the one certificate the API names active", async () => {
125
+ const g = gcloud({ host: IP, instance: shared });
126
+ const target = await convergeDatabaseTarget(PLACEMENT, {
127
+ run: g.run,
128
+ http: api({ activeVersion: OLD.toUpperCase(), serverCerts: [] }),
129
+ readSharedCaBundle: () => SHARED_BUNDLE,
130
+ });
131
+ expect(target).toEqual({
132
+ kind: "ip",
133
+ host: IP,
134
+ caPem: `${OWN_CA}\n${SHARED_BUNDLE}`,
135
+ pinnedSha1: [OLD],
136
+ });
137
+ });
138
+
139
+ it("shared CA: a listing that does not positively name the active certificate is refused (fail closed)", async () => {
140
+ const answers: { body: unknown; why: RegExp }[] = [
141
+ { body: {}, why: /does not name the active certificate/ },
142
+ { body: { serverCerts: [{ sha1Fingerprint: ACTIVE }] }, why: /does not name/ },
143
+ { body: { activeVersion: "", serverCerts: [] }, why: /does not name/ },
144
+ { body: { activeVersion: "old-per-instance", serverCerts: [] }, why: /does not name/ },
145
+ {
146
+ body: { activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: "nope" }] },
147
+ why: /does not name/,
148
+ },
149
+ { body: { activeVersion: ACTIVE, serverCerts: [{}] }, why: /does not name/ },
150
+ { body: { activeVersion: ACTIVE, serverCerts: "x" }, why: /does not name/ },
151
+ ];
152
+ for (const a of answers) {
153
+ await expect(
154
+ convergeDatabaseTarget(PLACEMENT, {
155
+ run: gcloud({ host: IP, instance: shared }).run,
156
+ http: api(a.body as object),
157
+ readSharedCaBundle: () => SHARED_BUNDLE,
158
+ }),
159
+ ).rejects.toThrow(a.why);
160
+ }
161
+ const notJson: HttpFetch = async () => ({ ok: true, status: 200, text: async () => "<html>" });
162
+ await expect(
163
+ convergeDatabaseTarget(PLACEMENT, {
164
+ run: gcloud({ host: IP, instance: shared }).run,
165
+ http: notJson,
166
+ readSharedCaBundle: () => SHARED_BUNDLE,
167
+ }),
168
+ ).rejects.toThrow(/answered no JSON/);
169
+ });
170
+
171
+ it("refuses a CA mode it cannot reason about", async () => {
172
+ await expect(
173
+ convergeDatabaseTarget(PLACEMENT, {
174
+ run: gcloud({
175
+ host: IP,
176
+ instance: {
177
+ serverCaCert: { cert: OWN_CA },
178
+ settings: { ipConfiguration: { serverCaMode: "CUSTOMER_MANAGED_CAS_CA" } },
179
+ },
180
+ }).run,
181
+ }),
182
+ ).rejects.toThrow(/CUSTOMER_MANAGED_CAS_CA/);
183
+ });
184
+
185
+ it("an instance that names no CA mode is on the per-instance default", async () => {
186
+ const g = gcloud({ host: IP, instance: { serverCaCert: { cert: OWN_CA } } });
187
+ expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
188
+ kind: "ip",
189
+ host: IP,
190
+ caPem: OWN_CA,
191
+ pinnedSha1: null,
192
+ });
193
+ });
194
+
195
+ it("refuses to connect when it cannot tell whom to trust", async () => {
196
+ await expect(
197
+ convergeDatabaseTarget(PLACEMENT, { run: gcloud({ host: IP, describeFails: true }).run }),
198
+ ).rejects.toThrow("PERMISSION_DENIED");
199
+ await expect(
200
+ convergeDatabaseTarget(PLACEMENT, {
201
+ run: gcloud({
202
+ host: IP,
203
+ instance: {
204
+ settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
205
+ },
206
+ }).run,
207
+ }),
208
+ ).rejects.toThrow("names no server CA certificate");
209
+ await expect(
210
+ convergeDatabaseTarget(PLACEMENT, {
211
+ run: gcloud({ host: IP, instance: shared }).run,
212
+ http: api({ error: "denied" }, 403),
213
+ }),
214
+ ).rejects.toThrow("HTTP 403");
215
+ });
216
+ });
217
+
218
+ describe("serverIdentityProblem", () => {
219
+ const pinned = { kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] } as const;
220
+
221
+ it("accepts a pinned certificate in node:tls's colon notation", () => {
222
+ const colons = ACTIVE.toUpperCase().replace(/(..)(?!$)/g, "$1:");
223
+ expect(serverIdentityProblem(pinned, colons)).toBeNull();
224
+ });
225
+
226
+ it("refuses a certificate the instance does not list", () => {
227
+ expect(serverIdentityProblem(pinned, "00:11")).toContain("does not list");
228
+ });
229
+
230
+ it("without a pin the verified chain to the instance's own CA decides", () => {
231
+ expect(serverIdentityProblem({ ...pinned, pinnedSha1: null }, "00:11")).toBeNull();
232
+ });
233
+
234
+ it("normalizes fingerprints", () => {
235
+ expect(normalizedSha1("AB:cd:01")).toBe("abcd01");
236
+ });
237
+ });