@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
@@ -13,8 +13,10 @@
13
13
  * as the Postgres superuser BEFORE it writes the new Compose file: it
14
14
  * creates missing roles and databases and sets each role's password to the
15
15
  * configured one. It also drops the former `gateway_role` (#635: the
16
- * gateway has no cms_admin login), with whatever it still holds. Every
17
- * statement is idempotent.
16
+ * gateway has no cms_admin login), with whatever it still holds, and
17
+ * (#1329) creates the staging database `cms_public_staging` with the
18
+ * staging gateway's login `public_staging_role` on an install from before
19
+ * it. Every statement is idempotent.
18
20
  *
19
21
  * Passwords travel on psql's stdin, never on argv.
20
22
  */
@@ -35,7 +37,10 @@ function literal(s: string): string {
35
37
  export const AGENT_DB_ROLE = "caelo_agent";
36
38
 
37
39
  /** The application roles bootstrap.sh and the convergence create. */
38
- const APP_ROLES = ["admin_role", "public_role"] as const;
40
+ const APP_ROLES = ["admin_role", "public_role", "public_staging_role"] as const;
41
+
42
+ /** #1329 — the gateway logins that carry {@link PUBLIC_ROLE_LIMITS}: the live and the staging one. */
43
+ const GATEWAY_LOGINS = ["public_role", "public_staging_role"] as const;
39
44
  type AppRole = (typeof APP_ROLES)[number];
40
45
 
41
46
  /**
@@ -47,7 +52,8 @@ type AppRole = (typeof APP_ROLES)[number];
47
52
  * (packages/migrations role-audit.ts) refuses an install without them.
48
53
  * Self-hosted admin_role has no CREATEROLE, so the superuser applies them
49
54
  * here — on the volume's first start (bootstrap.sh), on every `up`, and
50
- * before the migrations of every upgrade ({@link publicRoleLimitsScript}).
55
+ * before the migrations of every upgrade ({@link preMigrationRolesScript}).
56
+ * Since #1329 the staging gateway's public_staging_role carries the same.
51
57
  */
52
58
  export const PUBLIC_ROLE_LIMITS = {
53
59
  connectionLimit: 30,
@@ -56,31 +62,57 @@ export const PUBLIC_ROLE_LIMITS = {
56
62
  workMem: "1MB",
57
63
  } as const;
58
64
 
59
- /** The statements that apply {@link PUBLIC_ROLE_LIMITS} (idempotent). */
65
+ /** The statements that apply {@link PUBLIC_ROLE_LIMITS} to both gateway logins (idempotent). */
60
66
  function publicRoleLimitsSql(): string[] {
61
67
  const l = PUBLIC_ROLE_LIMITS;
68
+ return GATEWAY_LOGINS.flatMap((role) => [
69
+ `ALTER ROLE ${role} CONNECTION LIMIT ${l.connectionLimit};`,
70
+ `ALTER ROLE ${role} SET statement_timeout = '${l.statementTimeout}';`,
71
+ `ALTER ROLE ${role} SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
72
+ `ALTER ROLE ${role} SET work_mem = '${l.workMem}';`,
73
+ ]);
74
+ }
75
+
76
+ /** How a role's password appears in the SQL: a literal, or a psql variable. */
77
+ type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
78
+
79
+ /**
80
+ * #1329 — the staging database and its gateway login, with each gateway
81
+ * login on its own database only (the same as bootstrap.sh and cms_admin
82
+ * migration 0530). Idempotent: an install from before #1329 gets both, an
83
+ * install that has them gets the password re-applied.
84
+ */
85
+ function stagingDatabaseSql(password: PasswordSql): string[] {
62
86
  return [
63
- `ALTER ROLE public_role CONNECTION LIMIT ${l.connectionLimit};`,
64
- `ALTER ROLE public_role SET statement_timeout = '${l.statementTimeout}';`,
65
- `ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
66
- `ALTER ROLE public_role SET work_mem = '${l.workMem}';`,
87
+ "SELECT 'CREATE ROLE public_staging_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'public_staging_role') \\gexec",
88
+ `ALTER ROLE public_staging_role WITH LOGIN PASSWORD ${password("public_staging_role")};`,
89
+ "SELECT 'CREATE DATABASE cms_public_staging OWNER admin_role' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cms_public_staging') \\gexec",
90
+ "REVOKE ALL ON DATABASE cms_public_staging FROM PUBLIC;",
91
+ "GRANT CONNECT ON DATABASE cms_public_staging TO public_staging_role, admin_role;",
92
+ "REVOKE ALL ON DATABASE cms_public_staging FROM public_role;",
93
+ "REVOKE ALL ON DATABASE cms_public FROM public_staging_role;",
94
+ "REVOKE ALL ON DATABASE cms_admin FROM public_staging_role;",
67
95
  ];
68
96
  }
69
97
 
70
98
  /**
71
- * The psql script (superuser) that applies only {@link PUBLIC_ROLE_LIMITS}.
72
- * The self-hosted upgrade runs it BEFORE the migrations: their role audit
73
- * refuses public_role without the limits, and the full convergence must
99
+ * The psql script (superuser) the self-hosted upgrade runs BEFORE the
100
+ * migrations: the gateway logins' {@link PUBLIC_ROLE_LIMITS} (#1338) and
101
+ * (#1329) the staging database with its login. The migrations' role audit
102
+ * refuses either gateway login without its limits, and the
103
+ * cms_public_staging run needs the database; the full convergence must
74
104
  * wait until after them (it drops roles a migration retired). Fails when
75
105
  * public_role does not exist — the install's roles were never created.
76
106
  */
77
- export function publicRoleLimitsScript(): string {
78
- return ["\\set ON_ERROR_STOP on", ...publicRoleLimitsSql(), ""].join("\n");
107
+ export function preMigrationRolesScript(stagingPassword: string): string {
108
+ return [
109
+ "\\set ON_ERROR_STOP on",
110
+ ...stagingDatabaseSql(() => literal(stagingPassword)),
111
+ ...publicRoleLimitsSql(),
112
+ "",
113
+ ].join("\n");
79
114
  }
80
115
 
81
- /** How a role's password appears in the SQL: a literal, or a psql variable. */
82
- type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
83
-
84
116
  /**
85
117
  * The convergence SQL (psql, as the superuser, connected to any database).
86
118
  * `password` renders each role's password; `agent` adds the agent's login.
@@ -111,11 +143,15 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
111
143
  "REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
112
144
  "GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
113
145
  "GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
146
+ // #1329 — after the other two databases exist: its revokes name them.
147
+ ...stagingDatabaseSql(password),
114
148
  // #635 — the gateway's former cms_admin login goes, with whatever it
115
149
  // still holds in either database (the superuser may drop what
116
150
  // migration 0285 could only revoke).
117
151
  "\\connect cms_public",
118
152
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
153
+ "\\connect cms_public_staging",
154
+ "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
119
155
  "\\connect cms_admin",
120
156
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
121
157
  "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
@@ -136,6 +172,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
136
172
  const byRole: Record<AppRole, string> = {
137
173
  admin_role: passwords.admin,
138
174
  public_role: passwords.public,
175
+ public_staging_role: passwords.publicStaging,
139
176
  };
140
177
  return convergenceSql(
141
178
  (role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
@@ -147,6 +184,7 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
147
184
  const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
148
185
  admin_role: "ADMIN_ROLE_PASSWORD",
149
186
  public_role: "PUBLIC_ROLE_PASSWORD",
187
+ public_staging_role: "PUBLIC_STAGING_ROLE_PASSWORD",
150
188
  [AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
151
189
  };
152
190
 
@@ -185,18 +223,20 @@ ${convergenceSql((role) => `:'${role}_pw'`, opts.agent)}CAELO_SQL
185
223
  export type SuperuserPsql = (script: string) => Promise<{ exitCode: number; stderr: string }>;
186
224
 
187
225
  /**
188
- * Apply {@link publicRoleLimitsScript} through the running Postgres
226
+ * Apply {@link preMigrationRolesScript} through the running Postgres
189
227
  * container. Unlike {@link convergeSelfHostedRoles} a stopped database is a
190
228
  * failure: the upgrade runs it right before migrations that need it.
191
229
  */
192
- export async function applyPublicRoleLimits(deps: {
230
+ export async function applyPreMigrationRoles(deps: {
193
231
  readonly probe: () => Promise<boolean>;
194
232
  readonly psql: SuperuserPsql;
233
+ /** #1329 — public_staging_role's password from `.caelo/config.json`. */
234
+ readonly stagingPassword: string;
195
235
  }): Promise<ConvergeOutcome> {
196
236
  if (!(await deps.probe())) {
197
237
  return { ok: false, reachable: false, error: "the Postgres container is not running" };
198
238
  }
199
- const r = await deps.psql(publicRoleLimitsScript());
239
+ const r = await deps.psql(preMigrationRolesScript(deps.stagingPassword));
200
240
  if (r.exitCode === 0) return { ok: true };
201
241
  return { ok: false, reachable: true, error: r.stderr.trim() || `psql exited ${r.exitCode}` };
202
242
  }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1140 — the self-hosted Pulumi stack and the CLI render the same Compose
5
+ * file, so the scheduler interval `init` recorded survives a `pulumi up`,
6
+ * and a disagreeing Pulumi key is refused instead of winning silently.
7
+ */
8
+
9
+ import { afterAll, describe, expect, it } from "bun:test";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ recordedSchedulerIntervalMinutes,
15
+ selfHostedSchedulerIntervalMinutes,
16
+ } from "./self-hosted-scheduler-interval.js";
17
+
18
+ const dir = mkdtempSync(join(tmpdir(), "caelo-scheduler-interval-"));
19
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
20
+
21
+ function config(name: string, body: string): string {
22
+ const path = join(dir, name);
23
+ writeFileSync(path, body);
24
+ return path;
25
+ }
26
+
27
+ describe("recordedSchedulerIntervalMinutes", () => {
28
+ it("reads what init recorded", () => {
29
+ expect(
30
+ recordedSchedulerIntervalMinutes(config("a.json", '{"schedulerIntervalMinutes":2}')),
31
+ ).toBe(2);
32
+ });
33
+
34
+ it("is undefined without a config.json or without the key", () => {
35
+ expect(recordedSchedulerIntervalMinutes(join(dir, "missing.json"))).toBeUndefined();
36
+ expect(recordedSchedulerIntervalMinutes(config("b.json", '{"domain":"x"}'))).toBeUndefined();
37
+ });
38
+
39
+ it("refuses a broken file or an out-of-range value", () => {
40
+ expect(() => recordedSchedulerIntervalMinutes(config("c.json", "{"))).toThrow("not valid JSON");
41
+ expect(() =>
42
+ recordedSchedulerIntervalMinutes(config("d.json", '{"schedulerIntervalMinutes":30}')),
43
+ ).toThrow("between 1 and 15");
44
+ expect(() =>
45
+ recordedSchedulerIntervalMinutes(config("e.json", '{"schedulerIntervalMinutes":"2"}')),
46
+ ).toThrow("not a number");
47
+ });
48
+ });
49
+
50
+ describe("selfHostedSchedulerIntervalMinutes", () => {
51
+ it("keeps the recorded interval when the Pulumi key is unset", () => {
52
+ expect(selfHostedSchedulerIntervalMinutes(undefined, 2, "config.json")).toBe(2);
53
+ expect(selfHostedSchedulerIntervalMinutes(undefined, undefined, "config.json")).toBeUndefined();
54
+ });
55
+
56
+ it("takes the Pulumi key on a Pulumi-only install, or when both agree", () => {
57
+ expect(selfHostedSchedulerIntervalMinutes(3, undefined, "config.json")).toBe(3);
58
+ expect(selfHostedSchedulerIntervalMinutes(2, 2, "config.json")).toBe(2);
59
+ });
60
+
61
+ it("refuses a Pulumi key that disagrees with the record", () => {
62
+ expect(() => selfHostedSchedulerIntervalMinutes(5, 2, "config.json")).toThrow(
63
+ "config.json records 2",
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1140 — the self-hosted scheduler interval has two places it can be set:
5
+ * `cms-provision init --scheduler-interval-minutes` records it in
6
+ * `.caelo/config.json` (which every CLI render — `up`, `upgrade`, agent
7
+ * apply — reads), and the Pulumi stack has the config key
8
+ * `caelo-self-hosted:schedulerIntervalMinutes`. Both rewrite the same
9
+ * `docker-compose.yml`, so if each read only its own source a `pulumi up`
10
+ * over a CLI install would silently put the interval back to the default,
11
+ * and the next CLI `upgrade` would undo a Pulumi-only value the same way.
12
+ *
13
+ * The recorded `.caelo/config.json` is the install's one record (the CLI
14
+ * renders from nothing else), so the stack takes the interval from it; the
15
+ * Pulumi key may restate it but a different value is refused rather than
16
+ * one side winning silently (CLAUDE.md §2: no fallbacks).
17
+ */
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { parseSchedulerIntervalMinutes } from "./stack-contract.js";
21
+
22
+ /**
23
+ * Read the interval `init` recorded in a `.caelo/config.json`.
24
+ *
25
+ * @param configPath - the install's `config.json`.
26
+ * @returns the recorded minutes, or undefined when there is no config.json
27
+ * (a Pulumi-only install) or it records no interval (the default).
28
+ * @throws when the file is not JSON or the recorded value is out of bounds.
29
+ */
30
+ export function recordedSchedulerIntervalMinutes(configPath: string): number | undefined {
31
+ if (!existsSync(configPath)) return undefined;
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
35
+ } catch (e) {
36
+ throw new Error(`${configPath} is not valid JSON: ${(e as Error).message}`);
37
+ }
38
+ const raw =
39
+ parsed !== null && typeof parsed === "object"
40
+ ? (parsed as { schedulerIntervalMinutes?: unknown }).schedulerIntervalMinutes
41
+ : undefined;
42
+ if (raw === undefined) return undefined;
43
+ if (typeof raw !== "number") {
44
+ throw new Error(
45
+ `${configPath}: schedulerIntervalMinutes ${JSON.stringify(raw)} is not a number of minutes`,
46
+ );
47
+ }
48
+ return parseSchedulerIntervalMinutes(raw, `${configPath}: schedulerIntervalMinutes`);
49
+ }
50
+
51
+ /**
52
+ * The interval the self-hosted Pulumi stack renders into the Compose file.
53
+ *
54
+ * @param pulumiValue - `caelo-self-hosted:schedulerIntervalMinutes`, if set.
55
+ * @param recorded - what {@link recordedSchedulerIntervalMinutes} read.
56
+ * @param configPath - named in the refusal.
57
+ * @returns the minutes to render; undefined means neither source sets one
58
+ * (the generator's default).
59
+ * @throws when both are set and differ.
60
+ */
61
+ export function selfHostedSchedulerIntervalMinutes(
62
+ pulumiValue: number | undefined,
63
+ recorded: number | undefined,
64
+ configPath: string,
65
+ ): number | undefined {
66
+ if (pulumiValue === undefined) return recorded;
67
+ const minutes = parseSchedulerIntervalMinutes(
68
+ pulumiValue,
69
+ "caelo-self-hosted:schedulerIntervalMinutes",
70
+ );
71
+ if (recorded !== undefined && recorded !== minutes) {
72
+ throw new Error(
73
+ `caelo-self-hosted:schedulerIntervalMinutes is ${minutes} but ${configPath} records ${recorded}; ` +
74
+ "the CLI renders the Compose file from config.json, so the two must agree. " +
75
+ `Set the Pulumi key to ${recorded}, or remove it (\`pulumi config rm caelo-self-hosted:schedulerIntervalMinutes\`) to use the recorded value.`,
76
+ );
77
+ }
78
+ return minutes;
79
+ }
@@ -36,6 +36,7 @@ describe("startSelfHosted (#707 — one command ends with a serving install)", (
36
36
  "up -d --wait postgres",
37
37
  `${MIGRATE} admin`,
38
38
  `${MIGRATE} public`,
39
+ `${MIGRATE} public-staging`,
39
40
  "up -d --remove-orphans",
40
41
  "config --services",
41
42
  "exec -T caelo-admin bun -e <probe>",
@@ -151,7 +151,7 @@ function deps(
151
151
  runner.calls.push("converge roles");
152
152
  return roles;
153
153
  },
154
- applyPublicRoleLimits: async () => {
154
+ applyPreMigrationRoles: async () => {
155
155
  runner.calls.push("public role limits");
156
156
  return { ok: true } as const;
157
157
  },
@@ -187,6 +187,7 @@ describe("upgradeSelfHosted", () => {
187
187
  "public role limits",
188
188
  "migrate admin",
189
189
  "migrate public",
190
+ "migrate public-staging",
190
191
  "converge roles",
191
192
  "up caelo-admin",
192
193
  "health admin",
@@ -12,7 +12,9 @@
12
12
  * 3b. #1338 — recreate Postgres when the Compose file changed it (TLS with
13
13
  * the install's own certificate, self-hosted-db-tls.ts: the new images
14
14
  * connect with verify-full only; the old containers keep connecting,
15
- * Postgres still accepts them) and apply public_role's limits as the
15
+ * Postgres still accepts them) and, as the superuser, create the
16
+ * staging database with its gateway login (#1329) and apply both gateway
17
+ * logins' limits as the
16
18
  * superuser (the migrations' role audit refuses public_role without
17
19
  * them, and the self-hosted admin_role cannot set them);
18
20
  * 4. run the migrations from the NEW admin image (`docker compose run`,
@@ -65,6 +67,7 @@ import {
65
67
  MEDIA_DIR,
66
68
  type ReleaseImages,
67
69
  } from "./compose.js";
70
+ import { MIGRATION_TARGETS } from "./migration-targets.js";
68
71
  import { selfCommand } from "./self-command.js";
69
72
  import {
70
73
  type HostRunner,
@@ -125,11 +128,13 @@ export interface SelfHostedUpgradeDeps {
125
128
  */
126
129
  readonly convergeRoles: () => Promise<ConvergeOutcome>;
127
130
  /**
128
- * #1338 — applies public_role's limits as the Postgres superuser
129
- * (`applyPublicRoleLimits`), before the migrations whose role audit
131
+ * #1338 / #1329 — as the Postgres superuser (`applyPreMigrationRoles`):
132
+ * creates the staging database and its gateway login on an install from
133
+ * before them, and applies both gateway logins' limits, before the
134
+ * migrations that run on that database and whose role audit
130
135
  * checks them.
131
136
  */
132
- readonly applyPublicRoleLimits: () => Promise<ConvergeOutcome>;
137
+ readonly applyPreMigrationRoles: () => Promise<ConvergeOutcome>;
133
138
  /**
134
139
  * Re-writes the Caddyfile from the install's config + domains table and
135
140
  * reloads Caddy (#891): the edge headers (the SVG CSP, nosniff) live in
@@ -401,12 +406,12 @@ export async function upgradeSelfHosted(
401
406
  back.exitCode === 0,
402
407
  );
403
408
  }
404
- const limits = await deps.applyPublicRoleLimits();
409
+ const limits = await deps.applyPreMigrationRoles();
405
410
  if (!limits.ok) {
406
411
  restoreCompose(file, previousCompose);
407
412
  return fail(
408
413
  "roles",
409
- `Could not apply public_role's connection and statement limits: ${limits.error}\nThe previous release keeps running; no migration ran. Database backup: ${backup.dir}`,
414
+ `Could not prepare the database roles for the migrations (the staging database cms_public_staging and the gateway logins' connection and statement limits): ${limits.error}\nThe previous release keeps running; no migration ran. Database backup: ${backup.dir}`,
410
415
  true,
411
416
  );
412
417
  }
@@ -600,7 +605,8 @@ export async function dumpDatabases(
600
605
  }
601
606
 
602
607
  /**
603
- * Apply the committed migrations (cms_admin, then cms_public) with the
608
+ * Apply the committed migrations (cms_admin, then cms_public and its staging
609
+ * twin cms_public_staging, #1329) with the
604
610
  * admin image the Compose file names, as a one-off container — the
605
611
  * self-hosted equivalent of the GCP `caelo-migrate-<target>` Cloud Run job.
606
612
  * drizzle's bookkeeping makes re-runs apply only new migrations.
@@ -609,7 +615,7 @@ export async function runMigrations(
609
615
  caeloDir: string,
610
616
  runner: HostRunner,
611
617
  ): Promise<{ ok: true } | { ok: false; error: string }> {
612
- for (const target of ["admin", "public"] as const) {
618
+ for (const target of MIGRATION_TARGETS) {
613
619
  const r = await runner.run(
614
620
  compose(
615
621
  caeloDir,
@@ -23,6 +23,7 @@ import {
23
23
  RETIRED_SECRET_FILES,
24
24
  RETIRED_SERVICE_ENV,
25
25
  runtimeSecretBindings,
26
+ SECRETS_SET_UP_AHEAD_OF_THEIR_READER,
26
27
  SERVICE_SECRET_ENV,
27
28
  SERVICE_SECRET_FILES,
28
29
  SERVICE_VPC_EGRESS,
@@ -52,6 +53,7 @@ const asRecord = (vars: CloudRunEnvVar[]) =>
52
53
  const ADMIN_SECRETS = {
53
54
  ADMIN_DATABASE_PASSWORD: "secret:caelo-production-postgres-password:latest",
54
55
  PUBLIC_ADMIN_DATABASE_PASSWORD: "secret:caelo-production-postgres-password:latest",
56
+ PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD: "secret:caelo-production-postgres-password:latest",
55
57
  CAELO_SECRET_KEK: "secret:caelo-production-secret-kek:latest",
56
58
  CAELO_INTERNAL_SECRET: "secret:caelo-production-internal-secret:latest",
57
59
  CAELO_TOOL_APPROVAL_SECRET: "secret:caelo-production-tool-approval-secret:latest",
@@ -67,6 +69,10 @@ describe("databaseUrls", () => {
67
69
  "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
68
70
  public:
69
71
  "postgresql://public_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
72
+ publicStagingAdmin:
73
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
74
+ publicStaging:
75
+ "postgresql://public_staging_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
70
76
  });
71
77
  });
72
78
  });
@@ -81,11 +87,14 @@ describe("adminEnvContract", () => {
81
87
  "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
82
88
  PUBLIC_ADMIN_DATABASE_URL:
83
89
  "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
90
+ PUBLIC_STAGING_ADMIN_DATABASE_URL:
91
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
84
92
  CAELO_SITE_URL: "https://acme.com",
85
93
  CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
86
94
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
87
95
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
88
96
  CAELO_MEDIA_BUCKET: "acme-caelo-production-media",
97
+ CAELO_SCHEDULER_SERVICE_ACCOUNT: "caelo-production-scheduler@acme.iam.gserviceaccount.com",
89
98
  // #806 — where Settings → MCP → Remote writes the OAuth client.
90
99
  CAELO_OPERATOR_OAUTH_CLIENT_STORE:
91
100
  "projects/acme/secrets/caelo-production-operator-oauth-client",
@@ -121,11 +130,14 @@ describe("adminEnvContract", () => {
121
130
  "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
122
131
  PUBLIC_ADMIN_DATABASE_URL:
123
132
  "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
133
+ PUBLIC_STAGING_ADMIN_DATABASE_URL:
134
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
124
135
  CAELO_SITE_URL: "https://acme.com",
125
136
  CAELO_GENERATOR_CLI: "/app/apps/static-generator/src/cli.ts",
126
137
  MEDIA_ROOT_DIR: "/app/apps/admin/data/media",
127
138
  CAELO_MCP_IAP_SERVICE_ACCOUNT: "caelo-mcp@acme.iam.gserviceaccount.com",
128
139
  CAELO_MEDIA_BUCKET: "acme-caelo-production-media",
140
+ CAELO_SCHEDULER_SERVICE_ACCOUNT: "caelo-production-scheduler@acme.iam.gserviceaccount.com",
129
141
  // #806 — where Settings → MCP → Remote writes the OAuth client.
130
142
  CAELO_OPERATOR_OAUTH_CLIENT_STORE:
131
143
  "projects/acme/secrets/caelo-production-operator-oauth-client",
@@ -191,6 +203,8 @@ describe("gatewayEnvContract", () => {
191
203
  CAELO_PROVIDER: "gcp",
192
204
  CAELO_ENV: "production",
193
205
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
206
+ // #1329 — the live site's gateway.
207
+ CAELO_GATEWAY_ENVIRONMENT: "live",
194
208
  // #845 — gcp: the LB appends <client>,<LB IP>.
195
209
  CAELO_TRUSTED_PROXY_HOPS: "2",
196
210
  PUBLIC_DATABASE_URL:
@@ -267,6 +281,7 @@ describe("database role secrets (#613)", () => {
267
281
  expect(DATABASE_ROLE_SECRET).toEqual({
268
282
  admin_role: "postgres-password",
269
283
  public_role: "public-role-password",
284
+ public_staging_role: "public-staging-role-password",
270
285
  });
271
286
  });
272
287
 
@@ -341,11 +356,24 @@ describe("runtime secrets", () => {
341
356
  ]);
342
357
  });
343
358
 
344
- it("every CLI-generated secret is read by some service", () => {
359
+ it("every CLI-generated secret is read by some service, or says what reads it until one does", () => {
360
+ const read = CLOUD_RUN_SLUGS.flatMap((slug) => serviceSecrets(slug));
345
361
  for (const s of CLI_GENERATED_SECRETS) {
346
- expect(CLOUD_RUN_SLUGS.flatMap((slug) => serviceSecrets(slug))).toContain(s);
362
+ if (SECRETS_SET_UP_AHEAD_OF_THEIR_READER[s]) {
363
+ // Set up ahead: no service may read it yet, or the entry is stale.
364
+ expect(read).not.toContain(s);
365
+ continue;
366
+ }
367
+ expect(read).toContain(s);
347
368
  }
348
369
  });
370
+
371
+ it("the two gateway logins share no password secret, and no service reads the staging one yet (#1329)", () => {
372
+ expect(DATABASE_ROLE_SECRET.public_staging_role).not.toBe(DATABASE_ROLE_SECRET.public_role);
373
+ expect(CLI_GENERATED_SECRETS).toContain("public-staging-role-password");
374
+ expect(serviceSecrets("gateway")).not.toContain("public-staging-role-password");
375
+ expect(serviceSecrets("admin")).not.toContain("public-staging-role-password");
376
+ });
349
377
  });
350
378
 
351
379
  describe("self-hosted compose", () => {
@@ -353,7 +381,7 @@ describe("self-hosted compose", () => {
353
381
  const yaml = generateDockerCompose({
354
382
  domain: "acme.com",
355
383
  postgresPassword: "p",
356
- rolePasswords: { admin: "a", public: "b" },
384
+ rolePasswords: { admin: "a", public: "b", publicStaging: "ps" },
357
385
  caeloSecretKek: "k",
358
386
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
359
387
  internalSecret: "i".repeat(48),