@homeflare/alchemy 0.7.0 → 0.10.0

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 (312) hide show
  1. package/README.md +45 -45
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +270 -44
  11. package/dist/caddy/index.js.map +15 -11
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-runner.d.ts +1 -0
  15. package/dist/launchd/fake-runner.d.ts.map +1 -1
  16. package/dist/launchd/fake-sudo.d.ts +51 -0
  17. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  18. package/dist/launchd/host-effect.d.ts +1 -1
  19. package/dist/launchd/host-file-form.d.ts +5 -0
  20. package/dist/launchd/host-file-form.d.ts.map +1 -1
  21. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  22. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  23. package/dist/launchd/host-file.d.ts +2 -1
  24. package/dist/launchd/host-file.d.ts.map +1 -1
  25. package/dist/launchd/index.d.ts +2 -0
  26. package/dist/launchd/index.d.ts.map +1 -1
  27. package/dist/launchd/index.js +642 -49
  28. package/dist/launchd/index.js.map +24 -14
  29. package/dist/launchd/job-lifecycle.d.ts +8 -1
  30. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  31. package/dist/launchd/job-preflight.d.ts +13 -1
  32. package/dist/launchd/job-preflight.d.ts.map +1 -1
  33. package/dist/launchd/job-validate.d.ts +2 -0
  34. package/dist/launchd/job-validate.d.ts.map +1 -1
  35. package/dist/launchd/job.d.ts +2 -1
  36. package/dist/launchd/job.d.ts.map +1 -1
  37. package/dist/launchd/local-runner.d.ts +2 -0
  38. package/dist/launchd/local-runner.d.ts.map +1 -1
  39. package/dist/launchd/providers.d.ts +2 -1
  40. package/dist/launchd/providers.d.ts.map +1 -1
  41. package/dist/launchd/runner.d.ts +14 -5
  42. package/dist/launchd/runner.d.ts.map +1 -1
  43. package/dist/launchd/sudo-acl.d.ts +25 -0
  44. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  45. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  46. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  47. package/dist/launchd/sudo-guard.d.ts +83 -0
  48. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  49. package/dist/launchd/sudo-runner.d.ts +60 -0
  50. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  51. package/dist/launchd/sudo-said.d.ts +10 -0
  52. package/dist/launchd/sudo-said.d.ts.map +1 -0
  53. package/dist/launchd/sudo-stage.d.ts +7 -0
  54. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  55. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  56. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  57. package/dist/openbao/auth-method.d.ts.map +1 -1
  58. package/dist/openbao/auth-role-form.d.ts +0 -9
  59. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  60. package/dist/openbao/auth-role.d.ts +3 -2
  61. package/dist/openbao/auth-role.d.ts.map +1 -1
  62. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  63. package/dist/openbao/fake-bao.d.ts +18 -0
  64. package/dist/openbao/fake-bao.d.ts.map +1 -1
  65. package/dist/openbao/fake-core.d.ts +39 -0
  66. package/dist/openbao/fake-core.d.ts.map +1 -0
  67. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  68. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  69. package/dist/openbao/fake-engines.d.ts +8 -2
  70. package/dist/openbao/fake-engines.d.ts.map +1 -1
  71. package/dist/openbao/fake-families.d.ts +52 -0
  72. package/dist/openbao/fake-families.d.ts.map +1 -0
  73. package/dist/openbao/fake-mounts.d.ts +16 -0
  74. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  75. package/dist/openbao/fake-stack.d.ts +5 -1
  76. package/dist/openbao/fake-stack.d.ts.map +1 -1
  77. package/dist/openbao/host-approles.d.ts.map +1 -1
  78. package/dist/openbao/index.js +709 -359
  79. package/dist/openbao/index.js.map +32 -25
  80. package/dist/openbao/jwt-config.d.ts +5 -0
  81. package/dist/openbao/jwt-config.d.ts.map +1 -1
  82. package/dist/openbao/jwt-role.d.ts +2 -1
  83. package/dist/openbao/jwt-role.d.ts.map +1 -1
  84. package/dist/openbao/kubernetes-role.d.ts +2 -1
  85. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  86. package/dist/openbao/mfa-enforcement.d.ts +12 -5
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  88. package/dist/openbao/mfa-totp.d.ts +6 -2
  89. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  90. package/dist/openbao/mount-reconcile.d.ts +5 -1
  91. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  92. package/dist/openbao/mount.d.ts.map +1 -1
  93. package/dist/openbao/pki-role.d.ts.map +1 -1
  94. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  95. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  96. package/dist/openbao/plugin.d.ts.map +1 -1
  97. package/dist/openbao/policy.d.ts.map +1 -1
  98. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  99. package/dist/openbao/rename-identity.d.ts +69 -0
  100. package/dist/openbao/rename-identity.d.ts.map +1 -0
  101. package/dist/openbao/rename.d.ts +13 -12
  102. package/dist/openbao/rename.d.ts.map +1 -1
  103. package/dist/openbao/role-reconcile.d.ts +11 -1
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  105. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  106. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  107. package/dist/openbao/ssh-role.d.ts.map +1 -1
  108. package/dist/ownership/adopt.d.ts +52 -0
  109. package/dist/ownership/adopt.d.ts.map +1 -0
  110. package/dist/ownership/probe.d.ts +13 -0
  111. package/dist/ownership/probe.d.ts.map +1 -0
  112. package/dist/ownership/resume.d.ts +40 -0
  113. package/dist/ownership/resume.d.ts.map +1 -0
  114. package/dist/ownership/rows.d.ts +33 -0
  115. package/dist/ownership/rows.d.ts.map +1 -0
  116. package/dist/ownership/whole.d.ts +14 -0
  117. package/dist/ownership/whole.d.ts.map +1 -0
  118. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  119. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  120. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  121. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  122. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  123. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  124. package/dist/proxmox/fake-pve.d.ts +38 -0
  125. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  126. package/dist/proxmox/index.d.ts +6 -2
  127. package/dist/proxmox/index.d.ts.map +1 -1
  128. package/dist/proxmox/index.js +970 -186
  129. package/dist/proxmox/index.js.map +22 -9
  130. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  131. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  132. package/dist/proxmox/lxc-harness.d.ts +34 -0
  133. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  134. package/dist/proxmox/lxc-identity.d.ts +18 -0
  135. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  136. package/dist/proxmox/lxc-judge.d.ts +46 -0
  137. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  138. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  139. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  140. package/dist/proxmox/lxc-props.d.ts +139 -0
  141. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  142. package/dist/proxmox/lxc-volume.d.ts +29 -0
  143. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  144. package/dist/proxmox/lxc-wire.d.ts +51 -0
  145. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  146. package/dist/proxmox/lxc.d.ts +27 -47
  147. package/dist/proxmox/lxc.d.ts.map +1 -1
  148. package/dist/proxmox/network-apply-read.d.ts +1 -1
  149. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  150. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  151. package/dist/proxmox/resource.d.ts.map +1 -1
  152. package/dist/proxmox/update-guard.d.ts +27 -0
  153. package/dist/proxmox/update-guard.d.ts.map +1 -0
  154. package/dist/verify/args.d.ts +16 -0
  155. package/dist/verify/args.d.ts.map +1 -0
  156. package/dist/verify/cli.d.ts +3 -0
  157. package/dist/verify/cli.d.ts.map +1 -0
  158. package/dist/verify/cli.js +320 -0
  159. package/dist/verify/cli.js.map +15 -0
  160. package/dist/verify/fake-engine.d.ts +57 -0
  161. package/dist/verify/fake-engine.d.ts.map +1 -0
  162. package/dist/verify/index.d.ts +16 -0
  163. package/dist/verify/index.d.ts.map +1 -0
  164. package/dist/verify/index.js +297 -0
  165. package/dist/verify/index.js.map +14 -0
  166. package/dist/verify/report.d.ts +6 -0
  167. package/dist/verify/report.d.ts.map +1 -0
  168. package/dist/verify/rows.d.ts +57 -0
  169. package/dist/verify/rows.d.ts.map +1 -0
  170. package/dist/verify/spy.d.ts +31 -0
  171. package/dist/verify/spy.d.ts.map +1 -0
  172. package/dist/verify/verify.d.ts +43 -0
  173. package/dist/verify/verify.d.ts.map +1 -0
  174. package/docs/adopt-verify.md +108 -0
  175. package/docs/adopted-deploys.md +85 -0
  176. package/docs/caddy-admin.md +24 -0
  177. package/docs/caddy.md +73 -54
  178. package/docs/credentials.md +15 -0
  179. package/docs/launchd-sudo.md +147 -0
  180. package/docs/launchd.md +14 -7
  181. package/docs/ownership.md +113 -0
  182. package/docs/peers.md +39 -0
  183. package/docs/proxmox-lxc.md +196 -0
  184. package/docs/proxmox.md +12 -0
  185. package/package.json +9 -2
  186. package/src/caddy/adopt-scope.test.ts +82 -0
  187. package/src/caddy/config-adopt.test.ts +235 -0
  188. package/src/caddy/config-lifecycle.test.ts +31 -53
  189. package/src/caddy/config-lifecycle.ts +98 -7
  190. package/src/caddy/config.ts +48 -10
  191. package/src/caddy/fake-caddy.ts +19 -0
  192. package/src/caddy/index.ts +2 -1
  193. package/src/caddy/providers.test.ts +8 -23
  194. package/src/caddy/with-file.ts +7 -3
  195. package/src/launchd/adopt-apply.test.ts +166 -0
  196. package/src/launchd/fake-runner.ts +26 -4
  197. package/src/launchd/fake-sudo.ts +136 -0
  198. package/src/launchd/host-effect.ts +1 -1
  199. package/src/launchd/host-file-form.ts +10 -2
  200. package/src/launchd/host-file-lifecycle.ts +41 -15
  201. package/src/launchd/host-file.ts +13 -4
  202. package/src/launchd/index.ts +2 -0
  203. package/src/launchd/job-lifecycle.ts +22 -14
  204. package/src/launchd/job-preflight.ts +21 -3
  205. package/src/launchd/job-validate.ts +1 -1
  206. package/src/launchd/job.ts +13 -4
  207. package/src/launchd/local-runner.ts +6 -3
  208. package/src/launchd/providers.test.ts +3 -0
  209. package/src/launchd/providers.ts +2 -1
  210. package/src/launchd/runner.ts +14 -5
  211. package/src/launchd/sudo-acl.ts +68 -0
  212. package/src/launchd/sudo-allowlist.test.ts +191 -0
  213. package/src/launchd/sudo-allowlist.ts +213 -0
  214. package/src/launchd/sudo-chain.test.ts +121 -0
  215. package/src/launchd/sudo-guard.test.ts +100 -0
  216. package/src/launchd/sudo-guard.ts +230 -0
  217. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  218. package/src/launchd/sudo-modes.test.ts +135 -0
  219. package/src/launchd/sudo-runner-files.test.ts +240 -0
  220. package/src/launchd/sudo-runner.test.ts +126 -0
  221. package/src/launchd/sudo-runner.ts +216 -0
  222. package/src/launchd/sudo-said.ts +58 -0
  223. package/src/launchd/sudo-stage.ts +28 -0
  224. package/src/openbao/README.md +11 -2
  225. package/src/openbao/REPLACE.md +67 -37
  226. package/src/openbao/adopt-core.test.ts +119 -0
  227. package/src/openbao/adopt-families.test.ts +97 -0
  228. package/src/openbao/adopt-holes.test.ts +168 -0
  229. package/src/openbao/adopt-recovery.test.ts +71 -0
  230. package/src/openbao/auth-method-reconcile.ts +15 -3
  231. package/src/openbao/auth-method.ts +15 -6
  232. package/src/openbao/auth-role-form.test.ts +2 -16
  233. package/src/openbao/auth-role-form.ts +0 -11
  234. package/src/openbao/auth-role.ts +48 -17
  235. package/src/openbao/cloudflare-role.ts +23 -18
  236. package/src/openbao/fake-bao.ts +52 -0
  237. package/src/openbao/fake-core.ts +141 -0
  238. package/src/openbao/fake-engines-roles.ts +114 -0
  239. package/src/openbao/fake-engines.ts +18 -11
  240. package/src/openbao/fake-families.ts +188 -0
  241. package/src/openbao/fake-mounts.ts +74 -0
  242. package/src/openbao/fake-stack.ts +17 -4
  243. package/src/openbao/host-approles.test.ts +15 -2
  244. package/src/openbao/host-approles.ts +19 -4
  245. package/src/openbao/jwt-config.ts +35 -11
  246. package/src/openbao/jwt-role.ts +33 -14
  247. package/src/openbao/kubernetes-role.ts +32 -14
  248. package/src/openbao/mfa-enforcement.ts +42 -13
  249. package/src/openbao/mfa-totp.ts +42 -14
  250. package/src/openbao/mount-move.test.ts +2 -35
  251. package/src/openbao/mount-reconcile.ts +13 -2
  252. package/src/openbao/mount.ts +15 -6
  253. package/src/openbao/pki-role.ts +33 -10
  254. package/src/openbao/plugin-reconcile.ts +16 -5
  255. package/src/openbao/plugin.ts +57 -20
  256. package/src/openbao/policy.ts +24 -11
  257. package/src/openbao/proxmox-role.ts +20 -20
  258. package/src/openbao/rename-adoption.test.ts +95 -0
  259. package/src/openbao/rename-families.test.ts +122 -0
  260. package/src/openbao/rename-identity.test.ts +129 -0
  261. package/src/openbao/rename-identity.ts +117 -0
  262. package/src/openbao/rename.ts +19 -15
  263. package/src/openbao/role-reconcile.ts +23 -0
  264. package/src/openbao/ssh-role-erase.ts +50 -0
  265. package/src/openbao/ssh-role.ts +29 -57
  266. package/src/ownership/adopt.ts +122 -0
  267. package/src/ownership/ownership.test.ts +200 -0
  268. package/src/ownership/probe.ts +65 -0
  269. package/src/ownership/resume.ts +118 -0
  270. package/src/ownership/rows.ts +117 -0
  271. package/src/ownership/whole.test.ts +39 -0
  272. package/src/ownership/whole.ts +60 -0
  273. package/src/proxmox/adopt-noop.test.ts +112 -0
  274. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  275. package/src/proxmox/ceph-pool-form.ts +3 -3
  276. package/src/proxmox/ceph-pool.ts +10 -3
  277. package/src/proxmox/fake-bao-env.ts +28 -0
  278. package/src/proxmox/fake-pve-lxc.ts +210 -0
  279. package/src/proxmox/fake-pve.ts +75 -0
  280. package/src/proxmox/index.test.ts +6 -6
  281. package/src/proxmox/index.ts +6 -2
  282. package/src/proxmox/lxc-adopt.test.ts +170 -0
  283. package/src/proxmox/lxc-create-form.ts +76 -0
  284. package/src/proxmox/lxc-create.test.ts +126 -0
  285. package/src/proxmox/lxc-guards.test.ts +158 -0
  286. package/src/proxmox/lxc-harness.ts +150 -0
  287. package/src/proxmox/lxc-identity.ts +43 -0
  288. package/src/proxmox/lxc-judge.test.ts +175 -0
  289. package/src/proxmox/lxc-judge.ts +196 -0
  290. package/src/proxmox/lxc-lifecycle.ts +200 -0
  291. package/src/proxmox/lxc-ownership.test.ts +159 -0
  292. package/src/proxmox/lxc-props.ts +215 -0
  293. package/src/proxmox/lxc-read.test.ts +60 -0
  294. package/src/proxmox/lxc-volume.test.ts +96 -0
  295. package/src/proxmox/lxc-volume.ts +153 -0
  296. package/src/proxmox/lxc-wire.ts +210 -0
  297. package/src/proxmox/lxc.ts +216 -93
  298. package/src/proxmox/network-apply-read.ts +3 -1
  299. package/src/proxmox/pbs-datastore.ts +4 -3
  300. package/src/proxmox/resource.ts +7 -7
  301. package/src/proxmox/update-guard.ts +31 -0
  302. package/src/verify/args.ts +82 -0
  303. package/src/verify/cli.test.ts +86 -0
  304. package/src/verify/cli.ts +49 -0
  305. package/src/verify/fake-engine.ts +158 -0
  306. package/src/verify/index.ts +36 -0
  307. package/src/verify/report.ts +52 -0
  308. package/src/verify/rows.ts +169 -0
  309. package/src/verify/spy.test.ts +141 -0
  310. package/src/verify/spy.ts +149 -0
  311. package/src/verify/verify.test.ts +124 -0
  312. package/src/verify/verify.ts +172 -0
