@homeflare/alchemy 0.10.1 → 0.12.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 (265) hide show
  1. package/README.md +3 -3
  2. package/dist/caddy/admin-guard.d.ts +1 -1
  3. package/dist/caddy/index.js.map +4 -4
  4. package/dist/cloudflare/client.d.ts +1 -1
  5. package/dist/cloudflare/index.js.map +2 -2
  6. package/dist/cloudflare/lock-rules.d.ts +1 -1
  7. package/dist/forgejo/branch-protection-form.d.ts +1 -1
  8. package/dist/forgejo/branch-protection.d.ts +1 -1
  9. package/dist/forgejo/index.js.map +10 -10
  10. package/dist/forgejo/org-actions-secrets.d.ts +1 -1
  11. package/dist/forgejo/org-label.d.ts +1 -1
  12. package/dist/forgejo/org-team.d.ts +1 -1
  13. package/dist/forgejo/repo-webhook.d.ts +1 -1
  14. package/dist/forgejo/repository.d.ts +2 -2
  15. package/dist/forgejo/repository.d.ts.map +1 -1
  16. package/dist/forgejo/resource.d.ts +2 -2
  17. package/dist/forgejo/team-member.d.ts +1 -1
  18. package/dist/launchd/index.js.map +2 -2
  19. package/dist/openbao/auth-role.d.ts +1 -1
  20. package/dist/openbao/bao-http.d.ts +4 -4
  21. package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
  22. package/dist/openbao/cloudflare-role-form.d.ts +2 -2
  23. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
  24. package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
  25. package/dist/openbao/digest.d.ts +1 -1
  26. package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
  27. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
  28. package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
  29. package/dist/openbao/index.js +10 -8
  30. package/dist/openbao/index.js.map +18 -18
  31. package/dist/openbao/mount.d.ts +1 -1
  32. package/dist/openbao/pki-role.d.ts +1 -1
  33. package/dist/openbao/policy.d.ts +2 -2
  34. package/dist/openbao/proxmox-role-form.d.ts +2 -2
  35. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  36. package/dist/openbao/proxmox-role.d.ts +10 -10
  37. package/dist/openbao/ssh-role.d.ts +3 -3
  38. package/dist/ownership/adopting.d.ts +34 -0
  39. package/dist/ownership/adopting.d.ts.map +1 -0
  40. package/dist/ownership/probe.d.ts +15 -5
  41. package/dist/ownership/probe.d.ts.map +1 -1
  42. package/dist/ownership/rows.d.ts +12 -0
  43. package/dist/ownership/rows.d.ts.map +1 -1
  44. package/dist/ownership/store-fixture.d.ts +10 -0
  45. package/dist/ownership/store-fixture.d.ts.map +1 -0
  46. package/dist/proxmox/acl.d.ts +2 -2
  47. package/dist/proxmox/acl.d.ts.map +1 -1
  48. package/dist/proxmox/api-token-form.d.ts +2 -2
  49. package/dist/proxmox/api-token.d.ts +11 -11
  50. package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
  51. package/dist/proxmox/ceph-daemon.d.ts +19 -19
  52. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  53. package/dist/proxmox/ceph-flag.d.ts +5 -5
  54. package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
  55. package/dist/proxmox/ceph-fs.d.ts +16 -16
  56. package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
  57. package/dist/proxmox/ceph-osd.d.ts +5 -5
  58. package/dist/proxmox/ceph-pool-form.d.ts +13 -13
  59. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
  60. package/dist/proxmox/ceph-pool.d.ts +3 -3
  61. package/dist/proxmox/client.d.ts +2 -2
  62. package/dist/proxmox/credentials.d.ts +8 -8
  63. package/dist/proxmox/credentials.d.ts.map +1 -1
  64. package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
  65. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  66. package/dist/proxmox/firewall-alias-form.d.ts +1 -1
  67. package/dist/proxmox/firewall-alias.d.ts +1 -1
  68. package/dist/proxmox/generated/pbs.d.ts +1 -1
  69. package/dist/proxmox/generated/pve.d.ts +1 -1
  70. package/dist/proxmox/group.d.ts +8 -8
  71. package/dist/proxmox/ha-resource.d.ts +3 -2
  72. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  73. package/dist/proxmox/ha-rule-form.d.ts +5 -5
  74. package/dist/proxmox/ha-rule.d.ts +3 -3
  75. package/dist/proxmox/index.d.ts +6 -0
  76. package/dist/proxmox/index.d.ts.map +1 -1
  77. package/dist/proxmox/index.js +465 -217
  78. package/dist/proxmox/index.js.map +63 -58
  79. package/dist/proxmox/lease-cache.d.ts +4 -4
  80. package/dist/proxmox/lxc-adoption.d.ts +32 -0
  81. package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
  82. package/dist/proxmox/lxc-harness.d.ts +12 -0
  83. package/dist/proxmox/lxc-harness.d.ts.map +1 -1
  84. package/dist/proxmox/lxc-props.d.ts +2 -2
  85. package/dist/proxmox/lxc.d.ts +8 -8
  86. package/dist/proxmox/lxc.d.ts.map +1 -1
  87. package/dist/proxmox/members.d.ts +3 -3
  88. package/dist/proxmox/network-apply-read.d.ts +5 -5
  89. package/dist/proxmox/network-apply.d.ts +9 -9
  90. package/dist/proxmox/node-network-form.d.ts +6 -6
  91. package/dist/proxmox/node-network.d.ts +17 -17
  92. package/dist/proxmox/node-network.d.ts.map +1 -1
  93. package/dist/proxmox/notification-target.d.ts +3 -2
  94. package/dist/proxmox/notification-target.d.ts.map +1 -1
  95. package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
  96. package/dist/proxmox/pbs-datastore.d.ts +10 -10
  97. package/dist/proxmox/pbs-prune-job.d.ts +2 -2
  98. package/dist/proxmox/pbs-sync-job.d.ts +4 -4
  99. package/dist/proxmox/pbs-verify-job.d.ts +1 -1
  100. package/dist/proxmox/pool.d.ts +4 -4
  101. package/dist/proxmox/provision-baseline.d.ts +74 -0
  102. package/dist/proxmox/provision-baseline.d.ts.map +1 -0
  103. package/dist/proxmox/provision-bootstrap.d.ts +33 -0
  104. package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
  105. package/dist/proxmox/provision-cli-fake.d.ts +38 -0
  106. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
  107. package/dist/proxmox/provision-declare.d.ts +25 -0
  108. package/dist/proxmox/provision-declare.d.ts.map +1 -0
  109. package/dist/proxmox/replication-job.d.ts +9 -8
  110. package/dist/proxmox/replication-job.d.ts.map +1 -1
  111. package/dist/proxmox/resource.d.ts +2 -2
  112. package/dist/proxmox/resource.d.ts.map +1 -1
  113. package/dist/proxmox/sdn-apply-read.d.ts +7 -7
  114. package/dist/proxmox/sdn-apply.d.ts +1 -1
  115. package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
  116. package/dist/proxmox/sdn-subnet.d.ts +5 -5
  117. package/dist/proxmox/sdn-vnet.d.ts +5 -6
  118. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  119. package/dist/proxmox/storage.d.ts +4 -3
  120. package/dist/proxmox/storage.d.ts.map +1 -1
  121. package/dist/proxmox/user.d.ts +3 -2
  122. package/dist/proxmox/user.d.ts.map +1 -1
  123. package/dist/proxmox/values.d.ts +1 -1
  124. package/dist/proxmox/zfs-pool-write.d.ts +1 -1
  125. package/dist/proxmox/zfs-pool.d.ts +5 -5
  126. package/dist/talos/credentials.d.ts +1 -1
  127. package/dist/talos/credentials.d.ts.map +1 -1
  128. package/dist/talos/index.js.map +1 -1
  129. package/docs/adopt-verify.md +21 -5
  130. package/docs/adopted-deploys.md +5 -3
  131. package/docs/ownership.md +27 -2
  132. package/docs/provision-baseline.md +106 -0
  133. package/docs/proxmox-lxc-adopt.md +83 -0
  134. package/docs/proxmox-lxc.md +12 -49
  135. package/docs/proxmox.md +8 -0
  136. package/package.json +1 -1
  137. package/src/caddy/admin-guard.ts +1 -1
  138. package/src/caddy/local-admin.ts +1 -1
  139. package/src/cloudflare/client.ts +1 -1
  140. package/src/cloudflare/lock-rules.ts +1 -1
  141. package/src/forgejo/branch-protection-form.ts +1 -1
  142. package/src/forgejo/branch-protection.ts +1 -1
  143. package/src/forgejo/client.ts +1 -1
  144. package/src/forgejo/org-actions-secrets.ts +1 -1
  145. package/src/forgejo/org-label.ts +1 -1
  146. package/src/forgejo/org-team.ts +1 -1
  147. package/src/forgejo/repo-webhook.ts +1 -1
  148. package/src/forgejo/repository.ts +2 -2
  149. package/src/forgejo/resource.ts +2 -2
  150. package/src/forgejo/team-member.ts +1 -1
  151. package/src/openbao/README.md +4 -4
  152. package/src/openbao/auth-role.ts +1 -1
  153. package/src/openbao/bao-http.test.ts +2 -2
  154. package/src/openbao/bao-http.ts +6 -6
  155. package/src/openbao/bao-status.test.ts +1 -1
  156. package/src/openbao/bao-status.ts +1 -1
  157. package/src/openbao/cloudflare-group-scope.test.ts +1 -1
  158. package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
  159. package/src/openbao/cloudflare-permission-groups.ts +3 -3
  160. package/src/openbao/cloudflare-policy.ts +1 -1
  161. package/src/openbao/cloudflare-role-form.ts +2 -2
  162. package/src/openbao/cloudflare-roles-expand.ts +1 -1
  163. package/src/openbao/digest.ts +1 -1
  164. package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
  165. package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
  166. package/src/openbao/forgejo-bootstrap.ts +4 -4
  167. package/src/openbao/kubernetes-role-form.test.ts +5 -5
  168. package/src/openbao/mount.ts +1 -1
  169. package/src/openbao/pki-role.ts +1 -1
  170. package/src/openbao/policy-wire.test.ts +3 -3
  171. package/src/openbao/policy.ts +2 -2
  172. package/src/openbao/proxmox-role-form.ts +5 -5
  173. package/src/openbao/proxmox-role.ts +11 -11
  174. package/src/openbao/ssh-role.ts +3 -3
  175. package/src/ownership/adopting.test.ts +63 -0
  176. package/src/ownership/adopting.ts +46 -0
  177. package/src/ownership/ownership.test.ts +1 -34
  178. package/src/ownership/probe.ts +27 -14
  179. package/src/ownership/rows.ts +8 -2
  180. package/src/ownership/store-fixture.ts +50 -0
  181. package/src/proxmox/acl.ts +4 -4
  182. package/src/proxmox/adopt-noop.test.ts +6 -6
  183. package/src/proxmox/api-token-form.ts +4 -4
  184. package/src/proxmox/api-token.ts +13 -13
  185. package/src/proxmox/ceph-daemon-form.ts +3 -3
  186. package/src/proxmox/ceph-daemon.ts +20 -20
  187. package/src/proxmox/ceph-flag.ts +7 -7
  188. package/src/proxmox/ceph-fs-wire.ts +7 -7
  189. package/src/proxmox/ceph-fs.ts +18 -18
  190. package/src/proxmox/ceph-osd-tree.ts +16 -16
  191. package/src/proxmox/ceph-osd.ts +6 -6
  192. package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
  193. package/src/proxmox/ceph-pool-form.ts +13 -13
  194. package/src/proxmox/ceph-pool.ts +6 -6
  195. package/src/proxmox/client.test.ts +3 -3
  196. package/src/proxmox/client.ts +3 -3
  197. package/src/proxmox/credentials.test.ts +6 -8
  198. package/src/proxmox/credentials.ts +8 -8
  199. package/src/proxmox/fake-pve-lxc.ts +13 -1
  200. package/src/proxmox/firewall-alias-form.ts +1 -1
  201. package/src/proxmox/firewall-alias.ts +1 -1
  202. package/src/proxmox/generated/pbs.ts +1 -1
  203. package/src/proxmox/generated/pve.ts +1 -1
  204. package/src/proxmox/group.ts +10 -10
  205. package/src/proxmox/ha-resource.ts +3 -2
  206. package/src/proxmox/ha-rule-form.ts +5 -5
  207. package/src/proxmox/ha-rule.ts +3 -3
  208. package/src/proxmox/index.test.ts +7 -0
  209. package/src/proxmox/index.ts +14 -0
  210. package/src/proxmox/lease-cache.test.ts +19 -19
  211. package/src/proxmox/lease-cache.ts +4 -4
  212. package/src/proxmox/lxc-adopt.test.ts +16 -23
  213. package/src/proxmox/lxc-adoption.ts +50 -0
  214. package/src/proxmox/lxc-create-form.ts +1 -1
  215. package/src/proxmox/lxc-guards.test.ts +10 -17
  216. package/src/proxmox/lxc-harness.ts +27 -4
  217. package/src/proxmox/lxc-judge.test.ts +5 -5
  218. package/src/proxmox/lxc-lifecycle.ts +1 -1
  219. package/src/proxmox/lxc-ownership.test.ts +19 -14
  220. package/src/proxmox/lxc-props.ts +2 -2
  221. package/src/proxmox/lxc-read.test.ts +3 -3
  222. package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
  223. package/src/proxmox/lxc-volume.test.ts +9 -9
  224. package/src/proxmox/lxc-volume.ts +1 -1
  225. package/src/proxmox/lxc-wire.ts +1 -1
  226. package/src/proxmox/lxc.ts +19 -15
  227. package/src/proxmox/members.test.ts +1 -1
  228. package/src/proxmox/members.ts +3 -3
  229. package/src/proxmox/metric-server-otel.ts +2 -2
  230. package/src/proxmox/network-apply-read.ts +10 -10
  231. package/src/proxmox/network-apply.ts +10 -10
  232. package/src/proxmox/node-network-form.ts +7 -7
  233. package/src/proxmox/node-network.ts +18 -18
  234. package/src/proxmox/notification-target.ts +3 -2
  235. package/src/proxmox/pbs-datastore-guard.ts +1 -1
  236. package/src/proxmox/pbs-datastore.ts +10 -10
  237. package/src/proxmox/pbs-prune-job.ts +2 -2
  238. package/src/proxmox/pbs-sync-job.ts +4 -4
  239. package/src/proxmox/pbs-verify-job.ts +1 -1
  240. package/src/proxmox/pool.test.ts +2 -2
  241. package/src/proxmox/pool.ts +4 -4
  242. package/src/proxmox/provision-baseline.test.ts +87 -0
  243. package/src/proxmox/provision-baseline.ts +162 -0
  244. package/src/proxmox/provision-bootstrap.test.ts +132 -0
  245. package/src/proxmox/provision-bootstrap.ts +135 -0
  246. package/src/proxmox/provision-cli-fake.ts +217 -0
  247. package/src/proxmox/provision-declare.test.ts +111 -0
  248. package/src/proxmox/provision-declare.ts +103 -0
  249. package/src/proxmox/replication-job.ts +9 -8
  250. package/src/proxmox/resource.ts +3 -3
  251. package/src/proxmox/sdn-apply-read.test.ts +16 -16
  252. package/src/proxmox/sdn-apply-read.ts +7 -7
  253. package/src/proxmox/sdn-apply.ts +3 -3
  254. package/src/proxmox/sdn-subnet-form.ts +1 -1
  255. package/src/proxmox/sdn-subnet.ts +5 -5
  256. package/src/proxmox/sdn-vnet.ts +5 -6
  257. package/src/proxmox/storage.ts +4 -3
  258. package/src/proxmox/user.ts +3 -2
  259. package/src/proxmox/values.test.ts +2 -2
  260. package/src/proxmox/values.ts +1 -1
  261. package/src/proxmox/zfs-pool-write.ts +1 -1
  262. package/src/proxmox/zfs-pool.ts +6 -6
  263. package/src/talos/credentials.ts +3 -3
  264. package/src/talos/values.test.ts +9 -9
  265. package/src/verify/cli.test.ts +1 -1
