@caelo-cms/provisioning 0.10.49 → 0.10.51

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 (191) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/adapter.d.ts +4 -6
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/admin-internal.d.ts +41 -0
  6. package/dist/admin-internal.d.ts.map +1 -0
  7. package/dist/admin-internal.js +169 -0
  8. package/dist/admin-internal.js.map +1 -0
  9. package/dist/agent-db.js +93 -20
  10. package/dist/aws-task-roles.d.ts +27 -0
  11. package/dist/aws-task-roles.d.ts.map +1 -0
  12. package/dist/aws-task-roles.js +35 -0
  13. package/dist/aws-task-roles.js.map +1 -0
  14. package/dist/caddy-redirects.d.ts +15 -0
  15. package/dist/caddy-redirects.d.ts.map +1 -0
  16. package/dist/caddy-redirects.js +65 -0
  17. package/dist/caddy-redirects.js.map +1 -0
  18. package/dist/caddy.d.ts +22 -2
  19. package/dist/caddy.d.ts.map +1 -1
  20. package/dist/caddy.js +66 -17
  21. package/dist/caddy.js.map +1 -1
  22. package/dist/cli-routing.d.ts.map +1 -1
  23. package/dist/cli-routing.js +7 -0
  24. package/dist/cli-routing.js.map +1 -1
  25. package/dist/cli.js +282 -255
  26. package/dist/cli.js.map +1 -1
  27. package/dist/compose.d.ts +54 -2
  28. package/dist/compose.d.ts.map +1 -1
  29. package/dist/compose.js +88 -34
  30. package/dist/compose.js.map +1 -1
  31. package/dist/deployed-release.d.ts +3 -2
  32. package/dist/deployed-release.d.ts.map +1 -1
  33. package/dist/deployed-release.js +12 -5
  34. package/dist/deployed-release.js.map +1 -1
  35. package/dist/gateway-credentials.d.ts.map +1 -1
  36. package/dist/gateway-credentials.js +6 -3
  37. package/dist/gateway-credentials.js.map +1 -1
  38. package/dist/gcloud.d.ts +2 -2
  39. package/dist/gcloud.d.ts.map +1 -1
  40. package/dist/gcloud.js +3 -3
  41. package/dist/gcloud.js.map +1 -1
  42. package/dist/gcp-names.d.ts +12 -0
  43. package/dist/gcp-names.d.ts.map +1 -1
  44. package/dist/gcp-names.js +21 -0
  45. package/dist/gcp-names.js.map +1 -1
  46. package/dist/gcp-url-map.d.ts +27 -0
  47. package/dist/gcp-url-map.d.ts.map +1 -0
  48. package/dist/gcp-url-map.js +62 -0
  49. package/dist/gcp-url-map.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/infra-contract.d.ts +20 -1
  55. package/dist/infra-contract.d.ts.map +1 -1
  56. package/dist/infra-contract.js +44 -1
  57. package/dist/infra-contract.js.map +1 -1
  58. package/dist/owner-reset.d.ts +37 -0
  59. package/dist/owner-reset.d.ts.map +1 -0
  60. package/dist/owner-reset.js +80 -0
  61. package/dist/owner-reset.js.map +1 -0
  62. package/dist/public-role-isolation.d.ts +19 -0
  63. package/dist/public-role-isolation.d.ts.map +1 -0
  64. package/dist/public-role-isolation.js +69 -0
  65. package/dist/public-role-isolation.js.map +1 -0
  66. package/dist/release-contract.d.ts.map +1 -1
  67. package/dist/release-contract.js +3 -0
  68. package/dist/release-contract.js.map +1 -1
  69. package/dist/release-manifest.d.ts.map +1 -1
  70. package/dist/release-manifest.js +2 -1
  71. package/dist/release-manifest.js.map +1 -1
  72. package/dist/self-hosted-backup.d.ts +86 -0
  73. package/dist/self-hosted-backup.d.ts.map +1 -0
  74. package/dist/self-hosted-backup.js +350 -0
  75. package/dist/self-hosted-backup.js.map +1 -0
  76. package/dist/self-hosted-release.d.ts +12 -3
  77. package/dist/self-hosted-release.d.ts.map +1 -1
  78. package/dist/self-hosted-release.js +6 -4
  79. package/dist/self-hosted-release.js.map +1 -1
  80. package/dist/self-hosted-start.d.ts +50 -0
  81. package/dist/self-hosted-start.d.ts.map +1 -0
  82. package/dist/self-hosted-start.js +59 -0
  83. package/dist/self-hosted-start.js.map +1 -0
  84. package/dist/self-hosted-upgrade.d.ts +30 -0
  85. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  86. package/dist/self-hosted-upgrade.js +29 -7
  87. package/dist/self-hosted-upgrade.js.map +1 -1
  88. package/dist/stack-contract.d.ts +9 -10
  89. package/dist/stack-contract.d.ts.map +1 -1
  90. package/dist/stack-contract.js +21 -10
  91. package/dist/stack-contract.js.map +1 -1
  92. package/dist/staging-admin.d.ts +12 -20
  93. package/dist/staging-admin.d.ts.map +1 -1
  94. package/dist/staging-admin.js +7 -159
  95. package/dist/staging-admin.js.map +1 -1
  96. package/dist/tar-entries.d.ts +14 -0
  97. package/dist/tar-entries.d.ts.map +1 -0
  98. package/dist/tar-entries.js +104 -0
  99. package/dist/tar-entries.js.map +1 -0
  100. package/dist/upgrade/converge.d.ts +2 -2
  101. package/dist/upgrade/converge.d.ts.map +1 -1
  102. package/dist/upgrade/converge.js +44 -2
  103. package/dist/upgrade/converge.js.map +1 -1
  104. package/dist/upgrader-assess.d.ts.map +1 -1
  105. package/dist/upgrader-assess.js +2 -1
  106. package/dist/upgrader-assess.js.map +1 -1
  107. package/dist/url-map-redirects-access.d.ts +15 -0
  108. package/dist/url-map-redirects-access.d.ts.map +1 -0
  109. package/dist/url-map-redirects-access.js +81 -0
  110. package/dist/url-map-redirects-access.js.map +1 -0
  111. package/dist/wizard.d.ts.map +1 -1
  112. package/dist/wizard.js +3 -1
  113. package/dist/wizard.js.map +1 -1
  114. package/dist/wizards/gcp-cost.js +1 -1
  115. package/dist/wizards/gcp.d.ts.map +1 -1
  116. package/dist/wizards/gcp.js +45 -0
  117. package/dist/wizards/gcp.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/adapter.ts +4 -6
  120. package/src/admin-internal.ts +212 -0
  121. package/src/admin-memory.test.ts +2 -2
  122. package/src/aws-task-roles.ts +58 -0
  123. package/src/caddy-redirects.docker.test.ts +255 -0
  124. package/src/caddy-redirects.test.ts +115 -0
  125. package/src/caddy-redirects.ts +68 -0
  126. package/src/caddy-validate.test.ts +119 -0
  127. package/src/caddy.test.ts +108 -0
  128. package/src/caddy.ts +89 -20
  129. package/src/checkpoint-secrets.test.ts +0 -1
  130. package/src/cli-routing.test.ts +12 -0
  131. package/src/cli-routing.ts +7 -0
  132. package/src/cli.ts +340 -288
  133. package/src/compose.ts +108 -36
  134. package/src/deployed-release.test.ts +22 -4
  135. package/src/deployed-release.ts +14 -7
  136. package/src/gateway-credentials.ts +6 -3
  137. package/src/gateway-env-isolation.test.ts +212 -0
  138. package/src/gcloud.ts +5 -3
  139. package/src/gcp-names.ts +23 -0
  140. package/src/gcp-url-map.test.ts +150 -0
  141. package/src/gcp-url-map.ts +81 -0
  142. package/src/index.test.ts +24 -152
  143. package/src/index.ts +0 -9
  144. package/src/infra-contract.test.ts +40 -0
  145. package/src/infra-contract.ts +47 -1
  146. package/src/owner-reset.test.ts +165 -0
  147. package/src/owner-reset.ts +106 -0
  148. package/src/public-role-isolation.test.ts +89 -0
  149. package/src/public-role-isolation.ts +83 -0
  150. package/src/release-contract.ts +3 -0
  151. package/src/release-manifest.test.ts +12 -0
  152. package/src/release-manifest.ts +2 -1
  153. package/src/runtime-secrets.test.ts +25 -8
  154. package/src/self-hosted-backup.test.ts +344 -0
  155. package/src/self-hosted-backup.ts +480 -0
  156. package/src/self-hosted-compose.test.ts +88 -4
  157. package/src/self-hosted-release.ts +22 -7
  158. package/src/self-hosted-start.test.ts +68 -0
  159. package/src/self-hosted-start.ts +123 -0
  160. package/src/self-hosted-upgrade.ts +35 -8
  161. package/src/stack-contract.test.ts +11 -20
  162. package/src/stack-contract.ts +25 -16
  163. package/src/stack-converge-secrets.test.ts +45 -10
  164. package/src/stack-converge.test.ts +19 -10
  165. package/src/staging-admin.ts +18 -181
  166. package/src/tar-entries.test.ts +52 -0
  167. package/src/tar-entries.ts +116 -0
  168. package/src/upgrade/converge.ts +51 -4
  169. package/src/upgrade/upgrade.test.ts +78 -1
  170. package/src/upgrader-assess.test.ts +22 -0
  171. package/src/upgrader-assess.ts +2 -1
  172. package/src/url-map-redirects-access.test.ts +88 -0
  173. package/src/url-map-redirects-access.ts +89 -0
  174. package/src/wizard.ts +3 -1
  175. package/src/wizards/gcp-cost.ts +1 -1
  176. package/src/wizards/gcp.ts +48 -0
  177. package/stacks/aws/README.md +1 -1
  178. package/stacks/aws/index.ts +89 -45
  179. package/stacks/azure/README.md +1 -1
  180. package/stacks/azure/index.ts +21 -14
  181. package/stacks/gcp/README.md +1 -1
  182. package/stacks/gcp/index.ts +26 -22
  183. package/stacks/gcp-firebase/index.ts +7 -3
  184. package/stacks/self-hosted/Pulumi.yaml +2 -2
  185. package/stacks/self-hosted/README.md +3 -3
  186. package/stacks/self-hosted/index.ts +45 -22
  187. package/dist/redirects-emit.d.ts +0 -52
  188. package/dist/redirects-emit.d.ts.map +0 -1
  189. package/dist/redirects-emit.js +0 -111
  190. package/dist/redirects-emit.js.map +0 -1
  191. package/src/redirects-emit.ts +0 -175
