@caelo-cms/provisioning 0.10.53 → 0.10.57

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 (244) hide show
  1. package/dist/agent-db.js +272 -128
  2. package/dist/audit-job-setup.d.ts +69 -0
  3. package/dist/audit-job-setup.d.ts.map +1 -0
  4. package/dist/audit-job-setup.js +317 -0
  5. package/dist/audit-job-setup.js.map +1 -0
  6. package/dist/caddy.d.ts +9 -0
  7. package/dist/caddy.d.ts.map +1 -1
  8. package/dist/caddy.js +38 -0
  9. package/dist/caddy.js.map +1 -1
  10. package/dist/cli-routing.js +1 -1
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +36 -9
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +5 -0
  15. package/dist/compose.js.map +1 -1
  16. package/dist/converge-checkpoint.d.ts +66 -0
  17. package/dist/converge-checkpoint.d.ts.map +1 -0
  18. package/dist/converge-checkpoint.js +61 -0
  19. package/dist/converge-checkpoint.js.map +1 -0
  20. package/dist/converge-job-gcp.d.ts +46 -0
  21. package/dist/converge-job-gcp.d.ts.map +1 -0
  22. package/dist/converge-job-gcp.js +368 -0
  23. package/dist/converge-job-gcp.js.map +1 -0
  24. package/dist/converge-job-setup.d.ts +62 -0
  25. package/dist/converge-job-setup.d.ts.map +1 -0
  26. package/dist/converge-job-setup.js +228 -0
  27. package/dist/converge-job-setup.js.map +1 -0
  28. package/dist/converge-job.d.ts +141 -0
  29. package/dist/converge-job.d.ts.map +1 -0
  30. package/dist/converge-job.js +325 -0
  31. package/dist/converge-job.js.map +1 -0
  32. package/dist/gcp-names.d.ts +25 -1
  33. package/dist/gcp-names.d.ts.map +1 -1
  34. package/dist/gcp-names.js +36 -0
  35. package/dist/gcp-names.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/infra-contract.d.ts +1 -1
  41. package/dist/infra-contract.d.ts.map +1 -1
  42. package/dist/infra-contract.js +28 -1
  43. package/dist/infra-contract.js.map +1 -1
  44. package/dist/install-job.d.ts +11 -3
  45. package/dist/install-job.d.ts.map +1 -1
  46. package/dist/install-job.js +22 -12
  47. package/dist/install-job.js.map +1 -1
  48. package/dist/lifecycle.d.ts +13 -1
  49. package/dist/lifecycle.d.ts.map +1 -1
  50. package/dist/lifecycle.js +115 -58
  51. package/dist/lifecycle.js.map +1 -1
  52. package/dist/operator-access.d.ts.map +1 -1
  53. package/dist/operator-access.js +13 -3
  54. package/dist/operator-access.js.map +1 -1
  55. package/dist/private-file.d.ts +26 -0
  56. package/dist/private-file.d.ts.map +1 -0
  57. package/dist/private-file.js +49 -0
  58. package/dist/private-file.js.map +1 -0
  59. package/dist/release-contract.d.ts +3 -2
  60. package/dist/release-contract.d.ts.map +1 -1
  61. package/dist/release-contract.js +8 -2
  62. package/dist/release-contract.js.map +1 -1
  63. package/dist/release-manifest.d.ts +2 -0
  64. package/dist/release-manifest.d.ts.map +1 -1
  65. package/dist/release-manifest.js.map +1 -1
  66. package/dist/remote-mcp-edge.d.ts +114 -0
  67. package/dist/remote-mcp-edge.d.ts.map +1 -0
  68. package/dist/remote-mcp-edge.js +116 -0
  69. package/dist/remote-mcp-edge.js.map +1 -0
  70. package/dist/remote-mcp.d.ts +117 -0
  71. package/dist/remote-mcp.d.ts.map +1 -0
  72. package/dist/remote-mcp.js +379 -0
  73. package/dist/remote-mcp.js.map +1 -0
  74. package/dist/runtime-secrets.d.ts +12 -0
  75. package/dist/runtime-secrets.d.ts.map +1 -1
  76. package/dist/runtime-secrets.js +32 -4
  77. package/dist/runtime-secrets.js.map +1 -1
  78. package/dist/seal-key-rotation.d.ts +38 -0
  79. package/dist/seal-key-rotation.d.ts.map +1 -0
  80. package/dist/seal-key-rotation.js +206 -0
  81. package/dist/seal-key-rotation.js.map +1 -0
  82. package/dist/stack-contract.d.ts +175 -3
  83. package/dist/stack-contract.d.ts.map +1 -1
  84. package/dist/stack-contract.js +183 -3
  85. package/dist/stack-contract.js.map +1 -1
  86. package/dist/stack-converge.d.ts +38 -1
  87. package/dist/stack-converge.d.ts.map +1 -1
  88. package/dist/stack-converge.js +74 -4
  89. package/dist/stack-converge.js.map +1 -1
  90. package/dist/state-lock-lease.d.ts +142 -0
  91. package/dist/state-lock-lease.d.ts.map +1 -0
  92. package/dist/state-lock-lease.js +347 -0
  93. package/dist/state-lock-lease.js.map +1 -0
  94. package/dist/state-store.d.ts +66 -9
  95. package/dist/state-store.d.ts.map +1 -1
  96. package/dist/state-store.js +249 -30
  97. package/dist/state-store.js.map +1 -1
  98. package/dist/tar-entries.d.ts.map +1 -1
  99. package/dist/tar-entries.js +84 -9
  100. package/dist/tar-entries.js.map +1 -1
  101. package/dist/upgrade/app-roll.d.ts +36 -7
  102. package/dist/upgrade/app-roll.d.ts.map +1 -1
  103. package/dist/upgrade/app-roll.js +88 -19
  104. package/dist/upgrade/app-roll.js.map +1 -1
  105. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  106. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  107. package/dist/upgrade/audit-job-roll.js +43 -0
  108. package/dist/upgrade/audit-job-roll.js.map +1 -0
  109. package/dist/upgrade/converge.d.ts.map +1 -1
  110. package/dist/upgrade/converge.js +141 -8
  111. package/dist/upgrade/converge.js.map +1 -1
  112. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  113. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  114. package/dist/upgrade/gcp-lookup.js +46 -7
  115. package/dist/upgrade/gcp-lookup.js.map +1 -1
  116. package/dist/upgrade/record-rolled.d.ts +40 -0
  117. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  118. package/dist/upgrade/record-rolled.js +46 -0
  119. package/dist/upgrade/record-rolled.js.map +1 -0
  120. package/dist/upgrade/upgrade.d.ts +45 -1
  121. package/dist/upgrade/upgrade.d.ts.map +1 -1
  122. package/dist/upgrade/upgrade.js +60 -17
  123. package/dist/upgrade/upgrade.js.map +1 -1
  124. package/dist/upgrader-assess.d.ts +15 -1
  125. package/dist/upgrader-assess.d.ts.map +1 -1
  126. package/dist/upgrader-assess.js +36 -6
  127. package/dist/upgrader-assess.js.map +1 -1
  128. package/dist/upgrader-gcp.d.ts +15 -0
  129. package/dist/upgrader-gcp.d.ts.map +1 -1
  130. package/dist/upgrader-gcp.js +42 -7
  131. package/dist/upgrader-gcp.js.map +1 -1
  132. package/dist/upgrader-iam-preflight.d.ts +123 -0
  133. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  134. package/dist/upgrader-iam-preflight.js +414 -0
  135. package/dist/upgrader-iam-preflight.js.map +1 -0
  136. package/dist/upgrader-job.d.ts +33 -9
  137. package/dist/upgrader-job.d.ts.map +1 -1
  138. package/dist/upgrader-job.js +169 -27
  139. package/dist/upgrader-job.js.map +1 -1
  140. package/dist/upgrader-records.d.ts +58 -0
  141. package/dist/upgrader-records.d.ts.map +1 -0
  142. package/dist/upgrader-records.js +87 -0
  143. package/dist/upgrader-records.js.map +1 -0
  144. package/dist/upgrader-reporter.d.ts +3 -2
  145. package/dist/upgrader-reporter.d.ts.map +1 -1
  146. package/dist/upgrader-reporter.js +10 -2
  147. package/dist/upgrader-reporter.js.map +1 -1
  148. package/dist/upgrader-setup.d.ts +25 -3
  149. package/dist/upgrader-setup.d.ts.map +1 -1
  150. package/dist/upgrader-setup.js +132 -14
  151. package/dist/upgrader-setup.js.map +1 -1
  152. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  153. package/dist/wizards/gcp-cost.js +14 -6
  154. package/dist/wizards/gcp-cost.js.map +1 -1
  155. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  156. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  157. package/dist/wizards/gcp-pulumi.js +7 -0
  158. package/dist/wizards/gcp-pulumi.js.map +1 -1
  159. package/dist/wizards/gcp.d.ts.map +1 -1
  160. package/dist/wizards/gcp.js +130 -4
  161. package/dist/wizards/gcp.js.map +1 -1
  162. package/package.json +4 -4
  163. package/src/agent/run.test.ts +15 -1
  164. package/src/audit-job-setup.test.ts +234 -0
  165. package/src/audit-job-setup.ts +360 -0
  166. package/src/caddy-redirects.docker.test.ts +19 -6
  167. package/src/caddy.test.ts +30 -0
  168. package/src/caddy.ts +43 -0
  169. package/src/cli-init-validation.test.ts +44 -0
  170. package/src/cli-routing.ts +1 -1
  171. package/src/cli.ts +40 -8
  172. package/src/compose.ts +5 -0
  173. package/src/converge-checkpoint.ts +75 -0
  174. package/src/converge-entrypoint.test.ts +213 -0
  175. package/src/converge-job-gcp.test.ts +222 -0
  176. package/src/converge-job-gcp.ts +443 -0
  177. package/src/converge-job-setup.test.ts +152 -0
  178. package/src/converge-job-setup.ts +268 -0
  179. package/src/converge-job.test.ts +486 -0
  180. package/src/converge-job.ts +542 -0
  181. package/src/gateway-env-isolation.test.ts +8 -1
  182. package/src/gcp-names.ts +43 -1
  183. package/src/gcp-url-map.test.ts +9 -1
  184. package/src/index.ts +8 -0
  185. package/src/infra-contract.test.ts +7 -4
  186. package/src/infra-contract.ts +28 -1
  187. package/src/install-job.test.ts +3 -1
  188. package/src/install-job.ts +31 -14
  189. package/src/lifecycle.ts +137 -71
  190. package/src/operator-access.test.ts +14 -0
  191. package/src/operator-access.ts +13 -3
  192. package/src/private-file.test.ts +80 -0
  193. package/src/private-file.ts +50 -0
  194. package/src/release-contract.ts +8 -2
  195. package/src/release-manifest.test.ts +42 -0
  196. package/src/release-manifest.ts +2 -0
  197. package/src/remote-mcp-edge.ts +137 -0
  198. package/src/remote-mcp.test.ts +517 -0
  199. package/src/remote-mcp.ts +460 -0
  200. package/src/runtime-secrets.test.ts +31 -6
  201. package/src/runtime-secrets.ts +41 -3
  202. package/src/seal-key-rotation.test.ts +131 -0
  203. package/src/seal-key-rotation.ts +258 -0
  204. package/src/stack-contract-parity.test.ts +20 -3
  205. package/src/stack-contract.test.ts +34 -2
  206. package/src/stack-contract.ts +305 -3
  207. package/src/stack-converge-secrets.test.ts +3 -0
  208. package/src/stack-converge.test.ts +21 -5
  209. package/src/stack-converge.ts +101 -7
  210. package/src/state-attach.test.ts +7 -1
  211. package/src/state-lock-lease.test.ts +302 -0
  212. package/src/state-lock-lease.ts +423 -0
  213. package/src/state-lock.test.ts +568 -0
  214. package/src/state-store.ts +347 -27
  215. package/src/tar-entries.test.ts +149 -7
  216. package/src/tar-entries.ts +90 -9
  217. package/src/upgrade/app-roll.ts +135 -21
  218. package/src/upgrade/audit-job-roll.ts +68 -0
  219. package/src/upgrade/converge.ts +170 -7
  220. package/src/upgrade/gcp-lookup.test.ts +80 -0
  221. package/src/upgrade/gcp-lookup.ts +65 -8
  222. package/src/upgrade/record-rolled.test.ts +105 -0
  223. package/src/upgrade/record-rolled.ts +91 -0
  224. package/src/upgrade/upgrade.test.ts +279 -5
  225. package/src/upgrade/upgrade.ts +120 -24
  226. package/src/upgrader-assess.test.ts +93 -3
  227. package/src/upgrader-assess.ts +58 -9
  228. package/src/upgrader-gcp.ts +47 -8
  229. package/src/upgrader-iam-preflight.test.ts +393 -0
  230. package/src/upgrader-iam-preflight.ts +510 -0
  231. package/src/upgrader-job.test.ts +587 -15
  232. package/src/upgrader-job.ts +237 -53
  233. package/src/upgrader-records.ts +151 -0
  234. package/src/upgrader-reporter.test.ts +6 -0
  235. package/src/upgrader-reporter.ts +7 -2
  236. package/src/upgrader-setup.test.ts +4 -3
  237. package/src/upgrader-setup.ts +137 -13
  238. package/src/wizards/gcp-cost.ts +15 -6
  239. package/src/wizards/gcp-pulumi.ts +7 -0
  240. package/src/wizards/gcp.ts +162 -4
  241. package/stacks/aws/index.ts +18 -0
  242. package/stacks/azure/index.ts +18 -0
  243. package/stacks/gcp/index.ts +59 -7
  244. package/stacks/gcp-firebase/index.ts +50 -6
