@caelo-cms/provisioning 0.10.61 → 0.10.64

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 (281) hide show
  1. package/caddy/Caddyfile.production +7 -0
  2. package/caddy/Caddyfile.staging +7 -0
  3. package/dist/admin-internal-answer.d.ts +12 -0
  4. package/dist/admin-internal-answer.d.ts.map +1 -0
  5. package/dist/admin-internal-answer.js +32 -0
  6. package/dist/admin-internal-answer.js.map +1 -0
  7. package/dist/admin-internal-google.d.ts +4 -0
  8. package/dist/admin-internal-google.d.ts.map +1 -0
  9. package/dist/admin-internal-google.js +161 -0
  10. package/dist/admin-internal-google.js.map +1 -0
  11. package/dist/admin-internal-self-hosted.d.ts +30 -0
  12. package/dist/admin-internal-self-hosted.d.ts.map +1 -0
  13. package/dist/admin-internal-self-hosted.js +92 -0
  14. package/dist/admin-internal-self-hosted.js.map +1 -0
  15. package/dist/admin-internal.d.ts +33 -12
  16. package/dist/admin-internal.d.ts.map +1 -1
  17. package/dist/admin-internal.js +27 -159
  18. package/dist/admin-internal.js.map +1 -1
  19. package/dist/agent/command.d.ts +7 -0
  20. package/dist/agent/command.d.ts.map +1 -1
  21. package/dist/agent/command.js +1 -0
  22. package/dist/agent/command.js.map +1 -1
  23. package/dist/agent/lock.d.ts +13 -1
  24. package/dist/agent/lock.d.ts.map +1 -1
  25. package/dist/agent/lock.js +62 -5
  26. package/dist/agent/lock.js.map +1 -1
  27. package/dist/agent/run.d.ts +5 -1
  28. package/dist/agent/run.d.ts.map +1 -1
  29. package/dist/agent/run.js +8 -1
  30. package/dist/agent/run.js.map +1 -1
  31. package/dist/agent-db.js +192 -90
  32. package/dist/caddy.d.ts.map +1 -1
  33. package/dist/caddy.js +11 -1
  34. package/dist/caddy.js.map +1 -1
  35. package/dist/cli-routing.js +1 -1
  36. package/dist/cli-routing.js.map +1 -1
  37. package/dist/cli.js +95 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/compose.d.ts +7 -0
  40. package/dist/compose.d.ts.map +1 -1
  41. package/dist/compose.js +5 -0
  42. package/dist/compose.js.map +1 -1
  43. package/dist/converge-job-gcp.d.ts.map +1 -1
  44. package/dist/converge-job-gcp.js +10 -7
  45. package/dist/converge-job-gcp.js.map +1 -1
  46. package/dist/converge-job-preflight.d.ts +21 -0
  47. package/dist/converge-job-preflight.d.ts.map +1 -0
  48. package/dist/converge-job-preflight.js +111 -0
  49. package/dist/converge-job-preflight.js.map +1 -0
  50. package/dist/converge-job.d.ts +8 -2
  51. package/dist/converge-job.d.ts.map +1 -1
  52. package/dist/converge-job.js +14 -0
  53. package/dist/converge-job.js.map +1 -1
  54. package/dist/gateway-credentials.d.ts +5 -3
  55. package/dist/gateway-credentials.d.ts.map +1 -1
  56. package/dist/gateway-credentials.js +5 -8
  57. package/dist/gateway-credentials.js.map +1 -1
  58. package/dist/gcloud.d.ts +29 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +66 -0
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/infra-contract.d.ts +1 -1
  63. package/dist/infra-contract.d.ts.map +1 -1
  64. package/dist/infra-contract.js +10 -1
  65. package/dist/infra-contract.js.map +1 -1
  66. package/dist/install-job.d.ts +3 -1
  67. package/dist/install-job.d.ts.map +1 -1
  68. package/dist/install-job.js.map +1 -1
  69. package/dist/install-state.d.ts +10 -0
  70. package/dist/install-state.d.ts.map +1 -1
  71. package/dist/install-state.js +12 -0
  72. package/dist/install-state.js.map +1 -1
  73. package/dist/lifecycle.d.ts +12 -0
  74. package/dist/lifecycle.d.ts.map +1 -1
  75. package/dist/lifecycle.js +113 -28
  76. package/dist/lifecycle.js.map +1 -1
  77. package/dist/mcp-iap.d.ts +2 -0
  78. package/dist/mcp-iap.d.ts.map +1 -1
  79. package/dist/mcp-iap.js +50 -17
  80. package/dist/mcp-iap.js.map +1 -1
  81. package/dist/owner-reset.d.ts +16 -19
  82. package/dist/owner-reset.d.ts.map +1 -1
  83. package/dist/owner-reset.js +92 -19
  84. package/dist/owner-reset.js.map +1 -1
  85. package/dist/preview-plan.d.ts +71 -0
  86. package/dist/preview-plan.d.ts.map +1 -0
  87. package/dist/preview-plan.js +168 -0
  88. package/dist/preview-plan.js.map +1 -0
  89. package/dist/public-role-isolation.d.ts +2 -0
  90. package/dist/public-role-isolation.d.ts.map +1 -1
  91. package/dist/public-role-isolation.js +9 -3
  92. package/dist/public-role-isolation.js.map +1 -1
  93. package/dist/release-contract.d.ts.map +1 -1
  94. package/dist/release-contract.js +4 -0
  95. package/dist/release-contract.js.map +1 -1
  96. package/dist/self-hosted-internal-secret.d.ts +27 -0
  97. package/dist/self-hosted-internal-secret.d.ts.map +1 -0
  98. package/dist/self-hosted-internal-secret.js +69 -0
  99. package/dist/self-hosted-internal-secret.js.map +1 -0
  100. package/dist/self-hosted-release.d.ts +3 -1
  101. package/dist/self-hosted-release.d.ts.map +1 -1
  102. package/dist/self-hosted-release.js +6 -2
  103. package/dist/self-hosted-release.js.map +1 -1
  104. package/dist/self-hosted-upgrade.d.ts +13 -1
  105. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  106. package/dist/self-hosted-upgrade.js +26 -2
  107. package/dist/self-hosted-upgrade.js.map +1 -1
  108. package/dist/stack-converge.d.ts +5 -2
  109. package/dist/stack-converge.d.ts.map +1 -1
  110. package/dist/stack-converge.js +73 -8
  111. package/dist/stack-converge.js.map +1 -1
  112. package/dist/staging-admin.d.ts +4 -1
  113. package/dist/staging-admin.d.ts.map +1 -1
  114. package/dist/staging-admin.js.map +1 -1
  115. package/dist/state-backend.d.ts +3 -2
  116. package/dist/state-backend.d.ts.map +1 -1
  117. package/dist/state-backend.js +3 -2
  118. package/dist/state-backend.js.map +1 -1
  119. package/dist/state-command.d.ts +17 -0
  120. package/dist/state-command.d.ts.map +1 -1
  121. package/dist/state-command.js +34 -5
  122. package/dist/state-command.js.map +1 -1
  123. package/dist/state-migrate.d.ts +14 -3
  124. package/dist/state-migrate.d.ts.map +1 -1
  125. package/dist/state-migrate.js +31 -7
  126. package/dist/state-migrate.js.map +1 -1
  127. package/dist/state-pulumi.d.ts +0 -2
  128. package/dist/state-pulumi.d.ts.map +1 -1
  129. package/dist/state-pulumi.js +19 -20
  130. package/dist/state-pulumi.js.map +1 -1
  131. package/dist/static-security-headers.d.ts +86 -0
  132. package/dist/static-security-headers.d.ts.map +1 -0
  133. package/dist/static-security-headers.js +126 -0
  134. package/dist/static-security-headers.js.map +1 -0
  135. package/dist/static-svg-gcp.d.ts +28 -0
  136. package/dist/static-svg-gcp.d.ts.map +1 -0
  137. package/dist/static-svg-gcp.js +106 -0
  138. package/dist/static-svg-gcp.js.map +1 -0
  139. package/dist/upgrade/app-roll.d.ts +3 -0
  140. package/dist/upgrade/app-roll.d.ts.map +1 -1
  141. package/dist/upgrade/app-roll.js +12 -5
  142. package/dist/upgrade/app-roll.js.map +1 -1
  143. package/dist/upgrade/backup.d.ts +2 -0
  144. package/dist/upgrade/backup.d.ts.map +1 -1
  145. package/dist/upgrade/backup.js +5 -4
  146. package/dist/upgrade/backup.js.map +1 -1
  147. package/dist/upgrade/converge.d.ts +7 -0
  148. package/dist/upgrade/converge.d.ts.map +1 -1
  149. package/dist/upgrade/converge.js +19 -7
  150. package/dist/upgrade/converge.js.map +1 -1
  151. package/dist/upgrade/gcp-lookup.d.ts +38 -3
  152. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  153. package/dist/upgrade/gcp-lookup.js +31 -9
  154. package/dist/upgrade/gcp-lookup.js.map +1 -1
  155. package/dist/upgrade/health.d.ts +31 -5
  156. package/dist/upgrade/health.d.ts.map +1 -1
  157. package/dist/upgrade/health.js +114 -25
  158. package/dist/upgrade/health.js.map +1 -1
  159. package/dist/upgrade/release-images.d.ts +17 -0
  160. package/dist/upgrade/release-images.d.ts.map +1 -1
  161. package/dist/upgrade/release-images.js +23 -12
  162. package/dist/upgrade/release-images.js.map +1 -1
  163. package/dist/upgrade/reporter.d.ts +2 -0
  164. package/dist/upgrade/reporter.d.ts.map +1 -1
  165. package/dist/upgrade/reporter.js +1 -0
  166. package/dist/upgrade/reporter.js.map +1 -1
  167. package/dist/upgrade/upgrade.d.ts +11 -1
  168. package/dist/upgrade/upgrade.d.ts.map +1 -1
  169. package/dist/upgrade/upgrade.js +5 -2
  170. package/dist/upgrade/upgrade.js.map +1 -1
  171. package/dist/upgrader-gcp.d.ts +12 -2
  172. package/dist/upgrader-gcp.d.ts.map +1 -1
  173. package/dist/upgrader-gcp.js +22 -13
  174. package/dist/upgrader-gcp.js.map +1 -1
  175. package/dist/upgrader-iam-preflight.d.ts +16 -3
  176. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  177. package/dist/upgrader-iam-preflight.js +9 -4
  178. package/dist/upgrader-iam-preflight.js.map +1 -1
  179. package/dist/upgrader-job.d.ts.map +1 -1
  180. package/dist/upgrader-job.js +10 -8
  181. package/dist/upgrader-job.js.map +1 -1
  182. package/dist/upgrader-reporter.d.ts.map +1 -1
  183. package/dist/upgrader-reporter.js +1 -0
  184. package/dist/upgrader-reporter.js.map +1 -1
  185. package/dist/wizards/gcp-pulumi.d.ts +70 -23
  186. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  187. package/dist/wizards/gcp-pulumi.js +118 -39
  188. package/dist/wizards/gcp-pulumi.js.map +1 -1
  189. package/dist/wizards/gcp.d.ts.map +1 -1
  190. package/dist/wizards/gcp.js +67 -11
  191. package/dist/wizards/gcp.js.map +1 -1
  192. package/package.json +2 -2
  193. package/src/admin-internal-answer.ts +43 -0
  194. package/src/admin-internal-google.ts +187 -0
  195. package/src/admin-internal-self-hosted.test.ts +70 -0
  196. package/src/admin-internal-self-hosted.ts +128 -0
  197. package/src/admin-internal.ts +59 -174
  198. package/src/admin-memory.test.ts +1 -0
  199. package/src/agent/command.ts +3 -0
  200. package/src/agent/lock-config.test.ts +57 -2
  201. package/src/agent/lock.ts +68 -5
  202. package/src/agent/run.test.ts +18 -1
  203. package/src/agent/run.ts +17 -6
  204. package/src/caddy-redirects.test.ts +1 -0
  205. package/src/caddy.ts +12 -0
  206. package/src/cli-routing.ts +1 -1
  207. package/src/cli.ts +102 -44
  208. package/src/compose.ts +12 -0
  209. package/src/converge-job-gcp.ts +16 -11
  210. package/src/converge-job-preflight.test.ts +108 -0
  211. package/src/converge-job-preflight.ts +124 -0
  212. package/src/converge-job.test.ts +50 -0
  213. package/src/converge-job.ts +38 -2
  214. package/src/gateway-credentials.test.ts +4 -3
  215. package/src/gateway-credentials.ts +6 -10
  216. package/src/gateway-env-isolation.test.ts +1 -0
  217. package/src/gcloud.test.ts +93 -1
  218. package/src/gcloud.ts +82 -0
  219. package/src/index.test.ts +38 -1
  220. package/src/infra-contract.test.ts +4 -3
  221. package/src/infra-contract.ts +10 -1
  222. package/src/install-job.ts +5 -2
  223. package/src/install-state.ts +13 -0
  224. package/src/lifecycle.ts +134 -28
  225. package/src/mcp-iap.test.ts +66 -5
  226. package/src/mcp-iap.ts +55 -17
  227. package/src/owner-reset-providers.test.ts +370 -0
  228. package/src/owner-reset.test.ts +2 -2
  229. package/src/owner-reset.ts +108 -19
  230. package/src/preview-plan.test.ts +233 -0
  231. package/src/preview-plan.ts +226 -0
  232. package/src/public-role-isolation.test.ts +13 -2
  233. package/src/public-role-isolation.ts +14 -4
  234. package/src/release-contract.ts +4 -0
  235. package/src/self-hosted-compose.test.ts +25 -0
  236. package/src/self-hosted-internal-secret.test.ts +61 -0
  237. package/src/self-hosted-internal-secret.ts +80 -0
  238. package/src/self-hosted-release.ts +10 -3
  239. package/src/self-hosted-upgrade.test.ts +35 -1
  240. package/src/self-hosted-upgrade.ts +40 -3
  241. package/src/stack-contract.test.ts +1 -0
  242. package/src/stack-converge.test.ts +89 -5
  243. package/src/stack-converge.ts +81 -8
  244. package/src/staging-admin.test.ts +1 -1
  245. package/src/staging-admin.ts +2 -1
  246. package/src/state-backend.ts +3 -2
  247. package/src/state-command.test.ts +31 -0
  248. package/src/state-command.ts +35 -8
  249. package/src/state-migrate.test.ts +58 -7
  250. package/src/state-migrate.ts +48 -10
  251. package/src/state-pulumi.ts +20 -21
  252. package/src/static-security-headers.test.ts +124 -0
  253. package/src/static-security-headers.ts +154 -0
  254. package/src/static-svg-gcp.test.ts +133 -0
  255. package/src/static-svg-gcp.ts +140 -0
  256. package/src/upgrade/app-roll.ts +18 -7
  257. package/src/upgrade/backup.ts +14 -5
  258. package/src/upgrade/converge.ts +25 -7
  259. package/src/upgrade/gcp-lookup.test.ts +80 -1
  260. package/src/upgrade/gcp-lookup.ts +61 -11
  261. package/src/upgrade/health.test.ts +132 -8
  262. package/src/upgrade/health.ts +169 -31
  263. package/src/upgrade/release-images.test.ts +17 -0
  264. package/src/upgrade/release-images.ts +26 -14
  265. package/src/upgrade/reporter.ts +3 -0
  266. package/src/upgrade/upgrade.test.ts +137 -8
  267. package/src/upgrade/upgrade.ts +14 -2
  268. package/src/upgrader-gcp.ts +28 -13
  269. package/src/upgrader-iam-preflight.test.ts +6 -3
  270. package/src/upgrader-iam-preflight.ts +26 -5
  271. package/src/upgrader-job.ts +14 -6
  272. package/src/upgrader-reporter.ts +1 -0
  273. package/src/wizards/gcp-pulumi.test.ts +1 -1
  274. package/src/wizards/gcp-pulumi.ts +147 -38
  275. package/src/wizards/gcp.ts +89 -9
  276. package/src/wizards/stack-work-dir.test.ts +175 -0
  277. package/stacks/aws/index.ts +40 -0
  278. package/stacks/azure/index.ts +22 -1
  279. package/stacks/gcp/README.md +4 -0
  280. package/stacks/gcp/index.ts +6 -2
  281. package/stacks/gcp/Pulumi.production.yaml +0 -16