package/dist/cli.js CHANGED
@@ -7,10 +7,14 @@
7
7
  * init [--domain D --owner-email E] — first-time setup; generates
8
8
  * secrets, resolves + cosign-verifies
9
9
  * the release images, writes .caelo/,
10
- * prints the start steps + the
11
- * bootstrap token URL. `--dev` runs a
12
- * Caelo checkout instead (contributors).
13
- * up — re-runs against existing config.
10
+ * starts the stack (migrations
11
+ * included), health-checks it and
12
+ * prints the bootstrap token URL.
13
+ * `--provider self-hosted` with no
14
+ * sub-command runs this. `--dev` runs
15
+ * a Caelo checkout (contributors).
16
+ * up — re-emits from the existing config
17
+ * and converges the running stack.
14
18
  * migrate — applies DB migrations with the
15
19
  * admin image the install runs.
16
20
  * upgrade [--version X] — verified, backed-up, health-checked
@@ -20,9 +24,10 @@
20
24
  * the Owner starts in the admin.
21
25
  * regenerate-caddy — re-emits Caddyfile from the
22
26
  * domains table; runs `caddy reload`.
23
- * backup --to <path> — pgBackRest full + MinIO mirror →
24
- * single .tar.zst.
25
- * restore --from <path> — wipes target + restores.
27
+ * backup [--to <path>] — pg_dump of both databases + the
28
+ * site + media files → one .tar.gz.
29
+ * restore --from <path> --yes — replaces databases + files with
30
+ * a backup, restarts, health-checks.
26
31
  * status — prints container health + cert