@@ -61,7 +61,7 @@
61
61
  * ⚠️ PRIVILEGES, AND FOR ONCE NOTHING NEEDS WIDENING. MEASURED 2026-09-13 from the cluster's own
62
62
  * schema and its live ACL: the item GET checks `["perm","/",["Sys.Audit"]]`, POST/PUT/DELETE
63
63
  * check `["perm","/",["Sys.Modify"]]`, `hf-read@pve` holds `PVEAuditor` at `/` with propagate and
64
- * `hf-provision@pve` holds `LXCProvisioner` at `/` with propagate. Both privileges are already
64
+ * `hf-provision@pve` holds the provision role (provision-baseline.ts) at `/` with propagate. Both privileges are already
65
65
  * held, so `readRole` stays the default `read` lane — unlike storage/sdn-zone/sdn-vnet, whose
66
66
  * ITEM reads are gated on an allocate privilege the auditor cannot have.
67
67
  * ⚠️ `Sys.Modify` ON `/` IS STILL A BIG HAMMER — it is the same grant `metric-server.ts` warns
@@ -2,7 +2,7 @@
2
2
  * Generated PBS API types — DO NOT EDIT BY HAND.
3
3
  *
4
4
  * Run: bun codegen/generate.ts
5
- * Source: house/proxmox/apidoc/ — see README there for provenance.
5
+ * Source: the cluster's own `apidoc.js` (pve-docs), extracted outside this repo.
6
6
  */