@@ -3,11 +3,24 @@
3
3
  /** The install's upgrade lock (agent/lock.ts) and the agent's settings (agent/config.ts). */
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
6
- import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
6
+ import {
7
+ chmodSync,
8
+ existsSync,
9
+ mkdtempSync,
10
+ readdirSync,
11
+ rmSync,
12
+ utimesSync,
13
+ writeFileSync,
14
+ } from "node:fs";
7
15
  import { tmpdir } from "node:os";
8
16
  import { join } from "node:path";
9
17
  import { AGENT_DB_PORT_DEFAULT, agentConfigFor, agentDbConnection } from "./config.js";
10
- import { acquireUpgradeLock, upgradeLockPath } from "./lock.js";
18
+ import {
19
+ acquireUpgradeLock,
20
+ UpgradeLockAccessError,
21
+ UpgradeLockHeldError,
22
+ upgradeLockPath,
23
+ } from "./lock.js";
11
24
 
12
25
  describe("acquireUpgradeLock", () => {
13
26
  let dir: string;
@@ -48,6 +61,48 @@ describe("acquireUpgradeLock", () => {
48
61
  acquireUpgradeLock(dir, "the update agent")();
49
62
  expect(readdirSync(dir)).toEqual([]);
50
63
  });
64
+
65
+ it("contention is an UpgradeLockHeldError (#804)", () => {
66
+ acquireUpgradeLock(dir, "the update agent", { pid: 101, alive: () => true });
67
+ expect(() => acquireUpgradeLock(dir, "x", { pid: 102, alive: () => true })).toThrow(
68
+ UpgradeLockHeldError,
69
+ );
70
+ writeFileSync(upgradeLockPath(dir), "{not json");
71
+ expect(() => acquireUpgradeLock(dir, "x")).toThrow(UpgradeLockHeldError);
72
+ });
73
+
74
+ // Root ignores permission bits, so the EACCES case only runs as a normal user.
75
+ it.skipIf(process.getuid?.() === 0)(
76
+ "a directory it cannot write is an access error naming the lock file, not busy (#804)",
77
+ () => {
78
+ chmodSync(dir, 0o500);
79
+ try {
80
+ let thrown: unknown;
81
+ try {
82
+ acquireUpgradeLock(dir, "the update agent");
83
+ } catch (e) {
84
+ thrown = e;
85
+ }
86
+ expect(thrown).toBeInstanceOf(UpgradeLockAccessError);
87
+ expect((thrown as Error).message).toContain(upgradeLockPath(dir));
88
+ expect((thrown as Error).message).toContain("write access");
89
+ expect((thrown as Error).message).not.toMatch(/running on this install|try again when/i);
90
+ } finally {
91
+ chmodSync(dir, 0o700);
92
+ }
93
+ },
94
+ );
95
+
96
+ it("a missing install directory is an access error, not busy (#804)", () => {
97
+ let thrown: unknown;
98
+ try {
99
+ acquireUpgradeLock(join(dir, "gone"), "the update agent");
100
+ } catch (e) {
101
+ thrown = e;
102
+ }
103
+ expect(thrown).toBeInstanceOf(UpgradeLockAccessError);
104
+ expect((thrown as Error).message).toContain("does not exist");
105
+ });
51
106
  });