27
32
  * expiry per domain.
28
33
  *
@@ -33,10 +38,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
38
  import { resolve } from "node:path";
34
39
  import { CAELO_VERSION, defaultStagingHost, normalizeStagingHost, stagingHostProblem, } from "@caelo-cms/shared";
35
40
  import { generateBootstrapToken } from "./bootstrap-token.js";
36
- import { generateCaddyfile } from "./caddy.js";
41
+ import { generateCaddyfile, selfHostedAdminHost, selfHostedSeedDomains, } from "./caddy.js";
37
42
  import { redactCredentials } from "./checkpoint-secrets.js";
38
43
  import { initDelegatesToWizard, resolveCliRoute } from "./cli-routing.js";
39
- import { BOOTSTRAP_DIR, generateDockerCompose, } from "./compose.js";
44
+ import { BOOTSTRAP_DIR, COMPOSE_ADMIN_UPSTREAM, COMPOSE_GATEWAY_UPSTREAM, composeProjectName, generateDockerCompose, isPublishablePort, MEDIA_DIR, SITE_OUTPUT_ROOT, selfHostedSiteUrl, } from "./compose.js";
40
45
  import { selfCommand } from "./self-command.js";
41
46
  import { bunHostRunner, readRelease, recordRelease, releaseImages, resolveVerifiedRelease, } from "./self-hosted-release.js";
42
47
  import { convergeSelfHostedRoles, decideUp, selfHostedBootstrapScript, } from "./self-hosted-roles.js";