7
7
 
8
8
  /** GET /config/datastore — `data` payload after client unwrap. */
@@ -2,7 +2,7 @@
2
2
  * Generated PVE API types — DO NOT EDIT BY HAND.
3
3
  *
4
4
  * Run: bun codegen/generate.ts
5
- * Source: house/proxmox/apidoc/ — see README there for provenance.
5
+ * Source: the cluster's own `apidoc.js` (pve-docs), extracted outside this repo.
6
6
  */
7
7
 
8
8
  /** GET /cluster/replication — `data` payload after client unwrap. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `Proxmox.Group` — a PVE group: a name, a comment, and the set of users PVE hangs off it.
3
3
  *
4
- * ★ DECLARING `hf-mint` IS THE WHOLE POINT OF THIS FAMILY. `vaultmint@pve` holds the `HFMint` role
4
+ * ★ DECLARING `hf-mint` IS THE WHOLE POINT OF THIS FAMILY. `mint@pve` holds the `MintTokens` role
5
5
  * ON THE PATH `/access/groups/hf-mint` — measured from `GET /access/acl`, 2026-09-13 — which is
6
6
  * what confines the OpenBao proxmox engine to minting inside that one group. The group is the
7
7
  * fence around the credential every other resource in this package runs on, and until now it was
@@ -17,7 +17,7 @@
17
17
  * in this file could ever settle.
18
18
  *
19
19
  * ⛔ THE INDEX AND THE ITEM DISAGREE ABOUT BOTH THE NAME AND THE TYPE, and this file reads only the
20
- * item. MEASURED against n2, 2026-09-13:
20
+ * item. MEASURED against node-b, 2026-09-13:
21
21
  * GET /access/groups -> [{"groupid":"hf-mint","comment":"…",
22
22
  * "users":"hf-provision@pve,hf-read@pve"}, …]
23
23
  * GET /access/groups/hf-mint -> {"comment":"…","members":["hf-provision@pve","hf-read@pve"]}
@@ -27,8 +27,8 @@
27
27
  *
28
28
  * ⛔ AND THE ITEM'S ORDER IS NOT STABLE BETWEEN TWO CONSECUTIVE CALLS. MEASURED, seconds apart, on
29
29
  * the same endpoint:
30
- * ["root@pam","tim@pve","tim@pam","tim@<account>.com@Schenanigans"]
31
- * ["tim@pam","tim@pve","tim@<account>.com@Schenanigans","root@pam"]
30
+ * ["root@pam","alice@pve","alice@pam","alice@example.com@corp"]
31
+ * ["alice@pam","alice@pve","alice@example.com@corp","root@pam"]
32
32
  * `read_group` builds it as `[keys %{ $data->{users} }]` — a bare Perl hash key list, whose order
33
33
  * is randomised per process — while the INDEX sorts (`join(',', sort keys …)`). So the array is
34
34
  * sorted on the way into `attributes` as well as being kept out of `matches`: unsorted, Alchemy's
@@ -45,7 +45,7 @@
45
45
  * So `readRole` is left at the default `read` — unlike storage/sdn-zone/sdn-vnet, whose ITEM
46
46
  * reads PVE gates on an allocate privilege. See the ⛔ on `readRole` in `resource.ts`.
47
47
  * · POST, PUT and DELETE each check `Group.Allocate` on `/access/groups`, and
48
- * `hf-provision@pve` holds it via `LXCProvisioner`.
48
+ * `hf-provision@pve` holds it via the provision role (`PROVISION_PRIVILEGES`).
49
49
  *
50
50
  * ⛔ `retain` BY DEFAULT, BECAUSE DELETING A GROUP DESTROYS TWO THINGS THIS FILE CANNOT PUT BACK.
51
51
  * Measured in `PVE::AccessControl` on the node:
@@ -53,8 +53,8 @@
53
53
  * <comment>:`) and DERIVES each user's `groups` map from it at parse time. Deleting the group
54
54
  * deletes the list, and this resource has no `members` prop to restore it from.
55
55
  * · `delete_group_acl` then walks the whole ACL tree and drops every grant where the group is
56
- * the SUBJECT. On this cluster that is `Schenanigans -> Administrator on /` and
57
- * `HomeAssistant -> PVEAuditor on /`: whole populations of access, gone in one call, with no
56
+ * the SUBJECT. On this cluster that is `admins -> Administrator on /` and
57
+ * `automation -> PVEAuditor on /`: whole populations of access, gone in one call, with no
58
58
  * confirmation and nothing in the plan to suggest it.
59
59
  * `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists, unlike the ACL
60
60
  * family's — so `.pipe(RemovalPolicy.destroy())` really removes the group. See the ★ in
@@ -120,7 +120,7 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
120
120
  * back as absent. Left alone, `matches` would be false on every plan and every deploy would
121
121
  * rewrite the same value into the same hole. Normalising BOTH the comparison and the form is how
122
122
  * `values.ts` already treats `''` as the absence of a boolean rather than as `false`.
123
- * ⚠️ MEASURED IN THE SOURCE ON n2, NOT ON THE WIRE — writing `'0'` to the cluster to watch it
123
+ * ⚠️ MEASURED IN THE SOURCE ON node-b, NOT ON THE WIRE — writing `'0'` to the cluster to watch it
124
124
  * vanish would have been a write, and this agent had read access only.
125
125
  * ⚠️ `pool.ts` HAS THE SAME LATENT HOLE — `pool:$pool:$comment:…` is written by that same
126
126
  * truthiness test and is not guarded. Not fixed from here; flagged so it is fixed on purpose.
@@ -160,7 +160,7 @@ const handlers = pveHandlers<GroupProps, GroupAttributes>({
160
160
  * without one answers `{"members":[…]}` and nothing else. THAT ONE IS READ OFF THE HANDLER'S
161
161
  * SOURCE, not off the wire — every group on this cluster happens to carry a comment, so the
162
162
  * commentless shape was not there to measure. The empty-MEMBERS shape WAS measured —
163
- * `HomeAssistant` answers `{"comment":"…","members":[]}`. `''` is what an undeclared
163
+ * `automation` answers `{"comment":"…","members":[]}`. `''` is what an undeclared
164
164
  * `comment` prop normalises to, so the two sides meet either way.
165
165
  */