52
107
 
53
108
  describe("agentConfigFor", () => {
package/src/agent/lock.ts CHANGED
@@ -13,6 +13,13 @@
13
13
  * into place, so the lock file never exists half-written: a reader that
14
14
  * finds it can always trust its pid. (An unreadable file is only taken over
15
15
  * once it is older than any run could be.)
16
+ *
17
+ * Only real contention throws {@link UpgradeLockHeldError} (#804): a live
18
+ * holder, a young unreadable record, a creation race. Anything else — the
19
+ * directory is not writable, the disk is full — throws
20
+ * {@link UpgradeLockAccessError} naming the lock file and the fix, so
21
+ * callers can tell "busy, try later" from "broken, fix the server" (the GCP
22
+ * upgrader's StateLockHeldError, #799).
16
23
  */
17
24
 
18
25
  import { linkSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -23,6 +30,36 @@ export function upgradeLockPath(caeloDir: string): string {
23
30
  return join(caeloDir, "upgrade.lock");
24
31
  }
25
32
 
33
+ /** Another upgrade holds the lock: the one failure that waiting fixes. */
34
+ export class UpgradeLockHeldError extends Error {
35
+ override readonly name = "UpgradeLockHeldError";
36
+ }
37
+
38
+ /**
39
+ * The lock could not be taken for a reason waiting does not fix (no write
40
+ * access, a full disk); the message names the lock file and the fix.
41
+ */
42
+ export class UpgradeLockAccessError extends Error {
43
+ override readonly name = "UpgradeLockAccessError";
44
+ }
45
+
46
+ /** {@link UpgradeLockAccessError}'s message for filesystem error `e`. */
47
+ function lockAccessFailure(caeloDir: string, e: unknown): string {
48
+ const path = upgradeLockPath(caeloDir);
49
+ const code = (e as NodeJS.ErrnoException | null)?.code;
50
+ const said = e instanceof Error ? e.message : String(e);
51
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
52
+ return `the upgrade lock ${path} cannot be written (${said}). Give the account the update runs as write access to ${caeloDir} (the agent runs as root through its systemd unit), then try again.`;
53
+ }
54
+ if (code === "ENOSPC" || code === "EDQUOT") {
55
+ return `the upgrade lock ${path} cannot be written because the disk is full (${said}). Free space on the server, then try again.`;
56
+ }
57
+ if (code === "ENOENT") {
58
+ return `the upgrade lock ${path} cannot be created: ${caeloDir} does not exist (${said}). Run the command from the install's directory, or re-run the installer to restore it.`;
59
+ }
60
+ return `the upgrade lock ${path} could not be taken (${said}). Check that ${caeloDir} is writable and the disk has space, then try again.`;
61
+ }
62
+
26
63
  interface LockRecord {
27
64
  readonly pid: number;
28
65
  readonly holder: string;
@@ -56,12 +93,27 @@ function processAlive(pid: number): boolean {
56
93
  * Take the install's upgrade lock for `holder`.
57
94
  *
58
95
  * @returns the release function (idempotent)
59
- * @throws when a live process holds it, naming that holder
96
+ * @throws UpgradeLockHeldError when another upgrade holds it, naming that holder
97
+ * @throws UpgradeLockAccessError for anything else (permissions, disk space)
60
98
  */
61
99
  export function acquireUpgradeLock(
62
100
  caeloDir: string,
63
101
  holder: string,
64
102
  deps: { readonly pid?: number; readonly alive?: (pid: number) => boolean } = {},
103
+ ): () => void {
104
+ try {
105
+ return takeUpgradeLock(caeloDir, holder, deps);
106
+ } catch (e) {
107
+ if (e instanceof UpgradeLockHeldError) throw e;
108
+ throw new UpgradeLockAccessError(lockAccessFailure(caeloDir, e), { cause: e });
109
+ }
110
+ }
111
+
112
+ /** {@link acquireUpgradeLock} without the classification of its errors. */
113
+ function takeUpgradeLock(
114
+ caeloDir: string,
115
+ holder: string,
116
+ deps: { readonly pid?: number; readonly alive?: (pid: number) => boolean } = {},
65
117
  ): () => void {
66
118
  const path = upgradeLockPath(caeloDir);
67
119
  const alive = deps.alive ?? processAlive;
@@ -86,18 +138,29 @@ export function acquireUpgradeLock(
86
138
  } catch (e) {
87
139
  if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
88
140
  }
141
+ let text: string;
142
+ try {
143
+ text = readFileSync(path, "utf8");
144
+ } catch (e) {
145
+ // Released between the link and the read: try again. A file this
146
+ // account may not read is an access problem, not a busy lock.
147
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
148
+ throw e;
149
+ }
89
150
  let held: Partial<LockRecord> | null = null;
90
151
  try {
91
- held = JSON.parse(readFileSync(path, "utf8")) as Partial<LockRecord>;
152
+ held = JSON.parse(text) as Partial<LockRecord>;
92
153
  } catch {
93
154
  // Corrupt (never half-written, see above): take it over only when old.
94
155
  }
95
156
  if (held === null) {
96
157
  if (Date.now() - lockMtime(path) < UNREADABLE_LOCK_MAX_AGE_MS) {
97
- throw new Error(`another upgrade holds ${path} (its record is unreadable).`);
158
+ throw new UpgradeLockHeldError(
159
+ `another upgrade holds ${path} (its record is unreadable).`,
160
+ );
98
161
  }
99
162
  } else if (typeof held.pid === "number" && alive(held.pid)) {
100
- throw new Error(
163
+ throw new UpgradeLockHeldError(
101
164
  `${held.holder ?? "another upgrade"} is running on this install (pid ${held.pid}, since ${held.at ?? "?"}).`,
102
165
  );
103
166
  }
@@ -106,5 +169,5 @@ export function acquireUpgradeLock(
106
169
  } finally {
107
170
  rmSync(temp, { force: true });
108
171
  }
109
- throw new Error(`could not take ${path}; another process keeps creating it.`);
172
+ throw new UpgradeLockHeldError(`could not take ${path}; another process keeps creating it.`);
110
173
  }
@@ -12,6 +12,7 @@ import type { StoredUpdatePolicy } from "@caelo-cms/shared";
12
12
  import type { ReleaseManifest, ReleaseManifestResult } from "../release-manifest.js";
13
13
  import type { SelfHostedRelease } from "../self-hosted-release.js";
14
14
  import type { ClaimedUpdate, UpdateEnd, UpdateFacts } from "../upgrader-job.js";
15
+ import { UpgradeLockAccessError, UpgradeLockHeldError } from "./lock.js";
15
16
  import type { AgentHeartbeat, AgentQueue } from "./queue.js";
16
17
  import { type AgentApplyRequest, type AgentRunDeps, runAgentOnce } from "./run.js";
17
18
 
@@ -216,10 +217,26 @@ describe("runAgentOnce", () => {
216
217
  name: "another upgrade holds the lock",
217
218
  over: {
218
219
  lock: () => {
219
- throw new Error("`cms-provision upgrade` is running on this install (pid 42).");
220
+ throw new UpgradeLockHeldError(
221
+ "`cms-provision upgrade` is running on this install (pid 42).",
222
+ );
220
223
  },
221
224
  },
222
225
  expect: "failed/state-locked",
226
+ reason: /Try again when it has finished/,
227
+ },
228
+ {
229
+ name: "a lock the agent cannot write is not reported as busy (#804)",
230
+ over: {
231
+ lock: () => {
232
+ throw new UpgradeLockAccessError(
233
+ "the upgrade lock /opt/caelo/.caelo/upgrade.lock cannot be written (EACCES). Give the account write access, then try again.",
234
+ );
235
+ },
236
+ },
237
+ expect: "failed/lock-access",
238
+ reason:
239
+ /^Nothing was changed: the upgrade lock \/opt\/caelo\/.caelo\/upgrade.lock cannot be written \(EACCES\)\. Give the account write access, then try again\.$/,
223
240
  },
224
241
  {
225
242
  name: "a malformed release.json",
package/src/agent/run.ts CHANGED
@@ -39,6 +39,7 @@ import type { UpdateEnd, UpdateEndStatus } from "../upgrader-job.js";
39
39
  import { recordUpdateEnd } from "../upgrader-job.js";
40
40
  import { eventReporter, type JobLog } from "../upgrader-reporter.js";
41
41
  import type { VerifiedCli } from "./cli-provenance.js";
42
+ import { UpgradeLockHeldError } from "./lock.js";
42
43
  import type { AgentQueue } from "./queue.js";
43
44
 
44
45
  /** What the target release's `agent apply` is told (argv of the child). */
@@ -70,7 +71,11 @@ export interface AgentRunDeps {
70
71
  readonly readInstalled: () => SelfHostedRelease | null;
71
72
  /** fetchVerifiedReleaseManifest against the fixed registry. */
72
73
  readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
73
- /** Take the install's upgrade lock (agent/lock.ts); throws when held. */
74
+ /**
75
+ * Take the install's upgrade lock (agent/lock.ts): throws
76
+ * UpgradeLockHeldError when another upgrade holds it, any other error
77
+ * when the lock cannot be written.
78
+ */
74
79
  readonly lock: () => () => void;
75
80
  /**
76
81
  * Install the target release's CLI and verify its npm signatures +
@@ -169,11 +174,17 @@ export async function runAgentOnce(deps: AgentRunDeps): Promise<AgentRunResult>
169
174
  try {
170
175
  release = deps.lock();
171
176
  } catch (e) {
172
- return await stopWith(
173
- "failed",
174
- ["state-locked"],
175
- `Nothing was changed: ${errorText(e)} Try again when it has finished.`,
176
- );
177
+ // #804 — only another upgrade holding the lock is "busy"; a lock the
178
+ // agent cannot write (permissions, a full disk) is a server problem
179
+ // that waiting never fixes.
180
+ if (e instanceof UpgradeLockHeldError) {
181
+ return await stopWith(
182
+ "failed",
183
+ ["state-locked"],
184
+ `Nothing was changed: ${errorText(e)} Try again when it has finished.`,
185
+ );
186
+ }
187
+ return await stopWith("failed", ["lock-access"], `Nothing was changed: ${errorText(e)}`);
177
188
  }
178
189
 
179
190
  r.phase("verify");
@@ -63,6 +63,7 @@ describe("Compose — Caddy picks up new redirects (#587)", () => {
63
63
  postgresPassword: "pgpw",
64
64
  rolePasswords: { admin: "a", public: "p" },
65
65
  caeloSecretKek: "0".repeat(64),
66
+ internalSecret: "i".repeat(48),
66
67
  diskSize: "20Gi",
67
68
  ports: { http: 80, https: 443 },
68
69
  project: "caelo-example-com",
package/src/caddy.ts CHANGED
@@ -49,12 +49,21 @@
49
49
  * icon MIME the media library stores and `<link rel="icon" type>`
50
50
  * declares. file_server only fills Content-Type when no earlier handler
51
51
  * set it, so the `header` line wins over the host's mime.types name.
52
+ *
53
+ * #891 — every response of a public vhost is `nosniff`, and every SVG
54
+ * gets the sandboxing CSP (`SVG_DOCUMENT_CSP`, @caelo-cms/shared): an
55
+ * uploaded SVG opened directly must not run script on the site's origin,
56
+ * which also serves `/api/*`. Caddy's `path` matcher is case-insensitive,
57
+ * so `*.svg` covers `.SVG` too. `cms-provision upgrade` re-writes this file
58
+ * (self-hosted-upgrade.ts), so existing installs get the headers too.
52
59
  */
53
60
 
54
61
  import {
55
62
  CONTENT_HASHED_PATH_PATTERN,
56
63
  caddyRedirectsImport,
57
64
  IMMUTABLE_CACHE_CONTROL,
65
+ NOSNIFF,
66
+ SVG_DOCUMENT_CSP,
58
67
  } from "@caelo-cms/shared";
59
68
 
60
69
  export interface CaddyDomainSpec {
@@ -208,6 +217,9 @@ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
208
217
  header @content_hashed Cache-Control "${IMMUTABLE_CACHE_CONTROL}"
209
218
  @ico path *.ico
210
219
  header @ico Content-Type "image/x-icon"
220
+ header X-Content-Type-Options "${NOSNIFF}"
221
+ @svg path *.svg
222
+ header @svg Content-Security-Policy "${SVG_DOCUMENT_CSP}"
211
223
  handle /api/* {
212
224
  reverse_proxy ${spec.gatewayUpstream}
213
225
  }
@@ -93,7 +93,7 @@ export const CLI_COMMANDS: readonly CliCommandDoc[] = [
93
93
  },
94
94
  {
95
95
  name: "upgrade",
96
- args: "[--version x.y.z] [--channel stable|rc|beta] [--region <r>] [--skip-verify] [--skip-health-check] [--install <id|domain|all>]",
96
+ args: "[--version x.y.z] [--channel stable|rc|beta] [--region <r>] [--skip-verify] [--skip-health-check] [--install <id|domain|all>] [--project <gcp-project-id>]",
97
97
  summary:
98
98
  "Roll the install to a newer release (latest stable by default) after verifying the image signatures.",
99
99
  },
package/src/cli.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  normalizeStagingHost,
46
46
  stagingHostProblem,
47
47
  } from "@caelo-cms/shared";
48
+ import type { AdminInternalTarget } from "./admin-internal.js";
48
49
  import type { AgentCliContext } from "./agent/command.js";
49
50
  import type { AgentConfig } from "./agent/config.js";
50
51
  import { generateBootstrapToken } from "./bootstrap-token.js";
@@ -74,6 +75,7 @@ import {
74
75
  } from "./compose.js";
75
76
  import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
76
77
  import { selfCommand } from "./self-command.js";
78
+ import { ensureInternalSecret, writeInternalSecret } from "./self-hosted-internal-secret.js";
77
79
  import {
78
80
  bunHostRunner,
79
81
  readRelease,
@@ -277,15 +279,23 @@ function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): voi
277
279
  cfg.caeloSecretKek = randomSecret(32);
278
280
  saveConfig(cfg);
279
281
  }
282
+ emitComposeFiles(cfg);
283
+ writeCaddyfile(cfg, extraDomains);
284
+ }
285
+
286
+ /**
287
+ * Write the Caddyfile for `cfg` — the seed hosts (admin, primary domain,
288
+ * staging host) plus `extraDomains`, the domains added via
289
+ * /settings/domains that regenerate-caddy / upgrade read from the live
290
+ * domains table. Never touches the Compose file: `upgrade` calls this after
291
+ * it rolled the containers onto the new Compose file (#891).
292
+ */
293
+ function writeCaddyfile(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[]): void {
280
294
  if (!cfg.stagingDomain) {
281
295
  cfg.stagingDomain = defaultStagingHost(cfg.domain);
282
296
  saveConfig(cfg);
283
297
  }
284
298
  if (!cfg.tls) throw new Error("config has no tls — it is back-filled on load; this is a bug");
285
- emitComposeFiles(cfg);
286
- // Seed the install with the admin host, the primary domain and the
287
- // staging host. Extra domains added via /settings/domains are appended by
288
- // regenerate-caddy when it queries the live domains table.
289
299
  const caddy = generateCaddyfile({
290
300
  ownerEmail: cfg.ownerEmail,
291
301
  tls: cfg.tls,
@@ -339,6 +349,12 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
339
349
  postgresPassword: cfg.postgresPassword,
340
350
  rolePasswords: cfg.rolePasswords,
341
351
  caeloSecretKek: cfg.caeloSecretKek,
352
+ // #770 — every render (up, upgrade, agent apply) hands the admin the
353
+ // secret host-side commands sign their /api/internal/* tokens with.
354
+ internalSecret: ensureInternalSecret(CAELO_DIR, {
355
+ randomSecret: () => randomSecret(48),
356
+ report: (line) => console.log(line),
357
+ }),
342
358
  anthropicApiKey: cfg.anthropicApiKey,
343
359
  resendApiKey: cfg.resendApiKey,
344
360
  diskSize: "20Gi",
@@ -382,6 +398,7 @@ function agentContext(cfg: CaeloConfig): AgentCliContext {
382
398
  });
383
399
  },
384
400
  renderCompose: (images) => renderCompose(cfg, images),
401
+ refreshCaddy: () => refreshCaddyfile(cfg),
385
402
  randomSecret: () => randomSecret(32),
386
403
  };
387
404
  }
@@ -478,6 +495,24 @@ async function refreshCaddy(
478
495
  return reloadCaddy(CAELO_DIR, bunHostRunner);
479
496
  }
480
497
 
498
+ /**
499
+ * Re-render only the Caddyfile (config + the domains table) and reload the
500
+ * running Caddy — what `upgrade` runs after the roll, so an install picks up
501
+ * the edge headers of the release it moved to (#891) without its Compose
502
+ * file being re-written behind the upgrade's back.
503
+ */
504
+ async function refreshCaddyfile(
505
+ cfg: CaeloConfig,
506
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
507
+ try {
508
+ writeCaddyfile(cfg, await fetchExtraDomains());
509
+ } catch (e) {
510
+ return { ok: false, error: `writing ${CADDYFILE_PATH} failed: ${(e as Error).message}` };
511
+ }
512
+ const { reloadCaddy } = await import("./self-hosted-start.js");
513
+ return reloadCaddy(CAELO_DIR, bunHostRunner);
514
+ }
515
+
481
516
  /**
482
517
  * Pull, start Postgres, migrate, start everything, health-check
483
518
  * (self-hosted-start.ts). Exits non-zero with the reason on failure; the
@@ -602,6 +637,13 @@ async function init(): Promise<void> {
602
637
  composeProject: composeProjectName(domain),
603
638
  };
604
639
  saveConfig(cfg);
640
+ // P15.1 — the shared HMAC secret for /api/internal/* endpoints, read by
641
+ // the admin (CAELO_INTERNAL_SECRET, from the Compose file emitConfig
642
+ // writes next — so it must exist first, #770) and by host-side commands
643
+ // such as `owner-reset`. Long enough to defeat brute-force. GCP installs
644
+ // keep their own copy in Secret Manager (runtime-secrets.ts), rotated by
645
+ // `rotate-secret internal-secret`.
646
+ writeInternalSecret(CAELO_DIR, randomSecret(48));
605
647
  if (images.mode === "release") {
606
648
  recordRelease(
607
649
  CAELO_DIR,
@@ -620,17 +662,6 @@ async function init(): Promise<void> {
620
662
  mkdirSync(resolve(CAELO_DIR, BOOTSTRAP_DIR), { recursive: true, mode: 0o700 });
621
663
  writePrivateFile(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2));
622
664
 
623
- // P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
624
- // Same secret is read by:
625
- // - admin app (process.env.CAELO_INTERNAL_SECRET),
626
- // - cms-provision pulumi-output-sync (when running in CI / by hand),
627
- // - any future internal-only orchestration.
628
- // Long enough to defeat brute-force. GCP installs keep their own copy in
629
- // Secret Manager (runtime-secrets.ts), rotated by `rotate-secret
630
- // internal-secret`.
631
- const internalSecret = randomSecret(48);
632
- const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
633
- writePrivateFile(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
634
665
  // #696 Phase 4c — the update agent (Linux + systemd): the fresh volume's
635
666
  // bootstrap.sh creates its database login on Postgres's first start.
636
667
  const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
@@ -993,13 +1024,20 @@ async function upgradeSelfHostedCommand(opts: {
993
1024
  process.exit(2);
994
1025
  }
995
1026
  const { upgradeSelfHosted } = await import("./self-hosted-upgrade.js");
996
- const { acquireUpgradeLock } = await import("./agent/lock.js");
1027
+ const { acquireUpgradeLock, UpgradeLockHeldError } = await import("./agent/lock.js");
997
1028
  // The update agent rolls through the same lock: never two rolls at once.
998
1029
  let unlock: () => void;
999
1030
  try {
1000
1031
  unlock = acquireUpgradeLock(CAELO_DIR, "`cms-provision upgrade`");
1001
1032
  } catch (e) {
1002
- console.error(`Nothing was changed: ${(e as Error).message} Wait for it to finish.`);
1033
+ // #804 — "wait" only when another upgrade holds the lock; an access
1034
+ // failure's message already names the lock file and the fix.
1035
+ const message = e instanceof Error ? e.message : String(e);
1036
+ console.error(
1037
+ e instanceof UpgradeLockHeldError
1038
+ ? `Nothing was changed: ${message} Wait for it to finish.`
1039
+ : `Nothing was changed: ${message}`,
1040
+ );
1003
1041
  process.exit(1);
1004
1042
  }
1005
1043
  let result: Awaited<ReturnType<typeof upgradeSelfHosted>>;
@@ -1009,6 +1047,7 @@ async function upgradeSelfHostedCommand(opts: {
1009
1047
  runner: bunHostRunner,
1010
1048
  renderCompose: (images) => renderCompose(cfg, images),
1011
1049
  convergeRoles: agentContext(cfg).convergeRoles,
1050
+ refreshCaddy: () => refreshCaddyfile(cfg),
1012
1051
  log: {
1013
1052
  step: (m) => console.log(`\n==> ${m}`),
1014
1053
  info: (m) => console.log(` ${m}`),
@@ -1201,8 +1240,12 @@ async function lifecycleUpgrade(): Promise<void> {
1201
1240
  // #607 — optional; must match the install's recorded region (upgrade
1202
1241
  // never moves an install between regions).
1203
1242
  const region = arg("region");
1243
+ // #816 — attach the install from its project first (break-glass upgrade
1244
+ // from a fresh Cloud Shell; nothing in $HOME has to survive).
1245
+ const project = arg("project");
1204
1246
  const { upgradeCommand } = await import("./lifecycle.js");
1205
1247
  await upgradeCommand({
1248
+ ...(project ? { project } : {}),
1206
1249
  ...(region ? { region } : {}),
1207
1250
  ...(version ? { version } : {}),
1208
1251
  ...(channel ? { channel } : {}),
@@ -1231,42 +1274,56 @@ async function lifecycleTruncate(): Promise<void> {
1231
1274
  }
1232
1275
 
1233
1276
  /**
1234
- * #769 `owner-reset [--install <id>] [--email <addr>]` — print a one-time
1235
- * reset link for a locked-out Owner, for installs whose admin cannot email
1236
- * one (see owner-reset.ts for why the operator's cloud identity may).
1277
+ * #769 / #770 `owner-reset [--install <id>] [--email <addr>]` — print a
1278
+ * one-time reset link for a locked-out Owner, for installs whose admin
1279
+ * cannot email one (see owner-reset.ts for why the operator may). Run in the
1280
+ * self-hosted install's directory, or with `--install` for a cloud install.
1237
1281
  */
1238
1282
  async function ownerReset(): Promise<void> {
1283
+ const email = arg("email");
1284
+ const target = selfHostedInstallPresent()
1285
+ ? selfHostedOwnerResetTarget()
1286
+ : await cloudOwnerResetTarget();
1287
+ const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
1288
+ const r = await requestOwnerReset(target, email ? { email } : {});
1289
+ if (!r.ok) {
1290
+ console.error(`owner-reset: ${r.error}`);
1291
+ process.exit(1);
1292
+ }
1293
+ // Prints the reset link ON PURPOSE: stdout is the one place it goes; it is
1294
+ // never written to a file or a log.
1295
+ for (const line of describeOwnerReset(r.value)) console.log(line);
1296
+ }
1297
+
1298
+ /** The self-hosted install in this directory, reached through `docker compose exec`. */
1299
+ function selfHostedOwnerResetTarget(): AdminInternalTarget {
1300
+ const cfg = loadConfig();
1301
+ if (!cfg) {
1302
+ console.error("no config — run `cms-provision init` first");
1303
+ process.exit(2);
1304
+ }
1305
+ return {
1306
+ provider: "self-hosted",
1307
+ caeloDir: CAELO_DIR,
1308
+ adminUrl: siteUrl(cfg, selfHostedAdminHost(cfg.domain)),
1309
+ };
1310
+ }
1311
+
1312
+ /** The cloud install `--install` names (or the only one), on a provider that has the channel. */
1313
+ async function cloudOwnerResetTarget(): Promise<AdminInternalTarget> {
1239
1314
  const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
1240
1315
  const selected = selectInstall(listInstalls(), installFlag(process.argv));
1241
1316
  if (!selected.ok) {
1242
1317
  console.error(selected.message);
1243
1318
  process.exit(1);
1244
1319
  }
1245
- const meta = selected.meta;
1246
- if (meta.provider !== "gcp-firebase") {
1247
- console.error(
1248
- `owner-reset works on gcp-firebase installs so far; this one is ${meta.provider} (issue #770). ` +
1249
- "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.",
1250
- );
1320
+ const { ownerResetTarget } = await import("./owner-reset.js");
1321
+ const target = ownerResetTarget(selected.meta);
1322
+ if (!target.ok) {
1323
+ console.error(`owner-reset: ${target.error}`);
1251
1324
  process.exit(1);
1252
1325
  }
1253
- if (!meta.projectId || !meta.region) {
1254
- console.error(
1255
- `install ${meta.installId} has no project or region recorded in install.json; re-run the wizard to record it.`,
1256
- );
1257
- process.exit(1);
1258
- }
1259
- const email = arg("email");
1260
- const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
1261
- const r = await requestOwnerReset(
1262
- { projectId: meta.projectId, region: meta.region },
1263
- email ? { email } : {},
1264
- );
1265
- if (!r.ok) {
1266
- console.error(`owner-reset: ${r.error}`);
1267
- process.exit(1);
1268
- }
1269
- for (const line of describeOwnerReset(r.value)) console.log(line);
1326
+ return target.value;
1270
1327
  }
1271
1328
 
1272
1329
  /**
@@ -1340,6 +1397,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1340
1397
  if (!requested && meta.provider === "gcp-firebase" && meta.projectId && meta.region) {
1341
1398
  const { inspectStagingAdmin } = await import("./staging-admin.js");
1342
1399
  const inspected = await inspectStagingAdmin({
1400
+ provider: "gcp-firebase",
1343
1401
  projectId: meta.projectId,
1344
1402
  region: meta.region,
1345
1403
  });
@@ -1425,7 +1483,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1425
1483
  // the password is printed once (never stored by the CLI).
1426
1484
  const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
1427
1485
  const applied = await applyStagingAdmin(
1428
- { projectId: meta.projectId, region: meta.region },
1486
+ { provider: "gcp-firebase", projectId: meta.projectId, region: meta.region },
1429
1487
  {
1430
1488
  host: r.hostname,
1431
1489
  ...(rotatePassword ? { rotateBasicAuth: true } : {}),
package/src/compose.ts CHANGED
@@ -188,6 +188,13 @@ export interface ComposeSpec {
188
188
  * via the same randomHex(32) the GCP stack uses.
189
189
  */
190
190
  readonly caeloSecretKek: string;
191
+ /**
192
+ * #770 — the shared HMAC secret of the admin's `/api/internal/*`
193
+ * endpoints (`.caelo/internal-secret.json`), so host-side commands such as
194
+ * `owner-reset` can reach them through `docker compose exec`. Admin only;
195
+ * the gateway never gets it.
196
+ */
197
+ readonly internalSecret: string;
191
198
  readonly anthropicApiKey?: string;
192
199
  readonly resendApiKey?: string;
193
200
  readonly diskSize: string;
@@ -245,6 +252,10 @@ export function generateDockerCompose(spec: ComposeSpec): string {
245
252
  if (spec.agent && !isAgentDbPort(spec.agent.dbPort)) {
246
253
  throw new Error(`agent dbPort ${spec.agent.dbPort} is not a TCP port between 1024 and 65535`);
247
254
  }
255
+ // The admin refuses a shorter one on every /api/internal/* call (internal-jwt.ts).
256
+ if (spec.internalSecret.length < 32) {
257
+ throw new Error("internalSecret is shorter than 32 characters (.caelo/internal-secret.json)");
258
+ }
248
259
  return `# SPDX-License-Identifier: MPL-2.0
249
260
  # Generated by cms-provision — do not edit; re-run \`bunx cms-provision up\`.
250
261
  # Domain: ${spec.domain}
@@ -377,6 +388,7 @@ ${serviceRuntime(
377
388
  ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
378
389
  PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
379
390
  CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
391
+ CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
380
392
  CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
381
393
  PORT: "${COMPOSE_ADMIN_PORT}"
382
394
  CAELO_OUTPUT_ROOT: "${SITE_OUTPUT_ROOT}"