@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
@@ -0,0 +1,375 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1140 — set up (and converge) the scheduler tick on a Google Cloud install
5
+ * (`gcp`, `gcp-firebase`): the ONE Cloud Scheduler job that calls the
6
+ * admin's `/api/internal/scheduler/tick` every `schedulerIntervalMinutes`
7
+ * (1–15, default 5). Each task's own cadence lives in the admin's
8
+ * due-table; the tick only says "run what is due now". Run by the wizard
9
+ * after migrations and by `cms-provision upgrade`'s converge (and the
10
+ * converge job), as the operator's or the provisioner's identity.
11
+ *
12
+ * Why not Cloud Scheduler → admin directly. The admin sits behind IAP, and
13
+ * IAP accepts a service account's OIDC ID token only when its `aud` is an
14
+ * OAuth client allowlisted for programmatic access; an install on the
15
+ * Google-managed IAP client has none (cloud.google.com/iap/docs/
16
+ * authentication-howto). Cloud Scheduler's other credential, an OAuth
17
+ * access token, it sends only to `*.googleapis.com` (cloud.google.com/
18
+ * scheduler/docs/http-target-auth). What passes IAP for a service account
19
+ * on every install is a self-signed service-account JWT with `aud` =
20
+ * `<admin origin>/*` — the path caelo-mcp and admin-internal-google.ts
21
+ * take. Cloud Scheduler cannot sign one, so the job starts a Cloud
22
+ * Workflows execution (a googleapis.com target, OAuth), and the workflow
23
+ * signs the JWT as the scheduler account through the IAM Credentials API
24
+ * and POSTs the tick. No container per tick and no image to keep in step
25
+ * with the app-roll; cents a month (wizards/gcp-cost.ts).
26
+ *
27
+ * Why the CLI owns it and not the stacks: the same as audit-job-setup.ts —
28
+ * `upgrade` never runs Pulumi, and existing installs get the tick from here.
29
+ *
30
+ * Trust boundary. The scheduler's identity, `caelo-<env>-scheduler`, holds:
31
+ *
32
+ * - `roles/iam.serviceAccountTokenCreator` on ITSELF only (signJwt);
33
+ * - `roles/iap.httpsResourceAccessor` on the admin's IAP resource (the
34
+ * operator-access sync job keeps it there, operator-access.ts);
35
+ * - `roles/workflows.invoker` on the project (Cloud Scheduler starts the
36
+ * workflow as it; Workflows has no per-workflow IAM policy to bind to);
37
+ * - `roles/logging.logWriter` (the workflow's error call logs).
38
+ *
39
+ * Nothing else: no Secret Manager, no Cloud SQL, no Cloud Run rights. What
40
+ * it can do with the admin is the admin's to decide: the tick endpoint
41
+ * accepts exactly this account's IAP identity (`CAELO_SCHEDULER_SERVICE_
42
+ * ACCOUNT` on the admin) and only ever runs the due-table, never a build
43
+ * or a publish (scripts/no-automatic-production-deploy.test.ts).
44
+ * {@link SCHEDULER_FORBIDDEN_ROLES} names that line; the tests assert no
45
+ * grant crosses it.
46
+ */
47
+
48
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
49
+ import { tmpdir } from "node:os";
50
+ import { join } from "node:path";
51
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
52
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
53
+ import {
54
+ schedulerJobName,
55
+ schedulerServiceAccountEmail,
56
+ schedulerServiceAccountId,
57
+ schedulerWorkflowName,
58
+ } from "./gcp-names.js";
59
+ import { type IapResource, iapResourceArgs } from "./mcp-iap.js";
60
+ import { schedulerCron } from "./stack-contract.js";
61
+
62
+ /** The admin path the workflow calls (apps/admin `api/internal/scheduler/tick`). */
63
+ export const SCHEDULER_TICK_PATH = "/api/internal/scheduler/tick";
64
+
65
+ /**
66
+ * How long the workflow waits for the admin's answer, in seconds: Workflows'
67
+ * maximum (cloud.google.com/workflows/docs/reference/stdlib/http/post). The
68
+ * admin holds the request until the tick's work is done: it claims tasks for
69
+ * at most its budget (4 minutes, admin-core scheduler/tick.ts) and then waits
70
+ * at most one task's lease (10 minutes) for the last one, so 14 minutes in
71
+ * the worst case, inside the admin's own 3600 s request timeout.
72
+ */
73
+ export const SCHEDULER_TICK_TIMEOUT_SECONDS = 1800;
74
+
75
+ /** Lifetime of the IAP credential the workflow signs per tick. */
76
+ const JWT_LIFETIME_SECONDS = 600;
77
+
78
+ /** APIs the tick needs on the project. */
79
+ export const SCHEDULER_APIS = [
80
+ "cloudscheduler.googleapis.com",
81
+ "workflows.googleapis.com",
82
+ "iamcredentials.googleapis.com",
83
+ ] as const;
84
+
85
+ /**
86
+ * Roles the scheduler's identity must never hold anywhere. Prefix matches
87
+ * (`roles/secretmanager.` = every Secret Manager role).
88
+ */
89
+ export const SCHEDULER_FORBIDDEN_ROLES = [
90
+ "roles/owner",
91
+ "roles/editor",
92
+ "roles/viewer",
93
+ "roles/run.",
94
+ "roles/iam.serviceAccountUser",
95
+ "roles/iam.serviceAccountAdmin",
96
+ "roles/iam.securityAdmin",
97
+ "roles/iap.admin",
98
+ "roles/iap.settingsAdmin",
99
+ "roles/secretmanager.",
100
+ "roles/cloudsql.",
101
+ "roles/compute.",
102
+ "roles/storage.",
103
+ "roles/resourcemanager.",
104
+ "roles/workflows.admin",
105
+ "roles/workflows.editor",
106
+ "roles/cloudscheduler.",
107
+ ] as const;
108
+
109
+ export interface SchedulerTickTarget {
110
+ readonly projectId: string;
111
+ readonly region: string;
112
+ /** Pulumi stack name (`production`). */
113
+ readonly env: string;
114
+ /**
115
+ * Where the admin answers behind IAP, an origin: `https://admin.<domain>`
116
+ * on gcp, the admin service's URL on gcp-firebase (remote-mcp.ts
117
+ * RemoteMcpIdentity.adminUrl, the same value as the admin's
118
+ * CAELO_ADMIN_URL).
119
+ */
120
+ readonly adminUrl: string;
121
+ /** The admin's IAP resource (the service on gcp-firebase, the LB backend on gcp). */
122
+ readonly resource: IapResource;
123
+ /** The `schedulerIntervalMinutes` knob (1–15). */
124
+ readonly intervalMinutes: number;
125
+ }
126
+
127
+ export type SchedulerTickResult =
128
+ | { readonly ok: true; readonly done: string[]; readonly serviceAccount: string }
129
+ | { readonly ok: false; readonly done: string[]; readonly error: string };
130
+
131
+ /**
132
+ * The tick's admin origin, or an error naming why it is not one: an https
133
+ * origin without path, query or credentials (it is written into the
134
+ * workflow's source, so nothing but an origin may get there).
135
+ */
136
+ export function tickOrigin(
137
+ adminUrl: string,
138
+ ): { ok: true; origin: string } | { ok: false; error: string } {
139
+ let u: URL;
140
+ try {
141
+ u = new URL(adminUrl);
142
+ } catch {
143
+ return { ok: false, error: `the admin URL ${JSON.stringify(adminUrl)} is not a URL` };
144
+ }
145
+ if (
146
+ u.protocol !== "https:" ||
147
+ u.username ||
148
+ u.password ||
149
+ (u.pathname !== "/" && u.pathname !== "") ||
150
+ u.search ||
151
+ u.hash ||
152
+ !/^[a-z0-9.-]+$/.test(u.hostname)
153
+ ) {
154
+ return {
155
+ ok: false,
156
+ error: `the admin URL ${JSON.stringify(adminUrl)} is not a plain https origin`,
157
+ };
158
+ }
159
+ return { ok: true, origin: u.origin };
160
+ }
161
+
162
+ /**
163
+ * The workflow one tick runs (Cloud Workflows YAML): sign a service-account
164
+ * JWT for the admin's IAP as the scheduler account itself (the IAM
165
+ * Credentials API, authenticated with the workflow's own OAuth token), then
166
+ * POST the tick with it. A refused or failed tick (HTTP ≥ 400) fails the
167
+ * execution, so it shows in the workflow's execution history and logs.
168
+ */
169
+ export function schedulerWorkflowSource(opts: {
170
+ readonly serviceAccount: string;
171
+ readonly origin: string;
172
+ }): string {
173
+ const { serviceAccount: sa, origin } = opts;
174
+ return `# Caelo scheduler tick (#1140) — generated by cms-provision, do not edit.
175
+ main:
176
+ steps:
177
+ - claims:
178
+ assign:
179
+ - now: \${int(sys.now())}
180
+ - jwtClaims:
181
+ iss: "${sa}"
182
+ sub: "${sa}"
183
+ aud: "${origin}/*"
184
+ iat: \${now}
185
+ exp: \${now + ${JWT_LIFETIME_SECONDS}}
186
+ - sign:
187
+ call: http.post
188
+ args:
189
+ url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${sa}:signJwt"
190
+ auth:
191
+ type: OAuth2
192
+ body:
193
+ payload: \${json.encode_to_string(jwtClaims)}
194
+ result: signed
195
+ - tick:
196
+ call: http.post
197
+ args:
198
+ url: "${origin}${SCHEDULER_TICK_PATH}"
199
+ headers:
200
+ Authorization: \${"Bearer " + signed.body.signedJwt}
201
+ timeout: ${SCHEDULER_TICK_TIMEOUT_SECONDS}
202
+ result: answer
203
+ - done:
204
+ return: \${answer.code}
205
+ `;
206
+ }
207
+
208
+ /** Ensure the scheduler tick and everything it needs. Never throws for a gcloud failure. */
209
+ export async function ensureSchedulerTick(
210
+ target: SchedulerTickTarget,
211
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
212
+ ): Promise<SchedulerTickResult> {
213
+ const run = deps.run ?? defaultGcloud;
214
+ const sleep = deps.sleep ?? realSleep;
215
+ const retry = (args: string[]) => runWithRetry(run, sleep, args);
216
+ const { projectId, region, env } = target;
217
+ const project = `--project=${projectId}`;
218
+ const sa = schedulerServiceAccountEmail(projectId, env);
219
+ const member = `serviceAccount:${sa}`;
220
+ const workflow = schedulerWorkflowName(env);
221
+ const job = schedulerJobName(env);
222
+ const done: string[] = [];
223
+ const fail = (step: string, r: GcloudResult): SchedulerTickResult => ({
224
+ ok: false,
225
+ done,
226
+ error: `${step}: ${r.stderr.trim()}`,
227
+ });
228
+
229
+ const origin = tickOrigin(target.adminUrl);
230
+ if (!origin.ok) return { ok: false, done, error: origin.error };
231
+ let cron: string;
232
+ try {
233
+ cron = schedulerCron(target.intervalMinutes);
234
+ } catch (e) {
235
+ return { ok: false, done, error: (e as Error).message };
236
+ }
237
+
238
+ // 1. APIs.
239
+ const api = await run(["services", "enable", ...SCHEDULER_APIS, project, "--quiet"]);
240
+ if (!api.ok) return fail(`enable ${SCHEDULER_APIS.join(", ")}`, api);
241
+ done.push(`APIs enabled (${SCHEDULER_APIS.join(", ")})`);
242
+
243
+ // 2. The scheduler's own service account.
244
+ const describe = await run([
245
+ "iam",
246
+ "service-accounts",
247
+ "describe",
248
+ sa,
249
+ project,
250
+ "--format=value(email)",
251
+ ]);
252
+ if (!describe.ok) {
253
+ const create = await run([
254
+ "iam",
255
+ "service-accounts",
256
+ "create",
257
+ schedulerServiceAccountId(env),
258
+ "--display-name=Caelo scheduler tick",
259
+ "--description=Calls the admin's scheduler tick through IAP every few minutes; signs its own IAP credential, nothing else.",
260
+ project,
261
+ ]);
262
+ if (!create.ok && !/already exists/i.test(create.stderr)) return fail(`create ${sa}`, create);
263
+ }
264
+ done.push(`service account ${sa}`);
265
+
266
+ // 3. Its grants (see the file comment).
267
+ const self = await retry([
268
+ "iam",
269
+ "service-accounts",
270
+ "add-iam-policy-binding",
271
+ sa,
272
+ `--member=${member}`,
273
+ "--role=roles/iam.serviceAccountTokenCreator",
274
+ "--condition=None",
275
+ project,
276
+ "--quiet",
277
+ "--format=none",
278
+ ]);
279
+ if (!self.ok) return fail("let the scheduler sign its IAP credential as itself", self);
280
+ done.push(`scheduler: roles/iam.serviceAccountTokenCreator on ${sa} (itself, signJwt)`);
281
+
282
+ for (const g of [
283
+ { role: "roles/workflows.invoker", why: "Cloud Scheduler starts the tick workflow as it" },
284
+ { role: "roles/logging.logWriter", why: "the workflow logs failed calls" },
285
+ ]) {
286
+ const r = await retry([
287
+ "projects",
288
+ "add-iam-policy-binding",
289
+ projectId,
290
+ `--member=${member}`,
291
+ `--role=${g.role}`,
292
+ "--condition=None",
293
+ "--quiet",
294
+ "--format=none",
295
+ ]);
296
+ if (!r.ok) return fail(`grant the scheduler ${g.role} (${g.why})`, r);
297
+ done.push(`scheduler: ${g.role} on the project`);
298
+ }
299
+
300
+ const iap = await retry([
301
+ "iap",
302
+ "web",
303
+ "add-iam-policy-binding",
304
+ ...iapResourceArgs(target.resource),
305
+ `--member=${member}`,
306
+ "--role=roles/iap.httpsResourceAccessor",
307
+ "--condition=None",
308
+ project,
309
+ "--quiet",
310
+ ]);
311
+ if (!iap.ok) return fail("let the scheduler pass the admin's IAP", iap);
312
+ done.push(`scheduler: roles/iap.httpsResourceAccessor on the admin (${target.resource.service})`);
313
+
314
+ // 4. The workflow. A private directory (mkdtemp, mode 700): no other local
315
+ // user can swap the file between write and read.
316
+ const dir = mkdtempSync(join(tmpdir(), "caelo-scheduler-"));
317
+ try {
318
+ const file = join(dir, "tick.yaml");
319
+ writeFileSync(file, schedulerWorkflowSource({ serviceAccount: sa, origin: origin.origin }), {
320
+ mode: 0o600,
321
+ });
322
+ const deploy = await retry([
323
+ "workflows",
324
+ "deploy",
325
+ workflow,
326
+ `--source=${file}`,
327
+ `--location=${region}`,
328
+ `--service-account=${sa}`,
329
+ "--call-log-level=log-errors-only",
330
+ `--description=Caelo scheduler tick (#1140): calls ${origin.origin}${SCHEDULER_TICK_PATH}`,
331
+ project,
332
+ "--quiet",
333
+ ]);
334
+ if (!deploy.ok) return fail(`deploy workflow ${workflow}`, deploy);
335
+ } finally {
336
+ rmSync(dir, { recursive: true, force: true });
337
+ }
338
+ done.push(`workflow ${workflow} (calls ${origin.origin}${SCHEDULER_TICK_PATH})`);
339
+
340
+ // 5. The one Cloud Scheduler job.
341
+ const scheduleArgs = [
342
+ job,
343
+ `--location=${region}`,
344
+ project,
345
+ `--schedule=${cron}`,
346
+ "--time-zone=Etc/UTC",
347
+ `--uri=https://workflowexecutions.googleapis.com/v1/projects/${projectId}/locations/${region}/workflows/${workflow}/executions`,
348
+ "--http-method=POST",
349
+ `--oauth-service-account-email=${sa}`,
350
+ // A tick that has not answered when the next one is due is the admin's
351
+ // lease to sort out (its due-table), not a reason to retry this one.
352
+ "--max-retry-attempts=0",
353
+ "--quiet",
354
+ ];
355
+ const existing = await run([
356
+ "scheduler",
357
+ "jobs",
358
+ "describe",
359
+ job,
360
+ `--location=${region}`,
361
+ project,
362
+ "--format=value(name)",
363
+ ]);
364
+ const sched = await retry([
365
+ "scheduler",
366
+ "jobs",
367
+ existing.ok ? "update" : "create",
368
+ "http",
369
+ ...scheduleArgs,
370
+ ]);
371
+ if (!sched.ok) return fail(`schedule ${job}`, sched);
372
+ done.push(`Cloud Scheduler job ${job} (${cron}, UTC)`);
373
+
374
+ return { ok: true, done, serviceAccount: sa };
375
+ }
@@ -203,6 +203,7 @@ describe("restoreSelfHosted", () => {
203
203
  "<converge roles>",
204
204
  `${MIGRATE} admin`,
205
205
  `${MIGRATE} public`,
206
+ `${MIGRATE} public-staging`,
206
207
  "run --rm --no-deps -T --entrypoint sh caelo-admin -c set -e; find /srv/caelo /data/media -mindepth 1 -delete; tar -C / -xf -",
207
208
  "up -d --remove-orphans",
208
209
  "exec -T caelo-admin bun -e <probe>",
@@ -9,6 +9,8 @@ import {
9
9
  COMPOSE_MCP_AUTH_PROXY_SERVICE,
10
10
  COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
11
11
  COMPOSE_POOL_BUDGET,
12
+ COMPOSE_SCHEDULER_SERVICE,
13
+ COMPOSE_SCHEDULER_TICK_URL,
12
14
  type ComposeSpec,
13
15
  composeProjectName,
14
16
  generateDockerCompose,
@@ -22,7 +24,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
22
24
  const base: Omit<ComposeSpec, "images"> = {
23
25
  domain: "example.com",
24
26
  postgresPassword: "pgpw",
25
- rolePasswords: { admin: "adminpw", public: "publicpw" },
27
+ rolePasswords: { admin: "adminpw", public: "publicpw", publicStaging: "ps" },
26
28
  caeloSecretKek: "0".repeat(64),
27
29
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
28
30
  internalSecret: "i".repeat(48),
@@ -410,3 +412,72 @@ describe("self-hosted Compose — the MCP-Auth-Proxy (#1328)", () => {
410
412
  ).toThrow(/mcp-auth-proxy image/);
411
413
  });
412
414
  });
415
+
416
+ describe("self-hosted Compose — the scheduler tick (#1140)", () => {
417
+ const yaml = generateDockerCompose({
418
+ ...base,
419
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
420
+ });
421
+ const scheduler = service(yaml, COMPOSE_SCHEDULER_SERVICE);
422
+
423
+ it("runs the admin image's clock against the admin's tick endpoint", () => {
424
+ expect(scheduler).toContain(`image: ${ADMIN}`);
425
+ expect(scheduler).toContain(
426
+ 'command: ["bun", "--bun", "/app/packages/admin-core/src/scheduler/clock.ts"]',
427
+ );
428
+ expect(COMPOSE_SCHEDULER_TICK_URL).toBe("http://caelo-admin:5173/api/internal/scheduler/tick");
429
+ expect(scheduler).toContain(`CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"`);
430
+ expect(scheduler).toContain("depends_on: [caelo-admin]");
431
+ });
432
+
433
+ it("ticks every 5 minutes by default and follows the knob", () => {
434
+ expect(scheduler).toContain('CAELO_SCHEDULER_INTERVAL_MINUTES: "5"');
435
+ const fast = generateDockerCompose({
436
+ ...base,
437
+ schedulerIntervalMinutes: 1,
438
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
439
+ });
440
+ expect(service(fast, COMPOSE_SCHEDULER_SERVICE)).toContain(
441
+ 'CAELO_SCHEDULER_INTERVAL_MINUTES: "1"',
442
+ );
443
+ });
444
+
445
+ it("refuses an interval outside 1-15 minutes", () => {
446
+ for (const bad of [0, 16, 2.5]) {
447
+ expect(() =>
448
+ generateDockerCompose({
449
+ ...base,
450
+ schedulerIntervalMinutes: bad,
451
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
452
+ }),
453
+ ).toThrow("schedulerIntervalMinutes");
454
+ }
455
+ });
456
+
457
+ it("holds only the internal secret it signs with: no database, no KEK, no mount", () => {
458
+ expect(scheduler).toContain(`CAELO_INTERNAL_SECRET: "${"i".repeat(48)}"`);
459
+ expect(scheduler).not.toMatch(/DATABASE|PASSWORD|KEK|postgres:\/\/|volumes:/);
460
+ expect(scheduler).not.toContain("adminpw");
461
+ expect(scheduler).not.toContain("0".repeat(64));
462
+ });
463
+
464
+ it("runs locked down: read-only root, no capabilities, no privilege escalation", () => {
465
+ expect(scheduler).toContain("read_only: true");
466
+ expect(scheduler).toContain('cap_drop: ["ALL"]');
467
+ expect(scheduler).toContain('security_opt: ["no-new-privileges:true"]');
468
+ });
469
+
470
+ it("runs the checkout's clock in dev mode", () => {
471
+ const dev = generateDockerCompose({ ...base, images: { mode: "dev" } });
472
+ expect(service(dev, COMPOSE_SCHEDULER_SERVICE)).toContain(
473
+ "command: bun run packages/admin-core/src/scheduler/clock.ts",
474
+ );
475
+ });
476
+
477
+ it("is part of every rendered Compose file, so an upgrade of an older install adds it", () => {
478
+ // self-hosted-upgrade.ts rolls onto renderCompose(<new images>), which is
479
+ // this generator: the next upgrade of an install without the service
480
+ // writes a Compose file that has it, and `docker compose up -d` starts it.
481
+ expect(yaml).toContain(`\n ${COMPOSE_SCHEDULER_SERVICE}:\n`);
482
+ });
483
+ });
@@ -26,6 +26,7 @@ const required = [
26
26
  "POSTGRES_PORT",
27
27
  "ADMIN_ROLE_PASSWORD",
28
28
  "PUBLIC_ROLE_PASSWORD",
29
+ "PUBLIC_STAGING_ROLE_PASSWORD",
29
30
  "ADMIN_DATABASE_URL",
30
31
  "PUBLIC_DATABASE_URL",
31
32
  ] as const;
@@ -63,6 +64,7 @@ const psql: SuperuserPsql = async (script) => {
63
64
  const passwords = {
64
65
  admin: env.ADMIN_ROLE_PASSWORD as string,
65
66
  public: env.PUBLIC_ROLE_PASSWORD as string,
67
+ publicStaging: env.PUBLIC_STAGING_ROLE_PASSWORD as string,
66
68
  };
67
69
 
68
70
  describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
@@ -4,16 +4,16 @@ import { describe, expect, it } from "bun:test";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
6
  import {
7
- applyPublicRoleLimits,
7
+ applyPreMigrationRoles,
8
8
  convergeSelfHostedRoles,
9
9
  decideUp,
10
10
  PUBLIC_ROLE_LIMITS,
11
- publicRoleLimitsScript,
11
+ preMigrationRolesScript,
12
12
  roleConvergenceScript,
13
13
  selfHostedBootstrapScript,
14
14
  } from "./self-hosted-roles.js";
15
15
 
16
- const PASSWORDS = { admin: "a'pw", public: "p-pw" };
16
+ const PASSWORDS = { admin: "a'pw", public: "p-pw", publicStaging: "s'pw" };
17
17
 
18
18
  describe("roleConvergenceScript (#613 review)", () => {
19
19
  const script = roleConvergenceScript(PASSWORDS);
@@ -28,13 +28,13 @@ describe("roleConvergenceScript (#613 review)", () => {
28
28
  expect(script).not.toContain("CREATE ROLE gateway_role");
29
29
  });
30
30
 
31
- it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
31
+ it("drops the former gateway_role with what it holds in every database, and stops on the first error (#635, #1329)", () => {
32
32
  expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
33
33
  const publicDrop = script.indexOf("\\connect cms_public");
34
34
  const adminDrop = script.indexOf("\\connect cms_admin");
35
35
  expect(publicDrop).toBeGreaterThan(-1);
36
36
  expect(adminDrop).toBeGreaterThan(publicDrop);
37
- expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
37
+ expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(3);
38
38
  expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
39
39
  expect(script).not.toContain("caelo_grant_gateway_role");
40
40
  });
@@ -132,27 +132,56 @@ describe("decideUp — `cms-provision up` never switches URLs onto unapplied pas
132
132
  });
133
133
  });
134
134
 
135
- describe("public_role's limits (#1338, H3)", () => {
136
- const statements = [
137
- `ALTER ROLE public_role CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
138
- `ALTER ROLE public_role SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
139
- `ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
140
- `ALTER ROLE public_role SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
135
+ describe("the staging database and its login (#1329)", () => {
136
+ const staging = [
137
+ "SELECT 'CREATE ROLE public_staging_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'public_staging_role') \\gexec",
138
+ "SELECT 'CREATE DATABASE cms_public_staging OWNER admin_role' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cms_public_staging') \\gexec",
139
+ "REVOKE ALL ON DATABASE cms_public_staging FROM PUBLIC;",
140
+ "GRANT CONNECT ON DATABASE cms_public_staging TO public_staging_role, admin_role;",
141
+ "REVOKE ALL ON DATABASE cms_public_staging FROM public_role;",
142
+ "REVOKE ALL ON DATABASE cms_public FROM public_staging_role;",
141
143
  ];
142
144
 
143
- it("are set on the first start, on every `up`, and alone before an upgrade's migrations", () => {
145
+ it("are created on the first start, on every `up`, and before an upgrade's migrations", () => {
146
+ for (const script of [roleConvergenceScript(PASSWORDS), preMigrationRolesScript("s'pw")]) {
147
+ for (const st of staging) expect(script).toContain(st);
148
+ expect(script).toContain("ALTER ROLE public_staging_role WITH LOGIN PASSWORD 's''pw';");
149
+ }
150
+ const bootstrap = selfHostedBootstrapScript({ agent: false });
151
+ for (const st of staging) expect(bootstrap).toContain(st);
152
+ expect(bootstrap).toContain(
153
+ "ALTER ROLE public_staging_role WITH LOGIN PASSWORD :'public_staging_role_pw';",
154
+ );
155
+ expect(bootstrap).toContain('-v public_staging_role_pw="$PUBLIC_STAGING_ROLE_PASSWORD"');
156
+ });
157
+
158
+ it("drops the former gateway_role's leftovers in the staging database too", () => {
159
+ expect(roleConvergenceScript(PASSWORDS)).toContain("\\connect cms_public_staging");
160
+ });
161
+ });
162
+
163
+ describe("the gateway logins' limits (#1338, H3; #1329)", () => {
164
+ const statements = ["public_role", "public_staging_role"].flatMap((role) => [
165
+ `ALTER ROLE ${role} CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
166
+ `ALTER ROLE ${role} SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
167
+ `ALTER ROLE ${role} SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
168
+ `ALTER ROLE ${role} SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
169
+ ]);
170
+
171
+ it("are set on the first start, on every `up`, and before an upgrade's migrations", () => {
144
172
  for (const script of [
145
173
  selfHostedBootstrapScript({ agent: false }),
146
174
  roleConvergenceScript(PASSWORDS),
147
- publicRoleLimitsScript(),
175
+ preMigrationRolesScript("s'pw"),
148
176
  ]) {
149
177
  for (const st of statements) expect(script).toContain(st);
150
178
  }
151
- // The pre-migration script touches nothing else (no role drop, no grant).
152
- expect(publicRoleLimitsScript().split("\n").filter(Boolean)).toEqual([
153
- "\\set ON_ERROR_STOP on",
154
- ...statements,
155
- ]);
179
+ // The pre-migration script drops no role and touches no other database:
180
+ // only the staging database with its login, then the limits.
181
+ const lines = preMigrationRolesScript("s'pw").split("\n").filter(Boolean);
182
+ expect(lines[0]).toBe("\\set ON_ERROR_STOP on");
183
+ expect(lines.slice(-statements.length)).toEqual(statements);
184
+ expect(lines.some((l) => /DROP|\\connect/.test(l))).toBe(false);
156
185
  });
157
186
 
158
187
  it("match the values packages/migrations/src/bootstrap.sh sets (dev, CI, Cloud SQL bootstrap)", () => {
@@ -165,7 +194,8 @@ describe("public_role's limits (#1338, H3)", () => {
165
194
 
166
195
  it("refuse to run against a stopped database", async () => {
167
196
  let ran = false;
168
- const r = await applyPublicRoleLimits({
197
+ const r = await applyPreMigrationRoles({
198
+ stagingPassword: "s",
169
199
  probe: async () => false,
170
200
  psql: async () => {
171
201
  ran = true;