@@ -0,0 +1,360 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #875 — set up (and converge) the parallel quality-audit job on a Google
5
+ * Cloud install (`gcp`, `gcp-firebase`). Run by the wizard after `pulumi up`
6
+ * and by `cms-provision upgrade`'s converge, both as the operator's own
7
+ * gcloud identity. App-roll moves the job's image with the admin's
8
+ * (upgrade/app-roll.ts), because the job runs the admin image.
9
+ *
10
+ * Why a separate job at all (CLAUDE.md §11.B Tier 5 exception): Lighthouse
11
+ * is CPU-bound and simulates the CPU slowdown from observed task durations,
12
+ * so a run that shares a CPU with another run or with the editor's chat
13
+ * measures that interference too. One task per run measures the page alone,
14
+ * and all runs finish in about the time of one.
15
+ *
16
+ * Why the CLI owns it and not the stacks: `upgrade` never runs Pulumi, so
17
+ * existing installs get the job only from here, and a bucket or job both
18
+ * Pulumi and `upgrade` create would collide on the next wizard re-run (the
19
+ * same reasoning as operator-access.ts and upgrader-setup.ts).
20
+ *
21
+ * Trust boundary. The job's service account, `caelo-<env>-audit`, holds:
22
+ *
23
+ * - `roles/storage.objectCreator` on the audit results bucket — create
24
+ * only: it can neither read results back nor overwrite one;
25
+ * - on `gcp` only, `roles/storage.objectViewer` on the staging and static
26
+ * buckets: it serves the staged build to its own browser, as the admin's
27
+ * audit does (admin-core quality/staged-origin.ts). gcp-firebase audits
28
+ * the public preview channel and needs no bucket;
29
+ * - `roles/logging.logWriter`.
30
+ *
31
+ * Nothing else: no Secret Manager, no Cloud SQL, and no VPC egress, so the
32
+ * database's private IP is not even routable from a task. Plugin data on a
33
+ * page comes the way any visitor gets it: the task's browser calls the
34
+ * site's public `/api/*` (the gateway, behind Cloud Armor) over the
35
+ * internet. The admin's run
36
+ * SA may run the job WITH overrides (`roles/run.jobsExecutorWithOverrides`:
37
+ * it passes each audit's batch of orders), read its executions
38
+ * (`roles/run.viewer`) and read the results bucket. Unlike the
39
+ * operator-access and upgrader jobs, overrides are acceptable here: a
40
+ * compromised admin can make the job load other URLs, but the job holds
41
+ * nothing worth taking. {@link AUDIT_FORBIDDEN_ROLES} names that line; the
42
+ * tests assert no grant crosses it.
43
+ */
44
+
45
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
46
+ import { tmpdir } from "node:os";
47
+ import { join } from "node:path";
48
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
49
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
50
+ import {
51
+ auditJobName,
52
+ auditServiceAccountEmail,
53
+ auditServiceAccountId,
54
+ gcpBucketName,
55
+ runServiceAccountEmail,
56
+ } from "./gcp-names.js";
57
+ import { readAdminConfig } from "./migration-runner.js";
58
+ import type { GcpProvider } from "./stack-contract.js";
59
+ import { describeAuditJobImage } from "./upgrade/audit-job-roll.js";
60
+
61
+ /** Where the admin image ships the task's entry point. */
62
+ export const AUDIT_TASK_SCRIPT = "/app/packages/admin-core/src/quality/audit-task.ts";
63
+
64
+ /** How many audit tasks run at once by default: 5 pages x 3 runs. */
65
+ export const AUDIT_PARALLELISM_DEFAULT = 15;
66
+
67
+ /**
68
+ * Per task: container start, browser start, CPU benchmark and one
69
+ * Lighthouse run. Matches admin-core `parallelAuditTimeoutMs`, which the
70
+ * admin also passes per execution.
71
+ */
72
+ const TASK_TIMEOUT = "5m";
73
+
74
+ /**
75
+ * Roles the audit job's identity must never hold anywhere. Prefix matches
76
+ * (`roles/secretmanager.` = every Secret Manager role).
77
+ */
78
+ export const AUDIT_FORBIDDEN_ROLES = [
79
+ "roles/owner",
80
+ "roles/editor",
81
+ "roles/run.",
82
+ "roles/iam.",
83
+ "roles/secretmanager.",
84
+ "roles/cloudsql.",
85
+ "roles/compute.",
86
+ "roles/resourcemanager.",
87
+ "roles/storage.admin",
88
+ "roles/storage.objectAdmin",
89
+ "roles/storage.objectUser",
90
+ ] as const;
91
+
92
+ /** Delete results after a day: the admin reads them within minutes. */
93
+ export const AUDIT_RESULTS_LIFECYCLE = {
94
+ rule: [{ action: { type: "Delete" }, condition: { age: 1 } }],
95
+ };
96
+
97
+ export interface AuditJobTarget {
98
+ readonly provider: GcpProvider;
99
+ readonly projectId: string;
100
+ readonly region: string;
101
+ /** Pulumi stack name (`production`). */
102
+ readonly env: string;
103
+ /** The admin image the job runs, digest-pinned (`…/admin@sha256:…`). */
104
+ readonly adminImageRef: string;
105
+ /**
106
+ * Tasks at once (`auditParallelism`, 1–15). Absent on converge: an
107
+ * existing job keeps the value it has, so an operator's choice survives
108
+ * `upgrade`; a job the converge creates gets
109
+ * {@link AUDIT_PARALLELISM_DEFAULT}, as a fresh install's does.
110
+ */
111
+ readonly parallelism?: number;
112
+ }
113
+
114
+ export type AuditJobResult =
115
+ | { readonly ok: true; readonly done: string[] }
116
+ | { readonly ok: false; readonly done: string[]; readonly error: string };
117
+
118
+ /** Ensure the audit job and everything it needs. Never throws for a gcloud failure. */
119
+ export async function ensureAuditJob(
120
+ target: AuditJobTarget,
121
+ deps: { run?: GcloudRunner; sleep?: Sleep } = {},
122
+ ): Promise<AuditJobResult> {
123
+ const run = deps.run ?? defaultGcloud;
124
+ const sleep = deps.sleep ?? realSleep;
125
+ const retry = (args: string[]) => runWithRetry(run, sleep, args);
126
+ const { projectId, region, env, provider } = target;
127
+ const project = `--project=${projectId}`;
128
+ const sa = auditServiceAccountEmail(projectId, env);
129
+ const member = `serviceAccount:${sa}`;
130
+ const runSaMember = `serviceAccount:${runServiceAccountEmail(projectId, env)}`;
131
+ const job = auditJobName(env);
132
+ const results = gcpBucketName(projectId, env, "audit-results");
133
+ const resultsUrl = `gs://${results}`;
134
+ const done: string[] = [];
135
+ const fail = (step: string, r: GcloudResult): AuditJobResult => ({
136
+ ok: false,
137
+ done,
138
+ error: `${step}: ${r.stderr.trim()}`,
139
+ });
140
+
141
+ if (!/@sha256:[0-9a-f]{64}$/.test(target.adminImageRef)) {
142
+ return {
143
+ ok: false,
144
+ done,
145
+ error: `the audit job's image must be pinned by digest, got ${target.adminImageRef}`,
146
+ };
147
+ }
148
+ if (
149
+ target.parallelism !== undefined &&
150
+ (!Number.isInteger(target.parallelism) || target.parallelism < 1 || target.parallelism > 15)
151
+ ) {
152
+ return {
153
+ ok: false,
154
+ done,
155
+ error: `auditParallelism must be a whole number from 1 to 15, got ${target.parallelism}`,
156
+ };
157
+ }
158
+
159
+ // 1. The job's own service account.
160
+ const describe = await run([
161
+ "iam",
162
+ "service-accounts",
163
+ "describe",
164
+ sa,
165
+ project,
166
+ "--format=value(email)",
167
+ ]);
168
+ if (!describe.ok) {
169
+ const create = await run([
170
+ "iam",
171
+ "service-accounts",
172
+ "create",
173
+ auditServiceAccountId(env),
174
+ "--display-name=Caelo quality audit",
175
+ "--description=Runs the quality audit's Lighthouse runs in parallel; reads the staged build, writes audit results, nothing else.",
176
+ project,
177
+ ]);
178
+ if (!create.ok && !/already exists/i.test(create.stderr)) return fail(`create ${sa}`, create);
179
+ }
180
+ done.push(`service account ${sa}`);
181
+
182
+ // 2. The results bucket: private, results deleted after a day.
183
+ const owned = await run(["storage", "buckets", "list", project, "--format=value(name)"]);
184
+ if (!owned.ok) return fail(`list the buckets of project ${projectId}`, owned);
185
+ const names = owned.stdout.split("\n").map((l) =>
186
+ l
187
+ .trim()
188
+ .replace(/^gs:\/\//, "")
189
+ .replace(/\/$/, ""),
190
+ );
191
+ if (!names.includes(results)) {
192
+ const created = await run([
193
+ "storage",
194
+ "buckets",
195
+ "create",
196
+ resultsUrl,
197
+ project,
198
+ `--location=${region}`,
199
+ "--default-storage-class=STANDARD",
200
+ "--uniform-bucket-level-access",
201
+ "--public-access-prevention",
202
+ "--soft-delete-duration=0",
203
+ ]);
204
+ if (!created.ok) {
205
+ return {
206
+ ok: false,
207
+ done,
208
+ error: /already exists|409/i.test(created.stderr)
209
+ ? `${resultsUrl} exists but does not belong to project ${projectId}; delete or rename that bucket, this install needs the name`
210
+ : `create ${resultsUrl}: ${created.stderr.trim()}`,
211
+ };
212
+ }
213
+ }
214
+ // A private directory (mkdtemp, mode 700): no other local user can swap
215
+ // the file between write and read.
216
+ const dir = mkdtempSync(join(tmpdir(), "caelo-audit-"));
217
+ try {
218
+ const file = join(dir, "lifecycle.json");
219
+ writeFileSync(file, JSON.stringify(AUDIT_RESULTS_LIFECYCLE), { mode: 0o600 });
220
+ const lifecycle = await run([
221
+ "storage",
222
+ "buckets",
223
+ "update",
224
+ resultsUrl,
225
+ project,
226
+ `--lifecycle-file=${file}`,
227
+ ]);
228
+ if (!lifecycle.ok) return fail(`set the lifecycle of ${resultsUrl}`, lifecycle);
229
+ } finally {
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ done.push(`bucket ${resultsUrl} (results deleted after 1 day)`);
233
+
234
+ // 3. Bucket grants: the job creates results and (gcp) reads the build;
235
+ // the admin reads results.
236
+ const bucketGrants: { bucket: string; member: string; role: string; why: string }[] = [
237
+ { bucket: results, member, role: "roles/storage.objectCreator", why: "tasks write results" },
238
+ ...(provider === "gcp"
239
+ ? (["staging", "static"] as const).map((b) => ({
240
+ bucket: gcpBucketName(projectId, env, b),
241
+ member,
242
+ role: "roles/storage.objectViewer",
243
+ why: "tasks serve the staged build to their browser",
244
+ }))
245
+ : []),
246
+ {
247
+ bucket: results,
248
+ member: runSaMember,
249
+ role: "roles/storage.objectViewer",
250
+ why: "the admin reads the results",
251
+ },
252
+ ];
253
+ for (const g of bucketGrants) {
254
+ const r = await retry([
255
+ "storage",
256
+ "buckets",
257
+ "add-iam-policy-binding",
258
+ `gs://${g.bucket}`,
259
+ `--member=${g.member}`,
260
+ `--role=${g.role}`,
261
+ project,
262
+ "--quiet",
263
+ "--format=none",
264
+ ]);
265
+ if (!r.ok) return fail(`grant ${g.role} on gs://${g.bucket} (${g.why})`, r);
266
+ done.push(`${g.member === member ? "audit job" : "admin"}: ${g.role} on gs://${g.bucket}`);
267
+ }
268
+
269
+ // 4. Logs.
270
+ const logs = await retry([
271
+ "projects",
272
+ "add-iam-policy-binding",
273
+ projectId,
274
+ `--member=${member}`,
275
+ "--role=roles/logging.logWriter",
276
+ "--condition=None",
277
+ "--quiet",
278
+ "--format=none",
279
+ ]);
280
+ if (!logs.ok) return fail("grant the audit job roles/logging.logWriter", logs);
281
+ done.push("audit job: roles/logging.logWriter on the project");
282
+
283
+ // 5. The job: the admin image, its own SA, no secrets, no VPC. A job
284
+ // created without a parallelism would run every task at once with no
285
+ // cap; an existing job keeps its own.
286
+ let parallelism = target.parallelism;
287
+ if (parallelism === undefined) {
288
+ const existing = await describeAuditJobImage(run, { projectId, region, env });
289
+ if (!existing.ok) return { ok: false, done, error: `read job ${job}: ${existing.error}` };
290
+ if (existing.image === null) parallelism = AUDIT_PARALLELISM_DEFAULT;
291
+ }
292
+ const envVars = [
293
+ `CAELO_PROVIDER=${provider}`,
294
+ `CAELO_AUDIT_RESULTS_BUCKET=${results}`,
295
+ ...(provider === "gcp"
296
+ ? [
297
+ `CAELO_STAGING_BUCKET=${gcpBucketName(projectId, env, "staging")}`,
298
+ `CAELO_STATIC_BUCKET=${gcpBucketName(projectId, env, "static")}`,
299
+ ]
300
+ : []),
301
+ ];
302
+ const deploy = await run([
303
+ "run",
304
+ "jobs",
305
+ "deploy",
306
+ job,
307
+ `--image=${target.adminImageRef}`,
308
+ `--region=${region}`,
309
+ project,
310
+ `--service-account=${sa}`,
311
+ "--command=bun",
312
+ `--args=--bun,${AUDIT_TASK_SCRIPT}`,
313
+ `--set-env-vars=${envVars.join(",")}`,
314
+ "--max-retries=0",
315
+ `--task-timeout=${TASK_TIMEOUT}`,
316
+ // Lighthouse wants two cores: the browser and the trace processing.
317
+ "--cpu=2",
318
+ "--memory=2Gi",
319
+ ...(parallelism !== undefined ? [`--parallelism=${parallelism}`] : []),
320
+ "--quiet",
321
+ ]);
322
+ if (!deploy.ok) return fail(`deploy job ${job}`, deploy);
323
+ done.push(`job ${job} (${target.adminImageRef.split("@")[1]?.slice(0, 19)}...)`);
324
+
325
+ // 6. The admin may run it with its batch and read its executions.
326
+ for (const role of ["roles/run.jobsExecutorWithOverrides", "roles/run.viewer"]) {
327
+ const r = await retry([
328
+ "run",
329
+ "jobs",
330
+ "add-iam-policy-binding",
331
+ job,
332
+ `--region=${region}`,
333
+ project,
334
+ `--member=${runSaMember}`,
335
+ `--role=${role}`,
336
+ "--quiet",
337
+ "--format=none",
338
+ ]);
339
+ if (!r.ok) return fail(`grant the admin ${role} on ${job}`, r);
340
+ done.push(`admin: ${role} on ${job}`);
341
+ }
342
+ return { ok: true, done };
343
+ }
344
+
345
+ /**
346
+ * {@link ensureAuditJob}'s image: the deployed admin's, so the job runs the
347
+ * code the admin runs.
348
+ */
349
+ export async function resolveAdminImage(
350
+ opts: { readonly projectId: string; readonly region: string; readonly env: string },
351
+ deps: { run?: GcloudRunner } = {},
352
+ ): Promise<{ ok: true; imageRef: string } | { ok: false; error: string }> {
353
+ const cfg = await readAdminConfig(opts, deps.run ?? defaultGcloud);
354
+ if (!cfg)
355
+ return {
356
+ ok: false,
357
+ error: "could not read the admin service's image (is the admin deployed?)",
358
+ };
359
+ return { ok: true, imageRef: cfg.imageRef };
360
+ }
@@ -47,6 +47,8 @@ const LIVE = "example.test";
47
47
  const STAGING = "staging.example.test";
48
48
  const CADDY_DIR = resolve(import.meta.dir, "../caddy");
49
49
  const SERVED = ["index.html", "about/index.html", "blog/index.html"];
50
+ /** Consecutive matching answers that count as "the reload settled". */
51
+ const SETTLED_PROBES = 5;
50
52
 
51
53
  function docker(args: string[]): { ok: boolean; out: string } {
52
54
  const r = Bun.spawnSync(["docker", ...args], { stdout: "pipe", stderr: "pipe" });
@@ -100,11 +102,25 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
100
102
  const location = head.match(/^location: ([^\r\n]*)/im)?.[1]?.trim() ?? null;
101
103
  return { status, location };
102
104
  };
105
+ /**
106
+ * Waits until `path` answers with `location` on {@link SETTLED_PROBES}
107
+ * consecutive connections. One matching answer is not enough: Caddy's
108
+ * graceful reload keeps the old config's listener open next to the new
109
+ * one (SO_REUSEPORT) while it drains, so the connection after the first
110
+ * new answer can still reach the old config (CI runs 38867358899 and
111
+ * 39242522450). Only a streak of new answers shows the reload settled.
112
+ */
103
113
  const eventually = async (host: string, path: string, location: string | null) => {
104
114
  const deadline = Date.now() + 30_000;
115
+ let streak = 0;
105
116
  for (;;) {
106
117
  const got = probe(host, path).location;
107
- if (got === location) return;
118
+ streak = got === location ? streak + 1 : 0;
119
+ if (streak >= SETTLED_PROBES) return;
120
+ if (got === location) {
121
+ await Bun.sleep(200);
122
+ continue;
123
+ }
108
124
  if (Date.now() > deadline) {
109
125
  throw new Error(
110
126
  `${host}${path} → ${got}, expected ${location}: ${docker(["logs", name]).out}`,
@@ -241,11 +257,8 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
241
257
  it("the reload loop picks up a deploy's new snippet", async () => {
242
258
  write("production", snippet([["/brand-new", "/blog", 301]]));
243
259
  await eventually(LIVE, "/brand-new", "/blog/");
244
- // Caddy's graceful reload keeps the old config's listener open for a
245
- // moment next to the new one (SO_REUSEPORT), so a connection right
246
- // after the first new answer can still reach the old config: the old
247
- // redirect may answer once more (CI run 38867358899). What must hold is
248
- // that the new snippet REPLACES the old one once the reload settled.
260
+ // What must hold is that the new snippet REPLACES the old one once the
261
+ // reload settled (see `eventually` for why one answer is not enough).
249
262
  await eventually(LIVE, "/old", null);
250
263
  expect(probe(LIVE, "/old").status).toBe(404);
251
264
  expect(probe(LIVE, "/brand-new").location).toBe("/blog/");
package/src/caddy.test.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
5
  type CaddyfileSpec,
6
+ caddyTokenProblem,
6
7
  generateCaddyfile,
7
8
  selfHostedAdminHost,
8
9
  selfHostedSeedDomains,
@@ -105,4 +106,33 @@ describe("Caddyfile TLS", () => {
105
106
  const out = generateCaddyfile({ ...spec, tls: "internal" });
106
107
  expect(out.startsWith("{\n email owner@example.com\n local_certs\n}\n")).toBe(true);
107
108
  });
109
+
110
+ it("rejects unsafe characters in ownerEmail and domain hostnames", () => {
111
+ expect(() => generateCaddyfile({ ...spec, ownerEmail: "owner@example.com\n debug" })).toThrow(
112
+ /invalid ownerEmail/,
113
+ );
114
+ expect(() =>
115
+ generateCaddyfile({
116
+ ...spec,
117
+ domains: [
118
+ { hostname: "evil.example.com {\n respond 200\n}", kind: "public", env: "production" },
119
+ ],
120
+ }),
121
+ ).toThrow(/invalid hostname/);
122
+ });
123
+
124
+ it("refuses a comma in a site address — it would add a second address to the block", () => {
125
+ // `admin.example.com,:80` would serve the admin on every host on port 80.
126
+ expect(() =>
127
+ generateCaddyfile({
128
+ ...spec,
129
+ domains: [{ hostname: "admin.example.com,:80", kind: "admin", env: "production" }],
130
+ }),
131
+ ).toThrow(/invalid hostname/);
132
+ // A comma is harmless inside a token like the email.
133
+ expect(caddyTokenProblem("a,b@example.com", "token")).toBeNull();
134
+ expect(caddyTokenProblem("a,b.example.com", "address")).toMatch(/','/);
135
+ expect(caddyTokenProblem("", "token")).toBe("it is empty");
136
+ expect(caddyTokenProblem("192.168.1.10", "address")).toBeNull();
137
+ });
108
138
  });
package/src/caddy.ts CHANGED
@@ -103,9 +103,52 @@ export function selfHostedSeedDomains(domain: string, stagingDomain: string): Ca
103
103
  ];
104
104
  }
105
105
 
106
+ /**
107
+ * Characters that would let a value end its Caddyfile token and start new
108
+ * directives or blocks: whitespace (incl. newlines), both quote styles Caddy
109
+ * tokenizes (`"` and backtick), braces (blocks and `{placeholders}`), the
110
+ * comment marker, escapes and semicolons. A site address additionally may
111
+ * not hold a comma, which would add further addresses to the same block.
112
+ */
113
+ const UNSAFE_CADDY_TOKEN_RE = /[\s"`{}#\\;]/;
114
+ const UNSAFE_CADDY_ADDRESS_RE = /[\s"`{}#\\;,]/;
115
+
116
+ /**
117
+ * Why `value` cannot go into the Caddyfile as one token, or null when it can.
118
+ * `init` checks the operator's domains and email with this before it writes
119
+ * anything, so a bad value never leaves a half-made install behind.
120
+ *
121
+ * @param kind `address` for a site address (hostname), `token` for any other value
122
+ */
123
+ export function caddyTokenProblem(value: string, kind: "address" | "token"): string | null {
124
+ if (value.length === 0) return "it is empty";
125
+ const unsafe = kind === "address" ? UNSAFE_CADDY_ADDRESS_RE : UNSAFE_CADDY_TOKEN_RE;
126
+ if (unsafe.test(value)) {
127
+ return `it contains whitespace, a quote, a brace, '#', '\\', ';'${kind === "address" ? " or ','" : ""}`;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function assertSafeCaddyToken(label: string, value: string, kind: "address" | "token"): void {
133
+ const problem = caddyTokenProblem(value, kind);
134
+ if (problem) {
135
+ throw new Error(
136
+ `Caddyfile: invalid ${label} ${JSON.stringify(value)} — ${problem}. Fix the value in .caelo/config.json (or the domain you added) and run the command again.`,
137
+ );
138
+ }
139
+ }
140
+
141
+ /** Every caller-supplied value is checked before any of it is emitted (#888). */
106
142
  export function generateCaddyfile(spec: CaddyfileSpec): string {
143
+ assertSafeCaddyToken("ownerEmail", spec.ownerEmail, "token");
144
+ assertSafeCaddyToken("publicSiteRoot", spec.publicSiteRoot, "token");
145
+ assertSafeCaddyToken("stagingSiteRoot", spec.stagingSiteRoot, "token");
146
+ assertSafeCaddyToken("adminUpstream", spec.adminUpstream, "token");
147
+ assertSafeCaddyToken("gatewayUpstream", spec.gatewayUpstream, "token");
148
+
107
149
  const hostnames = new Set<string>();
108
150
  for (const d of spec.domains) {
151
+ assertSafeCaddyToken("hostname", d.hostname, "address");
109
152
  if (hostnames.has(d.hostname)) {
110
153
  // Caddy refuses a config with two site blocks for one address, so a
111
154
  // duplicate would take every vhost down, not just this one.
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+
7
+ const CLI = resolve(import.meta.dir, "cli.ts");
8
+
9
+ let dir: string;
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "caelo-init-validation-"));
12
+ });
13
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
14
+
15
+ function init(...args: string[]) {
16
+ return Bun.spawnSync([process.execPath, CLI, "init", ...args], {
17
+ env: { ...process.env, CAELO_SELF_HOSTED_DIR: join(dir, ".caelo") },
18
+ stdout: "pipe",
19
+ stderr: "pipe",
20
+ });
21
+ }
22
+
23
+ describe("cms-provision init refuses a value the Caddyfile cannot hold before writing anything", () => {
24
+ // Checked only when the Caddyfile was generated, a bad value left
25
+ // config.json behind, and the re-run stopped at "config already exists".
26
+ for (const [flag, value] of [
27
+ ["--domain", "example.com,:80"],
28
+ ["--domain", "example.com {\n respond 200\n}"],
29
+ ["--owner-email", "owner@example.com\n debug"],
30
+ ["--staging-domain", "staging.example.com #"],
31
+ ] as const) {
32
+ it(`${flag} ${JSON.stringify(value)}`, () => {
33
+ const base: Record<string, string> = {
34
+ "--domain": "example.com",
35
+ "--owner-email": "owner@example.com",
36
+ };
37
+ base[flag] = value;
38
+ const r = init(...Object.entries(base).flat());
39
+ expect(r.exitCode).toBe(2);
40
+ expect(r.stderr.toString()).toContain(flag);
41
+ expect(existsSync(join(dir, ".caelo"))).toBe(false);
42
+ });
43
+ }
44
+ });
@@ -111,7 +111,7 @@ export const CLI_COMMANDS: readonly CliCommandDoc[] = [
111
111
  },
112
112
  {
113
113
  name: "rotate-secret",
114
- args: "<name>",
114
+ args: "<name> [--finish [--now]]",
115
115
  summary: "Rotate one of the install's runtime secrets; run without a name to list them.",
116
116
  },
117
117
  {
package/src/cli.ts CHANGED
@@ -51,6 +51,7 @@ import { generateBootstrapToken } from "./bootstrap-token.js";
51
51
  import {
52
52
  type CaddyDomainSpec,
53
53
  type CaddyTlsMode,
54
+ caddyTokenProblem,
54
55
  generateCaddyfile,
55
56
  selfHostedAdminHost,
56
57
  selfHostedSeedDomains,
@@ -71,6 +72,7 @@ import {
71
72
  SITE_OUTPUT_ROOT,
72
73
  selfHostedSiteUrl,
73
74
  } from "./compose.js";
75
+ import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
74
76
  import { selfCommand } from "./self-command.js";
75
77
  import {
76
78
  bunHostRunner,
@@ -192,8 +194,8 @@ function loadProvider(): Provider {
192
194
  }
193
195
 
194
196
  function saveProvider(provider: Provider): void {
195
- if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
196
- writeFileSync(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
197
+ if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true, mode: 0o700 });
198
+ writePrivateFile(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
197
199
  }
198
200
 
199
201
  /**
@@ -224,6 +226,20 @@ function randomSecret(bytes = 32): string {
224
226
 
225
227
  function loadConfig(): CaeloConfig | null {
226
228
  if (!existsSync(CONFIG_PATH)) return null;
229
+ // Every command that works on an install reads its config first, so this
230
+ // is where an install made by an older CLI loses its world-readable
231
+ // secrets (#888) — not only once each file happens to be rewritten.
232
+ tightenPrivateTree(
233
+ CAELO_DIR,
234
+ [BOOTSTRAP_DIR, "backups"],
235
+ [
236
+ "config.json",
237
+ "provider.json",
238
+ "internal-secret.json",
239
+ "docker-compose.yml",
240
+ `${BOOTSTRAP_DIR}/pending-token.json`,
241
+ ],
242
+ );
227
243
  const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as CaeloConfig;
228
244
  // #707 — settings added later, back-filled (and stored) with what every
229
245
  // install ran before they existed, so `upgrade` / `agent` render the same
@@ -238,8 +254,8 @@ function loadConfig(): CaeloConfig | null {
238
254
  }
239
255
 
240
256
  function saveConfig(c: CaeloConfig): void {
241
- if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
242
- writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2));
257
+ if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true, mode: 0o700 });
258
+ writePrivateFile(CONFIG_PATH, JSON.stringify(c, null, 2));
243
259
  }
244
260
 
245
261
  function arg(name: string): string | undefined {
@@ -293,7 +309,7 @@ function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): voi
293
309
  * operator-added domains only `up` / `regenerate-caddy` read back).
294
310
  */
295
311
  function emitComposeFiles(cfg: CaeloConfig): void {
296
- writeFileSync(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)), { mode: 0o600 });
312
+ writePrivateFile(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)));
297
313
  // No secrets (it reads the passwords from the container's environment),
298
314
  // and the postgres user inside the container must be able to read it.
299
315
  writeFileSync(
@@ -527,6 +543,19 @@ async function init(): Promise<void> {
527
543
  console.error(`--staging-domain: ${stagingProblem}`);
528
544
  process.exit(2);
529
545
  }
546
+ // The Caddyfile check would only fire in emitConfig, after config.json is
547
+ // written — and a re-run then stops at "config already exists".
548
+ for (const [flag, value, kind] of [
549
+ ["--domain", domain, "address"],
550
+ ["--staging-domain", stagingArg, "address"],
551
+ ["--owner-email", ownerEmail, "token"],
552
+ ] as const) {
553
+ const problem = value === undefined ? null : caddyTokenProblem(value, kind);
554
+ if (problem) {
555
+ console.error(`${flag} ${JSON.stringify(value)}: ${problem}.`);
556
+ process.exit(2);
557
+ }
558
+ }
530
559
  // #696 — the prebuilt release images, resolved to digests and
531
560
  // cosign-verified BEFORE anything is written, so a failed check leaves no
532
561
  // half-made install. `--dev` (contributors only) runs this checkout instead.
@@ -589,7 +618,7 @@ async function init(): Promise<void> {
589
618
  // staging file.
590
619
  const tok = generateBootstrapToken();
591
620
  mkdirSync(resolve(CAELO_DIR, BOOTSTRAP_DIR), { recursive: true, mode: 0o700 });
592
- writeFileSync(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2), { mode: 0o600 });
621
+ writePrivateFile(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2));
593
622
 
594
623
  // P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
595
624
  // Same secret is read by:
@@ -601,7 +630,7 @@ async function init(): Promise<void> {
601
630
  // internal-secret`.
602
631
  const internalSecret = randomSecret(48);
603
632
  const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
604
- writeFileSync(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
633
+ writePrivateFile(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
605
634
  // #696 Phase 4c — the update agent (Linux + systemd): the fresh volume's
606
635
  // bootstrap.sh creates its database login on Postgres's first start.
607
636
  const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
@@ -1187,7 +1216,10 @@ async function lifecycleBackup(): Promise<void> {
1187
1216
  }
1188
1217
  async function lifecycleRotateSecret(): Promise<void> {
1189
1218
  const { rotateSecretCommand } = await import("./lifecycle.js");
1190
- await rotateSecretCommand(process.argv[3]);
1219
+ await rotateSecretCommand(process.argv[3], {
1220
+ finish: process.argv.includes("--finish"),
1221
+ now: process.argv.includes("--now"),
1222
+ });
1191
1223
  }
1192
1224
  async function lifecycleDestroy(): Promise<void> {
1193
1225
  const { destroyCommand } = await import("./lifecycle.js");