@caelo-cms/provisioning 0.10.47 → 0.10.48

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 (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,14 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { CAELO_VERSION } from "@caelo-cms/shared";
5
+ import { selfCommand } from "./self-command.js";
6
+
7
+ describe("selfCommand", () => {
8
+ it("pins every printed command to the running CLI's release (#696)", () => {
9
+ expect(selfCommand()).toBe(`bunx @caelo-cms/provisioning@${CAELO_VERSION}`);
10
+ expect(selfCommand("rotate-secret", "internal-secret", "--install", "example.com")).toBe(
11
+ `bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret internal-secret --install example.com`,
12
+ );
13
+ });
14
+ });
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { CAELO_VERSION, provisioningCommand } from "@caelo-cms/shared";
4
+
5
+ /**
6
+ * A command line for this CLI, pinned to the version that is running
7
+ * (#696): `bunx @caelo-cms/provisioning@<this version> <args>`. Hints the
8
+ * CLI prints — resume the wizard, rotate a secret, check status — then
9
+ * run the same release the operator is using now, instead of whatever
10
+ * copy `bunx` cached for the bare package name.
11
+ *
12
+ * Not for upgrades: an upgrade must run as the *target* release, so
13
+ * `status` and the admin's Settings → Updates page build that command
14
+ * with the target version (`provisioningUpgradeCommand` in @caelo-cms/shared).
15
+ *
16
+ * @example selfCommand("rotate-secret", "internal-secret")
17
+ * // → "bunx @caelo-cms/provisioning@0.10.43 rotate-secret internal-secret"
18
+ */
19
+ export function selfCommand(...args: string[]): string {
20
+ return provisioningCommand(CAELO_VERSION, args);
21
+ }
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ type ComposeSpec,
6
+ generateDockerCompose,
7
+ ghcrImageDigest,
8
+ ghcrImageRef,
9
+ } from "./compose.js";
10
+
11
+ const ADMIN = `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`;
12
+ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
13
+
14
+ const base: Omit<ComposeSpec, "images"> = {
15
+ domain: "example.com",
16
+ postgresPassword: "pgpw",
17
+ rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
18
+ minioRootUser: "caelo",
19
+ minioRootPassword: "miniopw",
20
+ caeloSecretKek: "0".repeat(64),
21
+ anthropicApiKey: "sk-ant-x",
22
+ diskSize: "20Gi",
23
+ };
24
+
25
+ /** The block of one Compose service, up to the next blank line. */
26
+ function service(yaml: string, name: string): string {
27
+ const start = yaml.indexOf(` ${name}:\n`);
28
+ const end = yaml.indexOf("\n\n", start);
29
+ return yaml.slice(start, end === -1 ? undefined : end);
30
+ }
31
+
32
+ describe("self-hosted Compose — release images (#696)", () => {
33
+ const yaml = generateDockerCompose({
34
+ ...base,
35
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
36
+ });
37
+
38
+ it("runs the admin + gateway from the digest-pinned GHCR release images", () => {
39
+ expect(service(yaml, "caelo-admin")).toContain(`image: ${ADMIN}`);
40
+ expect(service(yaml, "caelo-gateway")).toContain(`image: ${GATEWAY}`);
41
+ });
42
+
43
+ it("mounts no source checkout and runs no build tooling", () => {
44
+ expect(yaml).not.toContain("../..");
45
+ expect(yaml).not.toContain("oven/bun");
46
+ expect(yaml).not.toContain("working_dir");
47
+ expect(yaml).not.toMatch(/^\s+command: bun/m);
48
+ expect(yaml).not.toContain("DEV MODE");
49
+ });
50
+
51
+ it("mounts the bootstrap-token, site output + media dirs, never .caelo/ with its secrets", () => {
52
+ const admin = service(yaml, "caelo-admin");
53
+ expect(admin).toContain(
54
+ " volumes:\n - ./bootstrap:/app/.caelo\n - ./output:/srv/caelo\n - ./media:/data/media",
55
+ );
56
+ expect(yaml).not.toMatch(/- \.\/?:/);
57
+ expect(service(yaml, "caelo-gateway")).not.toContain("volumes:");
58
+ expect(admin).toContain('CAELO_OUTPUT_ROOT: "/srv/caelo"');
59
+ expect(admin).toContain('MEDIA_ROOT_DIR: "/data/media"');
60
+ expect(service(yaml, "caddy")).toContain("- ./output:/srv/caelo:ro");
61
+ });
62
+
63
+ it("keeps the env + secret wiring of each service", () => {
64
+ const admin = service(yaml, "caelo-admin");
65
+ expect(admin).toContain(
66
+ 'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin"',
67
+ );
68
+ expect(admin).toContain(
69
+ 'PUBLIC_ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_public"',
70
+ );
71
+ expect(admin).toContain(`CAELO_SECRET_KEK: "${"0".repeat(64)}"`);
72
+ expect(admin).toContain('CAELO_SITE_URL: "https://example.com"');
73
+ expect(admin).toContain('PORT: "5173"');
74
+ expect(admin).toContain('ANTHROPIC_API_KEY: "sk-ant-x"');
75
+ const gateway = service(yaml, "caelo-gateway");
76
+ expect(gateway).toContain('GATEWAY_PORT: "8090"');
77
+ expect(gateway).not.toMatch(/ADMIN_DATABASE|adminpw|CAELO_SECRET_KEK/);
78
+ });
79
+
80
+ it("keeps Caddy and Postgres as they were", () => {
81
+ expect(service(yaml, "caddy")).toContain("image: caddy:2-alpine");
82
+ expect(service(yaml, "postgres")).toContain("image: postgres:16-alpine");
83
+ });
84
+
85
+ it("refuses a tag, a foreign repository or a malformed digest", () => {
86
+ for (const admin of [
87
+ "ghcr.io/caelo-cms/admin:latest",
88
+ `ghcr.io/evil/admin@sha256:${"a".repeat(64)}`,
89
+ "ghcr.io/caelo-cms/admin@sha256:abc",
90
+ GATEWAY,
91
+ ]) {
92
+ expect(() =>
93
+ generateDockerCompose({ ...base, images: { mode: "release", admin, gateway: GATEWAY } }),
94
+ ).toThrow(/is not ghcr\.io\/caelo-cms\/admin@sha256/);
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("self-hosted Compose — dev mode", () => {
100
+ const yaml = generateDockerCompose({ ...base, images: { mode: "dev" } });
101
+
102
+ it("bind-mounts the checkout, clearly labelled as contributor-only", () => {
103
+ for (const name of ["caelo-admin", "caelo-gateway"]) {
104
+ const block = service(yaml, name);
105
+ expect(block).toContain("DEV MODE");
106
+ expect(block).toContain("- ../..:/app");
107
+ expect(block).not.toMatch(/command:.*\n\s+- /);
108
+ expect(block).not.toContain("ghcr.io");
109
+ }
110
+ });
111
+
112
+ it("also mounts the bootstrap-token dir into the admin", () => {
113
+ expect(service(yaml, "caelo-admin")).toContain(
114
+ " volumes:\n - ../..:/app\n - ./bootstrap:/app/.caelo",
115
+ );
116
+ });
117
+
118
+ it("keeps the same env wiring as release mode", () => {
119
+ const release = generateDockerCompose({
120
+ ...base,
121
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
122
+ });
123
+ const envOf = (y: string, name: string) =>
124
+ service(y, name).slice(service(y, name).indexOf("environment:"));
125
+ for (const name of ["caelo-admin", "caelo-gateway"]) {
126
+ expect(envOf(yaml, name)).toBe(envOf(release, name));
127
+ }
128
+ });
129
+ });
130
+
131
+ describe("release image references", () => {
132
+ it("round-trips a digest", () => {
133
+ const digest = `sha256:${"c".repeat(64)}`;
134
+ expect(ghcrImageDigest("gateway", ghcrImageRef("gateway", digest))).toBe(digest);
135
+ });
136
+
137
+ it("refuses to build a reference from a non-digest", () => {
138
+ expect(() => ghcrImageRef("admin", "latest")).toThrow(/not an image digest/);
139
+ });
140
+ });
141
+
142
+ describe("self-hosted Compose — the host update agent (#696 Phase 4c)", () => {
143
+ const images = { mode: "release", admin: ADMIN, gateway: GATEWAY } as const;
144
+
145
+ it("publishes no database port without the agent, and tells the admin its provider", () => {
146
+ const yaml = generateDockerCompose({ ...base, images });
147
+ expect(service(yaml, "postgres")).not.toContain("ports:");
148
+ expect(yaml).not.toContain("AGENT_ROLE_PASSWORD");
149
+ expect(service(yaml, "caelo-admin")).toContain('CAELO_PROVIDER: "self-hosted"');
150
+ });
151
+
152
+ it("publishes Postgres for the agent on the loopback interface only", () => {
153
+ const yaml = generateDockerCompose({
154
+ ...base,
155
+ images,
156
+ agent: { dbPort: 15432, dbPassword: 'ag"pw' },
157
+ });
158
+ const pg = service(yaml, "postgres");
159
+ expect(pg).toContain(' ports: ["127.0.0.1:15432:5432"]');
160
+ expect(pg).toContain('AGENT_ROLE_PASSWORD: "ag\\"pw"');
161
+ // The only published ports: Caddy's, and the agent's loopback one.
162
+ expect(yaml.match(/ports:/g)?.length).toBe(2);
163
+ expect(yaml).not.toMatch(/"(0\.0\.0\.0:)?15432:5432"/);
164
+ // Only Postgres sees the agent's password.
165
+ expect(service(yaml, "caelo-admin")).not.toContain('ag\\"pw');
166
+ expect(service(yaml, "caelo-gateway")).not.toContain("AGENT");
167
+ });
168
+
169
+ it("refuses a privileged or invalid port", () => {
170
+ expect(() =>
171
+ generateDockerCompose({ ...base, images, agent: { dbPort: 5432.5, dbPassword: "x" } }),
172
+ ).toThrow();
173
+ expect(() =>
174
+ generateDockerCompose({ ...base, images, agent: { dbPort: 443, dbPassword: "x" } }),
175
+ ).toThrow(/between 1024 and 65535/);
176
+ });
177
+ });
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #696 Phase 4 — which Caelo release a self-hosted install runs, and how a
5
+ * release becomes a pair of verified image digests.
6
+ *
7
+ * `.caelo/release.json` is the install metadata for self-hosted (the cloud
8
+ * providers keep theirs in `~/.caelo-<id>/install.json`): the release
9
+ * version, the digest-pinned admin + gateway image references the Compose
10
+ * file runs, and a short history of installs/upgrades/rollbacks. It holds no
11
+ * secrets (config.json does), so it is safe to read from tooling and
12
+ * backups; writes refuse secret material like install.json's do.
13
+ *
14
+ * Every image reference that reaches the Compose file comes through
15
+ * {@link resolveVerifiedRelease}: the tag is resolved to a digest in GHCR
16
+ * (anonymously) and that digest — not the tag — is cosign-verified against
17
+ * the Caelo release workflow identity. `init` and `upgrade` share it.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { assertNoSecrets, writeJsonAtomic } from "./checkpoint-secrets.js";
23
+ import { ghcrImageDigest, ghcrImageRef, type ReleaseImages } from "./compose.js";
24
+ import { selfCommand } from "./self-command.js";
25
+ import {
26
+ cosignVerifyArgv,
27
+ GHCR_NAMESPACE,
28
+ GHCR_REGISTRY,
29
+ RELEASE_SERVICES,
30
+ resolveRegistryDigest,
31
+ } from "./upgrade/release-images.js";
32
+
33
+ /** What a host command returned. */
34
+ export interface CommandResult {
35
+ readonly exitCode: number;
36
+ readonly stdout: string;
37
+ readonly stderr: string;
38
+ }
39
+
40
+ /**
41
+ * Runs `docker` / `cosign` on the install's host. A seam so the install and
42
+ * upgrade orchestration is unit-testable with a fake (no real containers).
43
+ */
44
+ export interface HostRunner {
45
+ /**
46
+ * Run `argv` to completion. With `stdoutPath`, stdout streams into that
47
+ * file (mode 600) instead of being captured — used for database dumps.
48
+ */
49
+ run(argv: readonly string[], opts?: { readonly stdoutPath?: string }): Promise<CommandResult>;
50
+ }
51
+
52
+ /** The real runner: Bun.spawn; a missing binary is exit code 127, not a throw. */
53
+ export const bunHostRunner: HostRunner = {
54
+ async run(argv, opts) {
55
+ try {
56
+ const proc = Bun.spawn([...argv], {
57
+ stdout: opts?.stdoutPath ? Bun.file(opts.stdoutPath) : "pipe",
58
+ stderr: "pipe",
59
+ });
60
+ const stdout = opts?.stdoutPath
61
+ ? ""
62
+ : await new Response(proc.stdout as ReadableStream).text();
63
+ const stderr = await new Response(proc.stderr).text();
64
+ return { exitCode: await proc.exited, stdout, stderr };
65
+ } catch (e) {
66
+ return { exitCode: 127, stdout: "", stderr: e instanceof Error ? e.message : String(e) };
67
+ }
68
+ },
69
+ };
70
+
71
+ /** One install / upgrade / rollback of a self-hosted install. */
72
+ export interface ReleaseHistoryEntry {
73
+ readonly at: string;
74
+ readonly from: string | null;
75
+ readonly to: string;
76
+ readonly outcome: "installed" | "upgraded" | "rolled-back";
77
+ readonly detail?: string;
78
+ }
79
+
80
+ /** `.caelo/release.json`. */
81
+ export interface SelfHostedRelease {
82
+ /** The release tag the images were resolved from (e.g. `0.10.43`). */
83
+ readonly version: string;
84
+ readonly images: { readonly admin: string; readonly gateway: string };
85
+ readonly installedAt: string;
86
+ /** Newest last; capped at {@link HISTORY_LIMIT}. */
87
+ readonly history: readonly ReleaseHistoryEntry[];
88
+ }
89
+
90
+ const HISTORY_LIMIT = 20;
91
+
92
+ export function releasePath(caeloDir: string): string {
93
+ return join(caeloDir, "release.json");
94
+ }
95
+
96
+ /** The images of a recorded release, as the Compose generator takes them. */
97
+ export function releaseImages(release: SelfHostedRelease): ReleaseImages {
98
+ return { mode: "release", admin: release.images.admin, gateway: release.images.gateway };
99
+ }
100
+
101
+ /**
102
+ * The install's recorded release, or null when none is recorded (a dev-mode
103
+ * install, or one created before #696 that still runs the repo bind mount).
104
+ * Throws on a malformed record rather than running a guessed image.
105
+ */
106
+ export function readRelease(caeloDir: string): SelfHostedRelease | null {
107
+ const path = releasePath(caeloDir);
108
+ if (!existsSync(path)) return null;
109
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<SelfHostedRelease>;
110
+ const admin = raw.images?.admin ?? "";
111
+ const gateway = raw.images?.gateway ?? "";
112
+ if (
113
+ typeof raw.version !== "string" ||
114
+ !ghcrImageDigest("admin", admin) ||
115
+ !ghcrImageDigest("gateway", gateway)
116
+ ) {
117
+ throw new Error(
118
+ `${path} is malformed (needs version + digest-pinned ghcr.io/caelo-cms images); fix it or re-run \`${selfCommand("upgrade", "--version", "<x.y.z>")}\`.`,
119
+ );
120
+ }
121
+ return {
122
+ version: raw.version,
123
+ images: { admin, gateway },
124
+ installedAt: raw.installedAt ?? "",
125
+ history: Array.isArray(raw.history) ? raw.history : [],
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Record that the install now runs `images` of `version` (or, for a
131
+ * rollback, keeps running the previous ones), appending to the history.
132
+ */
133
+ export function recordRelease(
134
+ caeloDir: string,
135
+ next: { version: string; images: ReleaseImages },
136
+ entry: Omit<ReleaseHistoryEntry, "at">,
137
+ now: Date = new Date(),
138
+ ): SelfHostedRelease {
139
+ const prior = readRelease(caeloDir);
140
+ const at = now.toISOString();
141
+ const record: SelfHostedRelease = {
142
+ version: next.version,
143
+ images: { admin: next.images.admin, gateway: next.images.gateway },
144
+ installedAt:
145
+ prior && prior.version === next.version && entry.outcome === "rolled-back"
146
+ ? prior.installedAt
147
+ : at,
148
+ history: [...(prior?.history ?? []), { at, ...entry }].slice(-HISTORY_LIMIT),
149
+ };
150
+ assertNoSecrets(record, "release.json");
151
+ writeJsonAtomic(releasePath(caeloDir), record);
152
+ return record;
153
+ }
154
+
155
+ /** Record a failed-and-rolled-back upgrade without changing the running release. */
156
+ export function recordRollback(
157
+ caeloDir: string,
158
+ current: SelfHostedRelease,
159
+ attempted: string,
160
+ detail: string,
161
+ now: Date = new Date(),
162
+ ): void {
163
+ recordRelease(
164
+ caeloDir,
165
+ { version: current.version, images: releaseImages(current) },
166
+ { from: current.version, to: attempted, outcome: "rolled-back", detail },
167
+ now,
168
+ );
169
+ }
170
+
171
+ export type VerifiedRelease =
172
+ | { readonly ok: true; readonly images: ReleaseImages }
173
+ | { readonly ok: false; readonly error: string };
174
+
175
+ /**
176
+ * Resolve the admin + gateway images of release `tag` in GHCR to digests,
177
+ * then cosign-verify each digest against the Caelo release workflow
178
+ * identity (unless `skipVerify`, the escape hatch for forks). The returned
179
+ * references are digest-pinned, so what was verified is exactly what runs.
180
+ */
181
+ export async function resolveVerifiedRelease(
182
+ tag: string,
183
+ deps: {
184
+ readonly runner: HostRunner;
185
+ readonly skipVerify?: boolean;
186
+ readonly fetchImpl?: typeof fetch;
187
+ readonly onProgress?: (message: string) => void;
188
+ },
189
+ ): Promise<VerifiedRelease> {
190
+ const refs: Partial<Record<"admin" | "gateway", string>> = {};
191
+ for (const service of RELEASE_SERVICES) {
192
+ deps.onProgress?.(`Resolving ${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}:${tag}`);
193
+ const r = await resolveRegistryDigest(
194
+ GHCR_REGISTRY,
195
+ `${GHCR_NAMESPACE}/${service}`,
196
+ tag,
197
+ deps.fetchImpl,
198
+ );
199
+ if (!r.ok) {
200
+ return {
201
+ ok: false,
202
+ error:
203
+ `Could not resolve ${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}:${tag} (${r.reason}).\n` +
204
+ "Check the version exists at https://github.com/caelo-cms/caelo-cms/releases and pass it as --version x.y.z.",
205
+ };
206
+ }
207
+ refs[service] = ghcrImageRef(service, r.digest);
208
+ }
209
+ const images: ReleaseImages = {
210
+ mode: "release",
211
+ admin: refs.admin ?? "",
212
+ gateway: refs.gateway ?? "",
213
+ };
214
+ if (deps.skipVerify) return { ok: true, images };
215
+
216
+ const probe = await deps.runner.run(["cosign", "version"]);
217
+ if (probe.exitCode !== 0) {
218
+ return {
219
+ ok: false,
220
+ error:
221
+ "cosign is required to verify the Caelo release image signatures and was not found on PATH.\n" +
222
+ "Install it (https://docs.sigstore.dev/cosign/system_config/installation/ — e.g. `brew install cosign`,\n" +
223
+ "or the release binary for Linux), then re-run. --skip-verify skips the check (NOT for production).",
224
+ };
225
+ }
226
+ for (const service of RELEASE_SERVICES) {
227
+ deps.onProgress?.(`Verifying the signature of ${service}`);
228
+ const v = await deps.runner.run(cosignVerifyArgv(images[service]));
229
+ if (v.exitCode !== 0) {
230
+ return {
231
+ ok: false,
232
+ error:
233
+ `cosign verify FAILED for ${images[service]}: its signature does not match the Caelo release workflow.\n` +
234
+ "Either the registry was compromised, the image is a fork's, or sigstore had an outage (retry).\n" +
235
+ `cosign: ${v.stderr.trim().slice(0, 500)}`,
236
+ };
237
+ }
238
+ }
239
+ return { ok: true, images };
240
+ }
@@ -0,0 +1,161 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #613 review — `cms-provision up` on a self-hosted volume created before
5
+ * the per-role passwords: no `gateway_role`, `public_role` on another
6
+ * password. The convergence script, run as the superuser exactly as `up`
7
+ * runs it (psql, script on stdin), must leave the roles on the configured
8
+ * passwords with the gateway's grants in place — and be safe to re-run.
9
+ *
10
+ * Runs against the test database with the passwords the environment already
11
+ * uses, so the other suites' connection URLs keep working afterwards.
12
+ */
13
+
14
+ import { describe, expect, it } from "bun:test";
15
+ import { SQL } from "bun";
16
+ import {
17
+ convergeSelfHostedRoles,
18
+ type SuperuserPsql,
19
+ selfHostedBootstrapScript,
20
+ } from "./self-hosted-roles.js";
21
+
22
+ const env = process.env;
23
+ const required = [
24
+ "POSTGRES_USER",
25
+ "POSTGRES_PORT",
26
+ "ADMIN_ROLE_PASSWORD",
27
+ "PUBLIC_ROLE_PASSWORD",
28
+ "GATEWAY_ROLE_PASSWORD",
29
+ "ADMIN_DATABASE_URL",
30
+ "GATEWAY_DATABASE_URL",
31
+ ] as const;
32
+ const missing = required.filter((name) => !env[name]);
33
+ if (missing.length > 0) throw new Error(`${missing.join(", ")} required (see .env.example)`);
34
+
35
+ /** psql as the superuser — the local equivalent of `docker compose exec postgres psql`. */
36
+ const psql: SuperuserPsql = async (script) => {
37
+ const proc = Bun.spawn(
38
+ [
39
+ "psql",
40
+ "-h",
41
+ env.PGHOST ?? "localhost",
42
+ "-p",
43
+ env.POSTGRES_PORT as string,
44
+ "-U",
45
+ env.POSTGRES_USER as string,
46
+ "-d",
47
+ "postgres",
48
+ "-q",
49
+ "-f",
50
+ "-",
51
+ ],
52
+ {
53
+ stdin: new TextEncoder().encode(script),
54
+ stdout: "ignore",
55
+ stderr: "pipe",
56
+ env: { ...env, ...(env.POSTGRES_PASSWORD ? { PGPASSWORD: env.POSTGRES_PASSWORD } : {}) },
57
+ },
58
+ );
59
+ const stderr = await new Response(proc.stderr).text();
60
+ return { exitCode: await proc.exited, stderr };
61
+ };
62
+
63
+ const passwords = {
64
+ admin: env.ADMIN_ROLE_PASSWORD as string,
65
+ public: env.PUBLIC_ROLE_PASSWORD as string,
66
+ gateway: env.GATEWAY_ROLE_PASSWORD as string,
67
+ };
68
+
69
+ describe("self-hosted role convergence against Postgres (#613 review)", () => {
70
+ it("brings a pre-#613 volume to the configured roles, grants and passwords — twice", async () => {
71
+ // The pre-#613 volume: no gateway_role, public_role on another password.
72
+ const reset = await psql(
73
+ [
74
+ "\\set ON_ERROR_STOP on",
75
+ "\\connect cms_admin",
76
+ "DROP OWNED BY gateway_role;",
77
+ "\\connect postgres",
78
+ "DROP ROLE gateway_role;",
79
+ "ALTER ROLE public_role WITH PASSWORD 'the-old-shared-password';",
80
+ "",
81
+ ].join("\n"),
82
+ );
83
+ expect(reset).toEqual({ exitCode: 0, stderr: "" });
84
+
85
+ for (let run = 0; run < 2; run++) {
86
+ const r = await convergeSelfHostedRoles(passwords, { probe: async () => true, psql });
87
+ expect(r).toEqual({ ok: true });
88
+ }
89
+
90
+ // The gateway logs in with its configured password and reads what 0248 grants.
91
+ const gateway = new SQL(env.GATEWAY_DATABASE_URL as string);
92
+ try {
93
+ const rows = (await gateway.begin(async (tx) => {
94
+ await tx.unsafe("SET LOCAL caelo.actor_kind = 'system'");
95
+ return tx`SELECT current_user::text AS u, length(gateway_cookie_secret) > 0 AS s FROM site_settings`;
96
+ })) as { u: string; s: boolean }[];
97
+ expect(rows[0]).toEqual({ u: "gateway_role", s: true });
98
+ } finally {
99
+ await gateway.end();
100
+ }
101
+ const admin = new SQL(env.ADMIN_DATABASE_URL as string);
102
+ try {
103
+ const [attrs] = (await admin`
104
+ SELECT rolcanlogin, rolinherit FROM pg_roles WHERE rolname = 'gateway_role'`) as {
105
+ rolcanlogin: boolean;
106
+ rolinherit: boolean;
107
+ }[];
108
+ expect(attrs).toEqual({ rolcanlogin: true, rolinherit: false });
109
+ const policies = (await admin`
110
+ SELECT policyname::text AS p FROM pg_policies WHERE 'gateway_role' = ANY (roles) ORDER BY 1`) as {
111
+ p: string;
112
+ }[];
113
+ expect(policies.map((p) => p.p)).toEqual([
114
+ "audit_events_gateway_plugin_only",
115
+ // #634 (migration 0252): the visitor paths' row limits. The A/B
116
+ // ones went with the experiment tables (#676, migration 0259).
117
+ "comment_archive_gateway_approved_only",
118
+ "rate_limit_buckets_gateway_keys",
119
+ ]);
120
+ } finally {
121
+ await admin.end();
122
+ }
123
+ });
124
+ });
125
+
126
+ describe("the install's bootstrap.sh (#696 Phase 4c)", () => {
127
+ it("runs as the Postgres entrypoint would and creates the agent's login, idempotently", async () => {
128
+ const script = selfHostedBootstrapScript({ agent: true });
129
+ for (let run = 0; run < 2; run++) {
130
+ const proc = Bun.spawn(["sh", "-c", script], {
131
+ stdout: "ignore",
132
+ stderr: "pipe",
133
+ env: {
134
+ ...env,
135
+ PGHOST: env.PGHOST ?? "localhost",
136
+ PGPORT: env.POSTGRES_PORT,
137
+ ...(env.POSTGRES_PASSWORD ? { PGPASSWORD: env.POSTGRES_PASSWORD } : {}),
138
+ AGENT_ROLE_PASSWORD: "bootstrap-agent-pw",
139
+ },
140
+ });
141
+ const stderr = await new Response(proc.stderr).text();
142
+ expect({ exitCode: await proc.exited, stderr }).toEqual({ exitCode: 0, stderr: "" });
143
+ }
144
+ const agent = new SQL(
145
+ `postgres://caelo_agent:bootstrap-agent-pw@${env.PGHOST ?? "localhost"}:${env.POSTGRES_PORT}/cms_admin`,
146
+ );
147
+ try {
148
+ const rows = (await agent.begin(async (tx) => {
149
+ await tx.unsafe("SET LOCAL caelo.actor_kind = 'system'");
150
+ return tx`SELECT current_user::text AS u, count(*)::int AS n FROM platform_update_policy`;
151
+ })) as { u: string; n: number }[];
152
+ expect(rows[0]).toEqual({ u: "caelo_agent", n: 1 });
153
+ } finally {
154
+ await agent.end();
155
+ }
156
+ const dropped = await psql(
157
+ "\\set ON_ERROR_STOP on\n\\connect cms_admin\nDROP OWNED BY caelo_agent;\n\\connect cms_public\nDROP OWNED BY caelo_agent;\n\\connect postgres\nDROP ROLE caelo_agent;\n",
158
+ );
159
+ expect(dropped).toEqual({ exitCode: 0, stderr: "" });
160
+ });
161
+ });