@homeflare/alchemy 0.8.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 (240) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +320 -0
  121. package/dist/verify/cli.js.map +15 -0
  122. package/dist/verify/fake-engine.d.ts +57 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +297 -0
  127. package/dist/verify/index.js.map +14 -0
  128. package/dist/verify/report.d.ts +6 -0
  129. package/dist/verify/report.d.ts.map +1 -0
  130. package/dist/verify/rows.d.ts +57 -0
  131. package/dist/verify/rows.d.ts.map +1 -0
  132. package/dist/verify/spy.d.ts +31 -0
  133. package/dist/verify/spy.d.ts.map +1 -0
  134. package/dist/verify/verify.d.ts +43 -0
  135. package/dist/verify/verify.d.ts.map +1 -0
  136. package/docs/adopt-verify.md +108 -0
  137. package/docs/adopted-deploys.md +85 -0
  138. package/docs/credentials.md +15 -0
  139. package/docs/launchd-sudo.md +31 -6
  140. package/docs/launchd.md +7 -3
  141. package/docs/ownership.md +113 -0
  142. package/docs/proxmox-lxc.md +196 -0
  143. package/docs/proxmox.md +12 -0
  144. package/package.json +9 -2
  145. package/src/caddy/adopt-scope.test.ts +1 -1
  146. package/src/caddy/config-lifecycle.ts +1 -1
  147. package/src/caddy/config.ts +2 -1
  148. package/src/launchd/adopt-apply.test.ts +166 -0
  149. package/src/launchd/fake-runner.ts +26 -4
  150. package/src/launchd/host-effect.ts +1 -33
  151. package/src/launchd/host-file-lifecycle.ts +39 -14
  152. package/src/launchd/host-file.ts +13 -4
  153. package/src/launchd/job-lifecycle.ts +22 -14
  154. package/src/launchd/job-preflight.ts +18 -1
  155. package/src/launchd/job.ts +13 -4
  156. package/src/launchd/runner.ts +7 -0
  157. package/src/launchd/sudo-acl.ts +68 -0
  158. package/src/launchd/sudo-chain.test.ts +121 -0
  159. package/src/launchd/sudo-guard.ts +89 -10
  160. package/src/launchd/sudo-modes.test.ts +135 -0
  161. package/src/launchd/sudo-runner.ts +40 -59
  162. package/src/launchd/sudo-said.ts +58 -0
  163. package/src/openbao/README.md +5 -2
  164. package/src/openbao/REPLACE.md +13 -11
  165. package/src/openbao/adopt-core.test.ts +119 -0
  166. package/src/openbao/adopt-families.test.ts +97 -0
  167. package/src/openbao/adopt-holes.test.ts +168 -0
  168. package/src/openbao/adopt-recovery.test.ts +71 -0
  169. package/src/openbao/auth-method-reconcile.ts +15 -3
  170. package/src/openbao/auth-method.ts +15 -6
  171. package/src/openbao/auth-role.ts +14 -4
  172. package/src/openbao/cloudflare-role.ts +17 -4
  173. package/src/openbao/fake-bao.ts +52 -0
  174. package/src/openbao/fake-core.ts +141 -0
  175. package/src/openbao/fake-families.ts +11 -5
  176. package/src/openbao/fake-mounts.ts +74 -0
  177. package/src/openbao/fake-stack.ts +17 -4
  178. package/src/openbao/jwt-config.ts +10 -7
  179. package/src/openbao/jwt-role.ts +10 -7
  180. package/src/openbao/kubernetes-role.ts +13 -7
  181. package/src/openbao/mfa-enforcement.ts +20 -6
  182. package/src/openbao/mfa-totp.ts +22 -7
  183. package/src/openbao/mount-move.test.ts +2 -35
  184. package/src/openbao/mount-reconcile.ts +13 -2
  185. package/src/openbao/mount.ts +15 -6
  186. package/src/openbao/pki-role.ts +15 -4
  187. package/src/openbao/plugin-reconcile.ts +16 -5
  188. package/src/openbao/plugin.ts +17 -6
  189. package/src/openbao/policy.ts +17 -4
  190. package/src/openbao/proxmox-role.ts +13 -4
  191. package/src/openbao/rename-adoption.test.ts +32 -19
  192. package/src/openbao/role-reconcile.ts +23 -0
  193. package/src/openbao/ssh-role.ts +14 -4
  194. package/src/ownership/adopt.ts +122 -0
  195. package/src/ownership/ownership.test.ts +200 -0
  196. package/src/ownership/probe.ts +65 -0
  197. package/src/ownership/resume.ts +118 -0
  198. package/src/ownership/rows.ts +117 -0
  199. package/src/ownership/whole.test.ts +39 -0
  200. package/src/ownership/whole.ts +60 -0
  201. package/src/proxmox/adopt-noop.test.ts +112 -0
  202. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  203. package/src/proxmox/ceph-pool-form.ts +3 -3
  204. package/src/proxmox/ceph-pool.ts +10 -3
  205. package/src/proxmox/fake-bao-env.ts +28 -0
  206. package/src/proxmox/fake-pve-lxc.ts +210 -0
  207. package/src/proxmox/fake-pve.ts +75 -0
  208. package/src/proxmox/index.test.ts +6 -6
  209. package/src/proxmox/index.ts +6 -2
  210. package/src/proxmox/lxc-adopt.test.ts +170 -0
  211. package/src/proxmox/lxc-create-form.ts +76 -0
  212. package/src/proxmox/lxc-create.test.ts +126 -0
  213. package/src/proxmox/lxc-guards.test.ts +158 -0
  214. package/src/proxmox/lxc-harness.ts +150 -0
  215. package/src/proxmox/lxc-identity.ts +43 -0
  216. package/src/proxmox/lxc-judge.test.ts +175 -0
  217. package/src/proxmox/lxc-judge.ts +196 -0
  218. package/src/proxmox/lxc-lifecycle.ts +200 -0
  219. package/src/proxmox/lxc-ownership.test.ts +159 -0
  220. package/src/proxmox/lxc-props.ts +215 -0
  221. package/src/proxmox/lxc-read.test.ts +60 -0
  222. package/src/proxmox/lxc-volume.test.ts +96 -0
  223. package/src/proxmox/lxc-volume.ts +153 -0
  224. package/src/proxmox/lxc-wire.ts +210 -0
  225. package/src/proxmox/lxc.ts +216 -93
  226. package/src/proxmox/network-apply-read.ts +3 -1
  227. package/src/proxmox/pbs-datastore.ts +4 -3
  228. package/src/proxmox/resource.ts +7 -7
  229. package/src/proxmox/update-guard.ts +31 -0
  230. package/src/verify/args.ts +82 -0
  231. package/src/verify/cli.test.ts +86 -0
  232. package/src/verify/cli.ts +49 -0
  233. package/src/verify/fake-engine.ts +158 -0
  234. package/src/verify/index.ts +36 -0
  235. package/src/verify/report.ts +52 -0
  236. package/src/verify/rows.ts +169 -0
  237. package/src/verify/spy.test.ts +141 -0
  238. package/src/verify/spy.ts +149 -0
  239. package/src/verify/verify.test.ts +124 -0
  240. 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,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).
