@caelo-cms/provisioning 0.10.47 → 0.10.49

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 (321) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/apply.d.ts +45 -0
  4. package/dist/agent/apply.d.ts.map +1 -0
  5. package/dist/agent/apply.js +83 -0
  6. package/dist/agent/apply.js.map +1 -0
  7. package/dist/agent/cli-provenance.d.ts +42 -0
  8. package/dist/agent/cli-provenance.d.ts.map +1 -0
  9. package/dist/agent/cli-provenance.js +138 -0
  10. package/dist/agent/cli-provenance.js.map +1 -0
  11. package/dist/agent/command.d.ts +79 -0
  12. package/dist/agent/command.d.ts.map +1 -0
  13. package/dist/agent/command.js +454 -0
  14. package/dist/agent/command.js.map +1 -0
  15. package/dist/agent/config.d.ts +30 -0
  16. package/dist/agent/config.d.ts.map +1 -0
  17. package/dist/agent/config.js +60 -0
  18. package/dist/agent/config.js.map +1 -0
  19. package/dist/agent/lock.d.ts +13 -0
  20. package/dist/agent/lock.d.ts.map +1 -0
  21. package/dist/agent/lock.js +100 -0
  22. package/dist/agent/lock.js.map +1 -0
  23. package/dist/agent/queue.d.ts +35 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +50 -0
  26. package/dist/agent/queue.js.map +1 -0
  27. package/dist/agent/run.d.ts +97 -0
  28. package/dist/agent/run.d.ts.map +1 -0
  29. package/dist/agent/run.js +198 -0
  30. package/dist/agent/run.js.map +1 -0
  31. package/dist/agent/systemd.d.ts +55 -0
  32. package/dist/agent/systemd.d.ts.map +1 -0
  33. package/dist/agent/systemd.js +171 -0
  34. package/dist/agent/systemd.js.map +1 -0
  35. package/dist/agent/units.d.ts +44 -0
  36. package/dist/agent/units.d.ts.map +1 -0
  37. package/dist/agent/units.js +127 -0
  38. package/dist/agent/units.js.map +1 -0
  39. package/dist/agent-db.js +18402 -0
  40. package/dist/cli.js +119 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/cloudsql-iam-user.d.ts +39 -0
  43. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  44. package/dist/cloudsql-iam-user.js +119 -0
  45. package/dist/cloudsql-iam-user.js.map +1 -0
  46. package/dist/compose.d.ts +16 -0
  47. package/dist/compose.d.ts.map +1 -1
  48. package/dist/compose.js +26 -1
  49. package/dist/compose.js.map +1 -1
  50. package/dist/dns/index.d.ts +2 -0
  51. package/dist/dns/index.d.ts.map +1 -1
  52. package/dist/dns/index.js +3 -2
  53. package/dist/dns/index.js.map +1 -1
  54. package/dist/dns/manual.d.ts +5 -0
  55. package/dist/dns/manual.d.ts.map +1 -1
  56. package/dist/dns/manual.js +28 -12
  57. package/dist/dns/manual.js.map +1 -1
  58. package/dist/gcloud.d.ts +2 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +1 -1
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/gcp-names.d.ts +15 -0
  63. package/dist/gcp-names.d.ts.map +1 -1
  64. package/dist/gcp-names.js +23 -0
  65. package/dist/gcp-names.js.map +1 -1
  66. package/dist/index.d.ts +6 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/infra-contract.d.ts +1 -1
  71. package/dist/infra-contract.js +7 -1
  72. package/dist/infra-contract.js.map +1 -1
  73. package/dist/install-job-runner.d.ts +13 -0
  74. package/dist/install-job-runner.d.ts.map +1 -0
  75. package/dist/install-job-runner.js +155 -0
  76. package/dist/install-job-runner.js.map +1 -0
  77. package/dist/install-job.d.ts +210 -0
  78. package/dist/install-job.d.ts.map +1 -0
  79. package/dist/install-job.js +449 -0
  80. package/dist/install-job.js.map +1 -0
  81. package/dist/install-state.d.ts +10 -1
  82. package/dist/install-state.d.ts.map +1 -1
  83. package/dist/install-state.js +10 -2
  84. package/dist/install-state.js.map +1 -1
  85. package/dist/lifecycle.d.ts.map +1 -1
  86. package/dist/lifecycle.js +19 -4
  87. package/dist/lifecycle.js.map +1 -1
  88. package/dist/migration-runner.d.ts +5 -0
  89. package/dist/migration-runner.d.ts.map +1 -1
  90. package/dist/migration-runner.js +1 -1
  91. package/dist/migration-runner.js.map +1 -1
  92. package/dist/operator-access.d.ts +14 -8
  93. package/dist/operator-access.d.ts.map +1 -1
  94. package/dist/operator-access.js +16 -102
  95. package/dist/operator-access.js.map +1 -1
  96. package/dist/release-manifest.d.ts +2 -41
  97. package/dist/release-manifest.d.ts.map +1 -1
  98. package/dist/release-manifest.js +18 -121
  99. package/dist/release-manifest.js.map +1 -1
  100. package/dist/run-mode.d.ts +27 -0
  101. package/dist/run-mode.d.ts.map +1 -0
  102. package/dist/run-mode.js +22 -0
  103. package/dist/run-mode.js.map +1 -0
  104. package/dist/runtime-secrets.d.ts +19 -0
  105. package/dist/runtime-secrets.d.ts.map +1 -1
  106. package/dist/runtime-secrets.js +44 -0
  107. package/dist/runtime-secrets.js.map +1 -1
  108. package/dist/self-hosted-roles.d.ts +22 -1
  109. package/dist/self-hosted-roles.d.ts.map +1 -1
  110. package/dist/self-hosted-roles.js +81 -12
  111. package/dist/self-hosted-roles.js.map +1 -1
  112. package/dist/self-hosted-upgrade.d.ts +17 -0
  113. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  114. package/dist/self-hosted-upgrade.js +16 -1
  115. package/dist/self-hosted-upgrade.js.map +1 -1
  116. package/dist/stack-contract.d.ts +6 -0
  117. package/dist/stack-contract.d.ts.map +1 -1
  118. package/dist/stack-contract.js +10 -1
  119. package/dist/stack-contract.js.map +1 -1
  120. package/dist/upgrade/converge.d.ts +6 -0
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +59 -2
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  125. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  126. package/dist/upgrade/gcp-lookup.js +25 -0
  127. package/dist/upgrade/gcp-lookup.js.map +1 -1
  128. package/dist/upgrade/release-images.d.ts +26 -2
  129. package/dist/upgrade/release-images.d.ts.map +1 -1
  130. package/dist/upgrade/release-images.js +35 -2
  131. package/dist/upgrade/release-images.js.map +1 -1
  132. package/dist/upgrade/upgrade.d.ts +5 -0
  133. package/dist/upgrade/upgrade.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade.js +28 -1
  135. package/dist/upgrade/upgrade.js.map +1 -1
  136. package/dist/upgrader-assess.d.ts +85 -0
  137. package/dist/upgrader-assess.d.ts.map +1 -0
  138. package/dist/upgrader-assess.js +122 -0
  139. package/dist/upgrader-assess.js.map +1 -0
  140. package/dist/upgrader-gcp.d.ts +17 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -0
  142. package/dist/upgrader-gcp.js +87 -0
  143. package/dist/upgrader-gcp.js.map +1 -0
  144. package/dist/upgrader-job.d.ts +133 -0
  145. package/dist/upgrader-job.d.ts.map +1 -0
  146. package/dist/upgrader-job.js +259 -0
  147. package/dist/upgrader-job.js.map +1 -0
  148. package/dist/upgrader-reporter.d.ts +35 -0
  149. package/dist/upgrader-reporter.d.ts.map +1 -0
  150. package/dist/upgrader-reporter.js +79 -0
  151. package/dist/upgrader-reporter.js.map +1 -0
  152. package/dist/upgrader-setup.d.ts +59 -0
  153. package/dist/upgrader-setup.d.ts.map +1 -0
  154. package/dist/upgrader-setup.js +308 -0
  155. package/dist/upgrader-setup.js.map +1 -0
  156. package/dist/wizard.d.ts +6 -0
  157. package/dist/wizard.d.ts.map +1 -1
  158. package/dist/wizard.js +32 -4
  159. package/dist/wizard.js.map +1 -1
  160. package/dist/wizards/gcp-firebase.d.ts +3 -0
  161. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  162. package/dist/wizards/gcp-firebase.js +1 -0
  163. package/dist/wizards/gcp-firebase.js.map +1 -1
  164. package/dist/wizards/gcp-install-job.d.ts +74 -0
  165. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  166. package/dist/wizards/gcp-install-job.js +398 -0
  167. package/dist/wizards/gcp-install-job.js.map +1 -0
  168. package/dist/wizards/gcp-prompts.d.ts +3 -0
  169. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  170. package/dist/wizards/gcp-prompts.js +25 -0
  171. package/dist/wizards/gcp-prompts.js.map +1 -0
  172. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  173. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  174. package/dist/wizards/gcp-pulumi.js +1 -1
  175. package/dist/wizards/gcp-pulumi.js.map +1 -1
  176. package/dist/wizards/gcp.d.ts +40 -0
  177. package/dist/wizards/gcp.d.ts.map +1 -1
  178. package/dist/wizards/gcp.js +223 -59
  179. package/dist/wizards/gcp.js.map +1 -1
  180. package/package.json +6 -4
  181. package/src/adapter.ts +97 -0
  182. package/src/adc-impersonation.ts +176 -0
  183. package/src/admin-domain.test.ts +254 -0
  184. package/src/admin-domain.ts +245 -0
  185. package/src/admin-memory.test.ts +151 -0
  186. package/src/agent/apply.test.ts +185 -0
  187. package/src/agent/apply.ts +124 -0
  188. package/src/agent/cli-provenance.test.ts +159 -0
  189. package/src/agent/cli-provenance.ts +191 -0
  190. package/src/agent/command.test.ts +77 -0
  191. package/src/agent/command.ts +564 -0
  192. package/src/agent/config.ts +86 -0
  193. package/src/agent/lock-config.test.ts +97 -0
  194. package/src/agent/lock.ts +110 -0
  195. package/src/agent/queue.test.ts +10 -0
  196. package/src/agent/queue.ts +91 -0
  197. package/src/agent/run.test.ts +437 -0
  198. package/src/agent/run.ts +295 -0
  199. package/src/agent/systemd.ts +209 -0
  200. package/src/agent/units.test.ts +223 -0
  201. package/src/agent/units.ts +163 -0
  202. package/src/azure-front-door.test.ts +119 -0
  203. package/src/azure-front-door.ts +56 -0
  204. package/src/bootstrap-token.ts +20 -0
  205. package/src/caddy.ts +112 -0
  206. package/src/cdn-copy.ts +91 -0
  207. package/src/cert-wait.test.ts +194 -0
  208. package/src/cert-wait.ts +163 -0
  209. package/src/checkpoint-secrets.test.ts +380 -0
  210. package/src/checkpoint-secrets.ts +283 -0
  211. package/src/cli-routing.test.ts +45 -0
  212. package/src/cli-routing.ts +43 -0
  213. package/src/cli.ts +1491 -0
  214. package/src/cloudsql-iam-user.ts +147 -0
  215. package/src/compose.ts +334 -0
  216. package/src/default-firewall.test.ts +411 -0
  217. package/src/default-firewall.ts +342 -0
  218. package/src/deployed-release.test.ts +231 -0
  219. package/src/deployed-release.ts +220 -0
  220. package/src/dns/cloudflare.ts +182 -0
  221. package/src/dns/groups.test.ts +76 -0
  222. package/src/dns/groups.ts +86 -0
  223. package/src/dns/index.ts +56 -0
  224. package/src/dns/manual.ts +130 -0
  225. package/src/dns/types.ts +27 -0
  226. package/src/gateway-credentials.test.ts +180 -0
  227. package/src/gateway-credentials.ts +144 -0
  228. package/src/gcloud-components.test.ts +76 -0
  229. package/src/gcloud-components.ts +85 -0
  230. package/src/gcloud-retry.ts +41 -0
  231. package/src/gcloud-runner.test.ts +61 -0
  232. package/src/gcloud.test.ts +99 -0
  233. package/src/gcloud.ts +322 -0
  234. package/src/gcp-names.ts +135 -0
  235. package/src/index.test.ts +332 -0
  236. package/src/index.ts +114 -0
  237. package/src/infra-contract.ts +32 -0
  238. package/src/install-job-runner.ts +166 -0
  239. package/src/install-job.test.ts +566 -0
  240. package/src/install-job.ts +542 -0
  241. package/src/install-state.test.ts +239 -0
  242. package/src/install-state.ts +583 -0
  243. package/src/internal-token.ts +35 -0
  244. package/src/lifecycle.ts +900 -0
  245. package/src/mcp-iap-stacks.test.ts +58 -0
  246. package/src/mcp-iap.test.ts +149 -0
  247. package/src/mcp-iap.ts +131 -0
  248. package/src/media-volume.test.ts +296 -0
  249. package/src/migration-runner.ts +365 -0
  250. package/src/operator-access-stacks.test.ts +55 -0
  251. package/src/operator-access.test.ts +332 -0
  252. package/src/operator-access.ts +554 -0
  253. package/src/ops-backend.test.ts +320 -0
  254. package/src/ops-backend.ts +414 -0
  255. package/src/provisioner-identity.test.ts +482 -0
  256. package/src/provisioner-identity.ts +407 -0
  257. package/src/redirects-emit.ts +175 -0
  258. package/src/region-data.ts +428 -0
  259. package/src/regions.test.ts +277 -0
  260. package/src/regions.ts +370 -0
  261. package/src/release-contract.ts +57 -0
  262. package/src/release-manifest.test.ts +300 -0
  263. package/src/release-manifest.ts +310 -0
  264. package/src/run-mode.ts +40 -0
  265. package/src/runtime-secrets.test.ts +627 -0
  266. package/src/runtime-secrets.ts +754 -0
  267. package/src/self-command.test.ts +14 -0
  268. package/src/self-command.ts +21 -0
  269. package/src/self-hosted-compose.test.ts +177 -0
  270. package/src/self-hosted-release.ts +240 -0
  271. package/src/self-hosted-roles.integration.test.ts +161 -0
  272. package/src/self-hosted-roles.test.ts +124 -0
  273. package/src/self-hosted-roles.ts +203 -0
  274. package/src/self-hosted-upgrade.test.ts +394 -0
  275. package/src/self-hosted-upgrade.ts +548 -0
  276. package/src/stack-contract-parity.test.ts +199 -0
  277. package/src/stack-contract.test.ts +391 -0
  278. package/src/stack-contract.ts +888 -0
  279. package/src/stack-converge-secrets.test.ts +421 -0
  280. package/src/stack-converge.test.ts +833 -0
  281. package/src/stack-converge.ts +909 -0
  282. package/src/staging-admin.test.ts +246 -0
  283. package/src/staging-admin.ts +324 -0
  284. package/src/state-attach.test.ts +211 -0
  285. package/src/state-attach.ts +132 -0
  286. package/src/state-backend.test.ts +145 -0
  287. package/src/state-backend.ts +262 -0
  288. package/src/state-command.ts +293 -0
  289. package/src/state-migrate.test.ts +479 -0
  290. package/src/state-migrate.ts +434 -0
  291. package/src/state-pulumi.ts +289 -0
  292. package/src/state-store.test.ts +212 -0
  293. package/src/state-store.ts +243 -0
  294. package/src/upgrade/app-roll.ts +486 -0
  295. package/src/upgrade/backup.ts +109 -0
  296. package/src/upgrade/converge.ts +437 -0
  297. package/src/upgrade/gcp-lookup.ts +167 -0
  298. package/src/upgrade/health.test.ts +279 -0
  299. package/src/upgrade/health.ts +305 -0
  300. package/src/upgrade/release-images.test.ts +119 -0
  301. package/src/upgrade/release-images.ts +393 -0
  302. package/src/upgrade/reporter.ts +46 -0
  303. package/src/upgrade/upgrade.test.ts +626 -0
  304. package/src/upgrade/upgrade.ts +249 -0
  305. package/src/upgrader-assess.test.ts +306 -0
  306. package/src/upgrader-assess.ts +250 -0
  307. package/src/upgrader-gcp.ts +123 -0
  308. package/src/upgrader-job.test.ts +411 -0
  309. package/src/upgrader-job.ts +502 -0
  310. package/src/upgrader-reporter.test.ts +46 -0
  311. package/src/upgrader-reporter.ts +105 -0
  312. package/src/upgrader-setup.test.ts +169 -0
  313. package/src/upgrader-setup.ts +355 -0
  314. package/src/wizard.ts +425 -0
  315. package/src/wizards/gcp-cost.ts +188 -0
  316. package/src/wizards/gcp-firebase.ts +72 -0
  317. package/src/wizards/gcp-install-job.ts +506 -0
  318. package/src/wizards/gcp-prompts.ts +26 -0
  319. package/src/wizards/gcp-pulumi.test.ts +246 -0
  320. package/src/wizards/gcp-pulumi.ts +516 -0
  321. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,548 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #696 Phase 4 — `cms-provision upgrade` for a self-hosted Compose install.
