@caelo-cms/provisioning 0.10.47 → 0.10.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/apply.d.ts +45 -0
  4. package/dist/agent/apply.d.ts.map +1 -0
  5. package/dist/agent/apply.js +83 -0
  6. package/dist/agent/apply.js.map +1 -0
  7. package/dist/agent/cli-provenance.d.ts +42 -0
  8. package/dist/agent/cli-provenance.d.ts.map +1 -0
  9. package/dist/agent/cli-provenance.js +138 -0
  10. package/dist/agent/cli-provenance.js.map +1 -0
  11. package/dist/agent/command.d.ts +79 -0
  12. package/dist/agent/command.d.ts.map +1 -0
  13. package/dist/agent/command.js +454 -0
  14. package/dist/agent/command.js.map +1 -0
  15. package/dist/agent/config.d.ts +30 -0
  16. package/dist/agent/config.d.ts.map +1 -0
  17. package/dist/agent/config.js +60 -0
  18. package/dist/agent/config.js.map +1 -0
  19. package/dist/agent/lock.d.ts +13 -0
  20. package/dist/agent/lock.d.ts.map +1 -0
  21. package/dist/agent/lock.js +100 -0
  22. package/dist/agent/lock.js.map +1 -0
  23. package/dist/agent/queue.d.ts +35 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +50 -0
  26. package/dist/agent/queue.js.map +1 -0
  27. package/dist/agent/run.d.ts +97 -0
  28. package/dist/agent/run.d.ts.map +1 -0
  29. package/dist/agent/run.js +198 -0
  30. package/dist/agent/run.js.map +1 -0
  31. package/dist/agent/systemd.d.ts +55 -0
  32. package/dist/agent/systemd.d.ts.map +1 -0
  33. package/dist/agent/systemd.js +171 -0
  34. package/dist/agent/systemd.js.map +1 -0
  35. package/dist/agent/units.d.ts +44 -0
  36. package/dist/agent/units.d.ts.map +1 -0
  37. package/dist/agent/units.js +127 -0
  38. package/dist/agent/units.js.map +1 -0
  39. package/dist/agent-db.js +18402 -0
  40. package/dist/cli.js +119 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/cloudsql-iam-user.d.ts +39 -0
  43. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  44. package/dist/cloudsql-iam-user.js +119 -0
  45. package/dist/cloudsql-iam-user.js.map +1 -0
  46. package/dist/compose.d.ts +16 -0
  47. package/dist/compose.d.ts.map +1 -1
  48. package/dist/compose.js +26 -1
  49. package/dist/compose.js.map +1 -1
  50. package/dist/dns/index.d.ts +2 -0
  51. package/dist/dns/index.d.ts.map +1 -1
  52. package/dist/dns/index.js +3 -2
  53. package/dist/dns/index.js.map +1 -1
  54. package/dist/dns/manual.d.ts +5 -0
  55. package/dist/dns/manual.d.ts.map +1 -1
  56. package/dist/dns/manual.js +28 -12
  57. package/dist/dns/manual.js.map +1 -1
  58. package/dist/gcloud.d.ts +2 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +1 -1
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/gcp-names.d.ts +15 -0
  63. package/dist/gcp-names.d.ts.map +1 -1
  64. package/dist/gcp-names.js +23 -0
  65. package/dist/gcp-names.js.map +1 -1
  66. package/dist/index.d.ts +6 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/infra-contract.d.ts +1 -1
  71. package/dist/infra-contract.js +7 -1
  72. package/dist/infra-contract.js.map +1 -1
  73. package/dist/install-job-runner.d.ts +13 -0
  74. package/dist/install-job-runner.d.ts.map +1 -0
  75. package/dist/install-job-runner.js +155 -0
  76. package/dist/install-job-runner.js.map +1 -0
  77. package/dist/install-job.d.ts +210 -0
  78. package/dist/install-job.d.ts.map +1 -0
  79. package/dist/install-job.js +449 -0
  80. package/dist/install-job.js.map +1 -0
  81. package/dist/install-state.d.ts +10 -1
  82. package/dist/install-state.d.ts.map +1 -1
  83. package/dist/install-state.js +10 -2
  84. package/dist/install-state.js.map +1 -1
  85. package/dist/lifecycle.d.ts.map +1 -1
  86. package/dist/lifecycle.js +19 -4
  87. package/dist/lifecycle.js.map +1 -1
  88. package/dist/migration-runner.d.ts +5 -0
  89. package/dist/migration-runner.d.ts.map +1 -1
  90. package/dist/migration-runner.js +1 -1
  91. package/dist/migration-runner.js.map +1 -1
  92. package/dist/operator-access.d.ts +14 -8
  93. package/dist/operator-access.d.ts.map +1 -1
  94. package/dist/operator-access.js +16 -102
  95. package/dist/operator-access.js.map +1 -1
  96. package/dist/release-manifest.d.ts +2 -41
  97. package/dist/release-manifest.d.ts.map +1 -1
  98. package/dist/release-manifest.js +18 -121
  99. package/dist/release-manifest.js.map +1 -1
  100. package/dist/run-mode.d.ts +27 -0
  101. package/dist/run-mode.d.ts.map +1 -0
  102. package/dist/run-mode.js +22 -0
  103. package/dist/run-mode.js.map +1 -0
  104. package/dist/runtime-secrets.d.ts +19 -0
  105. package/dist/runtime-secrets.d.ts.map +1 -1
  106. package/dist/runtime-secrets.js +44 -0
  107. package/dist/runtime-secrets.js.map +1 -1
  108. package/dist/self-hosted-roles.d.ts +22 -1
  109. package/dist/self-hosted-roles.d.ts.map +1 -1
  110. package/dist/self-hosted-roles.js +81 -12
  111. package/dist/self-hosted-roles.js.map +1 -1
  112. package/dist/self-hosted-upgrade.d.ts +17 -0
  113. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  114. package/dist/self-hosted-upgrade.js +16 -1
  115. package/dist/self-hosted-upgrade.js.map +1 -1
  116. package/dist/stack-contract.d.ts +6 -0
  117. package/dist/stack-contract.d.ts.map +1 -1
  118. package/dist/stack-contract.js +10 -1
  119. package/dist/stack-contract.js.map +1 -1
  120. package/dist/upgrade/converge.d.ts +6 -0
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +59 -2
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  125. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  126. package/dist/upgrade/gcp-lookup.js +25 -0
  127. package/dist/upgrade/gcp-lookup.js.map +1 -1
  128. package/dist/upgrade/release-images.d.ts +26 -2
  129. package/dist/upgrade/release-images.d.ts.map +1 -1
  130. package/dist/upgrade/release-images.js +35 -2
  131. package/dist/upgrade/release-images.js.map +1 -1
  132. package/dist/upgrade/upgrade.d.ts +5 -0
  133. package/dist/upgrade/upgrade.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade.js +28 -1
  135. package/dist/upgrade/upgrade.js.map +1 -1
  136. package/dist/upgrader-assess.d.ts +85 -0
  137. package/dist/upgrader-assess.d.ts.map +1 -0
  138. package/dist/upgrader-assess.js +122 -0
  139. package/dist/upgrader-assess.js.map +1 -0
  140. package/dist/upgrader-gcp.d.ts +17 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -0
  142. package/dist/upgrader-gcp.js +87 -0
  143. package/dist/upgrader-gcp.js.map +1 -0
  144. package/dist/upgrader-job.d.ts +133 -0
  145. package/dist/upgrader-job.d.ts.map +1 -0
  146. package/dist/upgrader-job.js +259 -0
  147. package/dist/upgrader-job.js.map +1 -0
  148. package/dist/upgrader-reporter.d.ts +35 -0
  149. package/dist/upgrader-reporter.d.ts.map +1 -0
  150. package/dist/upgrader-reporter.js +79 -0
  151. package/dist/upgrader-reporter.js.map +1 -0
  152. package/dist/upgrader-setup.d.ts +59 -0
  153. package/dist/upgrader-setup.d.ts.map +1 -0
  154. package/dist/upgrader-setup.js +308 -0
  155. package/dist/upgrader-setup.js.map +1 -0
  156. package/dist/wizard.d.ts +6 -0
  157. package/dist/wizard.d.ts.map +1 -1
  158. package/dist/wizard.js +32 -4
  159. package/dist/wizard.js.map +1 -1
  160. package/dist/wizards/gcp-firebase.d.ts +3 -0
  161. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  162. package/dist/wizards/gcp-firebase.js +1 -0
  163. package/dist/wizards/gcp-firebase.js.map +1 -1
  164. package/dist/wizards/gcp-install-job.d.ts +74 -0
  165. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  166. package/dist/wizards/gcp-install-job.js +398 -0
  167. package/dist/wizards/gcp-install-job.js.map +1 -0
  168. package/dist/wizards/gcp-prompts.d.ts +3 -0
  169. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  170. package/dist/wizards/gcp-prompts.js +25 -0
  171. package/dist/wizards/gcp-prompts.js.map +1 -0
  172. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  173. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  174. package/dist/wizards/gcp-pulumi.js +1 -1
  175. package/dist/wizards/gcp-pulumi.js.map +1 -1
  176. package/dist/wizards/gcp.d.ts +40 -0
  177. package/dist/wizards/gcp.d.ts.map +1 -1
  178. package/dist/wizards/gcp.js +223 -59
  179. package/dist/wizards/gcp.js.map +1 -1
  180. package/package.json +6 -4
  181. package/src/adapter.ts +97 -0
  182. package/src/adc-impersonation.ts +176 -0
  183. package/src/admin-domain.test.ts +254 -0
  184. package/src/admin-domain.ts +245 -0
  185. package/src/admin-memory.test.ts +151 -0
  186. package/src/agent/apply.test.ts +185 -0
  187. package/src/agent/apply.ts +124 -0
  188. package/src/agent/cli-provenance.test.ts +159 -0
  189. package/src/agent/cli-provenance.ts +191 -0
  190. package/src/agent/command.test.ts +77 -0
  191. package/src/agent/command.ts +564 -0
  192. package/src/agent/config.ts +86 -0
  193. package/src/agent/lock-config.test.ts +97 -0
  194. package/src/agent/lock.ts +110 -0
  195. package/src/agent/queue.test.ts +10 -0
  196. package/src/agent/queue.ts +91 -0
  197. package/src/agent/run.test.ts +437 -0
  198. package/src/agent/run.ts +295 -0
  199. package/src/agent/systemd.ts +209 -0
  200. package/src/agent/units.test.ts +223 -0
  201. package/src/agent/units.ts +163 -0
  202. package/src/azure-front-door.test.ts +119 -0
  203. package/src/azure-front-door.ts +56 -0
  204. package/src/bootstrap-token.ts +20 -0
  205. package/src/caddy.ts +112 -0
  206. package/src/cdn-copy.ts +91 -0
  207. package/src/cert-wait.test.ts +194 -0
  208. package/src/cert-wait.ts +163 -0
  209. package/src/checkpoint-secrets.test.ts +380 -0
  210. package/src/checkpoint-secrets.ts +283 -0
  211. package/src/cli-routing.test.ts +45 -0
  212. package/src/cli-routing.ts +43 -0
  213. package/src/cli.ts +1491 -0
  214. package/src/cloudsql-iam-user.ts +147 -0
  215. package/src/compose.ts +334 -0
  216. package/src/default-firewall.test.ts +411 -0
  217. package/src/default-firewall.ts +342 -0
  218. package/src/deployed-release.test.ts +231 -0
  219. package/src/deployed-release.ts +220 -0
  220. package/src/dns/cloudflare.ts +182 -0
  221. package/src/dns/groups.test.ts +76 -0
  222. package/src/dns/groups.ts +86 -0
  223. package/src/dns/index.ts +56 -0
  224. package/src/dns/manual.ts +130 -0
  225. package/src/dns/types.ts +27 -0
  226. package/src/gateway-credentials.test.ts +180 -0
  227. package/src/gateway-credentials.ts +144 -0
  228. package/src/gcloud-components.test.ts +76 -0
  229. package/src/gcloud-components.ts +85 -0
  230. package/src/gcloud-retry.ts +41 -0
  231. package/src/gcloud-runner.test.ts +61 -0
  232. package/src/gcloud.test.ts +99 -0
  233. package/src/gcloud.ts +322 -0
  234. package/src/gcp-names.ts +135 -0
  235. package/src/index.test.ts +332 -0
  236. package/src/index.ts +114 -0
  237. package/src/infra-contract.ts +32 -0
  238. package/src/install-job-runner.ts +166 -0
  239. package/src/install-job.test.ts +566 -0
  240. package/src/install-job.ts +542 -0
  241. package/src/install-state.test.ts +239 -0
  242. package/src/install-state.ts +583 -0
  243. package/src/internal-token.ts +35 -0
  244. package/src/lifecycle.ts +900 -0
  245. package/src/mcp-iap-stacks.test.ts +58 -0
  246. package/src/mcp-iap.test.ts +149 -0
  247. package/src/mcp-iap.ts +131 -0
  248. package/src/media-volume.test.ts +296 -0
  249. package/src/migration-runner.ts +365 -0
  250. package/src/operator-access-stacks.test.ts +55 -0
  251. package/src/operator-access.test.ts +332 -0
  252. package/src/operator-access.ts +554 -0
  253. package/src/ops-backend.test.ts +320 -0
  254. package/src/ops-backend.ts +414 -0
  255. package/src/provisioner-identity.test.ts +482 -0
  256. package/src/provisioner-identity.ts +407 -0
  257. package/src/redirects-emit.ts +175 -0
  258. package/src/region-data.ts +428 -0
  259. package/src/regions.test.ts +277 -0
  260. package/src/regions.ts +370 -0
  261. package/src/release-contract.ts +57 -0
  262. package/src/release-manifest.test.ts +300 -0
  263. package/src/release-manifest.ts +310 -0
  264. package/src/run-mode.ts +40 -0
  265. package/src/runtime-secrets.test.ts +627 -0
  266. package/src/runtime-secrets.ts +754 -0
  267. package/src/self-command.test.ts +14 -0
  268. package/src/self-command.ts +21 -0
  269. package/src/self-hosted-compose.test.ts +177 -0
  270. package/src/self-hosted-release.ts +240 -0
  271. package/src/self-hosted-roles.integration.test.ts +161 -0
  272. package/src/self-hosted-roles.test.ts +124 -0
  273. package/src/self-hosted-roles.ts +203 -0
  274. package/src/self-hosted-upgrade.test.ts +394 -0
  275. package/src/self-hosted-upgrade.ts +548 -0
  276. package/src/stack-contract-parity.test.ts +199 -0
  277. package/src/stack-contract.test.ts +391 -0
  278. package/src/stack-contract.ts +888 -0
  279. package/src/stack-converge-secrets.test.ts +421 -0
  280. package/src/stack-converge.test.ts +833 -0
  281. package/src/stack-converge.ts +909 -0
  282. package/src/staging-admin.test.ts +246 -0
  283. package/src/staging-admin.ts +324 -0
  284. package/src/state-attach.test.ts +211 -0
  285. package/src/state-attach.ts +132 -0
  286. package/src/state-backend.test.ts +145 -0
  287. package/src/state-backend.ts +262 -0
  288. package/src/state-command.ts +293 -0
  289. package/src/state-migrate.test.ts +479 -0
  290. package/src/state-migrate.ts +434 -0
  291. package/src/state-pulumi.ts +289 -0
  292. package/src/state-store.test.ts +212 -0
  293. package/src/state-store.ts +243 -0
  294. package/src/upgrade/app-roll.ts +486 -0
  295. package/src/upgrade/backup.ts +109 -0
  296. package/src/upgrade/converge.ts +437 -0
  297. package/src/upgrade/gcp-lookup.ts +167 -0
  298. package/src/upgrade/health.test.ts +279 -0
  299. package/src/upgrade/health.ts +305 -0
  300. package/src/upgrade/release-images.test.ts +119 -0
  301. package/src/upgrade/release-images.ts +393 -0
  302. package/src/upgrade/reporter.ts +46 -0
  303. package/src/upgrade/upgrade.test.ts +626 -0
  304. package/src/upgrade/upgrade.ts +249 -0
  305. package/src/upgrader-assess.test.ts +306 -0
  306. package/src/upgrader-assess.ts +250 -0
  307. package/src/upgrader-gcp.ts +123 -0
  308. package/src/upgrader-job.test.ts +411 -0
  309. package/src/upgrader-job.ts +502 -0
  310. package/src/upgrader-reporter.test.ts +46 -0
  311. package/src/upgrader-reporter.ts +105 -0
  312. package/src/upgrader-setup.test.ts +169 -0
  313. package/src/upgrader-setup.ts +355 -0
  314. package/src/wizard.ts +425 -0
  315. package/src/wizards/gcp-cost.ts +188 -0
  316. package/src/wizards/gcp-firebase.ts +72 -0
  317. package/src/wizards/gcp-install-job.ts +506 -0
  318. package/src/wizards/gcp-prompts.ts +26 -0
  319. package/src/wizards/gcp-pulumi.test.ts +246 -0
  320. package/src/wizards/gcp-pulumi.ts +516 -0
  321. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,583 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Per-install state — every Caelo install gets its own