166
166
  comment: text(live['comment']),
@@ -190,7 +190,7 @@ const handlers = pveHandlers<GroupProps, GroupAttributes>({
190
190
 
191
191
  /**
192
192
  * ⛔ THE EMPTY `list` IS INHERITED FROM `pveHandlers` AND IT MATTERS HERE. `GET /access/groups`
193
- * answers with every group on the cluster — on this one that is `Schenanigans`, which grants
193
+ * answers with every group on the cluster — on this one that is `admins`, which grants
194
194
  * `Administrator` on `/` to every human who logs in. Handing it to Alchemy would invite adoption,
195
195
  * and therefore one day a delete that takes the whole admin group's access with it. Adoption
196
196
  * stays an explicit act, here as everywhere else in this package.
@@ -35,10 +35,11 @@
35
35
  * (node-affinity and resource-affinity) and that is the next family to add; do not port the old
36
36
  * groups endpoint back just because the docs still describe it.
37
37
  *
38
- * ⚠️ RECONCILE NEEDS `Sys.Console` ON `/`, WHICH `LXCProvisioner` DOES NOT HOLD. Read and diff are
38
+ * ⚠️ RECONCILE NEEDS `Sys.Console` ON `/`, WHICH THE PROVISION ROLE LACKED WHEN THIS WAS WRITTEN
39
+ * (`PROVISION_PRIVILEGES` carries it now — ha-rule.ts has the measurement). Read and diff are
39
40
  * fine — `GET /cluster/ha/resources/{sid}` checks `Sys.Audit`, which the role already has — while
40
41
  * POST, PUT and DELETE all check `Sys.Console` on `/` and will answer "Permission check failed
41
- * (/, Sys.Console)" until the role is widened. Widen it knowingly: `Sys.Console` is also what
42
+ * (/, Sys.Console)" on a role without it. Widen it knowingly: `Sys.Console` is also what
42
43
  * opens a root shell on every node (`/nodes/{node}/vncshell`, `termproxy`), so granting it to the
43
44
  * provision credential buys HA membership at the price of node console access. A separate role
44
45
  * for HA writes is the narrower answer if that trade is not wanted.
@@ -16,21 +16,21 @@ import { csv } from './values.ts';
16
16
  * A `<node>[:<pri>]` list, spelled the way PVE gives it back.
17
17
  *
18
18
  * ⛔ THIS IS NOT `csv`, AND THE DIFFERENCE IS A FOREVER-DIFF. MEASURED by reading
19
- * `/usr/share/perl5/PVE/HA/Rules/NodeAffinity.pm` on n2 (2026-09-13): `decode_plugin_value`
19
+ * `/usr/share/perl5/PVE/HA/Rules/NodeAffinity.pm` on node-b (2026-09-13): `decode_plugin_value`
20
20
  * turns the list into a HASH keyed by node name, and `encode_plugin_value` rebuilds it with
21
21
  * `for my $node (sort keys %$value)` — sorted by NODE NAME, never by the token — emitting the
22
22
  * bare name whenever the priority is falsy, because `PVE::HA::Tools::parse_node_priority` reads
23
- * a missing priority as 0. So a declared `n3:1,n2:0` is handed back as `n2,n3:1`: re-ordered AND
23
+ * a missing priority as 0. So a declared `node-c:1,node-b:0` is handed back as `node-b,node-c:1`: re-ordered AND
24
24
  * re-spelled. Compare the raw strings and every plan reports an update, forever.
25
25
  *
26
26
  * ⚠️ SORTED BY NAME, NOT BY TOKEN, AND THE TWO GENUINELY DIVERGE. `csv` sorts whole tokens. Node
27
27
  * names may contain `-` — `parse_node_priority`'s own regex is
28
- * `[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?` — and `-` (0x2D) sorts before `:` (0x3A), so `n2-a`
29
- * and `n2:5` come out in one order by token and the other by name. PVE sorts by the name, so
28
+ * `[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?` — and `-` (0x2D) sorts before `:` (0x3A), so `node-b-a`
29
+ * and `node-b:5` come out in one order by token and the other by name. PVE sorts by the name, so
30
30
  * this does too.
31
31
  *
32
32
  * ⚠️ LAST ENTRY WINS FOR A REPEATED NODE, because PVE's decode is a hash assignment and a hash
33
- * keeps one value per key. A declaration saying `n2:1,n2:5` is stored as `n2:5`; matching that
33
+ * keeps one value per key. A declaration saying `node-b:1,node-b:5` is stored as `node-b:5`; matching that
34
34
  * is what a Map gives for free.
35
35
  */
36
36
  export const nodeList = (value: readonly string[] | string | undefined) => {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `Proxmox.HaRule` — where the CRM may place a guest, and which guests must not share a node.
3
3
  *
4
- * ★ THIS IS THE PVE 9 SUCCESSOR TO HA GROUPS, AND THE GROUPS ENDPOINT IS GONE. MEASURED on n2
4
+ * ★ THIS IS THE PVE 9 SUCCESSOR TO HA GROUPS, AND THE GROUPS ENDPOINT IS GONE. MEASURED on node-b
5
5
  * (pve-manager/9.2.11, 2026-09-13): `pvesh get /cluster/ha/groups` answers `cannot index groups:
6
6
  * ha groups have been migrated to rules`, and `/etc/pve/ha/` holds `rules.cfg` with no
7
7
  * `groups.cfg` beside it. The published schema still documents groups and still offers a `group`
@@ -48,8 +48,8 @@
48
48
  * ⚠️ BOTH LANES ALREADY HAVE THE PRIVILEGES, WHICH IS NEW. MEASURED 2026-09-13:
49
49
  * GET (collection and item) checks `Sys.Audit` on `/` and the built-in `PVEAuditor` holds it, so
50
50
  * `readRole` stays the default `read` lease — unlike storage.ts and the SDN families. POST, PUT
51
- * and DELETE all check `Sys.Console` on `/`, and `pvesh get /access/roles/LXCProvisioner` now
52
- * returns `Sys.Console` among its 27 privileges. The warning in ha-resource.ts's header — that
51
+ * and DELETE all check `Sys.Console` on `/`, and the provision role now returns `Sys.Console` among
52
+ * its 27 privileges (`PROVISION_PRIVILEGES`, provision-baseline.ts). The warning in ha-resource.ts's header — that
53
53
  * `Sys.Console` is missing — is stale; the role was widened, and the price it named still stands:
54
54
  * `Sys.Console` is also what opens a root shell on every node.
55
55
  *
@@ -40,6 +40,13 @@ test('control-plane Resource constructors are on the public barrel', () => {
40
40
  }
41
41
  });
42
42
 
43
+ test('the provisioning baseline is on the barrel: the list, the declaration, the bootstrap', async () => {
44
+ const barrel = await import('./index.ts');
45
+ expect(barrel.PROVISION_PRIVILEGES).toHaveLength(27);
46
+ expect(typeof barrel.declareProvisionBaseline).toBe('function');
47
+ expect(barrel.provisionBootstrap()).toStartWith('#!/bin/sh\n');
48
+ });
49
+
43
50
  test('QEMU and NIC apply stay Provider-only', () => {
44
51
  expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
45
52
  expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
@@ -12,6 +12,9 @@
12
12
  * The provider-only version diffed four fields and would have planned a create over any read
13
13
  * failure. The Resource now adopts from the live config, refuses every change PVE cannot make
14
14
  * in place, and retains on destroy (lxc.ts).
15
+ * ★ THE PROVISIONING BASELINE JOINED 2026-09-21: one privilege list (`PROVISION_PRIVILEGES`), the
16
+ * helper that declares it (`declareProvisionBaseline`) and the pure generator of the one-time
17
+ * root commands that bootstrap it (`provisionBootstrap`) — docs/provision-baseline.md.
15
18
  * ★ Anything else unlisted is still reachable by path if you genuinely need
16
19
  * it — that is a deliberate, visible act rather than an accident of barrelling.
17
20
  */
@@ -40,6 +43,17 @@ export { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
40
43
  export { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
41
44
  export { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
42
45
  export { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
46
+ export {
47
+ PROVISION_DEFAULTS,
48
+ PROVISION_PRIVILEGES,
49
+ type ProvisionBaseline,
50
+ type ProvisionLane,
51
+ type ProvisionNames,
52
+ type ResolvedProvisionNames,
53
+ provisionBaseline,
54
+ } from './provision-baseline.ts';
55
+ export { provisionBootstrap } from './provision-bootstrap.ts';
56
+ export { type DeclareProvisionOptions, declareProvisionBaseline } from './provision-declare.ts';
43
57
  export { ProxmoxVmProvider } from './qemu.ts';
44
58
  export { ProxmoxReplicationJobProvider } from './replication-job.ts';
45
59
  export { ProxmoxRole, ProxmoxRoleProvider } from './role.ts';
@@ -17,13 +17,13 @@ import * as Exit from 'effect/Exit';
17
17
  import type { PveCredential } from './credentials.ts';
18
18
  import { type LeaseKey, makeLeases, timeToLive } from './lease-cache.ts';
19
19
 
20
- const TB4: LeaseKey = {
21
- mount: 'proxmox-tb4',
20
+ const C1: LeaseKey = {
21
+ mount: 'proxmox-c1',
22
22
  role: 'read',
23
23
  scheme: 'pve',
24
24
  };
25
- /** ⚠️ Same role as TB4, DIFFERENT mount — the collision the key has to survive. */
26
- const OPS: LeaseKey = { ...TB4, mount: 'proxmox-ops' };
25
+ /** ⚠️ Same role as C1, DIFFERENT mount — the collision the key has to survive. */
26
+ const C2: LeaseKey = { ...C1, mount: 'proxmox-c2' };
27
27
 
28
28
  const cred = (tokenId: string, leaseSeconds: number): PveCredential => ({
29
29
  leaseSeconds,
@@ -85,10 +85,10 @@ describe('lease cache under concurrency', () => {
85
85
  await Effect.runPromise(
86
86
  Effect.gen(function* () {
87
87
  const leases = yield* makeLeases(mint.mintFor);
88
- const exits = yield* concurrently(20, Cache.get(leases, TB4));
88
+ const exits = yield* concurrently(20, Cache.get(leases, C1));
89
89
  assert.equal(exits.filter(Exit.isSuccess).length, 20);
90
90
  // An equal but separately built key is the same entry.
91
- yield* Cache.get(leases, { ...TB4 });
91
+ yield* Cache.get(leases, { ...C1 });
92
92
  }),
93
93
  );
94
94
  assert.equal(mint.calls(), 1);
@@ -102,9 +102,9 @@ describe('lease cache under concurrency', () => {
102
102
  await Effect.runPromise(
103
103
  Effect.gen(function* () {
104
104
  const leases = yield* makeLeases(mint.mintFor);
105
- const first = yield* concurrently(20, Cache.get(leases, TB4));
105
+ const first = yield* concurrently(20, Cache.get(leases, C1));
106
106
  assert.equal(first.filter(Exit.isFailure).length, 20, 'the waiters share the failure');
107
- const second = yield* concurrently(20, Cache.get(leases, TB4));
107
+ const second = yield* concurrently(20, Cache.get(leases, C1));
108
108
  assert.equal(second.filter(Exit.isSuccess).length, 20);
109
109
  }),
110
110
  );
@@ -116,8 +116,8 @@ describe('lease cache under concurrency', () => {
116
116
  await Effect.runPromise(
117
117
  Effect.gen(function* () {
118
118
  const leases = yield* makeLeases(mint.mintFor);
119
- yield* concurrently(20, Cache.get(leases, TB4));
120
- yield* Cache.get(leases, TB4);
119
+ yield* concurrently(20, Cache.get(leases, C1));
120
+ yield* Cache.get(leases, C1);
121
121
  }),
122
122
  );
123
123
  assert.equal(mint.calls(), 2);
@@ -125,25 +125,25 @@ describe('lease cache under concurrency', () => {
125
125
 
126
126
  it('does not serve one cluster the credential of another with the same mount and role', async () => {
127
127
  const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
128
- const [tb4, ops] = await Effect.runPromise(
128
+ const [c1, c2] = await Effect.runPromise(
129
129
  Effect.gen(function* () {
130
130
  const leases = yield* makeLeases(mint.mintFor);
131
- return [yield* Cache.get(leases, TB4), yield* Cache.get(leases, OPS)] as const;
131
+ return [yield* Cache.get(leases, C1), yield* Cache.get(leases, C2)] as const;
132
132
  }),
133
133
  );
134
- assert.notEqual(tb4.tokenId, ops.tokenId);
134
+ assert.notEqual(c1.tokenId, c2.tokenId);
135
135
  assert.equal(mint.calls(), 2);
136
136
  });
137
137
 
138
138
  it('shares one lease across two members of the same cluster mount', async () => {
139
139
  const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
140
- const N2: LeaseKey = { mount: 'proxmox-tb4', role: 'read', scheme: 'pve' };
141
- const N3: LeaseKey = { mount: 'proxmox-tb4', role: 'read', scheme: 'pve' };
140
+ const NODE_B: LeaseKey = { mount: 'proxmox-c1', role: 'read', scheme: 'pve' };
141
+ const NODE_C: LeaseKey = { mount: 'proxmox-c1', role: 'read', scheme: 'pve' };
142
142
  await Effect.runPromise(
143
143
  Effect.gen(function* () {
144
144
  const leases = yield* makeLeases(mint.mintFor);
145
- yield* Cache.get(leases, N2);
146
- yield* Cache.get(leases, N3);
145
+ yield* Cache.get(leases, NODE_B);
146
+ yield* Cache.get(leases, NODE_C);
147
147
  }),
148
148
  );
149
149
  assert.equal(mint.calls(), 1);
@@ -151,11 +151,11 @@ describe('lease cache under concurrency', () => {
151
151
 
152
152
  it('keeps read and provision apart', async () => {
153
153
  const mint = countingMint((call) => cred(`hf@pve!${String(call)}`, 3600));
154
- const PROVISION: LeaseKey = { ...TB4, role: 'provision' };
154
+ const PROVISION: LeaseKey = { ...C1, role: 'provision' };
155
155
  const [read, provision] = await Effect.runPromise(
156
156
  Effect.gen(function* () {
157
157
  const leases = yield* makeLeases(mint.mintFor);
158
- return [yield* Cache.get(leases, TB4), yield* Cache.get(leases, PROVISION)] as const;
158
+ return [yield* Cache.get(leases, C1), yield* Cache.get(leases, PROVISION)] as const;
159
159
  }),
160
160
  );
161
161
  assert.notEqual(read.tokenId, provision.tokenId);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * One credential per (target, role) for the life of a run, instead of one per API call.
3
3
  *
4
- * 🔴 THE MEASUREMENT THAT MADE THIS NECESSARY. A single `alchemy plan` plus `deploy` of the TB4
5
- * stack — 98 resources — left **760 API token entries in `/etc/pve/user.cfg`**, counted on n2
4
+ * 🔴 THE MEASUREMENT THAT MADE THIS NECESSARY. A single `alchemy plan` plus `deploy` of the C1
5
+ * stack — 98 resources — left **760 API token entries in `/etc/pve/user.cfg`**, counted on node-b
6
6
  * immediately afterwards. `mint()` asked OpenBao for a new credential on EVERY call, and each
7
7
  * mint creates a real Proxmox token: read, diff, reconcile and read-back each got their own. They
8
8
  * drain as the leases expire (760 down to 712 over five minutes), so nothing was broken — but
@@ -66,9 +66,9 @@ const REMINT_MARGIN_SECONDS = 60;
66
66
  * What a credential is cached under.
67
67
  *
68
68
  * ⛔ KEYED ON THE OPENBAO MOUNT AND ROLE, NOT ON A MEMBER HOSTNAME. A PVE API token is
69
- * cluster-wide — `user.cfg` is replicated — so n2, n3 and n4 share one credential. Keying on
69
+ * cluster-wide — `user.cfg` is replicated — so node-b, node-c and node-d share one credential. Keying on
70
70
  * `api` would mint three identical tokens when failover rotates members.
71
- * ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-tb4`, `proxmox-ops` and `pbs-tb4` are
71
+ * ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-c1`, `proxmox-c2` and `pbs-c1` are
72
72
  * distinct OpenBao mounts with distinct policies; two estates never share a mount name.
73
73
  * ★ A PLAIN OBJECT IS A SAFE KEY BECAUSE EFFECT COMPARES KEYS STRUCTURALLY — two separately built
74
74
  * literals with the same fields are one entry, which the probe above measured.
@@ -4,28 +4,20 @@
4
4
  *
5
5
  * ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
6
6
  * its deploy writes NOTHING, and the next plan is `noop`; the create spellings (`storage:size`,
7
- * a NIC without its MAC) adopt as cleanly; drift is named at plan and written as one PUT that
8
- * keeps the live MAC and carries the digest; and every change PVE cannot make in place fails the
9
- * plan with nothing written. Deleting both empty-change guards (reconcile's in lxc.ts and
10
- * `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in lxc-wire.ts, fails a test here —
11
- * each MEASURED by making that edit and running this file, 2026-09-21.
7
+ * a NIC without its MAC) adopt as cleanly; after that adoption, drift is named at plan and
8
+ * written as one PUT that keeps the live MAC and carries the digest; and every change PVE cannot
9
+ * make in place fails the plan with nothing written. Deleting both empty-change guards
10
+ * (reconcile's in lxc.ts and `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
11
+ * lxc-wire.ts, fails a test here — each MEASURED by making that edit and running this file,
12
+ * 2026-09-21. An adoption that WOULD write fails instead: lxc-strict-adopt.test.ts.
12
13
  */
13
14
  import { afterEach, describe, expect, test } from 'bun:test';
14
15
  import { adopt } from 'alchemy/AdoptPolicy';
15
16
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
16
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
17
+ import { NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
17
18
  import { ProxmoxLxc } from './lxc.ts';
18
19
  import type { LxcProps } from './lxc-props.ts';
19
20
 
20
- const NODE = 'pve1';
21
- const VMID = 100;
22
-
23
- /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
24
- const pasted = (): LxcProps => {
25
- const { lxc: _raw, ...config } = LIVE;
26
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID };
27
- };
28
-
29
21
  const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
30
22
 
31
23
  let fake: FakePve | undefined;
@@ -84,17 +76,17 @@ describe('adopting a live container', () => {
84
76
  });
85
77
  });
86
78
 
87
- describe('drift at adoption is named at plan and written once', () => {
79
+ describe('drift after the adoption is named at plan and written once', () => {
88
80
  test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
89
81
  const pve = live();
90
- const stack = lxcEngine(pve);
82
+ const stack = await adoptedAsIs(pve);
91
83
  const props: LxcProps = {
92
84
  ...pasted(),
93
85
  memory: 4096,
94
86
  net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
95
87
  };
96
88
  const planned = await stack.plan(ct(props));
97
- expect(planned.actions).toEqual({ ct: 'adopted' });
89
+ expect(planned.actions).toEqual({ ct: 'update' });
98
90
  expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
99
91
  expect((await stack.deploy(ct(props))).failure).toBe('');
100
92
  expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
@@ -107,8 +99,8 @@ describe('drift at adoption is named at plan and written once', () => {
107
99
 
108
100
  test('a larger rootfs is a resize after the config, never a new volume', async () => {
109
101
  const pve = live();
110
- const stack = lxcEngine(pve);
111
- const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-100-disk-0,size=64G' };
102
+ const stack = await adoptedAsIs(pve);
103
+ const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-900-disk-0,size=64G' };
112
104
  expect((await stack.deploy(ct(props))).failure).toBe('');
113
105
  expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
114
106
  expect(pve.guests.get(`${NODE}/${String(VMID)}`)?.['rootfs']).toContain('size=64G');
@@ -117,15 +109,16 @@ describe('drift at adoption is named at plan and written once', () => {
117
109
 
118
110
  describe('changes PVE cannot make in place fail the plan and write nothing', () => {
119
111
  test.each([
120
- ['a smaller mount point', { mp0: 'tank:subvol-100-disk-0,mp=/data,size=100G' }, /shrink/],
112
+ ['a smaller mount point', { mp0: 'tank:subvol-900-disk-0,mp=/data,size=100G' }, /shrink/],
121
113
  ['a storage move', { rootfs: 'other:40' }, /pct move-volume/],
122
114
  ['an unprivileged flip', { unprivileged: 0 as const }, /read-only option/],
123
- ['a device passthrough change', { dev1: '/dev/fuse' }, /pct set 100 --dev1/],
115
+ ['a device passthrough change', { dev1: '/dev/fuse' }, /pct set 900 --dev1/],
124
116
  ['a feature beyond nesting', { features: 'nesting=1,keyctl=1' }, /root@pam/],
125
117
  ['detaching a mount point', { mp0: '' }, /unusedN/],
126
118
  ])('%s', async (_, change, reason) => {
127
119
  const pve = live();
128
- const run = await lxcEngine(pve).deploy(ct({ ...pasted(), ...change } as LxcProps));
120
+ const stack = await adoptedAsIs(pve);
121
+ const run = await stack.deploy(ct({ ...pasted(), ...change } as LxcProps));
129
122
  expect(run.failure).toMatch(reason);
130
123
  expect(writesOf(pve)).toEqual([]);
131
124
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The strict adoption gate for `Proxmox.Lxc`: AN ADOPTION NEVER CHANGES A GUEST (decided
3
+ * 2026-09-21). A guest this stack adopts — no recorded instance, or one the plan only kept because
4
+ * `--adopt` said so (ownership/adopting.ts) — is taken over exactly as it runs, or not at all.
5
+ *
6
+ * ⛔ ANY DIFFERENCE FAILS, AT PLAN AND AGAIN AT APPLY. Before this, a cold adoption planned `adopted`
7
+ * whatever it would write (alchemy beta.79 prints no property diff for it) and only LOGGED the
8
+ * keys; `deploy --adopt --yes` then wrote them. A `net0` pasted without the live `tag=` moved the
9
+ * guest off its VLAN on the deploy that was meant to change nothing. Now the plan fails naming
10
+ * the keys, and reconcile re-asks against a fresh read, so a hand edit between plan and deploy
11
+ * is refused too rather than written back.
12
+ * ⛔ KEYS ONLY, NEVER VALUES. The refusal names what differs and nothing it differs by: a live
13
+ * config can carry anything (`description` is where people paste credentials), and plan output
14
+ * lands in CI logs.
15
+ * ★ ASKED BEFORE `judge`'s OWN REFUSALS. Those name a declared value (the `pct set` to run), and
16
+ * for an adoption the fix is the other direction: declare what is live.
17
+ * ★ THE STORE IS READ ONLY WHEN SOMETHING DIFFERS. A clean adoption — the only kind that proceeds
18
+ * — costs nothing here.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import type { Owner } from '../ownership/adopt.ts';
22
+ import { adopting } from '../ownership/adopting.ts';
23
+ import { type LxcChange, judge } from './lxc-judge.ts';
24
+ import { LxcRefusedError } from './lxc-lifecycle.ts';
25
+ import type { LxcProps } from './lxc-props.ts';
26
+
27
+ /** The refusal: keys, where, and what to do — never a value. */
28
+ export const adoptionRefusal = (props: LxcProps, drift: readonly string[]): string =>
29
+ `CT ${String(props.vmid)} on ${props.node}: adopting it would change ${drift.join(', ')}. An ` +
30
+ 'adoption never changes a guest, so nothing is written. Declare what is live for these keys ' +
31
+ '(the plan names keys, never values) and plan again; change them after the adoption, as an ' +
32
+ 'update.';
33
+
34
+ /**
35
+ * Fails with `adoptionRefusal` when `change` would write to a guest `owner`'s call adopts.
36
+ * `settled` for a recovered create is the same judge, of the recording row's props against `live`.
37
+ */
38
+ export const refuseAdoptedDrift = (
39
+ owner: Owner,
40
+ props: LxcProps,
41
+ live: Record<string, unknown>,
42
+ change: LxcChange,
43
+ ): Effect.Effect<void, LxcRefusedError> =>
44
+ Effect.gen(function* () {
45
+ if (change.drift.length === 0) return;
46
+ const settled = (recorded: unknown) =>
47
+ Effect.sync(() => judge(recorded as LxcProps, live).drift.length === 0);
48
+ if (!(yield* adopting(owner, settled))) return;
49
+ return yield* Effect.fail(new LxcRefusedError(adoptionRefusal(props, change.drift)));
50
+ });
@@ -23,7 +23,7 @@ import { bool } from './values.ts';
23
23
  /**
24
24
  * ⛔ A CREATE ALLOCATES NEW VOLUMES ONLY. pve-container's `create_disks` allocates `storage:GiB`
25
25
  * and takes anything else as "use specified/existing volid", then `restore_archive` unpacks the
26
- * template into the mounted rootfs. So a create naming `local-zfs:subvol-100-disk-0` — a pasted
26
+ * template into the mounted rootfs. So a create naming `local-zfs:subvol-900-disk-0` — a pasted
27
27
  * adoption, re-created after its config vanished while the volume survived (a `pct destroy`
28
28
  * only WARNS when a volume will not delete) — unpacks a template over the data on it.
29
29
  */
@@ -11,19 +11,12 @@
11
11
  import { afterEach, describe, expect, test } from 'bun:test';
12
12
  import { adopt } from 'alchemy/AdoptPolicy';
13
13
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
14
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
14
+ import { KEY, NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
15
15
  import { ProxmoxLxc } from './lxc.ts';
16
16
  import type { LxcProps } from './lxc-props.ts';
17
17
 
18
- const NODE = 'pve1';
19
- const VMID = 100;
20
- const KEY = `${NODE}/${String(VMID)}`;
21
18
  const TEMPLATE = 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst';
22
-
23
- const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
24
- const { lxc: _raw, ...config } = LIVE;
25
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
26
- };
19
+ const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
27
20
 
28
21
  let fake: FakePve | undefined;
29
22
  afterEach(() => {
@@ -70,8 +63,8 @@ describe('a guest the cluster no longer lists, with state still held', () => {
70
63
  expect((await stack.deploy(declared())).failure).toBe('');
71
64
  pve.guests.delete(KEY);
72
65
  const run = await stack.deploy(declared());
73
- expect(run.failure).toContain('rootfs: local-zfs:subvol-100-disk-0,size=40G names an existing');
74
- expect(run.failure).toContain('mp0: tank:subvol-100-disk-0');
66
+ expect(run.failure).toContain('rootfs: local-zfs:subvol-900-disk-0,size=40G names an existing');
67
+ expect(run.failure).toContain('mp0: tank:subvol-900-disk-0');
75
68
  expect(writesOf(pve)).toEqual([]);
76
69
  });
77
70
 
@@ -113,10 +106,9 @@ describe('a write is claimed only when PVE says it finished', () => {
113
106
  test('a config write PVE accepted but did not store fails the read-back', async () => {
114
107
  const pve = cluster();
115
108
  seed(pve, NODE, VMID);
109
+ const stack = await adoptedAsIs(pve);
116
110
  pve.ignore.add('memory');
117
- const run = await lxcEngine(pve).deploy(
118
- ProxmoxLxc('ct', pasted({ memory: 4096 })).pipe(adopt(true)),
119
- );
111
+ const run = await stack.deploy(ct(pasted({ memory: 4096 })));
120
112
  expect(run.failure).toContain('memory still differ');
121
113
  });
122
114
  });
@@ -137,13 +129,14 @@ describe('identity and volumes', () => {
137
129
  test('options and growth on one volume: the PUT keeps the live size, the resize follows', async () => {
138
130
  const pve = cluster();
139
131
  seed(pve, NODE, VMID);
140
- const mp0 = 'tank:subvol-100-disk-0,mp=/data,backup=1,size=300G';
141
- const run = await lxcEngine(pve).deploy(ProxmoxLxc('ct', pasted({ mp0 })).pipe(adopt(true)));
132
+ const stack = await adoptedAsIs(pve);
133
+ const mp0 = 'tank:subvol-900-disk-0,mp=/data,backup=1,size=300G';
134
+ const run = await stack.deploy(ct(pasted({ mp0 })));
142
135
  expect(run.failure).toBe('');
143
136
  const path = `nodes/${NODE}/lxc/${String(VMID)}`;
144
137
  expect(writesOf(pve)).toEqual([`PUT ${path}/config`, `PUT ${path}/resize`]);
145
138
  const put = pve.seen.find((call) => call.path.endsWith('/config') && call.method === 'PUT');
146
- expect(put?.form.get('mp0')).toBe('tank:subvol-100-disk-0,mp=/data,backup=1,size=200G');
139
+ expect(put?.form.get('mp0')).toBe('tank:subvol-900-disk-0,mp=/data,backup=1,size=200G');
147
140
  expect(pve.guests.get(KEY)?.['mp0']).toContain('size=300G');
148
141
  });
149
142
 
@@ -13,7 +13,7 @@
13
13
  * ★ ONE ARTIFACT STORE ACROSS A PLAN AND ITS APPLY, and `--adopt` as the AdoptPolicy service, as
14
14
  * openbao/fake-stack.ts provides them: the ownership rule reads both (src/ownership/).
15
15
  */
16
- import { AdoptPolicy } from 'alchemy/AdoptPolicy';
16
+ import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
17
17
  import { apply } from 'alchemy/Apply';
18
18
  import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
19
  import * as Plan from 'alchemy/Plan';
@@ -29,7 +29,8 @@ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
29
29
  import type { PveTarget } from './credentials.ts';
30
30
  import { withFakeBao } from './fake-bao-env.ts';
31
31
  import { FAKE_BAO, FAKE_MEMBER, type FakePve } from './fake-pve-lxc.ts';
32
- import { ProxmoxLxcProvider } from './lxc.ts';
32
+ import type { LxcProps } from './lxc-props.ts';
33
+ import { ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
33
34
 
34
35
  export const TARGET: PveTarget = {
35
36
  members: [FAKE_MEMBER],
@@ -53,7 +54,7 @@ export const LIVE: Readonly<Record<string, unknown>> = {
53
54
  hostname: 'ct-example',
54
55
  lxc: [['lxc.prlimit.memlock', 'unlimited']],
55
56
  memory: 8192,
56
- mp0: 'tank:subvol-100-disk-0,mp=/data,backup=0,size=200G',
57
+ mp0: 'tank:subvol-900-disk-0,mp=/data,backup=0,size=200G',
57
58
  nameserver: '192.0.2.254',
58
59
  net0:
59
60
  'name=eth0,bridge=vmbr0,firewall=0,gw=192.0.2.1,hwaddr=00:00:5E:00:53:01,' +
@@ -61,13 +62,24 @@ export const LIVE: Readonly<Record<string, unknown>> = {
61
62
  net1: 'name=eth1,bridge=vmbr1,hwaddr=00:00:5E:00:53:02,ip=198.51.100.4/24,mtu=1500,type=veth',
62
63
  onboot: 1,
63
64
  ostype: 'debian',
64
- rootfs: 'local-zfs:subvol-100-disk-0,size=40G',
65
+ rootfs: 'local-zfs:subvol-900-disk-0,size=40G',
65
66
  searchdomain: 'example.com',
66
67
  swap: 0,
67
68
  tags: 'web',
68
69
  unprivileged: 1,
69
70
  };
70
71
 
72
+ /** Where `seed` puts the production-shaped guest by default, and its `node/vmid` key. */
73
+ export const NODE = 'pve1';
74
+ export const VMID = 900;
75
+ export const KEY = `${NODE}/${String(VMID)}`;
76
+
77
+ /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
78
+ export const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
79
+ const { lxc: _raw, ...config } = LIVE;
80
+ return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
81
+ };
82
+
71
83
  /** Put `config` live on the fake as `node/vmid`, with a digest as PVE would add. */
72
84
  export const seed = (fake: FakePve, node: string, vmid: number, config = LIVE) => {
73
85
  fake.guests.set(`${node}/${String(vmid)}`, { ...config, digest: 'digest-live' });
@@ -148,3 +160,14 @@ export const lxcEngine = (fake: FakePve) => {
148
160
  status: (fqn: string) => rows['lxc']?.['test']?.[fqn]?.status,
149
161
  };
150
162
  };
163
+
164
+ /**
165
+ * The engine, with the guest `seed` put at NODE/VMID adopted exactly as it runs — the only adoption
166
+ * that proceeds (lxc-adoption.ts) — so a test can go on to change it, as an ordinary update.
167
+ */
168
+ export const adoptedAsIs = async (pve: FakePve): Promise<ReturnType<typeof lxcEngine>> => {
169
+ const stack = lxcEngine(pve);
170
+ const run = await stack.deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)));
171
+ if (run.failure !== '') throw new Error(`the clean adoption failed: ${run.failure}`);
172
+ return stack;
173
+ };