5
+ *
6
+ * Order, and why:
7
+ * 1. resolve the target release to digests + cosign-verify them — nothing
8
+ * on the host changes until the images are known-good;
9
+ * 2. back up both databases (pg_dump, hard-fail) — the last moment the
10
+ * schema is guaranteed to match the running images;
11
+ * 3. write the new digests into the Compose file, `docker compose pull`;
12
+ * 4. run the migrations from the NEW admin image (`docker compose run`,
13
+ * the same `migrate.ts` entrypoint the GCP migrate job runs) while the
14
+ * old containers keep serving — migrations are expand-only, so the
15
+ * running release works on the new schema;
16
+ * 5. recreate the admin, health-check it (`/_caelo/health`, which proves
17
+ * the DB pool and roles); then the gateway (`/healthz`);
18
+ * 6. record the release in `.caelo/release.json`.
19
+ * Any failure after step 3 puts the previous Compose file back; once a
20
+ * container was recreated the previous images are started again and
21
+ * health-checked (rollback). The database is never restored automatically:
22
+ * the expand-only migrations keep the previous release working, and the
23
+ * backup path is printed for the case they do not.
24
+ *
25
+ * Idempotent: a target equal to the recorded release is a no-op; a run that
26
+ * stopped half-way is simply repeated (pull, migrate and `up` converge).
27
+ *
28
+ * pgBackRest is NOT used: the Compose file's sidecar has no access to the
29
+ * Postgres data volume and no stanza is ever created, so `pg_dump` (from the
30
+ * Postgres container itself) is the backup that actually works here.
31
+ */
32
+
33
+ import {
34
+ chmodSync,
35
+ cpSync,
36
+ existsSync,
37
+ mkdirSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import { join } from "node:path";
44
+ import type { PlatformUpdatePhase } from "@caelo-cms/shared";
45
+ import { ghcrImageDigest, MEDIA_DIR, type ReleaseImages } from "./compose.js";
46
+ import { selfCommand } from "./self-command.js";
47
+ import {
48
+ type HostRunner,
49
+ readRelease,
50
+ recordRelease,
51
+ recordRollback,
52
+ releaseImages,
53
+ resolveVerifiedRelease,
54
+ } from "./self-hosted-release.js";
55
+ import { fetchLatestReleaseTag, RELEASE_SERVICES, releaseTag } from "./upgrade/release-images.js";
56
+
57
+ /** The Compose service names of the two Caelo services. */
58
+ const COMPOSE_SERVICE = { admin: "caelo-admin", gateway: "caelo-gateway" } as const;
59
+
60
+ /** Progress output; the CLI wires it to the terminal, tests to an array. */
61
+ export interface UpgradeLog {
62
+ step(message: string): void;
63
+ info(message: string): void;
64
+ warn(message: string): void;
65
+ error(message: string): void;
66
+ /**
67
+ * The stage the following lines belong to, in the vocabulary of the
68
+ * admin's update timeline. Only the host agent (#696 Phase 4c) listens;
69
+ * the terminal ignores it.
70
+ */
71
+ phase?(phase: PlatformUpdatePhase): void;
72
+ }
73
+
74
+ export interface SelfHostedUpgradeDeps {
75
+ /** The install's `.caelo/` directory (docker-compose.yml, release.json, backups/). */
76
+ readonly caeloDir: string;
77
+ readonly runner: HostRunner;
78
+ /** Renders the Compose file for `images` from the install's config (secrets included). */
79
+ readonly renderCompose: (images: ReleaseImages) => string;
80
+ readonly log: UpgradeLog;
81
+ readonly fetchImpl?: typeof fetch;
82
+ readonly sleep?: (ms: number) => Promise<void>;
83
+ readonly now?: () => Date;
84
+ /** Health probe attempts per service and the pause between them. */
85
+ readonly healthAttempts?: number;
86
+ readonly healthIntervalMs?: number;
87
+ }
88
+
89
+ export interface SelfHostedUpgradeOpts {
90
+ readonly version?: string;
91
+ readonly channel?: "stable" | "rc" | "beta";
92
+ readonly skipVerify?: boolean;
93
+ /**
94
+ * The admin + gateway digests the release's signed manifest names (the
95
+ * host agent passes them). The digests GHCR serves for the tag must match,
96
+ * or nothing changes — a registry cannot swap in another signed image of
97
+ * an older release under the new tag.
98
+ */
99
+ readonly expectedDigests?: { readonly admin: string; readonly gateway: string };
100
+ }
101
+
102
+ export type SelfHostedUpgradeResult =
103
+ | { readonly ok: true; readonly status: "upgraded" | "already-current"; readonly version: string }
104
+ | {
105
+ readonly ok: false;
106
+ readonly stage: UpgradeStage;
107
+ readonly error: string;
108
+ /** Whether the previous release is running again (true when nothing had changed yet). */
109
+ readonly previousRunning: boolean;
110
+ };
111
+
112
+ type UpgradeStage =
113
+ | "target"
114
+ | "verify"
115
+ | "converge"
116
+ | "backup"
117
+ | "pull"
118
+ | "migrate"
119
+ | "roll-admin"
120
+ | "roll-gateway";
121
+
122
+ /** Where an install from before #696 keeps its original Compose file during the move. */
123
+ export function legacyComposePath(caeloDir: string): string {
124
+ return join(caeloDir, "docker-compose.pre-release.yml");
125
+ }
126
+
127
+ /**
128
+ * The legacy (repo-mounted) Compose file, saved to
129
+ * {@link legacyComposePath} on the first upgrade and read from there on
130
+ * every retry — after a stopped run the live file may already name the
131
+ * release images, which would make a rollback a no-op.
132
+ */
133
+ function legacyComposeCheckpoint(caeloDir: string, live: string): string {
134
+ const saved = legacyComposePath(caeloDir);
135
+ try {
136
+ // `wx`: create only — an existing checkpoint (an earlier run's) wins.
137
+ writeFileSync(saved, live, { mode: 0o600, flag: "wx" });
138
+ return live;
139
+ } catch (e) {
140
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
141
+ return readFileSync(saved, "utf8");
142
+ }
143
+ }
144
+
145
+ /** Where the media of an install from before #696 lives: the checkout's admin cwd. */
146
+ export function legacyMediaDir(caeloDir: string): string {
147
+ return join(caeloDir, "..", "apps", "admin", "data", "media");
148
+ }
149
+
150
+ /**
151
+ * Copy (never move) a legacy install's uploads from the checkout
152
+ * (`apps/admin/data/media`, the admin's cwd under the repo bind mount) into
153
+ * `.caelo/media/`, which the release-mode admin mounts as MEDIA_ROOT_DIR.
154
+ * Skipped when there is nothing to copy or `.caelo/media/` already has
155
+ * content (a previous run copied it).
156
+ */
157
+ export function adoptLegacyMedia(
158
+ caeloDir: string,
159
+ ): { ok: true; copied: string | null } | { ok: false; error: string } {
160
+ const from = legacyMediaDir(caeloDir);
161
+ const to = join(caeloDir, MEDIA_DIR);
162
+ if (!existsSync(from)) return { ok: true, copied: null };
163
+ if (existsSync(to) && readdirSync(to).length > 0) return { ok: true, copied: null };
164
+ try {
165
+ cpSync(from, to, { recursive: true, preserveTimestamps: true });
166
+ return { ok: true, copied: to };
167
+ } catch (e) {
168
+ return {
169
+ ok: false,
170
+ error: `Could not copy the uploaded media from ${from} to ${to} (${e instanceof Error ? e.message : String(e)}). The upgrade did not start.`,
171
+ };
172
+ }
173
+ }
174
+
175
+ /** The file's size in bytes, 0 when it does not exist. */
176
+ function fileSize(path: string): number {
177
+ try {
178
+ return statSync(path).size;
179
+ } catch {
180
+ return 0;
181
+ }
182
+ }
183
+
184
+ /** The file's content, or null when it does not exist. */
185
+ function readIfExists(path: string): string | null {
186
+ try {
187
+ return readFileSync(path, "utf8");
188
+ } catch (e) {
189
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
190
+ throw e;
191
+ }
192
+ }
193
+
194
+ export function composePath(caeloDir: string): string {
195
+ return join(caeloDir, "docker-compose.yml");
196
+ }
197
+
198
+ /** `docker compose -f <file>` — every Compose call targets the install's file explicitly. */
199
+ function compose(caeloDir: string, ...args: string[]): string[] {
200
+ return ["docker", "compose", "-f", composePath(caeloDir), ...args];
201
+ }
202
+
203
+ /**
204
+ * Upgrade a self-hosted install to `opts.version` (or the newest stable
205
+ * release). Never throws for an expected failure; the result says which
206
+ * stage failed and whether the previous release is serving again.
207
+ */
208
+ export async function upgradeSelfHosted(
209
+ opts: SelfHostedUpgradeOpts,
210
+ deps: SelfHostedUpgradeDeps,
211
+ ): Promise<SelfHostedUpgradeResult> {
212
+ const { caeloDir, runner, log } = deps;
213
+ const now = deps.now ?? (() => new Date());
214
+ const current = readRelease(caeloDir);
215
+ const file = composePath(caeloDir);
216
+ const live = readIfExists(file);
217
+ if (live === null) {
218
+ return fail("target", `${file} does not exist — run \`${selfCommand("init")}\` first.`, true);
219
+ }
220
+ // The file to restore on failure: the recorded release re-rendered (so a
221
+ // run that stopped after writing new digests cannot "restore" those), or —
222
+ // for an install from before #696 with no record — the legacy file,
223
+ // checkpointed once so a retry after a stopped run still has it.
224
+ const previousCompose = current
225
+ ? deps.renderCompose(releaseImages(current))
226
+ : legacyComposeCheckpoint(caeloDir, live);
227
+
228
+ // 1. Target release → verified digests.
229
+ log.phase?.("verify");
230
+ const target = await targetVersion(opts, deps.fetchImpl);
231
+ if (!target.ok) return fail("target", target.error, true);
232
+ log.step(
233
+ `Upgrading ${current ? `${current.version} → ` : "(repo-mounted install) → "}${target.version}`,
234
+ );
235
+ const resolved = await resolveVerifiedRelease(target.tag, {
236
+ runner,
237
+ ...(opts.skipVerify ? { skipVerify: true } : {}),
238
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
239
+ onProgress: (m) => log.info(m),
240
+ });
241
+ if (!resolved.ok) return fail("verify", resolved.error, true);
242
+ if (opts.skipVerify) log.warn("--skip-verify set — image signatures NOT verified.");
243
+ const images = resolved.images;
244
+ if (opts.expectedDigests) {
245
+ const mismatch = RELEASE_SERVICES.filter(
246
+ (s) => ghcrImageDigest(s, images[s]) !== opts.expectedDigests?.[s],
247
+ );
248
+ if (mismatch.length > 0) {
249
+ return fail(
250
+ "verify",
251
+ `The registry serves other ${mismatch.join(" + ")} images for ${target.version} than its signed release manifest names; nothing was changed.`,
252
+ true,
253
+ );
254
+ }
255
+ }
256
+ if (
257
+ current &&
258
+ current.images.admin === images.admin &&
259
+ current.images.gateway === images.gateway &&
260
+ live === previousCompose
261
+ ) {
262
+ // Recorded, and the Compose file agrees. `up` converges containers a
263
+ // stopped run or a failed rollback may have left on other images (a
264
+ // no-op when they already match); the health checks prove it serves.
265
+ log.phase?.("health");
266
+ log.info(
267
+ `Already on ${current.version}; checking the running containers match and are healthy.`,
268
+ );
269
+ const converged = await startBoth(deps);
270
+ if (!converged.ok) {
271
+ return fail(
272
+ "converge",
273
+ `${converged.error}\nThe recorded release ${current.version} is not serving healthily — check \`docker compose logs\`.`,
274
+ false,
275
+ );
276
+ }
277
+ log.info(`Already on ${current.version} (${shortDigest(images.admin)}); nothing to do.`);
278
+ return { ok: true, status: "already-current", version: current.version };
279
+ }
280
+ // Otherwise (a different target, or a Compose file a stopped run left
281
+ // with other digests) the full flow below converges the install.
282
+
283
+ // Rendered before the backup so a config problem stops the run before
284
+ // anything on the host is touched.
285
+ const nextCompose = deps.renderCompose(images);
286
+
287
+ // 2. Backup — before anything on the host changes.
288
+ log.phase?.("backup");
289
+ log.step("Backing up the databases (pg_dump)");
290
+ const backup = await backupDatabases(caeloDir, runner, now());
291
+ if (!backup.ok) return fail("backup", backup.error, true);
292
+ log.info(`Backup written to ${backup.dir}`);
293
+ if (!current) {
294
+ const media = adoptLegacyMedia(caeloDir);
295
+ if (!media.ok) return fail("backup", media.error, true);
296
+ if (media.copied) log.info(`Copied the uploaded media into ${media.copied}`);
297
+ }
298
+
299
+ // 3. New digests into the Compose file; pull them.
300
+ writeFileSync(file, nextCompose, { mode: 0o600 });
301
+ log.phase?.("roll");
302
+ log.step("Pulling the new images");
303
+ const pull = await runner.run(
304
+ compose(caeloDir, "pull", COMPOSE_SERVICE.admin, COMPOSE_SERVICE.gateway),
305
+ );
306
+ if (pull.exitCode !== 0) {
307
+ restoreCompose(file, previousCompose);
308
+ return fail("pull", `docker compose pull failed: ${tail(pull.stderr)}`, true);
309
+ }
310
+
311
+ // 4. Migrations from the new admin image; the old containers keep serving.
312
+ log.phase?.("migrations");
313
+ log.step("Running database migrations with the new admin image");
314
+ const migrated = await runMigrations(caeloDir, runner);
315
+ if (!migrated.ok) {
316
+ restoreCompose(file, previousCompose);
317
+ return fail(
318
+ "migrate",
319
+ `${migrated.error}\nThe previous release keeps running. Database backup: ${backup.dir}`,
320
+ true,
321
+ );
322
+ }
323
+
324
+ // 5. Roll admin, then gateway, each health-checked.
325
+ for (const service of RELEASE_SERVICES) {
326
+ const stage = service === "admin" ? "roll-admin" : "roll-gateway";
327
+ log.phase?.("roll");
328
+ log.step(`Restarting ${service} on ${shortDigest(images[service])}`);
329
+ const up = await runner.run(
330
+ compose(caeloDir, "up", "-d", "--no-deps", COMPOSE_SERVICE[service]),
331
+ );
332
+ log.phase?.("health");
333
+ const healthy =
334
+ up.exitCode === 0
335
+ ? await waitHealthy(service, caeloDir, deps)
336
+ : { ok: false as const, error: `docker compose up failed: ${tail(up.stderr)}` };
337
+ if (!healthy.ok) {
338
+ log.error(`${service} is not healthy on ${target.version}: ${healthy.error}`);
339
+ const restored = await rollback(file, previousCompose, deps);
340
+ if (current) {
341
+ recordRollback(caeloDir, current, target.version, `${stage}: ${healthy.error}`, now());
342
+ }
343
+ return fail(
344
+ stage,
345
+ `${healthy.error}\n${
346
+ restored
347
+ ? "Rolled back: the previous release is running and healthy again."
348
+ : "ROLLBACK FAILED — the previous images did not come back healthy. Check `docker compose ps` / `docker compose logs`."
349
+ }\nDatabase backup: ${backup.dir}`,
350
+ restored,
351
+ );
352
+ }
353
+ log.info(`${service} healthy ✓`);
354
+ }
355
+
356
+ // 6. Record.
357
+ log.phase?.("record");
358
+ recordRelease(
359
+ caeloDir,
360
+ { version: target.version, images },
361
+ { from: current?.version ?? null, to: target.version, outcome: "upgraded" },
362
+ now(),
363
+ );
364
+ log.step(`Upgraded to ${target.version}. Backup kept at ${backup.dir}`);
365
+ return { ok: true, status: "upgraded", version: target.version };
366
+ }
367
+
368
+ function fail(
369
+ stage: UpgradeStage,
370
+ error: string,
371
+ previousRunning: boolean,
372
+ ): SelfHostedUpgradeResult {
373
+ return { ok: false, stage, error, previousRunning };
374
+ }
375
+
376
+ /** The tag to resolve and the version to record: explicit, channel, or newest stable. */
377
+ async function targetVersion(
378
+ opts: SelfHostedUpgradeOpts,
379
+ fetchImpl?: typeof fetch,
380
+ ): Promise<{ ok: true; tag: string; version: string } | { ok: false; error: string }> {
381
+ if (opts.version || opts.channel === "rc" || opts.channel === "beta") {
382
+ const tag = releaseTag(opts);
383
+ return { ok: true, tag, version: tag };
384
+ }
385
+ const latest = await fetchLatestReleaseTag(fetchImpl);
386
+ if (!latest.ok) {
387
+ return {
388
+ ok: false,
389
+ error: `Could not look up the newest Caelo release (${latest.reason}). Pass --version x.y.z.`,
390
+ };
391
+ }
392
+ const tag = releaseTag({ version: latest.tag });
393
+ return { ok: true, tag, version: tag };
394
+ }
395
+
396
+ /**
397
+ * pg_dump both databases (custom format) plus the cluster's roles into
398
+ * `.caelo/backups/pre-upgrade-<timestamp>/` (mode 700). Every dump must exit
399
+ * 0 and be non-empty, or the upgrade does not start.
400
+ */
401
+ export async function backupDatabases(
402
+ caeloDir: string,
403
+ runner: HostRunner,
404
+ at: Date,
405
+ ): Promise<{ ok: true; dir: string } | { ok: false; error: string }> {
406
+ const ready = await runner.run(
407
+ compose(caeloDir, "exec", "-T", "postgres", "pg_isready", "-U", "caelo"),
408
+ );
409
+ if (ready.exitCode !== 0) {
410
+ return {
411
+ ok: false,
412
+ error:
413
+ "Postgres is not running, so no backup can be taken and the upgrade will not start.\n" +
414
+ `Start it with \`docker compose -f ${composePath(caeloDir)} up -d postgres\` and re-run.`,
415
+ };
416
+ }
417
+ const dir = join(caeloDir, "backups", `pre-upgrade-${at.toISOString().replace(/[:.]/g, "-")}`);
418
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
419
+ chmodSync(dir, 0o700);
420
+ const dumps: [string, string[]][] = [
421
+ ["globals.sql", ["pg_dumpall", "-U", "caelo", "--globals-only"]],
422
+ ["cms_admin.dump", ["pg_dump", "-U", "caelo", "-Fc", "cms_admin"]],
423
+ ["cms_public.dump", ["pg_dump", "-U", "caelo", "-Fc", "cms_public"]],
424
+ ];
425
+ for (const [name, cmd] of dumps) {
426
+ const out = join(dir, name);
427
+ const r = await runner.run(compose(caeloDir, "exec", "-T", "postgres", ...cmd), {
428
+ stdoutPath: out,
429
+ });
430
+ if (r.exitCode !== 0 || fileSize(out) === 0) {
431
+ return {
432
+ ok: false,
433
+ error: `Backup failed (${cmd.join(" ")} → ${out}): ${tail(r.stderr) || "empty dump"}. The upgrade did not start.`,
434
+ };
435
+ }
436
+ chmodSync(out, 0o600);
437
+ }
438
+ return { ok: true, dir };
439
+ }
440
+
441
+ /**
442
+ * Apply the committed migrations (cms_admin, then cms_public) with the
443
+ * admin image the Compose file names, as a one-off container — the
444
+ * self-hosted equivalent of the GCP `caelo-migrate-<target>` Cloud Run job.
445
+ * drizzle's bookkeeping makes re-runs apply only new migrations.
446
+ */
447
+ export async function runMigrations(
448
+ caeloDir: string,
449
+ runner: HostRunner,
450
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
451
+ for (const target of ["admin", "public"] as const) {
452
+ const r = await runner.run(
453
+ compose(
454
+ caeloDir,
455
+ "run",
456
+ "--rm",
457
+ "--no-deps",
458
+ "-T",
459
+ COMPOSE_SERVICE.admin,
460
+ "bun",
461
+ "--bun",
462
+ "/app/packages/migrations/src/migrate.ts",
463
+ target,
464
+ ),
465
+ );
466
+ if (r.exitCode !== 0) {
467
+ return { ok: false, error: `${target} migrations failed: ${tail(r.stderr || r.stdout)}` };
468
+ }
469
+ }
470
+ return { ok: true };
471
+ }
472
+
473
+ /**
474
+ * In-container probe scripts (`bun -e`): the containers publish no host
475
+ * ports, so the probe runs where the service listens. The admin's
476
+ * `/_caelo/health` checks the DB pool + roles; the gateway's `/healthz` is
477
+ * its liveness endpoint.
478
+ */
479
+ const HEALTH_PROBE: Record<"admin" | "gateway", string> = {
480
+ admin:
481
+ 'const r=await fetch("http://127.0.0.1:"+(process.env.PORT||"3000")+"/_caelo/health").catch(()=>null);process.exit(r&&r.ok?0:1)',
482
+ gateway:
483
+ 'const r=await fetch("http://127.0.0.1:"+(process.env.PORT||process.env.GATEWAY_PORT||"8090")+"/healthz").catch(()=>null);process.exit(r&&r.ok?0:1)',
484
+ };
485
+
486
+ async function waitHealthy(
487
+ service: "admin" | "gateway",
488
+ caeloDir: string,
489
+ deps: SelfHostedUpgradeDeps,
490
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
491
+ const attempts = deps.healthAttempts ?? 45;
492
+ const interval = deps.healthIntervalMs ?? 2000;
493
+ const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
494
+ let last = "";
495
+ for (let i = 0; i < attempts; i++) {
496
+ const r = await deps.runner.run(
497
+ compose(caeloDir, "exec", "-T", COMPOSE_SERVICE[service], "bun", "-e", HEALTH_PROBE[service]),
498
+ );
499
+ if (r.exitCode === 0) return { ok: true };
500
+ last = tail(r.stderr);
501
+ if (i < attempts - 1) await sleep(interval);
502
+ }
503
+ return {
504
+ ok: false,
505
+ error: `health check did not pass after ${attempts} attempts${last ? ` (${last})` : ""}`,
506
+ };
507
+ }
508
+
509
+ /** Put the previous Compose file back, restart both services on it, health-check them. */
510
+ async function rollback(
511
+ file: string,
512
+ previousCompose: string,
513
+ deps: SelfHostedUpgradeDeps,
514
+ ): Promise<boolean> {
515
+ deps.log.phase?.("rollback");
516
+ deps.log.warn("Rolling back to the previous images");
517
+ restoreCompose(file, previousCompose);
518
+ return (await startBoth(deps)).ok;
519
+ }
520
+
521
+ /** `up -d` both services on the current Compose file, then health-check each. */
522
+ async function startBoth(
523
+ deps: SelfHostedUpgradeDeps,
524
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
525
+ const up = await deps.runner.run(
526
+ compose(deps.caeloDir, "up", "-d", "--no-deps", COMPOSE_SERVICE.admin, COMPOSE_SERVICE.gateway),
527
+ );
528
+ if (up.exitCode !== 0)
529
+ return { ok: false, error: `docker compose up failed: ${tail(up.stderr)}` };
530
+ for (const service of RELEASE_SERVICES) {
531
+ const healthy = await waitHealthy(service, deps.caeloDir, deps);
532
+ if (!healthy.ok) return { ok: false, error: `${service}: ${healthy.error}` };
533
+ }
534
+ return { ok: true };
535
+ }
536
+
537
+ function restoreCompose(file: string, previous: string): void {
538
+ writeFileSync(file, previous, { mode: 0o600 });
539
+ }
540
+
541
+ function shortDigest(ref: string): string {
542
+ const at = ref.indexOf("@sha256:");
543
+ return at === -1 ? ref : ref.slice(at + 1, at + 20);
544
+ }
545
+
546
+ function tail(s: string): string {
547
+ return s.trim().split("\n").slice(-5).join("\n").slice(0, 800);
548
+ }