@caelo-cms/provisioning 0.10.61 → 0.10.62

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
@@ -34,6 +34,7 @@ import { findSqlInstance } from "./gateway-credentials.js";
34
34
  import { gcloud as defaultGcloud } from "./gcloud.js";
35
35
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
36
36
  import type { EnsureOutcome } from "./runtime-secrets.js";
37
+ import { type GcloudCredentials, lookupFailure } from "./upgrade/gcp-lookup.js";
37
38
 
38
39
  /** The gcloud call that leaves `public_role` with no database roles. */
39
40
  export function revokePublicRoleMembershipsArgs(target: {
@@ -58,15 +59,24 @@ export function revokePublicRoleMembershipsArgs(target: {
58
59
  * instead of throwing; `failed` names the next step.
59
60
  */
60
61
  export async function revokePublicRoleMemberships(
61
- install: { readonly projectId: string; readonly env: string },
62
+ install: {
63
+ readonly projectId: string;
64
+ readonly env: string;
65
+ readonly credentials: GcloudCredentials;
66
+ },
62
67
  deps: { readonly run?: GcloudRunner; readonly sleep?: Sleep } = {},
63
68
  ): Promise<EnsureOutcome> {
64
69
  const run = deps.run ?? defaultGcloud;
65
70
  const id = "public_role database role memberships";
66
- const sqlInstance = await findSqlInstance(install, { run });
67
- if (!sqlInstance) {
68
- return { id, status: "failed", error: "the Cloud SQL instance was not found" };
71
+ const lookup = await findSqlInstance(install, { run });
72
+ if (lookup.status !== "found") {
73
+ return {
74
+ id,
75
+ status: "failed",
76
+ error: lookupFailure(lookup, "Cloud SQL instance", install.credentials),
77
+ };
69
78
  }
79
+ const sqlInstance = lookup.name;
70
80
  const r = await runWithRetry(
71
81
  run,
72
82
  deps.sleep ?? realSleep,
@@ -55,6 +55,10 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
55
55
  "packages/provisioning/src/audit-job-setup.ts",
56
56
  // #819 — the converge job, its database login and the admin's grant on it.
57
57
  "packages/provisioning/src/converge-job-setup.ts",
58
+ // #891 — the edge security headers the stacks attach, and the converge's
59
+ // SVG backfill + CDN purge on `gcp`.
60
+ "packages/provisioning/src/static-security-headers.ts",
61
+ "packages/provisioning/src/static-svg-gcp.ts",
58
62
  ];
59
63
 
60
64
  /**
@@ -21,6 +21,7 @@ const base: Omit<ComposeSpec, "images"> = {
21
21
  postgresPassword: "pgpw",
22
22
  rolePasswords: { admin: "adminpw", public: "publicpw" },
23
23
  caeloSecretKek: "0".repeat(64),
24
+ internalSecret: "i".repeat(48),
24
25
  anthropicApiKey: "sk-ant-x",
25
26
  diskSize: "20Gi",
26
27
  ports: { http: 80, https: 443 },
@@ -34,6 +35,30 @@ function service(yaml: string, name: string): string {
34
35
  return yaml.slice(start, end === -1 ? undefined : end);
35
36
  }
36
37
 
38
+ describe("self-hosted Compose — internal secret (#770)", () => {
39
+ const yaml = generateDockerCompose({
40
+ ...base,
41
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
42
+ });
43
+
44
+ it("gives the admin CAELO_INTERNAL_SECRET and the gateway nothing of it", () => {
45
+ expect(service(yaml, "caelo-admin")).toContain(
46
+ ` CAELO_INTERNAL_SECRET: "${"i".repeat(48)}"`,
47
+ );
48
+ expect(service(yaml, "caelo-gateway")).not.toContain("CAELO_INTERNAL_SECRET");
49
+ });
50
+
51
+ it("refuses a secret the admin would reject", () => {
52
+ expect(() =>
53
+ generateDockerCompose({
54
+ ...base,
55
+ internalSecret: "short",
56
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
57
+ }),
58
+ ).toThrow("internalSecret");
59
+ });
60
+ });
61
+
37
62
  describe("self-hosted Compose — release images (#696)", () => {
38
63
  const yaml = generateDockerCompose({
39
64
  ...base,
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #770 — the self-hosted internal secret the Compose file hands the admin:
5
+ * an existing one is kept as it is, a missing one is created once (and
6
+ * reported), a malformed one fails loudly instead of being replaced.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
10
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ ensureInternalSecret,
15
+ internalSecretPath,
16
+ readInternalSecret,
17
+ writeInternalSecret,
18
+ } from "./self-hosted-internal-secret.js";
19
+
20
+ let dir: string;
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "caelo-internal-secret-"));
23
+ });
24
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
25
+
26
+ describe("ensureInternalSecret", () => {
27
+ it("keeps the secret `init` wrote and reports nothing", () => {
28
+ writeInternalSecret(dir, "a".repeat(96));
29
+ const reported: string[] = [];
30
+ const secret = ensureInternalSecret(dir, {
31
+ randomSecret: () => "b".repeat(96),
32
+ report: (l) => reported.push(l),
33
+ });
34
+ expect(secret).toBe("a".repeat(96));
35
+ expect(reported).toEqual([]);
36
+ });
37
+
38
+ it("creates a missing one once, private to the owner, and says so", () => {
39
+ const reported: string[] = [];
40
+ const deps = { randomSecret: () => "c".repeat(96), report: (l: string) => reported.push(l) };
41
+ expect(ensureInternalSecret(dir, deps)).toBe("c".repeat(96));
42
+ expect(ensureInternalSecret(dir, { ...deps, randomSecret: () => "d".repeat(96) })).toBe(
43
+ "c".repeat(96),
44
+ );
45
+ expect(reported).toHaveLength(1);
46
+ expect(reported[0]).toContain(internalSecretPath(dir));
47
+ expect(statSync(internalSecretPath(dir)).mode & 0o077).toBe(0);
48
+ expect(JSON.parse(readFileSync(internalSecretPath(dir), "utf8"))).toEqual({
49
+ secret: "c".repeat(96),
50
+ });
51
+ });
52
+
53
+ it("refuses a malformed or short file rather than replacing it", () => {
54
+ writeFileSync(internalSecretPath(dir), "{not json");
55
+ expect(() => readInternalSecret(dir)).toThrow("not valid JSON");
56
+ writeFileSync(internalSecretPath(dir), JSON.stringify({ secret: "short" }));
57
+ expect(() =>
58
+ ensureInternalSecret(dir, { randomSecret: () => "e".repeat(96), report: () => {} }),
59
+ ).toThrow("at least 32");
60
+ });
61
+ });
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #770 — the self-hosted install's `/api/internal/*` secret,
5
+ * `.caelo/internal-secret.json` (`{ "secret": "<hex>" }`).
6
+ *
7
+ * `init` has written it since P15.1, but until #770 nothing handed it to the
8
+ * admin container, so the admin's internal endpoints answered "not set" on
9
+ * every self-hosted install. The Compose file now carries it as the admin's
10
+ * `CAELO_INTERNAL_SECRET`, and the host-side commands that call those
11
+ * endpoints (`owner-reset`) sign their tokens with the same file.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { writePrivateFile } from "./private-file.js";
17
+
18
+ /** The admin refuses shorter secrets (apps/admin internal-jwt.ts). */
19
+ const MIN_LENGTH = 32;
20
+
21
+ /** `<caeloDir>/internal-secret.json`. */
22
+ export function internalSecretPath(caeloDir: string): string {
23
+ return join(caeloDir, "internal-secret.json");
24
+ }
25
+
26
+ /**
27
+ * The install's internal secret, or null when the file does not exist.
28
+ * Throws on a malformed file rather than guessing a value: a wrong secret
29
+ * would only surface later as every internal call failing its signature.
30
+ */
31
+ export function readInternalSecret(caeloDir: string): string | null {
32
+ const path = internalSecretPath(caeloDir);
33
+ if (!existsSync(path)) return null;
34
+ let secret: unknown;
35
+ try {
36
+ secret = (JSON.parse(readFileSync(path, "utf8")) as { secret?: unknown }).secret;
37
+ } catch (e) {
38
+ throw new Error(
39
+ `${path} is not valid JSON (${String(e)}); fix it or delete it and run \`cms-provision up\`.`,
40
+ );
41
+ }
42
+ if (typeof secret !== "string" || secret.length < MIN_LENGTH) {
43
+ throw new Error(
44
+ `${path} has no "secret" of at least ${MIN_LENGTH} characters; fix it or delete it and run \`cms-provision up\`.`,
45
+ );
46
+ }
47
+ return secret;
48
+ }
49
+
50
+ /** Write a new internal secret (`init`, or {@link ensureInternalSecret}). */
51
+ export function writeInternalSecret(caeloDir: string, secret: string): void {
52
+ if (secret.length < MIN_LENGTH) throw new Error(`internal secret shorter than ${MIN_LENGTH}`);
53
+ writePrivateFile(internalSecretPath(caeloDir), JSON.stringify({ secret }, null, 2));
54
+ }
55
+
56
+ /**
57
+ * The secret the Compose file gives the admin. An install whose directory
58
+ * has none (made by a CLI that did not write the file, or with it deleted)
59
+ * gets one created here, and `report` says so.
60
+ *
61
+ * This is creation of a missing secret, not a fallback (CLAUDE.md §2): no
62
+ * stored state depends on a previous value — before #770 the admin never
63
+ * received the secret, so no token was ever signed with it — and the value
64
+ * is written to disk before the Compose file that uses it, so every later
65
+ * render and every host-side command read the same one. Same pattern as
66
+ * the `caeloSecretKek` back-fill in the CLI's `emitConfig`.
67
+ */
68
+ export function ensureInternalSecret(
69
+ caeloDir: string,
70
+ deps: { readonly randomSecret: () => string; readonly report: (line: string) => void },
71
+ ): string {
72
+ const existing = readInternalSecret(caeloDir);
73
+ if (existing !== null) return existing;
74
+ const secret = deps.randomSecret();
75
+ writeInternalSecret(caeloDir, secret);
76
+ deps.report(
77
+ `Created ${internalSecretPath(caeloDir)} — this install had no internal secret; the admin now gets it as CAELO_INTERNAL_SECRET (needed by \`cms-provision owner-reset\`).`,
78
+ );
79
+ return secret;
80
+ }
@@ -45,7 +45,8 @@ export interface HostRunner {
45
45
  /**
46
46
  * Run `argv` to completion. With `stdoutPath`, stdout streams into that
47
47
  * file (mode 600) instead of being captured — used for database dumps.
48
- * With `stdinPath`, that file is the process's stdin (restores). With
48
+ * With `stdinPath`, that file is the process's stdin (restores); with
49
+ * `stdinText`, that text is (secrets that must not appear in argv). With
49
50
  * `inherit`, stdout + stderr go to the terminal (long pulls and starts
50
51
  * the operator should watch) and come back empty.
51
52
  */
@@ -58,6 +59,7 @@ export interface HostRunOptions {
58
59
  readonly env?: Readonly<Record<string, string>>;
59
60
  readonly stdoutPath?: string;
60
61
  readonly stdinPath?: string;
62
+ readonly stdinText?: string;
61
63
  readonly inherit?: boolean;
62
64
  }
63
65
 
@@ -67,7 +69,12 @@ export const bunHostRunner: HostRunner = {
67
69
  try {
68
70
  const proc = Bun.spawn([...argv], {
69
71
  ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
70
- stdin: opts?.stdinPath ? Bun.file(opts.stdinPath) : "ignore",
72
+ stdin:
73
+ opts?.stdinText !== undefined
74
+ ? Buffer.from(opts.stdinText)
75
+ : opts?.stdinPath
76
+ ? Bun.file(opts.stdinPath)
77
+ : "ignore",
71
78
  stdout: opts?.stdoutPath ? Bun.file(opts.stdoutPath) : opts?.inherit ? "inherit" : "pipe",
72
79
  stderr: opts?.inherit ? "inherit" : "pipe",
73
80
  });
@@ -235,7 +242,7 @@ export async function resolveVerifiedRelease(
235
242
  error:
236
243
  "cosign is required to verify the Caelo release image signatures and was not found on PATH.\n" +
237
244
  "Install it (https://docs.sigstore.dev/cosign/system_config/installation/ — e.g. `brew install cosign`,\n" +
238
- "or the release binary for Linux), then re-run. --skip-verify skips the check (NOT for production).",
245
+ "or the signed release binary for Linux), then re-run.",
239
246
  };
240
247
  }
241
248
  for (const service of RELEASE_SERVICES) {
@@ -122,7 +122,11 @@ beforeEach(() => {
122
122
  });
123
123
  afterEach(() => rmSync(dir, { recursive: true, force: true }));
124
124
 
125
- function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome = { ok: true }) {
125
+ function deps(
126
+ runner: HostRunner & { calls: string[] },
127
+ roles: ConvergeOutcome = { ok: true },
128
+ caddy: { ok: true } | { ok: false; error: string } = { ok: true },
129
+ ) {
126
130
  return {
127
131
  caeloDir: dir,
128
132
  runner,
@@ -131,6 +135,10 @@ function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome =
131
135
  runner.calls.push("converge roles");
132
136
  return roles;
133
137
  },
138
+ refreshCaddy: async () => {
139
+ runner.calls.push("refresh caddy");
140
+ return caddy;
141
+ },
134
142
  log,
135
143
  fetchImpl: registry,
136
144
  sleep: async () => {},
@@ -161,6 +169,7 @@ describe("upgradeSelfHosted", () => {
161
169
  "health admin",
162
170
  "up caelo-gateway",
163
171
  "health gateway",
172
+ "refresh caddy",
164
173
  ]);
165
174
  // The migrations run the NEW admin image: the Compose file already names it.
166
175
  expect(readFileSync(composePath(dir), "utf8")).toBe(render(NEW));
@@ -200,9 +209,34 @@ describe("upgradeSelfHosted", () => {
200
209
  "up both",
201
210
  "health admin",
202
211
  "health gateway",
212
+ "refresh caddy",
203
213
  ]);
204
214
  });
205
215
 
216
+ // #891 — the SVG CSP + nosniff live in the Caddyfile the release's own
217
+ // generator writes; an upgrade that only rolled the containers left every
218
+ // existing install serving SVGs without them.
219
+ it("re-writes the Caddyfile after the roll, before recording the release", async () => {
220
+ const runner = fakeRunner();
221
+ expect((await upgradeSelfHosted({ version: "0.11.0" }, deps(runner))).ok).toBe(true);
222
+ const s = steps(runner.calls);
223
+ expect(s.indexOf("refresh caddy")).toBeGreaterThan(s.indexOf("health gateway"));
224
+ });
225
+
226
+ it("fails at the caddy stage (release not recorded, so a re-run retries) when Caddy cannot reload", async () => {
227
+ const runner = fakeRunner();
228
+ const r = await upgradeSelfHosted(
229
+ { version: "0.11.0" },
230
+ deps(runner, { ok: true }, { ok: false, error: "caddy reload failed: bad config" }),
231
+ );
232
+ expect(r).toMatchObject({ ok: false, stage: "caddy", previousRunning: false });
233
+ if (!r.ok) {
234
+ expect(r.error).toContain("bad config");
235
+ expect(r.error).toContain("regenerate-caddy");
236
+ }
237
+ expect(readRelease(dir)?.version).toBe("0.10.0");
238
+ });
239
+
206
240
  it("reports an unhealthy install that is already on the target", async () => {
207
241
  recordRelease(
208
242
  dir,
@@ -21,7 +21,9 @@
21
21
  * was retired in (run-order.ts): past it, nothing running uses it;
22
22
  * 6. recreate the admin, health-check it (`/_caelo/health`, which proves
23
23
  * the DB pool and roles); then the gateway (`/healthz`);
24
- * 7. record the release in `.caelo/release.json`.
24
+ * 7. re-write the Caddyfile and reload Caddy — the edge headers live there
25
+ * (#891), and only the new release's generator knows them;
26
+ * 8. record the release in `.caelo/release.json`.
25
27
  * Any failure after step 3 puts the previous Compose file back; once a
26
28
  * container was recreated the previous images are started again and
27
29
  * health-checked (rollback). The database is never restored automatically:
@@ -90,6 +92,13 @@ export interface SelfHostedUpgradeDeps {
90
92
  * (`convergeSelfHostedRoles`); drops roles a release retired (#635).
91
93
  */
92
94
  readonly convergeRoles: () => Promise<ConvergeOutcome>;
95
+ /**
96
+ * Re-writes the Caddyfile from the install's config + domains table and
97
+ * reloads Caddy (#891): the edge headers (the SVG CSP, nosniff) live in
98
+ * the Caddyfile this release's generator writes, so an upgrade that only
99
+ * rolled the containers would leave the install on the old edge config.
100
+ */
101
+ readonly refreshCaddy: () => Promise<{ ok: true } | { ok: false; error: string }>;
93
102
  readonly fetchImpl?: typeof fetch;
94
103
  readonly sleep?: (ms: number) => Promise<void>;
95
104
  readonly now?: () => Date;
@@ -130,7 +139,8 @@ type UpgradeStage =
130
139
  | "migrate"
131
140
  | "roles"
132
141
  | "roll-admin"
133
- | "roll-gateway";
142
+ | "roll-gateway"
143
+ | "caddy";
134
144
 
135
145
  /** Where an install from before #696 keeps its original Compose file during the move. */
136
146
  export function legacyComposePath(caeloDir: string): string {
@@ -287,6 +297,10 @@ export async function upgradeSelfHosted(
287
297
  false,
288
298
  );
289
299
  }
300
+ // A run that stopped after the roll (or an install upgraded before the
301
+ // Caddyfile was part of the upgrade) gets this release's edge config.
302
+ const caddy = await refreshCaddy(deps);
303
+ if (!caddy.ok) return caddy.failure;
290
304
  log.info(`Already on ${current.version} (${shortDigest(images.admin)}); nothing to do.`);
291
305
  return { ok: true, status: "already-current", version: current.version };
292
306
  }
@@ -378,7 +392,13 @@ export async function upgradeSelfHosted(
378
392
  log.info(`${service} healthy ✓`);
379
393
  }
380
394
 
381
- // 7. Record.
395
+ // 7. The Caddyfile this release generates (edge headers, #891). The new
396
+ // containers already serve; a failure is reported before the release is
397
+ // recorded, so a re-run (the already-current path above) retries it.
398
+ const caddy = await refreshCaddy(deps);
399
+ if (!caddy.ok) return caddy.failure;
400
+
401
+ // 8. Record.
382
402
  log.phase?.("record");
383
403
  recordRelease(
384
404
  caeloDir,
@@ -390,6 +410,23 @@ export async function upgradeSelfHosted(
390
410
  return { ok: true, status: "upgraded", version: target.version };
391
411
  }
392
412
 
413
+ /** Step 7: re-write + reload the Caddyfile; on failure, the result to return. */
414
+ async function refreshCaddy(
415
+ deps: SelfHostedUpgradeDeps,
416
+ ): Promise<{ ok: true } | { ok: false; failure: SelfHostedUpgradeResult }> {
417
+ deps.log.step("Re-writing the Caddyfile and reloading Caddy");
418
+ const r = await deps.refreshCaddy();
419
+ if (r.ok) return { ok: true };
420
+ return {
421
+ ok: false,
422
+ failure: fail(
423
+ "caddy",
424
+ `${r.error}\nThe new release is running, but Caddy may still serve the previous Caddyfile (without this release's security headers). Fix the cause, then run \`${selfCommand("regenerate-caddy")}\` or re-run the upgrade.`,
425
+ false,
426
+ ),
427
+ };
428
+ }
429
+
393
430
  function fail(
394
431
  stage: UpgradeStage,
395
432
  error: string,
@@ -340,6 +340,7 @@ describe("self-hosted compose", () => {
340
340
  postgresPassword: "p",
341
341
  rolePasswords: { admin: "a", public: "b" },
342
342
  caeloSecretKek: "k",
343
+ internalSecret: "i".repeat(48),
343
344
  diskSize: "10G",
344
345
  ports: { http: 80, https: 443 },
345
346
  project: "caelo-example-com",
@@ -21,6 +21,7 @@ import {
21
21
  rollService,
22
22
  serviceRollArgs,
23
23
  } from "./stack-converge.js";
24
+ import { GCP_STATIC_RESPONSE_HEADERS } from "./static-security-headers.js";
24
25
 
25
26
  const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
26
27
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
@@ -44,6 +45,12 @@ function fakeGcloud(answers: Record<string, GcloudResult[]>, fallback: GcloudRes
44
45
  return { run, calls };
45
46
  }
46
47
 
48
+ /** A static backend bucket already at the stack's CDN policy + headers. */
49
+ const IN_SHAPE_BACKEND = {
50
+ cdnPolicy: STATIC_CDN_POLICY,
51
+ customResponseHeaders: GCP_STATIC_RESPONSE_HEADERS,
52
+ };
53
+
47
54
  const policy = (bindings: { role: string; members: string[]; condition?: unknown }[]) =>
48
55
  ok(JSON.stringify({ bindings }));
49
56
 
@@ -64,6 +71,9 @@ const firebase: InstallTarget = {
64
71
  };
65
72
  const gcp: InstallTarget = { ...firebase, provider: "gcp" };
66
73
 
74
+ const ingressJson = (ingress: string) =>
75
+ JSON.stringify({ metadata: { annotations: { "run.googleapis.com/ingress": ingress } } });
76
+
67
77
  const serviceJson = (env: unknown[]) =>
68
78
  JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } });
69
79
 
@@ -494,6 +504,7 @@ describe("ensureStackInvariants", () => {
494
504
  policy([{ role: "roles/run.invoker", members: [IAP_AGENT] }]),
495
505
  ],
496
506
  "compute firewall-rules list": [ok("[]")],
507
+ "run services describe caelo-production-gateway-bbb": [ok(ingressJson("all"))],
497
508
  });
498
509
 
499
510
  it("is a read-only no-op on an install that is already in shape", async () => {
@@ -502,10 +513,59 @@ describe("ensureStackInvariants", () => {
502
513
  expect(report.mustAbort).toBe(false);
503
514
  expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
504
515
  expect(report.outcomes).toHaveLength(
505
- stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length,
516
+ stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length + 1,
506
517
  );
507
518
  expect(calls.some((c) => c.startsWith("compute firewall-rules delete"))).toBe(false);
508
519
  expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
520
+ expect(calls.some((c) => c.startsWith("run services update"))).toBe(false);
521
+ });
522
+
523
+ describe("gcp-firebase gateway ingress (#732)", () => {
524
+ it("opens a load-balancer-only gateway to all traffic, and a re-run is a no-op", async () => {
525
+ const answers = fullFirebase();
526
+ answers["run services describe caelo-production-gateway-bbb"] = [
527
+ ok(ingressJson("internal-and-cloud-load-balancing")),
528
+ ];
529
+ const { run, calls } = fakeGcloud(answers);
530
+ const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
531
+ expect(report.mustAbort).toBe(false);
532
+ expect(calls).toContain(
533
+ "run services update caelo-production-gateway-bbb --region=europe-west1 --project=acme --ingress=all --quiet",
534
+ );
535
+ expect(report.outcomes.filter((o) => o.status === "applied").map((o) => o.id)).toEqual([
536
+ "ingress internal-and-cloud-load-balancing → all on caelo-production-gateway-bbb",
537
+ ]);
538
+
539
+ const again = fakeGcloud(fullFirebase());
540
+ const rerun = await ensureStackInvariants(firebase, {
541
+ run: again.run,
542
+ sleep: async () => {},
543
+ });
544
+ expect(rerun.outcomes.every((o) => o.status === "present")).toBe(true);
545
+ expect(again.calls.some((c) => c.includes("--ingress"))).toBe(false);
546
+ });
547
+
548
+ it("aborts the upgrade when the ingress can't be changed", async () => {
549
+ const { run } = fakeGcloud({
550
+ ...fullFirebase(),
551
+ "run services describe caelo-production-gateway-bbb": [
552
+ ok(ingressJson("internal-and-cloud-load-balancing")),
553
+ ],
554
+ "run services update": [fail("PERMISSION_DENIED: run.services.update")],
555
+ });
556
+ const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
557
+ expect(report.mustAbort).toBe(true);
558
+ expect(report.outcomes.find((o) => o.status === "failed")?.error).toBe(
559
+ "PERMISSION_DENIED: run.services.update",
560
+ );
561
+ });
562
+
563
+ it("never reads or changes the gcp gateway's ingress (its stack declares load-balancer-only)", async () => {
564
+ const { run, calls } = fakeGcloud({ "projects describe": [ok("42")] });
565
+ await ensureStackInvariants(gcp, { run, sleep: async () => {} });
566
+ expect(calls.some((c) => c.startsWith("run services describe"))).toBe(false);
567
+ expect(calls.some((c) => c.includes("--ingress"))).toBe(false);
568
+ });
509
569
  });
510
570
 
511
571
  it("upgrade deletes GCP's pre-populated default-network firewall rules without aborting", async () => {
@@ -697,6 +757,17 @@ describe("ensureStackInvariants", () => {
697
757
  ],
698
758
  "compute backend-buckets list": [ok("caelo-production-static-backend-9f8e7d6\n")],
699
759
  "compute firewall-rules list": [ok("[]")],
760
+ // #891 — every live SVG already served as a download.
761
+ "storage objects list": [
762
+ ok(
763
+ JSON.stringify([
764
+ {
765
+ storage_url: "gs://acme-caelo-production-static/_assets/a.0123456789abcdef.svg",
766
+ content_disposition: "attachment",
767
+ },
768
+ ]),
769
+ ),
770
+ ],
700
771
  });
701
772
 
702
773
  it("raises the pre-#555 1h/24h TTLs to a year", async () => {
@@ -719,7 +790,7 @@ describe("ensureStackInvariants", () => {
719
790
  const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
720
791
  expect(report.mustAbort).toBe(false);
721
792
  expect(calls).toContain(
722
- `compute backend-buckets update caelo-production-static-backend-9f8e7d6 --project=acme --cache-mode=CACHE_ALL_STATIC --default-ttl=3600 --max-ttl=${STATIC_CDN_POLICY.maxTtl} --client-ttl=${STATIC_CDN_POLICY.clientTtl} --quiet`,
793
+ `compute backend-buckets update caelo-production-static-backend-9f8e7d6 --project=acme --cache-mode=CACHE_ALL_STATIC --default-ttl=3600 --max-ttl=${STATIC_CDN_POLICY.maxTtl} --client-ttl=${STATIC_CDN_POLICY.clientTtl} --custom-response-header=X-Content-Type-Options: nosniff --quiet`,
723
794
  );
724
795
  expect(report.outcomes.find((o) => o.id.startsWith("Cloud CDN"))?.status).toBe("applied");
725
796
  // Every IAM invariant was already present.
@@ -729,7 +800,7 @@ describe("ensureStackInvariants", () => {
729
800
  it("leaves a backend bucket already at the stack's policy alone", async () => {
730
801
  const answers = {
731
802
  ...fullGcp(),
732
- "compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
803
+ "compute backend-buckets describe": [ok(JSON.stringify(IN_SHAPE_BACKEND))],
733
804
  };
734
805
  const { run, calls } = fakeGcloud(answers);
735
806
  const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
@@ -737,6 +808,19 @@ describe("ensureStackInvariants", () => {
737
808
  expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
738
809
  });
739
810
 
811
+ // #891 regression: the backend bucket served every file without nosniff.
812
+ it("adds the nosniff response header to a bucket already at the CDN policy", async () => {
813
+ const answers = {
814
+ ...fullGcp(),
815
+ "compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
816
+ };
817
+ const { run, calls } = fakeGcloud(answers);
818
+ const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
819
+ const update = calls.find((c) => c.startsWith("compute backend-buckets update"));
820
+ expect(update).toContain("--custom-response-header=X-Content-Type-Options: nosniff");
821
+ expect(report.outcomes.find((o) => o.id.startsWith("Cloud CDN"))?.status).toBe("applied");
822
+ });
823
+
740
824
  it("warns (no abort) when the CDN update fails", async () => {
741
825
  const answers = {
742
826
  ...fullGcp(),
@@ -754,7 +838,7 @@ describe("ensureStackInvariants", () => {
754
838
  ...fullGcp(),
755
839
  "storage buckets get-iam-policy": [policy([])],
756
840
  "secrets get-iam-policy": [policy([])],
757
- "compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
841
+ "compute backend-buckets describe": [ok(JSON.stringify(IN_SHAPE_BACKEND))],
758
842
  };
759
843
  const { run, calls } = fakeGcloud(answers);
760
844
  await ensureStackInvariants(gcp, { run, sleep: async () => {} });
@@ -774,7 +858,7 @@ describe("ensureStackInvariants", () => {
774
858
  const answers = {
775
859
  ...fullGcp(),
776
860
  "secrets get-iam-policy": [policy([])],
777
- "compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
861
+ "compute backend-buckets describe": [ok(JSON.stringify(IN_SHAPE_BACKEND))],
778
862
  };
779
863
  const { run, calls } = fakeGcloud(answers);
780
864
  await ensureStackInvariants(gcp, { run, sleep: async () => {} });