@@ -111,7 +116,17 @@ function randomSecret(bytes = 32) {
111
116
  function loadConfig() {
112
117
  if (!existsSync(CONFIG_PATH))
113
118
  return null;
114
- return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
119
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
120
+ // #707 — settings added later, back-filled (and stored) with what every
121
+ // install ran before they existed, so `upgrade` / `agent` render the same
122
+ // Compose file as before.
123
+ if (!cfg.tls || !cfg.ports || !cfg.composeProject) {
124
+ cfg.tls ??= "acme";
125
+ cfg.ports ??= { http: 80, https: 443 };
126
+ cfg.composeProject ??= "caelo";
127
+ saveConfig(cfg);
128
+ }
129
+ return cfg;
115
130
  }
116
131
  function saveConfig(c) {
117
132
  if (!existsSync(CAELO_DIR))
@@ -140,22 +155,25 @@ function emitConfig(cfg, extraDomains = []) {
140
155
  cfg.stagingDomain = defaultStagingHost(cfg.domain);
141
156
  saveConfig(cfg);
142
157
  }
158
+ if (!cfg.tls)
159
+ throw new Error("config has no tls — it is back-filled on load; this is a bug");
143
160
  emitComposeFiles(cfg);
144
- // Seed the install with the operator's primary domain plus a staging
145
- // sibling. Extra domains added via /settings/domains are appended by
161
+ // Seed the install with the admin host, the primary domain and the
162
+ // staging host. Extra domains added via /settings/domains are appended by
146
163
  // regenerate-caddy when it queries the live domains table.
147
- const baseDomains = [
148
- { hostname: cfg.domain, kind: "admin", env: "production" },
149
- { hostname: cfg.domain, kind: "public", env: "production" },
150
- { hostname: cfg.stagingDomain, kind: "public", env: "staging" },
151
- ];
152
164
  const caddy = generateCaddyfile({
153
165
  ownerEmail: cfg.ownerEmail,
154
- publicSiteRoot: "/srv/caelo/output/production/current",
155
- stagingSiteRoot: "/srv/caelo/output/staging/current",
156
- adminPort: 5173,
157
- gatewayPort: 8090,
158
- domains: dedupeDomains([...baseDomains, ...extraDomains]),
166
+ tls: cfg.tls,
167
+ publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
168
+ stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
169
+ // Caddy runs in its own container: the Compose service names, never
170
+ // localhost (#707).
171
+ adminUpstream: COMPOSE_ADMIN_UPSTREAM,
172
+ gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
173
+ domains: dedupeDomains([
174
+ ...selfHostedSeedDomains(cfg.domain, cfg.stagingDomain),
175
+ ...extraDomains,
176
+ ]),
159
177
  });
160
178
  writeFileSync(CADDYFILE_PATH, caddy);
161
179
  }
@@ -169,6 +187,10 @@ function emitComposeFiles(cfg) {
169
187
  // No secrets (it reads the passwords from the container's environment),
170
188
  // and the postgres user inside the container must be able to read it.
171
189
  writeFileSync(BOOTSTRAP_SCRIPT_PATH, selfHostedBootstrapScript({ agent: activeAgent(cfg) !== undefined }), { mode: 0o755 });
190
+ // The bind-mounted content dirs, created by the CLI so Docker does not
191
+ // create them root-owned on a Linux host.
192
+ for (const dir of ["output", MEDIA_DIR])
193
+ mkdirSync(resolve(CAELO_DIR, dir), { recursive: true });
172
194
  }
173
195
  /** The Compose file for `cfg` running `images` (secrets come from config.json). */
174
196
  function renderCompose(cfg, images) {
@@ -178,17 +200,20 @@ function renderCompose(cfg, images) {
178
200
  }
179
201
  if (!cfg.caeloSecretKek)
180
202
  throw new Error("config has no caeloSecretKek — run `cms-provision up`");
203
+ if (!cfg.ports || !cfg.composeProject) {
204
+ throw new Error("config has no ports / composeProject — they are back-filled on load; this is a bug");
205
+ }
181
206
  return generateDockerCompose({
182
207
  domain: cfg.domain,
183
208
  postgresPassword: cfg.postgresPassword,
184
209
  rolePasswords: cfg.rolePasswords,
185
- minioRootUser: cfg.minioRootUser,
186
- minioRootPassword: cfg.minioRootPassword,
187
210
  caeloSecretKek: cfg.caeloSecretKek,
188
211
  anthropicApiKey: cfg.anthropicApiKey,
189
212
  resendApiKey: cfg.resendApiKey,
190
213
  diskSize: "20Gi",
191
214
  images,
215
+ ports: cfg.ports,
216
+ project: cfg.composeProject,
192
217
  ...(activeAgent(cfg) ? { agent: activeAgent(cfg) } : {}),
193
218
  });
194
219
  }
@@ -258,21 +283,77 @@ function installImages(cfg) {
258
283
  return releaseImages(release);
259
284
  }
260
285
  function dedupeDomains(list) {
261
- // Two domain entries with the same (hostname, kind) collapse to the
262
- // first occurrence — the seed wins over operator additions on a
263
- // collision (which keeps admin routing intact even if someone adds
264
- // their primary domain via the UI by mistake).
286
+ // One vhost per hostname (Caddy refuses two site blocks for one address):
287
+ // the first occurrence wins, so the seed wins over operator additions on
288
+ // a collision (which keeps admin routing intact even if someone adds the
289
+ // admin host via the UI as a public domain by mistake).
265
290
  const seen = new Set();
266
291
  const out = [];
267
292
  for (const d of list) {
268
- const key = `${d.hostname}::${d.kind}`;
269
- if (seen.has(key))
293
+ if (seen.has(d.hostname))
270
294
  continue;
271
- seen.add(key);
295
+ seen.add(d.hostname);
272
296
  out.push(d);
273
297
  }
274
298
  return out;
275
299
  }
300
+ /** `--http-port` / `--https-port` (default 80 / 443), or exit with a usage error. */
301
+ function portsArg() {
302
+ const parse = (name, fallback) => {
303
+ const raw = arg(name);
304
+ if (raw === undefined)
305
+ return fallback;
306
+ const port = Number(raw);
307
+ if (!isPublishablePort(port)) {
308
+ console.error(`--${name}: ${JSON.stringify(raw)} is not a TCP port`);
309
+ process.exit(2);
310
+ }
311
+ return port;
312
+ };
313
+ return { http: parse("http-port", 80), https: parse("https-port", 443) };
314
+ }
315
+ /** `--tls acme|internal` (default acme), or exit with a usage error. */
316
+ function tlsArg() {
317
+ const raw = arg("tls") ?? "acme";
318
+ if (raw !== "acme" && raw !== "internal") {
319
+ console.error(`--tls: ${JSON.stringify(raw)} — choose acme (public DNS) or internal (local CA)`);
320
+ process.exit(2);
321
+ }
322
+ return raw;
323
+ }
324
+ /** `https://<host>[:port]` for a host Caddy serves. */
325
+ function siteUrl(cfg, host) {
326
+ if (!cfg.ports)
327
+ throw new Error("config has no ports — they are back-filled on load; this is a bug");
328
+ return selfHostedSiteUrl(host, cfg.ports.https);
329
+ }
330
+ /**
331
+ * Re-render the Caddyfile from config + the domains table and reload the
332
+ * running Caddy (regenerate-caddy, restore).
333
+ */
334
+ async function refreshCaddy(cfg) {
335
+ emitConfig(cfg, await fetchExtraDomains());
336
+ const { reloadCaddy } = await import("./self-hosted-start.js");
337
+ return reloadCaddy(CAELO_DIR, bunHostRunner);
338
+ }
339
+ /**
340
+ * Pull, start Postgres, migrate, start everything, health-check
341
+ * (self-hosted-start.ts). Exits non-zero with the reason on failure; the
342
+ * operator re-runs `up`, which converges from wherever it stopped.
343
+ */
344
+ async function startStack() {
345
+ const { startSelfHosted } = await import("./self-hosted-start.js");
346
+ const r = await startSelfHosted({
347
+ caeloDir: CAELO_DIR,
348
+ runner: bunHostRunner,
349
+ log: (m) => console.log(`==> ${m}`),
350
+ });
351
+ if (!r.ok) {
352
+ console.error(`\nThe install did not come up (${r.stage}): ${r.error}`);
353
+ console.error(`Fix the cause and run \`${selfCommand("up")}\` — it continues from here.`);
354
+ process.exit(1);
355
+ }
356
+ }
276
357
  async function init() {
277
358
  const domain = arg("domain");
278
359
  const ownerEmail = arg("owner-email");
@@ -290,7 +371,7 @@ async function init() {
290
371
  return;
291
372
  }
292
373
  if (!domain || !ownerEmail) {
293
- console.error("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]\n" +
374
+ console.error("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" +
294
375
  "Cloud providers (gcp | gcp-firebase | aws | azure) run the wizard: cms-provision --provider <name>");
295
376
  process.exit(2);
296
377
  }
@@ -298,6 +379,12 @@ async function init() {
298
379
  console.error(`config already exists at ${CONFIG_PATH}; run \`cms-provision up\` to re-deploy`);
299
380
  process.exit(2);
300
381
  }
382
+ const tls = tlsArg();
383
+ const ports = portsArg();
384
+ if (ports.http === ports.https) {
385
+ console.error("--http-port and --https-port must differ");
386
+ process.exit(2);
387
+ }
301
388
  const stagingArg = arg("staging-domain");
302
389
  const stagingProblem = stagingArg
303
390
  ? stagingHostProblem(stagingArg, {
@@ -345,12 +432,13 @@ async function init() {
345
432
  rolePasswords: newRolePasswords(),
346
433
  // A fresh volume: bootstrap.sh creates the roles with these on first start.
347
434
  rolesConverged: true,
348
- minioRootUser: "caelo",
349
- minioRootPassword: randomSecret(32),
350
435
  caeloSecretKek: randomSecret(32),
351
436
  anthropicApiKey: process.env.ANTHROPIC_API_KEY,
352
437
  resendApiKey: process.env.RESEND_API_KEY,
353
438
  imageMode: dev ? "dev" : "release",
439
+ tls,
440
+ ports,
441
+ composeProject: composeProjectName(domain),
354
442
  };
355
443
  saveConfig(cfg);
356
444
  if (images.mode === "release") {
@@ -385,19 +473,32 @@ async function init() {
385
473
  console.log(`Wrote ${COMPOSE_PATH}`);
386
474
  console.log(`Wrote ${CADDYFILE_PATH}`);
387
475
  console.log(`Wrote ${PENDING_TOKEN_PATH} (admin will insert on first boot)`);
388
- console.log("");
389
476
  console.log(images.mode === "release"
390
477
  ? `Images: Caelo ${version}, pinned by digest (${images.admin.split("@")[1]?.slice(0, 19)}…)`
391
478
  : "Images: DEV MODE — admin + gateway run this checkout (contributors only).");
479
+ // Printed BEFORE the start: the admin consumes pending-token.json on its
480
+ // first boot, so a start that fails half-way and is resumed with `up`
481
+ // would otherwise leave the operator without the link.
482
+ const adminHost = selfHostedAdminHost(domain);
483
+ const setupUrl = `${siteUrl(cfg, adminHost)}/setup?token=${tok.token}`;
392
484
  console.log("");
393
- console.log("Next steps:");
394
- console.log(` docker compose -f ${COMPOSE_PATH} up -d postgres`);
395
- console.log(` ${selfCommand("migrate")} # applies the database migrations`);
396
- console.log(` docker compose -f ${COMPOSE_PATH} up -d`);
397
- console.log(" # Wait ~30s for Caddy + ACME");
485
+ console.log(`Owner setup link (save it; valid until ${tok.expiresAt}):`);
486
+ console.log(` ${setupUrl}`);
398
487
  console.log("");
399
- console.log("Then visit:");
400
- console.log(` https://${domain}/setup?token=${tok.token}`);
488
+ // #707 / §11.C — one command ends with a serving install, not with steps
489
+ // to copy.
490
+ await startStack();
491
+ console.log("");
492
+ console.log("Caelo is running. Point these DNS records at this host (A/AAAA):");
493
+ for (const host of [adminHost, domain, cfg.stagingDomain ?? defaultStagingHost(domain)]) {
494
+ console.log(` ${host}`);
495
+ }
496
+ console.log(tls === "acme"
497
+ ? "Caddy fetches the certificates once they resolve (ports 80 + 443 must be reachable)."
498
+ : "TLS: Caddy's local CA (--tls internal) — browsers warn until its root is trusted.");
499
+ console.log("");
500
+ console.log("Then create the Owner account:");
501
+ console.log(` ${setupUrl}`);
401
502
  console.log(` (token expires ${tok.expiresAt})`);
402
503
  if (agentSetup) {
403
504
  console.log("");
@@ -411,6 +512,12 @@ async function up() {
411
512
  console.error("no config — run `cms-provision init` first");
412
513
  process.exit(2);
413
514
  }
515
+ // Postgres runs on the CURRENT Compose file first, so the roles can be
516
+ // converged and the operator-added domains read before anything is
517
+ // re-emitted (a failure here surfaces through the convergence below).
518
+ if (existsSync(COMPOSE_PATH)) {
519
+ await bunHostRunner.run(["docker", "compose", "-f", COMPOSE_PATH, "up", "-d", "--wait", "postgres"], { inherit: true });
520
+ }
414
521
  // #613 — the database roles first, then the Compose URLs that use them.
415
522
  if (!cfg.rolePasswords) {
416
523
  cfg.rolePasswords = newRolePasswords();
@@ -432,9 +539,10 @@ async function up() {
432
539
  cfg.rolesConverged = true;
433
540
  saveConfig(cfg);
434
541
  }
435
- emitConfig(cfg, await tryFetchExtraDomains(cfg));
542
+ emitConfig(cfg, await fetchExtraDomains());
436
543
  console.log(`Re-emitted ${COMPOSE_PATH} + ${CADDYFILE_PATH}`);
437
- console.log("Run `docker compose -f .caelo/docker-compose.yml up -d` to apply.");
544
+ await startStack();
545
+ console.log(`\nCaelo is running: ${siteUrl(cfg, selfHostedAdminHost(cfg.domain))}`);
438
546
  }
439
547
  /** Whether the install's Postgres container answers (with the CURRENT Compose file). */
440
548
  async function postgresIsUp() {
@@ -477,63 +585,59 @@ async function superuserPsql(script) {
477
585
  return { exitCode: await proc.exited, stderr };
478
586
  }
479
587
  /**
480
- * Pull operator-added domains directly from Postgres so the regenerated
481
- * Caddyfile picks them up. Uses `docker compose exec` so we don't need
482
- * the operator's host to have psql installed. Fails open: a DB-down
483
- * install still emits the seed Caddyfile so the admin stays reachable.
588
+ * Operator-added domains (Settings → Domains) from `cms_admin.domains`, so
589
+ * the regenerated Caddyfile keeps serving them. Uses `docker compose exec`
590
+ * so the host needs no psql. A stopped database yields the seed vhosts only
591
+ * (with a warning — nothing can be read); a running database whose query
592
+ * fails is an error, never a silent drop of the operator's domains.
484
593
  */
485
- async function tryFetchExtraDomains(cfg) {
486
- try {
487
- const composeFile = COMPOSE_PATH;
488
- const sql = "SELECT hostname, kind, env FROM domains WHERE removed_at IS NULL ORDER BY hostname";
489
- const proc = Bun.spawn([
490
- "docker",
491
- "compose",
492
- "-f",
493
- composeFile,
494
- "exec",
495
- "-T",
496
- "postgres",
497
- "psql",
498
- "-U",
499
- "caelo",
500
- "-d",
501
- "caelo",
502
- "-At",
503
- "-F",
504
- "|",
505
- "-c",
506
- sql,
507
- ], { stdout: "pipe", stderr: "pipe", env: { ...process.env, PGPASSWORD: cfg.postgresPassword } });
508
- const stdout = await new Response(proc.stdout).text();
509
- await proc.exited;
510
- if (proc.exitCode !== 0) {
511
- console.warn("regenerate-caddy: could not query domains table (DB down?); using seed only");
594
+ async function fetchExtraDomains() {
595
+ if (!(await postgresIsUp())) {
596
+ console.warn("Postgres is not running: the Caddyfile gets the seed hosts only (domains added in the admin are re-read on the next `up`).");
597
+ return [];
598
+ }
599
+ // The superuser bypasses RLS; the table exists once the migrations ran.
600
+ const sql = "SELECT hostname, kind FROM domains WHERE kind IN ('admin', 'public') ORDER BY hostname";
601
+ const r = await bunHostRunner.run([
602
+ "docker",
603
+ "compose",
604
+ "-f",
605
+ COMPOSE_PATH,
606
+ "exec",
607
+ "-T",
608
+ "postgres",
609
+ "psql",
610
+ "-U",
611
+ "caelo",
612
+ "-d",
613
+ "cms_admin",
614
+ "-v",
615
+ "ON_ERROR_STOP=1",
616
+ "-At",
617
+ "-F",
618
+ "|",
619
+ "-c",
620
+ sql,
621
+ ]);
622
+ if (r.exitCode !== 0) {
623
+ // Before the first migration there is no domains table — and no domains.
624
+ if (/relation "domains" does not exist|database "cms_admin" does not exist/.test(r.stderr)) {
512
625
  return [];
513
626
  }
514
- const rows = [];
515
- for (const line of stdout.split("\n")) {
516
- if (!line.trim())
517
- continue;
518
- const [hostname, kind, env] = line.split("|");
519
- if (!hostname || !kind || !env)
520
- continue;
521
- rows.push({
522
- hostname,
523
- kind: kind,
524
- env: env,
525
- });
526
- }
527
- return rows.map((r) => ({
528
- hostname: r.hostname,
529
- kind: r.kind,
530
- env: r.env,
531
- }));
627
+ throw new Error(`could not read the domains table: ${r.stderr.trim()}`);
532
628
  }
533
- catch (e) {
534
- console.warn("regenerate-caddy: domains lookup threw (DB unreachable?); using seed only", e);
535
- return [];
629
+ return parseDomainRows(r.stdout);
630
+ }
631
+ /** `hostname|kind` rows from psql -At → vhosts (domains has no env column: they are live hosts). */
632
+ function parseDomainRows(stdout) {
633
+ const rows = [];
634
+ for (const line of stdout.split("\n")) {
635
+ const [hostname, kind] = line.trim().split("|");
636
+ if (!hostname || (kind !== "admin" && kind !== "public"))
637
+ continue;
638
+ rows.push({ hostname, kind, env: "production" });
536
639
  }
640
+ return rows;
537
641
  }
538
642
  async function regenerateCaddy() {
539
643
  requireSelfHosted("regenerate-caddy");
@@ -542,27 +646,12 @@ async function regenerateCaddy() {
542
646
  console.error("no config — run `cms-provision init` first");
543
647
  process.exit(2);
544
648
  }
545
- const extra = await tryFetchExtraDomains(cfg);
546
- emitConfig(cfg, extra);
547
- console.log(`Re-emitted ${CADDYFILE_PATH} (${extra.length} domain(s) from DB).`);
548
- // Reload caddy in-place — no downtime, picks up cert provisioning for
549
- // any new domains automatically via ACME.
550
- const proc = Bun.spawn([
551
- "docker",
552
- "compose",
553
- "-f",
554
- COMPOSE_PATH,
555
- "exec",
556
- "caddy",
557
- "caddy",
558
- "reload",
559
- "--config",
560
- "/etc/caddy/Caddyfile",
561
- ], { stdout: "inherit", stderr: "inherit" });
562
- await proc.exited;
563
- if (proc.exitCode !== 0) {
564
- console.warn("caddy reload exited non-zero — Caddyfile written, run reload manually once the container is up.");
649
+ const r = await refreshCaddy(cfg);
650
+ if (!r.ok) {
651
+ console.error(`${CADDYFILE_PATH} was re-written, but ${r.error}`);
652
+ process.exit(1);
565
653
  }
654
+ console.log(`Re-emitted ${CADDYFILE_PATH} and reloaded Caddy.`);
566
655
  }
567
656
  async function status() {
568
657
  const provider = loadProvider();
@@ -584,160 +673,65 @@ async function status() {
584
673
  console.log("Container health: run `docker compose -f .caelo/docker-compose.yml ps`");
585
674
  console.log("Cert status: visit /settings/domains in the admin");
586
675
  }
676
+ /** `backup [--to <file>]` for a self-hosted install (self-hosted-backup.ts). */
587
677
  async function backup() {
588
678
  requireSelfHosted("backup");
589
- const cfg = loadConfig();
590
- if (!cfg) {
591
- console.error("no config");
679
+ if (!loadConfig()) {
680
+ console.error("no config — run `cms-provision init` first");
592
681
  process.exit(2);
593
682
  }
594
- const to = arg("to") ?? resolve(process.cwd(), `caelo-backup-${Date.now()}.tar.zst`);
595
- console.log(`Backup target: ${to}`);
596
- // 1. pgBackRest full backup inside the sidecar container.
597
- console.log("Step 1/3: pgBackRest full backup …");
598
- const pgb = Bun.spawn([
599
- "docker",
600
- "compose",
601
- "-f",
602
- COMPOSE_PATH,
603
- "exec",
604
- "-T",
605
- "pgbackrest",
606
- "pgbackrest",
607
- "--type=full",
608
- "--stanza=caelo",
609
- "backup",
610
- ], { stdout: "inherit", stderr: "inherit" });
611
- await pgb.exited;
612
- if (pgb.exitCode !== 0) {
613
- console.error("pgBackRest backup failed; aborting");
614
- process.exit(pgb.exitCode ?? 1);
615
- }
616
- // 2. MinIO mirror to a tmp inside the minio container.
617
- console.log("Step 2/3: mirroring MinIO bucket …");
618
- const mc = Bun.spawn([
619
- "docker",
620
- "compose",
621
- "-f",
622
- COMPOSE_PATH,
623
- "exec",
624
- "-T",
625
- "minio",
626
- "mc",
627
- "mirror",
628
- "--overwrite",
629
- "/data",
630
- "/tmp/minio-backup",
631
- ], { stdout: "inherit", stderr: "inherit" });
632
- await mc.exited;
633
- if (mc.exitCode !== 0) {
634
- console.error("MinIO mirror failed; aborting");
635
- process.exit(mc.exitCode ?? 1);
636
- }
637
- // 3. tar+zstd from named volumes onto the host.
638
- console.log(`Step 3/3: archiving → ${to}`);
639
- const tar = Bun.spawn([
640
- "docker",
641
- "run",
642
- "--rm",
643
- "-v",
644
- "caelo_caelo-pg-backups:/pgb:ro",
645
- "-v",
646
- "caelo_caelo-minio:/minio:ro",
647
- "-v",
648
- `${resolve(to, "..")}:/out`,
649
- "alpine:3",
650
- "sh",
651
- "-c",
652
- `apk add --no-cache zstd tar >/dev/null && tar --zstd -cf /out/${to.split("/").pop()} -C /pgb . -C /minio .`,
653
- ], { stdout: "inherit", stderr: "inherit" });
654
- await tar.exited;
655
- if (tar.exitCode !== 0) {
656
- console.error("tar archive failed");
657
- process.exit(tar.exitCode ?? 1);
658
- }
659
- console.log(`Backup complete: ${to}`);
683
+ const to = resolve(arg("to") ?? `caelo-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz`);
684
+ const { backupSelfHosted } = await import("./self-hosted-backup.js");
685
+ const r = await backupSelfHosted(to, readRelease(CAELO_DIR)?.version ?? CAELO_VERSION, {
686
+ caeloDir: CAELO_DIR,
687
+ runner: bunHostRunner,
688
+ log: (m) => console.log(`==> ${m}`),
689
+ });
690
+ if (!r.ok) {
691
+ console.error(`Backup failed: ${r.error}`);
692
+ process.exit(1);
693
+ }
694
+ console.log(`Backup complete: ${r.path}`);
695
+ console.log(`It holds the databases, the published site and the media — not ${CONFIG_PATH} (the secrets); keep a copy of that separately.`);
660
696
  }
697
+ /** `restore --from <file> --yes` for a self-hosted install (self-hosted-backup.ts). */
661
698
  async function restore() {
662
699
  requireSelfHosted("restore");
663
700
  const from = arg("from");
664
701
  if (!from) {
665
- console.error("Usage: cms-provision restore --from <path> [--yes]");
702
+ console.error("Usage: cms-provision restore --from <backup.tar.gz> --yes");
666
703
  process.exit(2);
667
704
  }
668
705
  if (!process.argv.includes("--yes")) {
669
- console.error(`DESTRUCTIVE: would restore from ${from}, wiping current pgbackrest + minio volumes. Re-run with --yes to confirm.`);
706
+ console.error(`DESTRUCTIVE: replaces both databases, the published site and the media with ${from}. Re-run with --yes to confirm.`);
670
707
  process.exit(1);
671
708
  }
672
709
  const cfg = loadConfig();
673
- if (!cfg) {
674
- console.error("no config");
710
+ if (!cfg?.rolePasswords) {
711
+ console.error("no config with role passwords — run `cms-provision init` / `up` first");
675
712
  process.exit(2);
676
713
  }
677
- console.log(`Restoring from ${from} …`);
678
- // 1. Stop dependent services so they don't write while we restore.
679
- console.log("Step 1/4: stopping admin/gateway/orchestrator/runner …");
680
- await Bun.spawn([
681
- "docker",
682
- "compose",
683
- "-f",
684
- COMPOSE_PATH,
685
- "stop",
686
- "caelo-admin",
687
- "caelo-gateway",
688
- "caelo-orchestrator",
689
- "caelo-runner",
690
- ], { stdout: "inherit", stderr: "inherit" }).exited;
691
- // 2. Untar into the named volumes.
692
- console.log("Step 2/4: extracting archive into volumes …");
693
- const tarDir = resolve(from, "..");
694
- const tarFile = from.split("/").pop();
695
- const ex = Bun.spawn([
696
- "docker",
697
- "run",
698
- "--rm",
699
- "-v",
700
- "caelo_caelo-pg-backups:/pgb",
701
- "-v",
702
- "caelo_caelo-minio:/minio",
703
- "-v",
704
- `${tarDir}:/in:ro`,
705
- "alpine:3",
706
- "sh",
707
- "-c",
708
- `apk add --no-cache zstd tar >/dev/null && rm -rf /pgb/* /minio/* && tar --zstd -xf /in/${tarFile} -C /pgb && tar --zstd -xf /in/${tarFile} -C /minio`,
709
- ], { stdout: "inherit", stderr: "inherit" });
710
- await ex.exited;
711
- if (ex.exitCode !== 0) {
712
- console.error("extract failed");
713
- process.exit(ex.exitCode ?? 1);
714
- }
715
- // 3. pgBackRest restore.
716
- console.log("Step 3/4: pgBackRest restore …");
717
- const pgr = Bun.spawn([
718
- "docker",
719
- "compose",
720
- "-f",
721
- COMPOSE_PATH,
722
- "exec",
723
- "-T",
724
- "pgbackrest",
725
- "pgbackrest",
726
- "--stanza=caelo",
727
- "restore",
728
- ], { stdout: "inherit", stderr: "inherit" });
729
- await pgr.exited;
730
- if (pgr.exitCode !== 0) {
731
- console.error("pgBackRest restore failed");
732
- process.exit(pgr.exitCode ?? 1);
733
- }
734
- // 4. Bring services back up.
735
- console.log("Step 4/4: restarting services …");
736
- await Bun.spawn(["docker", "compose", "-f", COMPOSE_PATH, "up", "-d"], {
737
- stdout: "inherit",
738
- stderr: "inherit",
739
- }).exited;
740
- console.log("Restore complete.");
714
+ const passwords = cfg.rolePasswords;
715
+ const { restoreSelfHosted } = await import("./self-hosted-backup.js");
716
+ const r = await restoreSelfHosted(resolve(from), {
717
+ caeloDir: CAELO_DIR,
718
+ runner: bunHostRunner,
719
+ log: (m) => console.log(`==> ${m}`),
720
+ convergeRoles: () => convergeSelfHostedRoles(passwords, {
721
+ probe: postgresIsUp,
722
+ psql: superuserPsql,
723
+ ...(activeAgent(cfg) ? { agentPassword: activeAgent(cfg)?.dbPassword } : {}),
724
+ }),
725
+ refreshCaddy: () => refreshCaddy(cfg),
726
+ });
727
+ if (!r.ok) {
728
+ console.error(`Restore failed: ${r.error}`);
729
+ if (r.installChanged) {
730
+ console.error(`The install is partly restored. Fix the cause and run the same restore again — it starts over from the archive.`);
731
+ }
732
+ process.exit(1);
733
+ }
734
+ console.log(`Restore complete (backup of Caelo ${r.manifest.caeloVersion}, taken ${r.manifest.createdAt}).`);
741
735
  }
742
736
  /**
743
737
  * #696 — `migrate`: apply the database migrations with the admin image the
@@ -922,7 +916,7 @@ const handlers = {
922
916
  // back-compat scripts that bypassed the dispatcher.
923
917
  status: lifecycleStatus,
924
918
  "status-self-hosted": status,
925
- backup: lifecycleBackup,
919
+ backup: async () => (selfHostedInstallPresent() ? backup() : lifecycleBackup()),
926
920
  "backup-self-hosted": backup,
927
921
  restore,
928
922
  upgrade: lifecycleUpgrade,
@@ -931,6 +925,7 @@ const handlers = {
931
925
  truncate: lifecycleTruncate,
932
926
  "admin-domain": adminDomain,
933
927
  "staging-domain": stagingDomain,
928
+ "owner-reset": ownerReset,
934
929
  // #696 — Pulumi state in the install's own project.
935
930
  state: async () => (await import("./state-command.js")).stateCommand(process.argv),
936
931
  attach: async () => (await import("./state-command.js")).attachCommand(process.argv),
@@ -1003,6 +998,38 @@ async function lifecycleTruncate() {
1003
998
  const { truncateCommand } = await import("./lifecycle.js");
1004
999
  await truncateCommand();
1005
1000
  }
1001
+ /**
1002
+ * #769 `owner-reset [--install <id>] [--email <addr>]` — print a one-time
1003
+ * reset link for a locked-out Owner, for installs whose admin cannot email
1004
+ * one (see owner-reset.ts for why the operator's cloud identity may).
1005
+ */
1006
+ async function ownerReset() {
1007
+ const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
1008
+ const selected = selectInstall(listInstalls(), installFlag(process.argv));
1009
+ if (!selected.ok) {
1010
+ console.error(selected.message);
1011
+ process.exit(1);
1012
+ }
1013
+ const meta = selected.meta;
1014
+ if (meta.provider !== "gcp-firebase") {
1015
+ console.error(`owner-reset works on gcp-firebase installs so far; this one is ${meta.provider} (issue #770). ` +
1016
+ "Until then, another Owner can set a new password under Settings -> Users, or set up email under Settings -> Email so /forgot can send the link.");
1017
+ process.exit(1);
1018
+ }
1019
+ if (!meta.projectId || !meta.region) {
1020
+ console.error(`install ${meta.installId} has no project or region recorded in install.json; re-run the wizard to record it.`);
1021
+ process.exit(1);
1022
+ }
1023
+ const email = arg("email");
1024
+ const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
1025
+ const r = await requestOwnerReset({ projectId: meta.projectId, region: meta.region }, email ? { email } : {});
1026
+ if (!r.ok) {
1027
+ console.error(`owner-reset: ${r.error}`);
1028
+ process.exit(1);
1029
+ }
1030
+ for (const line of describeOwnerReset(r.value))
1031
+ console.log(line);
1032
+ }
1006
1033
  /**
1007
1034
  * `admin-domain enable` — bind admin.<domain> to a gcp-firebase admin
1008
1035
  * (see admin-domain.ts for why this runs as the operator, not Pulumi).
@@ -1253,7 +1280,7 @@ if (route.kind === "handler" || route.kind === "wizard") {
1253
1280
  }
1254
1281
  }
1255
1282
  else {
1256
- console.log("Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|state migrate|attach|pulumi-output-sync|version> [options]\n" +
1283
+ console.log("Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|owner-reset|state migrate|attach|pulumi-output-sync|version> [options]\n" +
1257
1284
  "With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
1258
1285
  "Pass --no-wizard with no sub-command to print this usage instead of the wizard.");
1259
1286
  process.exit(cmd && !cmd.startsWith("-") ? 2 : 0);