5
+ * `~/.caelo-<install-id>/` directory with:
6
+ *
7
+ * secrets/ — mode-700 dir for the install's secrets
8
+ * anthropic-api-key (mode 600) — Anthropic API key (one-time prompt)
9
+ * pulumi-passphrase (mode 600) — Pulumi local-backend passphrase
10
+ * (no service-account key: GCP installs impersonate `caelo-provisioner`
11
+ * with the operator's own Google login, CLAUDE.md §11.C. An
12
+ * `sa-key.json` minted by installers before #696 is deleted —
13
+ * locally and in IAM — on the next `upgrade` or installer run;
14
+ * provisioner-identity.ts)
15
+ * state/ — Pulumi local backend state (per-install isolated);
16
+ * absent once the install's state lives in its
17
+ * own cloud (#696, state-backend.ts)
18
+ * progress.json — wizard checkpoint so re-runs resume cleanly
19
+ * install.json — install metadata (provider, project id, region,
20
+ * domain, owner email, install id, created_at,
21
+ * where the state lives)
22
+ *
23
+ * #696 — for an install whose `stateBackend` is `gcs`, install.json and
24
+ * progress.json live in the install's ops bucket; the copies here are a
25
+ * mirror (state-store.ts). Every read/write below goes through
26
+ * {@link stateStoreFor}.
27
+ *
28
+ * Only `secrets/` (and Pulumi's encrypted state) hold secrets. progress.json
29
+ * and install.json are read by tooling, backups and agents, so writes to
30
+ * them refuse secret material (checkpoint-secrets.ts, #656).
31
+ *
32
+ * The CLAUDE.md §11.C contract: end-users never reach into this directory
33
+ * by hand. The wizard + lifecycle commands wrap every read + write.
34
+ */
35
+
36
+ import {
37
+ chmodSync,
38
+ existsSync,
39
+ mkdirSync,
40
+ readdirSync,
41
+ readFileSync,
42
+ writeFileSync,
43
+ } from "node:fs";
44
+ import { homedir } from "node:os";
45
+ import { join } from "node:path";
46
+ import {
47
+ assertNoSecrets,
48
+ rotationHints,
49
+ type ScrubReport,
50
+ scrubProgressFile,
51
+ } from "./checkpoint-secrets.js";
52
+ import { defaultStagingHost } from "./stack-contract.js";
53
+ import { parseStateBackend, type StateBackendRecord } from "./state-backend.js";
54
+ import {
55
+ GcsStateStore,
56
+ LocalStateStore,
57
+ realSyncGcloud,
58
+ type StateStore,
59
+ type SyncGcloud,
60
+ } from "./state-store.js";
61
+
62
+ export type Provider = "self-hosted" | "gcp" | "gcp-firebase" | "aws" | "azure";
63
+
64
+ export interface InstallMetadata {
65
+ installId: string;
66
+ provider: Provider;
67
+ /** Cloud-side project / account / subscription id. NULL for self-hosted. */
68
+ projectId: string | null;
69
+ domain: string;
70
+ ownerEmail: string;
71
+ region: string | null;
72
+ createdAt: string;
73
+ /**
74
+ * sha256 digests of the release images the install runs, recorded by the
75
+ * wizard after `pulumi up` and by `upgrade` after a roll. A wizard re-run
76
+ * deploys these instead of re-resolving `:latest`, so it never undoes a
77
+ * pinned or rc upgrade; versions change through `upgrade` only. Absent on
78
+ * installs that predate it.
79
+ */
80
+ imageDigests?: ImageDigests;
81
+ /**
82
+ * #696 — the Caelo release (`0.11.0`) those digests belong to, recorded
83
+ * next to them by `upgrade` and the upgrader job. The upgrader job reads
84
+ * the installed version from here, not from the running admin: a
85
+ * compromised admin could claim an older version to get a downgrade
86
+ * past the "newer than installed" check. Absent until the first upgrade
87
+ * that knew it; the job refuses to run without it.
88
+ */
89
+ releaseVersion?: string;
90
+ /**
91
+ * #650 — the protected staging site's host (wizard / `--staging-domain`,
92
+ * default `staging.<domain>`). Absent on installs that predate it;
93
+ * `upgrade` records the default ({@link installStagingHost}).
94
+ */
95
+ stagingDomain?: string;
96
+ /**
97
+ * #696 — where Pulumi's state and this metadata live (state-backend.ts).
98
+ * Absent on installs that predate it: those are `local`.
99
+ */
100
+ stateBackend?: StateBackendRecord;
101
+ /**
102
+ * #696 — the infrastructure contract version (infra-contract.ts) the last
103
+ * successful converge brought this install to. Absent until the first
104
+ * `upgrade` that converged completely; an app-roll-only upgrade refuses to
105
+ * run while it is lower than the release's (upgrade/converge.ts).
106
+ */
107
+ infraContractVersion?: number;
108
+ }
109
+
110
+ /** The install's staging host: its recorded setting, else the default. */
111
+ export function installStagingHost(
112
+ meta: Pick<InstallMetadata, "domain" | "stagingDomain">,
113
+ ): string {
114
+ return meta.stagingDomain ?? defaultStagingHost(meta.domain);
115
+ }
116
+
117
+ /**
118
+ * A bare, lower-case fully-qualified host name: dot-separated labels of
119
+ * `a-z0-9-` (no leading/trailing hyphen, at most 63 characters each), a
120
+ * letter-led top-level label. Deliberately free of `.`-wildcards and
121
+ * lookaheads so the check reads as a strict allow-list.
122
+ */
123
+ const RECORDABLE_HOST =
124
+ /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
125
+
126
+ /** DNS limit on a full host name. */
127
+ const MAX_HOST_LENGTH = 253;
128
+
129
+ /**
130
+ * The staging host exactly as install.json may record it, or null when
131
+ * `host` is anything but a bare host name. The host can come from the
132
+ * admin's HTTP answer (`staging-domain enable` reads the stored host), so
133
+ * only a value that passes this allow-list is written to disk — never a
134
+ * URL, path, port, whitespace or other payload.
135
+ *
136
+ * @example recordableStagingHost("staging.example.com") // → "staging.example.com"
137
+ * @example recordableStagingHost("https://x.example.com/") // → null
138
+ */
139
+ export function recordableStagingHost(host: string): string | null {
140
+ if (host.length > MAX_HOST_LENGTH) return null;
141
+ return RECORDABLE_HOST.test(host) ? host : null;
142
+ }
143
+
144
+ /** Release image digest per Cloud Run service. */
145
+ export interface ImageDigests {
146
+ readonly admin: string;
147
+ readonly gateway: string;
148
+ }
149
+
150
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
151
+
152
+ /**
153
+ * The image digests recorded for an install, or null when none are. Throws
154
+ * on a malformed record rather than deploying a guess.
155
+ */
156
+ export function recordedImageDigests(meta: InstallMetadata): ImageDigests | null {
157
+ const d = meta.imageDigests;
158
+ if (d === undefined) return null;
159
+ if (!DIGEST.test(d.admin ?? "") || !DIGEST.test(d.gateway ?? "")) {
160
+ throw new Error(
161
+ `install.json imageDigests is malformed (${JSON.stringify(d)}); fix or remove it, then re-run.`,
162
+ );
163
+ }
164
+ return d;
165
+ }
166
+
167
+ /**
168
+ * The metadata a non-interactive re-run of an existing install writes: the
169
+ * inputs it was given over what install.json already holds. Everything else
170
+ * (createdAt, region, the recorded `imageDigests`) survives, so the re-run
171
+ * keeps the release the install runs instead of looking like a new install.
172
+ */
173
+ export function resumedMetadata(
174
+ existing: InstallMetadata,
175
+ inputs: { domain: string; ownerEmail: string; projectId: string | null },
176
+ ): InstallMetadata {
177
+ return {
178
+ ...existing,
179
+ domain: inputs.domain,
180
+ ownerEmail: inputs.ownerEmail,
181
+ projectId: inputs.projectId ?? existing.projectId,
182
+ };
183
+ }
184
+
185
+ /** Record the image digests an install now runs (see {@link InstallMetadata.imageDigests}). */
186
+ export function recordImageDigests(
187
+ installId: string,
188
+ digests: ImageDigests,
189
+ releaseVersion?: string | null,
190
+ ): void {
191
+ const meta = readMetadata(installId);
192
+ if (!meta) throw new Error(`No install.json for install '${installId}'.`);
193
+ // A version belongs to its digests: unchanged digests keep the recorded
194
+ // version unless a new one is given; new digests without a known version
195
+ // drop it — a stale one would be worse than none (the upgrader job
196
+ // refuses on none and judges downgrades by it).
197
+ const same =
198
+ meta.imageDigests?.admin === digests.admin && meta.imageDigests?.gateway === digests.gateway;
199
+ const { releaseVersion: previous, ...rest } = meta;
200
+ const version = releaseVersion ?? (same ? previous : undefined);
201
+ writeMetadata(installId, {
202
+ ...rest,
203
+ imageDigests: { admin: digests.admin, gateway: digests.gateway },
204
+ ...(version ? { releaseVersion: version } : {}),
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Record the infrastructure contract version a converge just brought the
210
+ * install to (see {@link InstallMetadata.infraContractVersion}).
211
+ */
212
+ export function recordInfraContractVersion(installId: string, version: number): void {
213
+ const meta = readMetadata(installId);
214
+ if (!meta) throw new Error(`No install.json for install '${installId}'.`);
215
+ writeMetadata(installId, { ...meta, infraContractVersion: version });
216
+ }
217
+
218
+ export interface ProgressCheckpoint {
219
+ /** Last completed wizard step. Used for resume-after-failure. */
220
+ lastCompletedStep: string | null;
221
+ /** Per-step state (e.g. createdProjectId, billingAccountId, etc.). */
222
+ steps: Record<string, unknown>;
223
+ /** ISO timestamp of last successful update. */
224
+ updatedAt: string;
225
+ }
226
+
227
+ const ROOT_PREFIX = ".caelo-";
228
+
229
+ export function installRoot(installId: string): string {
230
+ return join(homedir(), `${ROOT_PREFIX}${installId}`);
231
+ }
232
+
233
+ /**
234
+ * Stable install id from the (provider, projectId-or-domain) pair so a re-run
235
+ * with the same inputs always lands on the same `~/.caelo-<id>/` directory.
236
+ * The id is short + readable — `gcp-caelo-website` / `self-hosted-mysite-com`.
237
+ */
238
+ export function deriveInstallId(provider: Provider, projectIdOrDomain: string): string {
239
+ const slug = projectIdOrDomain
240
+ .toLowerCase()
241
+ .replace(/[^a-z0-9]+/g, "-")
242
+ .replace(/^-+|-+$/g, "")
243
+ .slice(0, 40);
244
+ return `${provider}-${slug}`;
245
+ }
246
+
247
+ /**
248
+ * Ensure the install directory exists with the right mode + sub-dirs.
249
+ * Idempotent.
250
+ */
251
+ export function ensureInstallDir(installId: string): {
252
+ root: string;
253
+ secretsDir: string;
254
+ stateDir: string;
255
+ } {
256
+ const root = installRoot(installId);
257
+ const secretsDir = join(root, "secrets");
258
+ const stateDir = join(root, "state");
259
+
260
+ if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
261
+ if (!existsSync(secretsDir)) mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
262
+ // An install whose state lives in its ops bucket gets no local state dir:
263
+ // an empty one would read as "the state is here".
264
+ const remote = readLocalMetadata(installId)?.stateBackend?.kind === "gcs";
265
+ if (!remote && !existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: 0o700 });
266
+
267
+ // chmod every time in case the dirs existed with looser perms.
268
+ chmodSync(root, 0o700);
269
+ chmodSync(secretsDir, 0o700);
270
+ if (existsSync(stateDir)) chmodSync(stateDir, 0o700);
271
+
272
+ return { root, secretsDir, stateDir };
273
+ }
274
+
275
+ /**
276
+ * Scan `~/.caelo-*` for every install with a readable `install.json` (the
277
+ * local copy; see {@link currentMetadata}).
278
+ * Used by the wizard to offer "resume X" before prompting for the
279
+ * domain / owner / project again. Silently skips dirs that fail to
280
+ * parse — partial / orphaned dirs don't block the new-install flow.
281
+ */
282
+ export function listInstalls(): InstallMetadata[] {
283
+ const home = homedir();
284
+ let entries: string[];
285
+ try {
286
+ entries = readdirSync(home);
287
+ } catch {
288
+ return [];
289
+ }
290
+ const out: InstallMetadata[] = [];
291
+ for (const entry of entries) {
292
+ if (!entry.startsWith(ROOT_PREFIX)) continue;
293
+ const installId = entry.slice(ROOT_PREFIX.length);
294
+ // The local copy, so one unreachable bucket (a revoked grant, a deleted
295
+ // project) can't block commands for every other install. A command
296
+ // re-reads the install it picked with {@link currentMetadata}.
297
+ const meta = readLocalMetadata(installId);
298
+ if (meta) out.push(meta);
299
+ }
300
+ out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * The install a command names on its argv: `--install <id or domain>` or
306
+ * `--install=<…>`. `--install-id` is accepted as an alias, the spelling
307
+ * older docs and code comments used.
308
+ *
309
+ * @returns the value, or undefined when no flag is given
310
+ * @throws when the flag is given without a value
311
+ */
312
+ export function installFlag(argv: readonly string[]): string | undefined {
313
+ for (let i = 0; i < argv.length; i++) {
314
+ const a = argv[i] ?? "";
315
+ for (const name of ["--install", "--install-id"]) {
316
+ if (a === name) {
317
+ const v = argv[i + 1];
318
+ if (v === undefined || v.startsWith("-")) {
319
+ throw new Error(
320
+ `${name} needs a value: the install id or domain (e.g. ${name} example.com)`,
321
+ );
322
+ }
323
+ return v;
324
+ }
325
+ if (a.startsWith(`${name}=`)) {
326
+ const v = a.slice(name.length + 1);
327
+ if (v === "") throw new Error(`${name} needs a value: the install id or domain`);
328
+ return v;
329
+ }
330
+ }
331
+ }
332
+ return undefined;
333
+ }
334
+
335
+ /** Outcome of {@link selectInstall}. */
336
+ export type InstallSelection = { ok: true; meta: InstallMetadata } | { ok: false; message: string };
337
+
338
+ /**
339
+ * Pick the install a lifecycle command acts on. `wanted` (from `--install`)
340
+ * matches an install id or its domain. Without it, exactly one install may
341
+ * exist: with several, guessing would upgrade, back up or destroy the wrong
342
+ * site, so the operator is asked to name one instead.
343
+ *
344
+ * @param installs every install on this machine (see {@link listInstalls})
345
+ * @param wanted the `--install` value, if given
346
+ */
347
+ export function selectInstall(
348
+ installs: readonly InstallMetadata[],
349
+ wanted: string | undefined,
350
+ ): InstallSelection {
351
+ const listed = (ms: readonly InstallMetadata[]): string =>
352
+ ms.map((m) => ` ${m.installId} (${m.domain}, ${m.provider})`).join("\n");
353
+ if (wanted !== undefined) {
354
+ const hits = installs.filter((m) => m.installId === wanted || m.domain === wanted);
355
+ if (hits.length === 1 && hits[0]) return { ok: true, meta: hits[0] };
356
+ if (hits.length === 0) {
357
+ return {
358
+ ok: false,
359
+ message:
360
+ installs.length === 0
361
+ ? `No install "${wanted}" on this machine (no ~/.caelo-<install-id>/install.json found).`
362
+ : `No install "${wanted}" on this machine. Installs found:\n${listed(installs)}\nPass one of these ids (or its domain) with --install.`,
363
+ };
364
+ }
365
+ return {
366
+ ok: false,
367
+ message: `"${wanted}" matches several installs:\n${listed(hits)}\nPass the install id with --install.`,
368
+ };
369
+ }
370
+ if (installs.length === 1 && installs[0]) return { ok: true, meta: installs[0] };
371
+ if (installs.length === 0) {
372
+ return { ok: false, message: "No Caelo install found on this machine." };
373
+ }
374
+ return {
375
+ ok: false,
376
+ message: `Several Caelo installs on this machine:\n${listed(installs)}\nPick one with --install <install-id or domain>.`,
377
+ };
378
+ }
379
+
380
+ let syncGcloud: SyncGcloud = realSyncGcloud;
381
+ const gcsStores = new Map<string, GcsStateStore>();
382
+
383
+ /**
384
+ * Replace the gcloud runner bucket-backed stores use. Test seam only: the
385
+ * CLI always uses the real `gcloud`.
386
+ */
387
+ export function setStateStoreGcloud(run: SyncGcloud): void {
388
+ syncGcloud = run;
389
+ gcsStores.clear();
390
+ }
391
+
392
+ function parseMetadata(text: string, where: string): InstallMetadata {
393
+ const meta = JSON.parse(text) as InstallMetadata;
394
+ if (typeof meta !== "object" || meta === null || typeof meta.installId !== "string") {
395
+ throw new Error(`${where} is not an install.json (no installId).`);
396
+ }
397
+ const stateBackend = parseStateBackend(meta.stateBackend);
398
+ return stateBackend ? { ...meta, stateBackend } : meta;
399
+ }
400
+
401
+ /**
402
+ * The install.json copy in `~/.caelo-<id>/`, without consulting the bucket.
403
+ * For listing installs and for deciding which store to use.
404
+ */
405
+ export function readLocalMetadata(installId: string): InstallMetadata | null {
406
+ const text = new LocalStateStore(installRoot(installId)).read("install.json");
407
+ return text === null ? null : parseMetadata(text, `~/.caelo-${installId}/install.json`);
408
+ }
409
+
410
+ /**
411
+ * The store holding the install's metadata: its ops bucket when the local
412
+ * install.json says the install's state lives there, else `~/.caelo-<id>/`.
413
+ * One bucket store per install and process, so a run reads each document
414
+ * from the bucket once.
415
+ */
416
+ export function stateStoreFor(installId: string): StateStore {
417
+ const root = installRoot(installId);
418
+ const backend = readLocalMetadata(installId)?.stateBackend;
419
+ if (backend?.kind !== "gcs") return new LocalStateStore(root);
420
+ const key = `${installId}\0${backend.bucket}`;
421
+ let store = gcsStores.get(key);
422
+ if (!store) {
423
+ store = new GcsStateStore(backend.bucket, root, syncGcloud);
424
+ gcsStores.set(key, store);
425
+ }
426
+ return store;
427
+ }
428
+
429
+ /**
430
+ * The authoritative metadata of an install picked from {@link listInstalls}:
431
+ * for an install whose state lives in its ops bucket, the bucket's copy
432
+ * (another machine may have upgraded it since). Throws when that copy can't
433
+ * be read — acting on a stale mirror could roll an install back.
434
+ */
435
+ export function currentMetadata(meta: InstallMetadata): InstallMetadata {
436
+ if (meta.stateBackend?.kind !== "gcs") return meta;
437
+ const fresh = readMetadata(meta.installId);
438
+ if (!fresh) {
439
+ throw new Error(
440
+ `gs://${meta.stateBackend.bucket} has no install.json for ${meta.installId}; the install may have been destroyed.`,
441
+ );
442
+ }
443
+ return fresh;
444
+ }
445
+
446
+ export function readMetadata(installId: string): InstallMetadata | null {
447
+ const store = stateStoreFor(installId);
448
+ const text = store.read("install.json");
449
+ return text === null ? null : parseMetadata(text, `${store.kind} install.json of ${installId}`);
450
+ }
451
+
452
+ /**
453
+ * Write install.json. Never changes where the state lives — only
454
+ * {@link adoptStateBackend} (behind `state migrate`'s checks) does: the
455
+ * recorded `stateBackend` is kept whatever `meta` carries, so a caller
456
+ * holding metadata read before a migration can't switch it back. For a
457
+ * bucket-backed install the bucket's current record is checked first
458
+ * (uncached): if another machine moved the state off the bucket meanwhile,
459
+ * the write is refused.
460
+ */
461
+ export function writeMetadata(installId: string, meta: InstallMetadata): void {
462
+ const store = stateStoreFor(installId);
463
+ const recorded = readLocalMetadata(installId)?.stateBackend;
464
+ const { stateBackend: _ignored, ...rest } = meta;
465
+ const next: InstallMetadata = recorded ? { ...rest, stateBackend: recorded } : rest;
466
+ if (store instanceof GcsStateStore) {
467
+ const live = store.peek("install.json");
468
+ const liveBackend =
469
+ live === null ? undefined : parseStateBackend(JSON.parse(live).stateBackend);
470
+ if (live !== null && liveBackend?.kind !== "gcs") {
471
+ throw new Error(
472
+ `${installId}'s state was moved off its bucket by another machine (state migrate --to local); this run's metadata is out of date. Nothing was written; manage the install from that machine.`,
473
+ );
474
+ }
475
+ }
476
+ store.write("install.json", next);
477
+ }
478
+
479
+ /**
480
+ * Move the install's metadata to `backend`'s store and record the backend
481
+ * in install.json (#696). progress.json is written first and install.json
482
+ * last: install.json naming the new backend is the commit point, so an
483
+ * interrupted switch leaves the install on its old store. When the install
484
+ * leaves the bucket, the bucket's install.json is updated too, so a machine
485
+ * that attaches later learns where the state went.
486
+ *
487
+ * @returns the metadata as now recorded
488
+ */
489
+ export function adoptStateBackend(installId: string, backend: StateBackendRecord): InstallMetadata {
490
+ const current = readMetadata(installId);
491
+ if (!current) throw new Error(`No install.json for install '${installId}'.`);
492
+ const progress = readProgress(installId);
493
+ const next: InstallMetadata = { ...current, stateBackend: backend };
494
+ const root = installRoot(installId);
495
+ const previous = current.stateBackend;
496
+ gcsStores.clear();
497
+ if (backend.kind === "gcs") {
498
+ const gcs = new GcsStateStore(backend.bucket, root, syncGcloud);
499
+ gcs.write("progress.json", progress);
500
+ gcs.write("install.json", next);
501
+ } else {
502
+ if (previous?.kind === "gcs") {
503
+ new GcsStateStore(previous.bucket, root, syncGcloud).write("install.json", next);
504
+ }
505
+ const local = new LocalStateStore(root);
506
+ local.write("progress.json", progress);
507
+ local.write("install.json", next);
508
+ }
509
+ return next;
510
+ }
511
+
512
+ export function readProgress(installId: string): ProgressCheckpoint {
513
+ const text = stateStoreFor(installId).read("progress.json");
514
+ if (text === null) {
515
+ return { lastCompletedStep: null, steps: {}, updatedAt: new Date().toISOString() };
516
+ }
517
+ return JSON.parse(text) as ProgressCheckpoint;
518
+ }
519
+
520
+ export function writeProgress(installId: string, checkpoint: ProgressCheckpoint): void {
521
+ stateStoreFor(installId).write("progress.json", {
522
+ ...checkpoint,
523
+ updatedAt: new Date().toISOString(),
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Mark a step complete. Wizard re-runs check `isStepDone(installId, name)` to
529
+ * skip already-done steps. The payload is bookkeeping only: one holding a
530
+ * password, a credential URL or a secret-named key is refused (#656) —
531
+ * store an identifier and read the secret at the moment of use.
532
+ */
533
+ export function markStepDone(installId: string, stepName: string, payload?: unknown): void {
534
+ if (payload !== undefined) assertNoSecrets(payload, `progress.json step "${stepName}"`);
535
+ const cur = readProgress(installId);
536
+ cur.lastCompletedStep = stepName;
537
+ if (payload !== undefined) cur.steps[stepName] = payload;
538
+ writeProgress(installId, cur);
539
+ }
540
+
541
+ export function isStepDone(installId: string, stepName: string): boolean {
542
+ return readProgress(installId).steps[stepName] !== undefined;
543
+ }
544
+
545
+ export function getStepPayload<T>(installId: string, stepName: string): T | null {
546
+ const v = readProgress(installId).steps[stepName];
547
+ return (v ?? null) as T | null;
548
+ }
549
+
550
+ /**
551
+ * Remove secret material an older CLI checkpointed into the install's
552
+ * progress.json (#656). Idempotent; completed steps stay completed.
553
+ *
554
+ * @returns the lines to show the operator: what was removed and which
555
+ * secret to rotate with which command; empty when the file was clean
556
+ */
557
+ export function scrubInstallProgress(installId: string): string[] {
558
+ const { removed }: ScrubReport = scrubProgressFile(join(installRoot(installId), "progress.json"));
559
+ if (removed.length === 0) return [];
560
+ return [
561
+ `Removed ${removed.length} secret value(s) an earlier installer stored in ~/.caelo-${installId}/progress.json. Copies may remain in backups or synced copies of that file.`,
562
+ ...rotationHints(removed, installId),
563
+ ];
564
+ }
565
+
566
+ /**
567
+ * Read a secret file from the install's `secrets/` dir. Returns null if the
568
+ * file doesn't exist; throws if the file exists but has the wrong mode (a
569
+ * defence against the user copy-pasting a key into a 644 file by mistake).
570
+ */
571
+ export function readSecret(installId: string, name: string): string | null {
572
+ const path = join(installRoot(installId), "secrets", name);
573
+ if (!existsSync(path)) return null;
574
+ const contents = readFileSync(path, "utf8").trim();
575
+ if (contents.length === 0) return null;
576
+ return contents;
577
+ }
578
+
579
+ export function writeSecret(installId: string, name: string, value: string): void {
580
+ ensureInstallDir(installId);
581
+ const path = join(installRoot(installId), "secrets", name);
582
+ writeFileSync(path, value.endsWith("\n") ? value : `${value}\n`, { mode: 0o600 });
583
+ }
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The CLI's side of the admin's /api/internal/* token
5
+ * (apps/admin/src/lib/server/internal-jwt.ts): HMAC-SHA256 over
6
+ * `"<iat>:<exp>:<scope>"` with the install's internal secret, valid for the
7
+ * admin's 5-minute replay window. One token per call.
8
+ */
9
+
10
+ /** Replay window the admin enforces; tokens never outlive it. */
11
+ const TOKEN_TTL_MS = 5 * 60 * 1000;
12
+
13
+ /**
14
+ * Mint a token for `scope`.
15
+ *
16
+ * @param secret - the install's internal secret (≥32 chars; Secret Manager
17
+ * `<prefix>-internal-secret` on cloud installs, never written to disk).
18
+ * @param now - ms since epoch; tests pin it.
19
+ */
20
+ export async function mintInternalToken(
21
+ secret: string,
22
+ scope: string,
23
+ now: number = Date.now(),
24
+ ): Promise<string> {
25
+ const message = `${now}:${now + TOKEN_TTL_MS}:${scope}`;
26
+ const key = await crypto.subtle.importKey(
27
+ "raw",
28
+ new TextEncoder().encode(secret),
29
+ { name: "HMAC", hash: "SHA-256" },
30
+ false,
31
+ ["sign"],
32
+ );
33
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
34
+ return `${Buffer.from(message).toString("base64url")}.${Buffer.from(sig).toString("base64url")}`;
35
+ }