@@ -9,8 +9,9 @@ argv shapes, logging each one. It is an explicit opt-in: nothing falls back to i
9
9
  import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
10
10
 
11
11
  const runner = sudoRunner({
12
- // ⛔ Required: the directories root may write. Each must be a real directory that root owns
13
- // and only root may write; that is checked at every privileged call.
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.
14
15
  prefixes: ['/Library/LaunchDaemons', '/opt/example'],
15
16
  // log: (line) => …, // default: one line on stderr per privileged call
16
17
  });
@@ -45,6 +46,12 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never thro
45
46
 
46
47
  ★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
47
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
+
48
55
  ## Refused before sudo is asked
49
56
 
50
57
  - Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
@@ -57,6 +64,24 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never thro
57
64
  the next boot.
58
65
  - A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
59
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.
60
85
  - A path outside every prefix that needs root: another user as the owner, say.
61
86
  - A symlink or missing directory between the prefix and the file, or anything but a regular file
62
87
  at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
@@ -115,8 +140,8 @@ record of what ran as root.
115
140
  remove, then an add.
116
141
  - ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
117
142
  is the directory's group (a new file's group on macOS).
118
- - ⚠️ **The checks guard against a mistaken declaration, not against a hostile one.** They run as
119
- you, just before the call. The prefix itself must be root-only, but a directory _below_ it that
120
- another user owns is not checked for that, and that user could swap what lies under it in
121
- between. Keep every directory under a prefix root-owned, too. ACLs are not read, only mode bits.
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.
122
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
@@ -126,7 +127,10 @@ you deploy as.
126
127
  ## Adoption: nothing is taken over silently