@@ -0,0 +1,108 @@
1
+ # Adopt verifier — `adopted` must mean no-op
2
+
3
+ `hf-adopt-verify` plans a stack with Alchemy's own planner, never writes, and reports for each row
4
+ **without a state row** what the provider's `read` and its own `diff` said. It exits `0` only
5
+ when every such row is a no-op. Run it before any gated deploy that adopts.
6
+
7
+ ```sh
8
+ bunx --bun hf-adopt-verify --config alchemy.run.ts --stage live # rows without state
9
+ bunx --bun hf-adopt-verify --config alchemy.pbs.ts --stage live --all # every row + deletions
10
+ ```
11
+
12
+ Wrap it in the same credential lane the deploy uses (`bun ./scripts/with-mini-bao.ts --admin …`
13
+ in the Proxmox stacks): a lane that cannot read an object sees it as absent, and the verifier
14
+ then reports `create` for something that exists.
15
+
16
+ ## Why a plan line is not enough
17
+
18
+ Measured in alchemy `2.0.0-beta.79`:
19
+
20
+ - `Plan.ts`: when a row has no state, the engine calls `read` (the adoption probe). If the object
21
+ exists it sets `forceUpdateAfterAdoption`, calls `diff`, and turns a `noop` into `update`. The
22
+ node is `adopted` either way. Nothing is logged.
23
+ - `Apply.ts`: `adopted` goes down the `update` branch and calls `reconcile` with `olds: undefined`.
24
+ - So `alchemy plan` prints `(+) adopted` for a matching object **and** for a drifting one, and
25
+ the deploy reconciles both. Whether that writes is up to each family's `reconcile`.
26
+
27
+ Measured on TB4, `pvesh get /cluster/tasks`, 2026-09-21: six `cephsetpool` tasks by
28
+ `hf-provision@pve`, one per pool, on 2026-09-13 and again on 2026-09-20 — the deploys that
29
+ adopted the three Ceph pools. `Proxmox.CephPool` sent `setpool` whenever the pool existed,
30
+ whatever its diff said. It was the one PVE/PBS family that wrote on a no-op adoption, and it is
31
+ fixed. [adopted-deploys.md](./adopted-deploys.md) shows what every family does.
32
+
33
+ ## Where the answer is taken from
34
+
35
+ | looked at | what it offers |
36
+ | -------------------------- | --------------------------------------------------------------- |
37
+ | `alchemy plan` (CLI) | the forced action only; `--detailed` is declared YAML, no JSON |
38
+ | `Plan.describePlan` | serializable rows, again only the forced action |
39
+ | `forceUpdateAfterAdoption` | a local variable; no log, no warning, no field on the node |
40
+ | `alchemy drift` | rows **with** state only, and its non-dry run reconciles |
41
+ | `deploy --adopt --dry-run` | the adoption plan (`plan` has no `--adopt`), same forced action |
42
+ | **the provider service** | the raw `read` / `diff` answer, per FQN — used |
43
+
44
+ The engine finds every provider in the Effect context by resource type. The verifier opens the
45
+ stack the way `alchemy plan` does (`Alchemist.open`). It swaps each provider for a watched copy
46
+ (`src/verify/spy.ts`) and runs `Plan.make` itself, so output resolution, state lookup, renames,
47
+ the adoption probe and `adopt(…)` are all still Alchemy's. The copy records what `read` and
48
+ `diff` answered. `reconcile`, `delete` and `precreate` are replaced with a refusal, so a
49
+ verification cannot write even by accident.
50
+
51
+ ## The report
52
+
53
+ ```text
54
+ planned diff read type fqn
55
+ FAIL adopted update found Test.Thing drifted changed: comment
56
+ the provider's diff says update: the deploy writes
57
+ ok adopted noop found Test.Thing same
58
+ ```
59
+
60
+ | column | meaning |
61
+ | --------- | ---------------------------------------------------------------------------------------- |
62
+ | `planned` | the engine's action — what `alchemy plan` prints |
63
+ | `diff` | the provider's own answer before Plan.ts forced it; `none` = no diff; `not-run` |
64
+ | `read` | `found`, `unowned` (the deploy needs `--adopt`), `absent`, `failed`, `not-read` |
65
+ | `changed` | declared fields whose live value differs, **names only**, same-named keys of props/attrs |
66
+
67
+ A row is a no-op when `planned` is `adopted` or `noop`, the provider said `noop`, **and** no
68
+ binding changes (Plan.ts diffs bindings separately and `reconcile` applies them anyway). An
69
+ adopted row whose provider has no `diff` is not proven: the engine compared the declaration with
70
+ itself. A row with state and no diff is trusted at `noop`, as `alchemy plan` trusts it. A stack
71
+ task (an Alchemy action) that will `run` is always a failing row: no provider sees it.
72
+
73
+ Exit codes: `0` all no-op (or nothing to verify), `1` any row is not, `2` the plan could not be
74
+ computed (a provider died, an `adopt(false)` row is not ours, state unreadable). A gate should stop
75
+ on anything but `0`.
76
+
77
+ ## Flags and API
78
+
79
+ `--config` / `-c`, `--stage`, `--profile`, `--env-file` behave as in `alchemy plan`. The
80
+ difference is that `--stage` is never guessed: it comes from the flag or `$ALCHEMY_STAGE`, or the
81
+ run stops. `--all` adds every row with state (read again) and every pending delete. `--json`
82
+ prints the report.
83
+
84
+ ```ts
85
+ import { verifyStack, formatReport, exitCodeOf } from '@homeflare/alchemy/verify';
86
+
87
+ const report = await Effect.runPromise(
88
+ verifyStack({ entrypoint: 'alchemy.run.ts', stage: 'live' }),
89
+ );
90
+ ```
91
+
92
+ `verifySession({ stack, context })` takes an already-opened session. The kit's own tests pass it
93
+ a compiled stack (`src/verify/fake-engine.ts`).
94
+
95
+ ## Limits
96
+
97
+ - ⚠️ **`noop` covers what the family compares.** A family's `diff` and its `reconcile` guard share
98
+ one predicate (`matches`). A field that `matches` leaves out is never written on a no-op
99
+ adoption and never reported. `Proxmox.CephPool` `target_size_ratio` is the known case.
100
+ - ⚠️ **`changed` is a hint, not the verdict.** It compares same-named keys. A family whose
101
+ attributes rename or normalise a field shows nothing there, and its `diff` still decides.
102
+ - ⚠️ **Reads are what `alchemy plan` does.** A kit PVE/PBS read mints a lease through OpenBao,
103
+ which creates a short-lived API token on the cluster. `--all` adds one read per row with state.
104
+ - ⚠️ **Outside the kit, `noop` is only the diff's word.** For a kit PVE/PBS family, `noop` means
105
+ the deploy writes nothing ([adopted-deploys.md](./adopted-deploys.md)). For any other provider,
106
+ `noop` means its diff found nothing. Whether its `reconcile` still PUTs is up to that provider.
107
+ - It runs where `alchemy` runs: under bun it needs `@effect/platform-bun`, as the Alchemy CLI
108
+ does. Like `alchemy plan`, it writes a run log under `.alchemy/log/` in the working directory.
@@ -0,0 +1,85 @@
1
+ # What a deploy does to an adopted PVE/PBS row
2
+
3
+ Traced through the source on 2026-09-21, for every family the Proxmox and PBS stacks use. A row
4
+ **without state** that the adoption probe finds is planned `adopted` and reconciled, even when
5
+ its diff said `noop` (alchemy beta.79 `Plan.ts` `forceUpdateAfterAdoption`, then `Apply.ts`).
6
+ This page answers one question: does that reconcile write when live already equals declared?
7
+
8
+ **Answer: no, for every family below. `Proxmox.CephPool` did until this change.** Its
9
+ hand-written reconcile PUT `setpool` whenever the pool existed. It now skips the PUT when its own
10
+ `matches` holds. `src/proxmox/update-guard.ts` is the one predicate that `resource.ts`,
11
+ `ceph-pool.ts` and `pbs-datastore.ts` share. `hf-adopt-verify` ([adopt-verify.md](./adopt-verify.md))
12
+ reports the diff that decides it, before the deploy.
13
+
14
+ ## The shared path — every `pveHandlers` family
15
+
16
+ One `alchemy deploy` plans, then applies, in one process. For a row with no state:
17
+
18
+ | step | calls (path is the family's `path`) |
19
+ | -------------------- | ------------------------------------------------------------------------------------------------ |
20
+ | plan: adoption probe | `GET path` |
21
+ | plan: `diff` | `GET path`; `matches` → `noop` or `update` (printed `adopted`) |
22
+ | apply: `reconcile` | `GET path`; **PUT only when `matches` is false** and the form is non-empty; `GET path` read-back |
23
+
24
+ So a matching adoption costs **four GETs and no write**. A drifting one adds exactly one
25
+ `PUT path` carrying the family's `updateForm`. Each GET goes out on a lease, and each role's lease
26
+ is minted once per run (`lease-cache.ts`). The mint is an OpenBao call that creates a short-lived
27
+ API token on the cluster, just as it does for `alchemy plan`.
28
+
29
+ ## Per family
30
+
31
+ | family | read path | lease | reconcile on a no-op adoption |
32
+ | ------------------------------ | --------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
33
+ | `Proxmox.Pool` | `pools/{poolid}` | read | shared path: GET, GET — no write |
34
+ | `Proxmox.Storage` | `storage/{storage}` | provision ¹ | shared path — no write |
35
+ | `Proxmox.Role` | `access/roles/{roleid}` | read | shared path — no write |
36
+ | `Proxmox.Group` | `access/groups/{groupid}` | read | shared path — no write |
37
+ | `Proxmox.User` | `access/users/{userid}` | read | shared path — no write |
38
+ | `Proxmox.BackupJob` | `cluster/backup/{id}` | read | shared path — no write |
39
+ | `Proxmox.MetricServer` | `cluster/metrics/server/{id}` | read | shared path — no write |
40
+ | `Proxmox.NotificationTarget` ² | `cluster/notifications/endpoints/{type}/{name}` | read | shared path — no write |
41
+ | `Proxmox.HaResource` | `cluster/ha/resources/{sid}` | read | shared path — no write |
42
+ | `Proxmox.HaRule` | `cluster/ha/rules/{rule}` | read | shared path — no write |
43
+ | `Proxmox.SdnZone` | `cluster/sdn/zones/{zone}` | provision ¹ | shared path — no write |
44
+ | `Proxmox.SdnVnet` | `cluster/sdn/vnets/{vnet}` | provision ¹ | shared path — no write |
45
+ | `Proxmox.SdnSubnet` | `cluster/sdn/vnets/{vnet}/subnets/{id}` | read | shared path — no write |
46
+ | `Proxmox.Lxc` ³ | `nodes/{node}/lxc/{vmid}/config` | read | own: GET, `judge` finds no drift so no `PUT …/config`, GET — no write |
47
+ | `Pbs.PruneJob` | `config/prune/{id}` | read (PBS) | shared path — no write |
48
+ | `Pbs.SyncJob` | `config/sync/{id}` | read (PBS) | shared path — no write |
49
+ | `Pbs.VerifyJob` | `config/verify/{id}` | read (PBS) | shared path — no write |
50
+ | `Proxmox.Acl` | `access/acl` (whole list, filtered) | provision ¹ | shared path, then its own `bound` check — no write |
51
+ | `Pbs.Datastore` | `config/datastore/{name}` | read (PBS) | own: GET, path/backend guards, PUT skipped on `matches`, settle GET — no write |
52
+ | `Proxmox.SdnApply` | 5 × `cluster/sdn/*?pending=1` + fabrics `?pending` / `?running` | read | own: counts staged objects; zero returns at once — **no `PUT /cluster/sdn`** |
53
+ | `Proxmox.CephPool` ⁴ | `nodes/{node}/ceph/pool/{name}/status?verbose=1` | read | own: GET, **PUT skipped on `matches` (was: always `PUT nodes/{node}/ceph/pool/{name}`)**, settle GET |
54
+
55
+ ¹ These object reads are gated on an allocate privilege, so the family reads with the provision
56
+ lease (`readRole`, resource.ts). That is still a read.
57
+ ² The PVE notification endpoints: `smtp` (the mail target), `sendmail`, `gotify`, `webhook`. The
58
+ secrets are never props (notification-target.ts), so no write is ever needed to "reset" them.
59
+ ³ Rewritten 2026-09-21 (PR 80, [proxmox-lxc.md](./proxmox-lxc.md)): its own reconcile, and its
60
+ probe answers `Unowned`, so the deploy needs `--adopt`. Its `diff` also logs the keys a deploy
61
+ would write, by name. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with no write").
62
+ ⁴ `updateBody` sends `target_size_ratio`, but `matches` does not compare it (float equality). A
63
+ declared ratio that differs from the live one is never written on a no-op adoption and never
64
+ reported as a diff.
65
+
66
+ ## Before the fix: `Proxmox.CephPool`
67
+
68
+ `reconcile` read the pool and, whenever it existed, PUT the declared `size`, `min_size`,
69
+ `pg_autoscale_mode`, `crush_rule` (and any declared hints) to `nodes/{node}/ceph/pool/{name}`.
70
+ That PUT forks a `cephsetpool` worker under the provision token. PVE's `set_pool` skips each
71
+ setting whose value is unchanged (Tools.pm:293-300), so a matching pool was not re-tuned. It was
72
+ still a provision-lease write and a task on the node every time the pool was adopted. TB4's task
73
+ list shows six such tasks (2026-09-13 and 2026-09-20), one per declared pool per deploy.
74
+
75
+ ## How it is pinned
76
+
77
+ - `src/proxmox/ceph-pool-adopt.test.ts` runs the real CephPool provider through Alchemy's Plan
78
+ and Apply over a fake cluster. A matching pool gets GETs only. A drifted one gets exactly one PUT.
79
+ On the old reconcile the first test fails with that PUT.
80
+ - `src/proxmox/adopt-noop.test.ts` does the same for each reconcile shape: the shared path
81
+ (`Proxmox.Pool`), the wrapper (`Proxmox.Acl`), and the two hand-written ones (`Pbs.Datastore`,
82
+ `Proxmox.SdnApply`). A drifted Pool row proves the harness sees a write when there is one.
83
+ - `src/verify/verify.test.ts` pins Alchemy's own behaviour: `adopted` for a match and for a
84
+ drift, and a reconcile for both. If an Alchemy upgrade changes that, the test fails and this
85
+ page needs re-reading.
@@ -0,0 +1,24 @@
1
+ # Caddy's admin endpoint — `@homeflare/alchemy/caddy`
2
+
3
+ How the provider reaches Caddy's admin API, and what it refuses so a Caddyfile cannot cut it off.
4
+ The overview is [caddy.md](./caddy.md).
5
+
6
+ ⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
7
+ (`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
8
+
9
+ - **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
10
+ guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
11
+ `enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
12
+ Host check entirely.
13
+ - **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
14
+ ITS port): pass `hostHeader: 'localhost:2019'`.
15
+ - **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
16
+ `localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
17
+ - **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
18
+ if it turns the API off, listens off loopback or somewhere the transport does not reach (another
19
+ port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
20
+ no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
21
+ `enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
22
+ - ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
23
+ load. That is refused unless the transport is at that default: a Caddy reached on a socket or
24
+ another port declares `admin <address>` in its Caddyfile.
package/docs/caddy.md CHANGED
@@ -24,10 +24,12 @@ export const edge = Effect.gen(function* () {
24
24
  yield* LaunchdJob('caddy-daemon', {
25
25
  label: 'com.example.caddy',
26
26
  domain: 'system',
27
+ // --resume: a restart runs the last config Caddy accepted (see Managed Caddies below).
27
28
  // --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
28
29
  programArguments: [
29
30
  '/usr/local/bin/caddy',
30
31
  'run',
32
+ '--resume',
31
33
  '--config',
32
34
  file.path,
33
35
  '--adapter',
@@ -35,6 +37,8 @@ export const edge = Effect.gen(function* () {
35
37
  '--envfile',
36
38
  '/usr/local/etc/caddy.env',
37
39
  ],
40
+ // ⛔ Here, not in the envfile: Caddy fixes its autosave path before it reads --envfile.
41
+ environment: { XDG_CONFIG_HOME: '/usr/local/var/caddy-edge' },
38
42
  runAtLoad: true,
39
43
  keepAlive: true,
40
44
  });
@@ -56,15 +60,15 @@ export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
56
60
  | `endpoint` | where the admin API was reached |
57
61
  | `sourceFile` | as applied |
58
62
 
59
- | step | what happens |
60
- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
61
- | plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
62
- | apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
63
- | refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
64
- | read | the digest of `GET /config/` — with no state, a running Caddy is **adopted** (its live config becomes the baseline) |
65
- | drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
66
- | replace | never — a new Caddyfile is a reload of the same Caddy |
67
- | delete | ⛔ nothing. See [Removing it](#removing-it) |
63
+ | step | what happens |
64
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
66
+ | apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
67
+ | refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
68
+ | read | the digest of `GET /config/`; with no state, the adoption probe — ⛔ a config that is not the declared one needs `--adopt` ([Adoption](#adoption)) |
69
+ | drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
70
+ | replace | never — a new Caddyfile is a reload of the same Caddy |
71
+ | delete | ⛔ nothing. See [Removing it](#removing-it) |
68
72
 
69
73
  The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
70
74
  keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
@@ -86,31 +90,42 @@ before any admin API exists to load into. What bounds the cost:
86
90
  `/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
87
91
  adoption probe, and the check runs at apply instead.
88
92
  - ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
89
- disk when `/load` fails. Caddy keeps serving the old config — but a **restart before the fix loads
90
- the refused file**. Fix and redeploy, or run Caddy with `--resume` (below).
93
+ disk when `/load` fails. Caddy keeps serving the old config, and a managed Caddy's restart runs its
94
+ autosave, not the refused file (below). Without `--resume` a **restart before the fix loads the
95
+ refused file**: fix and redeploy.
91
96
  - ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
92
97
  - ★ Both resources retain: removing them from a stack leaves the file a restart needs.
93
98
 
94
- ## Autosave and `--resume`
95
-
96
- After every successful load (ours included) Caddy writes the config JSON to `autosave.json`
97
- (`$XDG_CONFIG_HOME/caddy/`, else `~/Library/Application Support/Caddy/` on macOS, `~/.config/caddy/`
98
- on Linux), unless the Caddyfile says `persist_config off`. ⚠️ A daemon started with no `HOME` and no
99
- `XDG_CONFIG_HOME` falls back to `./caddy/` under its working directory (storage.go AppConfigDir):
100
- set `XDG_CONFIG_HOME` in the launchd job so autosave lands where you expect.
101
-
102
- - **Without `--resume`** (the mini's Caddy today): a restart loads `--config`. The file and the
103
- running config agree after every successful deploy; see the ⚠️ above for a failed one.
104
- - **With `caddy run --resume --config <file>`**: a restart loads `autosave.json` — the last config
105
- Caddy **accepted** — and `--config` only when no autosave exists. A refused file on disk can then
106
- never take sites down. The trade: a hand change through the API also survives a restart; the next
107
- plan reports it as drift and the deploy puts the declaration back.
108
- - `--watch` reloads the file on change; our `/load` right after is then a no-op ("config is
109
- unchanged").
110
- - `sourceFile` rides as `Caddy-Config-Source-File`, as `caddy reload` sends it. Without it a load
111
- makes Caddy forget its source file, and SIGUSR1 stops reloading from it. The header only KEEPS the
112
- file Caddy started with (caddy.go ClearLastConfigIfDifferent); it never sets a new one. ⚠️ Under
113
- `--resume` with an autosave, Caddy records no source file at all, so SIGUSR1 has nothing to reload.
99
+ ## Managed Caddies: `--resume` and their own config dir
100
+
101
+ ★ **Decision, 2026-09-21.** A Caddy this package manages runs `caddy run --resume --config <file>`
102
+ with `XDG_CONFIG_HOME` set to a directory of its own, so a restart runs the last config Caddy
103
+ **accepted**. After every successful load (ours included) Caddy writes the config JSON to
104
+ `$XDG_CONFIG_HOME/caddy/autosave.json`; `--resume` starts from it, and from `--config` only when no
105
+ autosave exists (cmd/commandfuncs.go cmdRun). Read in caddyserver/caddy v2.11.4:
106
+
107
+ - ★ **A refused Caddyfile on disk cannot take sites down.** The file is the first-boot fallback: a
108
+ new host, or an autosave someone deleted.
109
+ - ★ **Its own directory,** so nothing else (a Homebrew service, a person's `caddy run`) writes the
110
+ autosave it resumes from. With neither `HOME` nor `XDG_CONFIG_HOME` a daemon would use `./caddy/`
111
+ under its working directory (storage.go AppConfigDir).
112
+ - ⛔ **Set `XDG_CONFIG_HOME` in the job's `environment`, not the envfile.** The autosave path is
113
+ fixed when the binary starts (storage.go `var ConfigAutosavePath`), before `--envfile` is read.
114
+ - ⛔ **No `persist_config off`** in a managed Caddyfile. It stops the autosave, so `--resume` starts
115
+ from a stale one (the last config before it) or, with none, from the file.
116
+ - ⚠️ **A hand change through the API survives a restart.** The next plan reports it as drift, and the
117
+ deploy puts the declaration back.
118
+ - ⚠️ **SIGUSR1 is not a way to change config.** After a resumed start Caddy records no source file
119
+ (cmdRun calls `SetLastConfig` only after loading `--config`), so SIGUSR1 is ignored with the log
120
+ line `last config unknown`. Our `Caddy-Config-Source-File` header keeps a source Caddy already
121
+ has (caddy.go ClearLastConfigIfDifferent) and never sets one. After a first boot from the file,
122
+ SIGUSR1 still reloads it: the text the deploy wrote. Change config by deploying. Editing the file
123
+ and running `caddy reload` is a hand `/load`, which the next plan reports as drift.
124
+ - ⛔ **No `--watch`.** After a resumed start it has no file to watch, and falls back to a `Caddyfile`
125
+ in the working directory (cmd/main.go watchConfigFile).
126
+
127
+ A Caddy not yet moved over (no `--resume`) restarts from `--config`: see the ⚠️ in
128
+ [Order](#order-file-then-load).
114
129
 
115
130
  ## Secrets
116
131
 
@@ -133,31 +148,33 @@ bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a sc
133
148
 
134
149
  ## The admin endpoint
135
150
 
136
- ⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
137
- (`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
138
-
139
- - **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
140
- guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
141
- `enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
142
- Host check entirely.
143
- - **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
144
- ITS port): pass `hostHeader: 'localhost:2019'`.
145
- - **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
146
- `localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
147
- - **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
148
- if it turns the API off, listens off loopback or somewhere the transport does not reach (another
149
- port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
150
- no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
151
- `enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
152
- - ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
153
- load. That is refused unless the transport is at that default: a Caddy reached on a socket or
154
- another port declares `admin <address>` in its Caddyfile.
151
+ ⛔ The admin API has no authentication: `localCaddyAdmin()` accepts only `http://` loopback or
152
+ `unix:///path` (default `http://127.0.0.1:2019`), and a Caddyfile whose `admin` block would move,
153
+ expose or lock out the API is refused before it loads. Host/Origin checks, narrowed `origins`, SSH
154
+ forwards and every refusal: [caddy-admin.md](./caddy-admin.md).
155
155
 
156
156
  ## Adoption
157
157
 
158
- With no state, `read` reports the running Caddy's live config as plain (owned) attributes, so the
159
- first deploy adopts it silently and its forced update loads the declared Caddyfile — a full replace
160
- of whatever ran before. ⚠️ Review the plan: the Caddy on this loopback admin API is the one you get.
158
+ ★ **Nothing is adopted silently** (decision, 2026-09-21) — the rule `HostFile` and `LaunchdJob` keep.
159
+ With no state, `read` is Alchemy's adoption probe, and what the Caddy runs decides:
160
+
161
+ | running | read | plan |
162
+ | ----------------------------------- | --------- | ---------------------------------------------------------- |
163
+ | the declared config (same digest) | ours | adopted; the forced update loads nothing |
164
+ | nothing (`null`, or no apps) | absent | create — nothing to take over, as an empty R2 lock is none |
165
+ | anything else | `Unowned` | refused unless the deploy runs with `--adopt` |
166
+ | a Caddyfile that cannot be compared | `Unowned` | the same, with a warning saying why |
167
+ | no Caddy answering | absent | create, with a warning; the apply checks again |
168
+
169
+ - ⚠️ **Alchemy skips the probe while the props hold an Output** — always on `caddyWithFile()`'s first
170
+ deploy, whose `sourceFile` is the HostFile's path. So the apply of a create checks too, and fails
171
+ before any `/load` unless the deploy runs with `--adopt`. The HostFile is written by then (a file
172
+ already at the path is the HostFile's own `Unowned`); under `--resume` a restart still runs the
173
+ autosave.
174
+ - ★ **That apply-time check resolves adoption as the planner does:** a resource-scoped `adopt(…)`,
175
+ else `--adopt`. So `.pipe(adopt(false))` still refuses under `--adopt`, and `adopt(true)` works.
176
+ - ★ **An uncomparable Caddyfile is `Unowned`, not an error**: the same read recovers an interrupted
177
+ create with that deploy's props, and a throw would fail every later plan, the fixed one included.
161
178
 
162
179
  ## Removing it
163
180
 
@@ -177,4 +194,6 @@ it by hand at its admin API.
177
194
  its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
178
195
  `retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
179
196
  - ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
180
- Caddy plans an update that loads there; the old one keeps what it had.
197
+ Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
198
+ applied to, so the apply loads there only if it serves nothing, runs the declared config or the
199
+ one last stored — anything else needs `--adopt`.
@@ -0,0 +1,15 @@
1
+ # Credentials
2
+
3
+ Moved out of the README (2026-09-21) to make room under its 200-line cap; the text is unchanged.
4
+
5
+ `CLOUDFLARE_API_TOKEN` is read from the environment at call time, never at module scope.
6
+ `MeshNode` instead resolves credentials and the account the way `Cloudflare.providers()` does
7
+ (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in CI).
8
+
9
+ ⛔ **Mint a short-lived, scoped token** — do not reuse a long-lived one, and never a Global
10
+ API Key. An empty value fails closed with a message saying so, because an empty render is
11
+ a denied grant rather than a missing file, and `Bearer ` + nothing 401s in a way that reads
12
+ like a bad credential.
13
+
14
+ The Proxmox subpath mints every call's token from an OpenBao mount instead; see
15
+ [proxmox.md](./proxmox.md).
@@ -0,0 +1,147 @@
1
+ # Deploying a Mac host as yourself — `sudoRunner()`
2
+
3
+ A host stack that declares system daemons needs root for a handful of calls. `sudoRunner()` lets
4
+ the deploy run as you (the operator) and sends **only those calls** through `sudo -n`, in fixed
5
+ argv shapes, logging each one. It is an explicit opt-in: nothing falls back to it, and
6
+ `localRunner()` stays the default and never elevates.
7
+
8
+ ```ts
9
+ import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
10
+
11
+ const runner = sudoRunner({
12
+ // ⛔ Required: the directories root may write. Each — every directory above it, and every
13
+ // directory below it on the way to a file — must be one that root owns and only root may
14
+ // write, by mode bits and by ACL; that is checked at every privileged call.
15
+ prefixes: ['/Library/LaunchDaemons', '/opt/example'],
16
+ // log: (line) => …, // default: one line on stderr per privileged call
17
+ });
18
+ // Provide launchdProviders(runner) alongside the stack's other provider layers.
19
+ ```
20
+
21
+ ## What runs as root: the whole list
22
+
23
+ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never through a shell.
24
+
25
+ | when | argv |
26
+ | -------------------------------- | ---------------------------------------------------------------------- |
27
+ | a system job is bootstrapped | `/bin/launchctl bootstrap system /Library/LaunchDaemons/<label>.plist` |
28
+ | a system job is booted out | `/bin/launchctl bootout system/<label>` |
29
+ | (your own code, via `exec`) | `/bin/launchctl kickstart [-k] [-p] system/<label>` |
30
+ | a file under a prefix is written | `/usr/bin/install -S -m <0644> [-o <uid>] [-g <gid>] <staged> <path>` |
31
+ | a file under a prefix is removed | `/bin/rm -f -- <path>` |
32
+
33
+ - A file's bytes never reach argv or the log: they are written, as you, to a `0600` file in a
34
+ fresh `0700` temp directory. `install` copies that file (and only that file) into place, and
35
+ the temp directory is removed afterwards, whether or not the install succeeded.
36
+ - `install` writes a temp file beside the target and renames it (install(1) on macOS 27.2), so the
37
+ target is never torn. `-S` adds the fsync.
38
+ - Owner and group are numeric ids, resolved by the provider before the call.
39
+
40
+ ## What stays as you
41
+
42
+ - `launchctl print` and `print-disabled`. launchctl(1): "Anyone may read or query the system
43
+ domain"; measured on macOS 27.2, too (see `src/launchd/launchctl.ts`).
44
+ - User and group lookups, `id -G`, every file read and `lstat`.
45
+ - Files outside every prefix, and your own `gui/<uid>` jobs.
46
+
47
+ ★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
48
+
49
+ ★ **A plan that will write runs the checks below first** (`HostRunner.checkWrite`): a `HostFile`
50
+ or `LaunchdJob` diff that plans an update or a replace asks the runner, as you, whether it would
51
+ refuse that write. A refusal then fails the plan before any resource is applied, instead of the
52
+ apply halfway through. A create has no diff, so its refusal still comes at the write — before
53
+ sudo.
54
+
55
+ ## Refused before sudo is asked
56
+
57
+ - Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
58
+ `bootstrap system <directory>` (which loads every plist in it), a plist anywhere but
59
+ `/Library/LaunchDaemons/<label>.plist` (the only place launchd loads daemons from at boot),
60
+ `rm -r`, `install -d`, a label under `org.nixos.`, `com.apple.` or `homebrew.mxcl.`, and any
61
+ argv whose program is `sudo`.
62
+ - A system job's `bootstrap` or `bootout` when `/Library/LaunchDaemons` is not a prefix. ⚠️ Without
63
+ it a delete would boot the job out, then fail to remove its plist, which launchd loads again at
64
+ the next boot.
65
+ - A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
66
+ symlinked prefix makes root write wherever it points, a path the log never names.
67
+ - **A directory between the prefix and the file** that is not owned by root, or that group or
68
+ other may write (checked since 0.9.0). ⛔ Its owner, or anyone who may write it, could swap
69
+ what lies under it for a symlink between the check and the call.
70
+ - **A directory above the prefix**, from `/` down, that is not owned by root or that group or other
71
+ may write (red team, 2026-09-21). ⛔ Whoever may change the prefix's parent may rename the prefix
72
+ away and put a symlink in its place: the same swap, one level up. A root-owned symlink among them
73
+ (`/etc` -> `private/etc`) is root's own and is followed.
74
+ - **An ACL from `/` down to the file that grants a write right** (`add_file`, `add_subdirectory`,
75
+ `delete_child`, `delete`, `write`, `append`, `writesecurity`, `chown`), read with `ls -lden` as
76
+ you. ⛔ On macOS an ACL entry can hand another user exactly the swap the mode bits forbid, and an
77
+ inheritable `allow write` hands them every file root installs there. Deny entries (a home
78
+ folder's `everyone deny delete`) and read-only rights refuse nothing; ACLs that cannot be read
79
+ refuse. Measured 2026-09-21: `/Library/LaunchDaemons`, `/private/etc`, `/opt` and `/usr/local`,
80
+ and everything above them, carry none.
81
+ - **A root-owned file that would be group- or world-writable, setuid or setgid** (`mode & 0o6022`;
82
+ an omitted owner is root). ⛔ Anyone in that class could rewrite a file root installed (a
83
+ daemon's config, a script it runs), and a setuid root file runs as root for whoever executes it.
84
+ A file handed to another user (`owner`) is theirs to change, so its mode is theirs too.
85
+ - A path outside every prefix that needs root: another user as the owner, say.
86
+ - A symlink or missing directory between the prefix and the file, or anything but a regular file
87
+ at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
88
+ the same.
89
+ - A file you could not read back. Both providers read every write back, and read again at every
90
+ plan, as you. A root-only `0600` would be installed, then fail with `EACCES` and stay on disk
91
+ with no state. Membership comes from `id -G`, because macOS caps `getgroups()` at 16 (measured
92
+ 2026-09-21: 16 against 18).
93
+ - Another user's `gui/<uid>` or `user/<uid>` domain. Only the system domain is elevated.
94
+
95
+ Every refusal above throws `SudoRefusedError`: nothing ran as root. A privileged command that ran
96
+ and failed throws a plain `Error` with its exit code.
97
+
98
+ ## When sudo wants a password
99
+
100
+ `sudo -n` never prompts. It fails at once, and the error says so: `a password is required, and
101
+ this runner never prompts`. The command did not run. You have two options.
102
+
103
+ 1. **`sudo -v` just before the deploy.** This caches a ticket. By default it lasts 5 minutes and
104
+ is tied to one terminal (sudoers(5): `timestamp_timeout`, `timestamp_type`).
105
+ ⚠️ A deploy that outlives the ticket fails at its next privileged call. Every step is
106
+ idempotent (Alchemy requires `delete` to be), so `sudo -v` and deploying again converges. The
107
+ exception is a `LaunchdJob` label or domain change: it is delete-first, so if it fails between
108
+ the delete and the create, the job is down until you redeploy.
109
+ 2. **A `NOPASSWD` sudoers rule for exactly the shapes above.**
110
+ - ⛔ **That rule is root-equivalent.** Anyone who may `install` into `/Library/LaunchDaemons`
111
+ and `launchctl bootstrap system` can run any program as root. The allowlist protects you
112
+ from mistakes, not from the account that holds the rule.
113
+ - ⛔ **Never use wildcards in the arguments.** sudoers(5): in arguments, `*` also matches spaces
114
+ and `/`, so `/bin/rm -f -- /opt/example/*` matches `../../etc/x` and two paths at once. Use
115
+ `^…$` regular expressions (sudo 1.9.10 and later; macOS 27.2 ships 1.9.17p2) and forbid
116
+ `..`. Check the file with `visudo -c -f <file>` before you install it.
117
+ - ⚠️ The kit ships no tested rule. Nothing here has run sudo.
118
+
119
+ ⚠️ The error text sudo prints is **reasoned, not measured**. It comes from sudoers(5) and sudo(8)
120
+ for sudo 1.9.17p2. A sudo refusal the runner does not recognise comes back as a failed command,
121
+ with sudo's own stderr.
122
+
123
+ ## The log
124
+
125
+ Each privileged call logs one line before it runs:
126
+
127
+ ```text
128
+ homeflare/launchd sudo -n ["/bin/launchctl","bootstrap","system","/Library/LaunchDaemons/com.example.exporter.plist"]
129
+ ```
130
+
131
+ The argv is written as JSON, so a space in a path cannot hide a second argument. The line holds
132
+ the argv only, never file content. ⛔ A custom `log` should keep every line: the log is the only
133
+ record of what ran as root.
134
+
135
+ ## Limits
136
+
137
+ - ⚠️ **Moving a job from `system` to another user's `gui/<uid>` passes the plan and fails at
138
+ apply**, after the old job was already removed. The runner reports `privileged: true`, so the
139
+ plan-time check lets it through, and the refusal only comes at the write. Do that move as a
140
+ remove, then an add.
141
+ - ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
142
+ is the directory's group (a new file's group on macOS).
143
+ - ⚠️ **The checks run as you, just before the call.** Every directory from `/` down to the file
144
+ must be root-only, by mode bits and by ACL, so no other user can swap a path in between; root
145
+ itself still could. The target of a root-owned symlink above the prefix is root's choice and is
146
+ not walked.
147
+ - ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.
package/docs/launchd.md CHANGED
@@ -87,8 +87,9 @@ The plist path is derived, never declared: `/Library/LaunchDaemons/<label>.plist
87
87
  it becomes visible — then renamed over the path. Read back after every write. Diffed by SHA-256,
88
88
  mode and owner. A new `path` is a create-before-delete replace. Delete removes the file.
89
89
 
90
- - ⛔ A different file already at a new `path` is refused, not overwritten: the adoption probe
91
- never looks at a replace's new path, so a typo there would otherwise overwrite a system file.
90
+ - ⛔ A different file already at a new `path` is refused, not overwritten, `--adopt` or not: the
91
+ adoption probe never looks at a replace's new path, so a typo there would otherwise overwrite a
92
+ system file.
92
93
 
93
94
  - ⛔ A symlink or directory at the path is refused, never replaced: rename over a symlink replaces
94
95
  the link, which takes the path from whatever tool owns it (nix-darwin's `/etc` entries point
@@ -112,8 +113,11 @@ The `system` domain, and another user's `gui/<uid>`, need root. The provider ref
112
113
  unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
113
114
  `privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
114
115
 
115
- - **system daemons:** start the whole deploy as root; or pass `launchdProviders(yourRunner)` where
116
- `yourRunner` is a `HostRunner` you wrote that already holds root (a root helper, say).
116
+ - **system daemons, deployed as yourself:** `launchdProviders(sudoRunner({ prefixes }))` sends only
117
+ the privileged calls through `sudo -n`, from a fixed allowlist, logging each. It never prompts.
118
+ See [launchd-sudo.md](./launchd-sudo.md).
119
+ - **system daemons, deployed as root:** start the whole deploy as root; or pass
120
+ `launchdProviders(yourRunner)` where `yourRunner` is a `HostRunner` that already holds root.
117
121
  - **your own agents** (`gui/<your uid>`): no root needed.
118
122
 
119
123
  Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
@@ -123,7 +127,10 @@ you deploy as.
123
127
  ## Adoption: nothing is taken over silently
124
128
 
125
129
  `read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
126
- `Unowned`, so Alchemy refuses to take it over without `--adopt`.
130
+ `Unowned`, so Alchemy refuses to take it over without `--adopt`. Where the plan never asked (a prop
131
+ still an `Output`), `reconcile` refuses the same takeover, and `--adopt` or the resource's own
132
+ `adopt(true)` lets a create (or an interrupted generation) through there too — never a fresh
133
+ replace's new path or label. The whole rule: [ownership.md](./ownership.md).
127
134
 
128
135
  Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
129
136
  rewrites its plists on every activation and unloads any it no longer lists, macOS owns
@@ -163,5 +170,5 @@ Nix job is booted out first. Per job:
163
170
  `HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
164
171
  `stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
165
172
  `privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
166
- `node:child_process`; tests use an in-memory fake, so nothing in the test suite runs `launchctl` or
167
- writes outside a temp directory.
173
+ `node:child_process`; `sudoRunner()` wraps it and elevates the allowlist. Tests use an in-memory
174
+ fake, so nothing in the test suite runs `launchctl` or `sudo`, or writes outside a temp directory.