127
128
 
128
129
  `read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
129
- `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).
130
134
 
131
135
  Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
132
136
  rewrites its plists on every activation and unloads any it no longer lists, macOS owns
@@ -0,0 +1,113 @@
1
+ # Ownership — nothing is adopted without `--adopt`
2
+
3
+ A live object this stack holds **no state for** is never taken over silently. That holds for every
4
+ `Bao.*` family, `HostFile`, `LaunchdJob`, `CaddyConfig` and `ProxmoxLxc`, and it holds even when the
5
+ live object is **identical** to the declaration: identical is not ours. Another stack, a person or an old
6
+ script put it there, and once state claims it, a delete under `RemovalPolicy.destroy()` removes it
7
+ from its real owner. (Decided 2026-09-21; the Bao families adopted silently until 0.9.0.)
8
+
9
+ To take an object over, say so:
10
+
11
+ ```sh
12
+ alchemy deploy --adopt --dry-run # read it first: `alchemy plan` has no --adopt (beta.79)
13
+ alchemy deploy --adopt # every resource in this deploy
14
+ ```
15
+
16
+ ⚠️ Only `deploy` declares `--adopt` in alchemy 2.0.0-beta.79, so `alchemy plan` fails "Cannot
17
+ adopt" before any resource can say what taking the object over would write. `--dry-run` shows it.
18
+ ★ Even then every adoption prints `adopted`, drift or not. `hf-adopt-verify` plans with adoption on,
19
+ never writes, and prints each row's own diff and an exit code: [adopt-verify.md](./adopt-verify.md).
20
+
21
+ ```ts
22
+ import { adopt } from 'alchemy/AdoptPolicy';
23
+
24
+ // this resource only
25
+ export const app = BaoPolicy('app', { fragments: './policies/app', name: 'app' }).pipe(adopt(true));
26
+ ```
27
+
28
+ A resource-scoped `adopt(…)` wins over the flag, both ways: `.pipe(adopt(false))` stays refused
29
+ under `--adopt`.
30
+
31
+ ## Where it is checked
32
+
33
+ | when | what happens to a live object with no state |
34
+ | ----------------------------------- | ---------------------------------------------------------------------------------------- |
35
+ | **plan** — Alchemy's adoption probe | `read` answers `Unowned`; the plan fails with `OwnedBySomeoneElse` unless adoption is on |
36
+ | **apply** — the probe never ran | `reconcile` refuses before any write unless adoption is on |
37
+
38
+ Alchemy skips the probe while a prop is still an `Output` (it comes from a resource created in the
39
+ same deploy), and it never probes the new generation of a replace. An object can also appear
40
+ between plan and apply. So each family checks again in `reconcile`, reading the object before it
41
+ writes, and resolves `--adopt` / `adopt(…)` exactly as the planner does
42
+ (`src/ownership/adopt.ts`).
43
+
44
+ - A refused create also **forgets the `creating` row** Apply wrote for it, so the next plan probes
45
+ again and refuses again. Left behind, that row's recovery read could have adopted the very object
46
+ the apply refused (measured: `Bao.Mount` and `Bao.AuthMethod`, whose omitted props are not managed).
47
+ - `Bao.Mount` / `Bao.AuthMethod`: a create with `remountFrom` moves a live mount that this stack
48
+ holds no state for. That move is a takeover too, and it is refused the same way.
49
+ - **Adoption at apply is for a create, or an unfinished generation.** The planner offers `--adopt`
50
+ to a resource with no state, and Alchemy offers it to resume an interrupted create; so at apply
51
+ it lets through a `creating` row, or a generation whose row the plan's `diff` saw with no
52
+ attributes (an interrupted create or replace). A **fresh** replace's new generation that finds
53
+ its new identity taken is refused, `--adopt` or not: the plan never asked about that identity
54
+ (measured 2026-09-21: a `HostFile` whose path changed onto a file it did not own overwrote it
55
+ under a deploy-wide `--adopt`). For the Bao families a rename onto a taken identity already fails
56
+ the plan; this keeps the apply to the same answer.
57
+
58
+ ## Crash recovery still works — when the row can prove it
59
+
60
+ A deploy that dies after its write and before its commit leaves a `creating` (or `replacing`) row
61
+ and a live object. The next deploy finishes it **without** `--adopt`:
62
+
63
+ - **An interrupted create.** Alchemy's recovery read passes the row's own instance id, which the
64
+ state store records (a probe's id is freshly minted, so no row holds it). The object is ours when
65
+ it also matches that row's props. If it does not, it may have lost a race to someone else, so it
66
+ is `Unowned`: the plan says `Cannot resume creating`, and `--adopt` resumes it.
67
+ - **An interrupted replace.** Alchemy never probes a `replacing` row; it calls `diff`. For a row
68
+ with no attributes, every Bao family's and `ProxmoxLxc`'s `diff` first asks its own `read` the
69
+ recovery question —
70
+ recorded instance, whole row, matching object (`provingResumes`, `src/ownership/resume.ts`) — and
71
+ only a "yes" leaves a note in Alchemy's per-deploy `Artifacts` bag that lets the apply's own
72
+ object through.
73
+
74
+ ⛔ **A row proves nothing by itself.** Apply commits it **before** reconcile runs, so a deploy killed
75
+ before the claim (an upstream that failed, a Ctrl-C) leaves a row whose create never asked whose
76
+ object sat at its identity. Found by the red team on 2026-09-21 (`openbao/adopt-holes.test.ts`):
77
+
78
+ - **A row missing part of its declaration proves nothing.** Apply strips a prop that was still an
79
+ `Output` from the row, and most families read an absent prop as "not managed". So the row must
80
+ carry every value the declaration names (`src/ownership/whole.ts`); one with a hole is `Unowned`.
81
+ Measured: a `Bao.Mount` whose `defaultLeaseTtl` was an Output adopted, and tuned, another owner's
82
+ mount; with the name an Output, every role family resumed onto another owner's role.
83
+ - **So a create or replace interrupted while a prop was still an Output resumes with `--adopt`.**
84
+ Its row cannot say what it wrote, and that deploy's plan never asked whose object was there.
85
+
86
+ ⚠️ **A multi-step create interrupted halfway is not proven ours.** `Bao.Mount` enables, then tunes.
87
+ If the deploy dies between the two, the mount does not match its props, and the resume needs
88
+ `--adopt`. That is the safe direction.
89
+
90
+ ⚠️ **It fails closed.** If plan and apply do not share an `Artifacts` bag, or the store cannot be
91
+ read, a resume reads as a fresh create and asks for `--adopt`. `alchemy deploy` and the Alchemist
92
+ session both share one. An orphan's interrupted create (no longer declared) cannot be compared with
93
+ a declaration, so Apply leaves its object in place with a note instead of deleting it.
94
+
95
+ ## Limits
96
+
97
+ - ⚠️ **A check, then a write: not a lock.** Two resources that declare the same name in **one**
98
+ deploy create concurrently. Both read nothing, and both write (measured with the fakes). Declare
99
+ each name once.
100
+ - ⚠️ **Proven at plan, written at apply.** A resume is proven against the object the plan read.
101
+ One swapped for another between that read and the apply is written over. That takes a race.
102
+ - ⚠️ **Identical is still ours to a recovery read.** A foreign object identical to a whole row's
103
+ props, at the identity an interrupted create or replace was going to write, reads as ours — the
104
+ rule Plan.ts itself applies to a recovery read.
105
+ - ⚠️ **`HostFile` and `LaunchdJob` answer `Unowned` to the recovery read too.** A create of theirs
106
+ interrupted after its write needs `--adopt` on the next deploy. Both remove what a failed create
107
+ wrote, so this needs a kill between the write and the commit.
108
+ - ⚠️ **`CaddyConfig` keeps its own probe.** A running config identical to the declaration reads as
109
+ ours, because adopting it changes nothing Caddy serves ([caddy.md](./caddy.md#adoption)).
110
+ `ProxmoxLxc` does not share that exception: its delete removes a guest and its volumes
111
+ ([proxmox-lxc.md](./proxmox-lxc.md#adopting-what-the-plan-says)).
112
+ - ⚠️ **The other `@homeflare/alchemy/proxmox` resources do not follow this rule yet.** Those built
113
+ on `pveHandlers` (`src/proxmox/resource.ts`) still read a live object with no state as ours.
@@ -0,0 +1,196 @@
1
+ # Proxmox.Lxc
2
+
3
+ A Proxmox VE container as an Alchemy resource: adopt one that runs today, or create one from a
4
+ template. It uses the same PVE client and OpenBao-minted tokens as every other
5
+ `@homeflare/alchemy/proxmox` resource. Nothing reads a stored credential.
6
+
7
+ ```ts
8
+ import { adopt } from 'alchemy/AdoptPolicy';
9
+ import * as Effect from 'effect/Effect';
10
+ import { ProxmoxLxc } from '@homeflare/alchemy/proxmox';
11
+
12
+ export const guests = Effect.gen(function* () {
13
+ // Adopt: paste `pvesh get /nodes/<node>/lxc/<vmid>/config --output-format json`,
14
+ // minus `digest`, `lxc`, `lock`, `parent` and `unusedN`, and add where it lives.
15
+ // adopt(true) (or one deploy with --adopt) says the claim is meant; without it, nothing is.
16
+ const ct = yield* ProxmoxLxc('ct', {
17
+ target,
18
+ node: 'pve1',
19
+ vmid: 100,
20
+ hostname: 'ct-example',
21
+ cores: 4,
22
+ memory: 8192,
23
+ swap: 0,
24
+ onboot: 1,
25
+ unprivileged: 1,
26
+ features: 'nesting=1',
27
+ rootfs: 'local-zfs:subvol-100-disk-0,size=40G',
28
+ mp0: 'tank:subvol-100-disk-0,mp=/data,backup=0,size=200G',
29
+ net0: 'name=eth0,bridge=vmbr0,hwaddr=00:00:5E:00:53:01,ip=192.0.2.10/24,type=veth',
30
+ dev0: '/dev/dri/renderD128,uid=0,gid=44,mode=0660',
31
+ }).pipe(adopt(true));
32
+
33
+ // Create: a template, a new volume as `storage:GiB`, and `start` for the first boot only.
34
+ const door = yield* ProxmoxLxc('door', {
35
+ target,
36
+ node: 'pve1',
37
+ vmid: 150,
38
+ ostemplate: 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst',
39
+ rootfs: 'local-zfs:8',
40
+ net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
41
+ features: 'nesting=1',
42
+ onboot: 1,
43
+ start: 1,
44
+ });
45
+ return { ct, door };
46
+ });
47
+ ```
48
+
49
+ Provide `ProxmoxLxcProvider()` with the other providers, plus `FetchHttpClient.layer`.
50
+
51
+ ## The props are PVE's keys
52
+
53
+ Every prop is the key and spelling PVE uses in `/nodes/{node}/lxc/{vmid}/config`: `mp0`, `net1`,
54
+ `dev0` and `rootfs` are property strings, and booleans may be `1`/`0` or `true`/`false`.
55
+
56
+ - ⛔ **An undeclared key is unmanaged.** It is never sent, compared or deleted, including the keys
57
+ PVE fills in itself (`arch`, `ostype`, a NIC's `hwaddr`).
58
+ - **To remove a key, declare it as `''`.** This works for `description`, `tags`, `startup`,
59
+ `features`, `nameserver`, `searchdomain`, `timezone` and `netN`. It is refused for a mount point.
60
+ - ⛔ `password`, `ssh-public-keys` and `env` are typed `never`, because state is stored
61
+ unencrypted. A container created here has no root password. Use `pct enter` on its node.
62
+ - ⛔ A key the resource does not manage (`hookscript`, `lock`, `force`, …) fails the plan, and
63
+ the create too: it is never sent.
64
+ - ⛔ The `config` attribute keeps only keys this resource manages (an allowlist, so a new PVE key
65
+ such as `entrypoint` is not stored). Never `description` (the UI's Notes panel, where people
66
+ paste credentials), `env`, `digest` or raw `lxc.*` values. A declared description is in props.
67
+
68
+ **Values are compared as PVE stores them, not as strings:**
69
+
70
+ - A property string is compared as a map. Key order does not matter. A default written out
71
+ (`backup=0`, `firewall=0`, `type=veth`, `deny-write=0`, `keyctl=0`) equals the key being absent.
72
+ - An undeclared `hwaddr` is ignored, and a NIC write keeps the live MAC. A new MAC would mean a
73
+ new DHCP lease.
74
+ - `rootfs: 'local-zfs:40'` equals `local-zfs:subvol-…,size=40G`: the storage and the size are
75
+ compared. An existing volume is always written back with its live volume id, never with the
76
+ new-disk spelling, which would allocate a new volume and detach the old one.
77
+ - Tags are compared as a lower-cased set, as PVE stores them by default. ⚠️ If your cluster sets
78
+ `tag-style case-sensitive=1`, a change of case is not seen.
79
+ - `nameserver` and `searchdomain` are compared in order. `description` ignores the trailing
80
+ newline PVE adds to it.
81
+
82
+ ## What a change does
83
+
84
+ | Change | What happens |
85
+ | ------------------------------------------------------------------ | ------------------------------------------------------------------- |
86
+ | cores, memory, swap, hostname, onboot, tags, NIC, mount options, … | `PUT …/config`, carrying the config `digest` |
87
+ | A larger `rootfs` / `mpN` | `PUT …/resize` after the config write, then wait for the task |
88
+ | A new `mpN` (`storage:GiB`) or `netN` | `PUT …/config` |
89
+ | Nothing differs | Nothing is written, including on the first deploy after an adoption |
90
+
91
+ The `digest` makes PVE refuse the write if the config changed since the deploy read it. ⚠️ That
92
+ read is the deploy's, not the plan's: a declared key edited by hand between `plan` and `deploy`
93
+ is written back to the declaration, even after a plan that warned of nothing.
94
+
95
+ On a running guest, PVE may keep a change as pending until the next restart. A read returns the
96
+ pending value, so the plan still converges.
97
+
98
+ ## What is refused, and why
99
+
100
+ The plan fails with a sentence, and nothing is written. Nothing here plans a replace, because a
101
+ replace of a guest deletes a running machine and its disks.
102
+
103
+ | Change | Why |
104
+ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
105
+ | `vmid` | A new vmid is a different guest. |
106
+ | `node`, while the guest is on another node | This resource never migrates. |
107
+ | `ostemplate` | The template is used up at create. Another template means a new guest. |
108
+ | `unprivileged` | PVE: "unable to modify read-only option" |
109
+ | A smaller `rootfs` / `mpN` | PVE: "unable to shrink disk size" |
110
+ | Another storage | Use `pct move-volume`, which copies. It is not a config edit. |
111
+ | Another volume, or `mpN: ''` | The live volume would be detached to `unusedN`. |
112
+ | A create naming an existing volume id (not `storage:GiB`) | PVE would unpack the template onto that volume. |
113
+ | `devN`, a bind or device mount, a feature other than `nesting`, any feature on a privileged guest | Only root@pam can write these (see below) |
114
+
115
+ A vmid that belongs to another node, or to a QEMU VM, fails the plan and says where it is. It
116
+ does not plan a create. After HA or `pct migrate` has moved a guest, set `node` to where it is
117
+ now: the plan is an update that writes nothing and records the new node.
118
+
119
+ ### root@pam only: device passthrough, bind mounts, most features
120
+
121
+ PVE's `check_ct_modify_config_perm` (pve-container 6.1.14) lets only `root@pam` write these. An
122
+ API token's user is always `user@realm!token`, never `root@pam`, so no minted token can write them.
123
+
124
+ For these, the plan refuses and prints the exact `pct set <vmid> --<key> '<value>'` to run on the
125
+ node. After you run it, declare the value, and the next plan compares it like any other key. A
126
+ guest that needs `/dev/net/tun` (a Mesh door) or `/dev/dri` is created in two steps:
127
+
128
+ 1. Declare the guest without the device, and deploy.
129
+ 2. Run the `pct set` on the node, then add `dev0` to the declaration.
130
+
131
+ `nesting` on an unprivileged guest is allowed. It needs `VM.Allocate`, which `LXCProvisioner` has.
132
+
133
+ ### Gaps
134
+
135
+ - **Raw `lxc.*` lines** (`lxc.prlimit.memlock`, `lxc.cgroup2.devices.allow`, `lxc.mount.entry`)
136
+ can be read through the API but not set. They are edited in `/etc/pve/lxc/<vmid>.conf` on the
137
+ node. The attribute `rawKeys` lists their names only. Values are never stored, because
138
+ `lxc.environment` can hold anything.
139
+ - **Power state is not declared.** `start: 1` boots the guest once, after its create. After
140
+ that, `onboot` is the setting, and starting or stopping is an operator's job.
141
+ - **Pool membership, HA, firewall rules, snapshots, replication** are not declared here. Use their
142
+ own resources, or manage them by hand.
143
+
144
+ ## Adopting: what the plan says
145
+
146
+ ⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, not even one that matches the
147
+ declaration** — the kit-wide rule in [ownership.md](./ownership.md). Without either, the plan fails
148
+ with "Cannot adopt" and nothing is written. Matching is not proof the guest is yours: a pasted
149
+ config or a mistyped vmid reads the same, and once state claims a guest, `RemovalPolicy.destroy()`
150
+ deletes it and its volumes. `.pipe(adopt(false))` stays refused under `--adopt`.
151
+
152
+ With adoption on, a cold adoption plans **`adopted`** even when the deploy would write. Alchemy
153
+ 2.0.0-beta.79 forces an update after it adopts, and prints no property diff for that update. So
154
+ `diff` logs a warning naming each key a deploy would write:
155
+
156
+ ```
157
+ Proxmox.Lxc pve1/100: live config differs from the declaration in memory, net1 -- a deploy writes these.
158
+ ```
159
+
160
+ - **No warning** means the declaration is the live config. The deploy writes nothing (two GETs),
161
+ and the next plan is `noop`. ★ That is the zero-change adoption check: read the plan first.
162
+ - **A warning** means fix the declaration, or accept the named writes. Keys are named, values are
163
+ not.
164
+
165
+ ⛔ **A warning does not stop the deploy.** `deploy --adopt --yes` writes each named key onto the
166
+ live guest. A `net0` declared without the live `tag=` is written without it, and the guest leaves
167
+ its VLAN. Only the plan you read makes an adoption zero-change.
168
+
169
+ ⚠️ **`alchemy plan` has no `--adopt` flag** (2.0.0-beta.79: only `deploy` declares it). Without
170
+ adoption on, the plan stops at "Cannot adopt" before `diff` can warn. To read the adoption's plan,
171
+ run `alchemy deploy --adopt --dry-run`, or declare `.pipe(adopt(true))` and run `alchemy plan`.
172
+
173
+ A deploy that planned a **create** never takes over a guest it then finds at that vmid (the plan
174
+ skips its adoption read while a prop is an unresolved Output, and a guest can appear after the
175
+ plan). Without adoption on, the deploy fails and forgets its `creating` row, so the next plan asks.
176
+ With it on, a matching guest is recorded with no write; any other fails until a plan can show it.
177
+
178
+ A create interrupted after its POST resumes on the next deploy **without** `--adopt` when the guest
179
+ still matches what it declared. If someone changed it meanwhile, the plan says "Cannot resume
180
+ creating", and `--adopt` resumes it (the warning names the keys it writes back).
181
+
182
+ A guest the cluster no longer lists at all, with state still held, plans `update` and warns that
183
+ the deploy **creates it again** from `ostemplate`, with new volumes. The plan fails instead when
184
+ there is no `ostemplate`, or when `rootfs`/`mpN` are declared by live volume id (as a paste is):
185
+ a create never reuses a volume.
186
+
187
+ `GET …/config` needs `VM.Audit`, which the `read` lease has. A config PUT uses the cached
188
+ `provision` lease. A create, resize or delete mints a fresh one and polls its task with it.
189
+
190
+ ## Removing
191
+
192
+ `defaultRemovalPolicy: 'retain'`. If you drop the declaration, the state row is dropped and the
193
+ guest keeps running. Only `.pipe(RemovalPolicy.destroy())` reaches the delete. That delete never
194
+ forces, purges or stops the guest first: PVE refuses to delete a running or protected guest, and
195
+ that refusal is kept. It also deletes only a guest that still matches its last declaration: a
196
+ vmid is reusable, so one that differs may be somebody else's, and the delete is refused.
@@ -0,0 +1,12 @@
1
+ # Proxmox — `@homeflare/alchemy/proxmox`
2
+
3
+ PVE and PBS objects over the PVE API, each call on a short-lived token minted from an OpenBao
4
+ mount. `ProxmoxLxc` adopts a live container from its own config (a paste of `pvesh get …/config`)
5
+ or creates one from a template.
6
+
7
+ - ⛔ **It never replaces or destroys a guest by default.** A move, a smaller disk, an unprivileged
8
+ flip or another template fails the plan. Destroy retains unless `RemovalPolicy.destroy()`.
9
+ - ⛔ **It adopts nothing without `--adopt` or `adopt(true)`**, a matching guest included
10
+ ([ownership.md](./ownership.md)): under `destroy`, a claimed guest's disks go with it.
11
+ - ⛔ **root@pam-only keys** (`devN`, bind mounts, features beyond `nesting`) are refused at plan,
12
+ with the `pct set` to run instead. Guide: [proxmox-lxc.md](./proxmox-lxc.md).