@homeflare/alchemy 0.1.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 (417) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/cloudflare/client.d.ts +45 -0
  4. package/dist/cloudflare/client.d.ts.map +1 -0
  5. package/dist/cloudflare/index.d.ts +11 -0
  6. package/dist/cloudflare/index.d.ts.map +1 -0
  7. package/dist/cloudflare/index.js +129 -0
  8. package/dist/cloudflare/index.js.map +13 -0
  9. package/dist/cloudflare/lock-rules.d.ts +50 -0
  10. package/dist/cloudflare/lock-rules.d.ts.map +1 -0
  11. package/dist/cloudflare/providers.d.ts +19 -0
  12. package/dist/cloudflare/providers.d.ts.map +1 -0
  13. package/dist/cloudflare/r2-bucket-lock.d.ts +100 -0
  14. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -0
  15. package/dist/forgejo/branch-protection-form.d.ts +19 -0
  16. package/dist/forgejo/branch-protection-form.d.ts.map +1 -0
  17. package/dist/forgejo/branch-protection.d.ts +47 -0
  18. package/dist/forgejo/branch-protection.d.ts.map +1 -0
  19. package/dist/forgejo/client.d.ts +37 -0
  20. package/dist/forgejo/client.d.ts.map +1 -0
  21. package/dist/forgejo/index.d.ts +18 -0
  22. package/dist/forgejo/index.d.ts.map +1 -0
  23. package/dist/forgejo/index.js +479 -0
  24. package/dist/forgejo/index.js.map +21 -0
  25. package/dist/forgejo/org-actions-secrets.d.ts +45 -0
  26. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -0
  27. package/dist/forgejo/org-label.d.ts +46 -0
  28. package/dist/forgejo/org-label.d.ts.map +1 -0
  29. package/dist/forgejo/org-team.d.ts +42 -0
  30. package/dist/forgejo/org-team.d.ts.map +1 -0
  31. package/dist/forgejo/repo-webhook-form.d.ts +27 -0
  32. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -0
  33. package/dist/forgejo/repo-webhook.d.ts +46 -0
  34. package/dist/forgejo/repo-webhook.d.ts.map +1 -0
  35. package/dist/forgejo/repository.d.ts +45 -0
  36. package/dist/forgejo/repository.d.ts.map +1 -0
  37. package/dist/forgejo/resource.d.ts +83 -0
  38. package/dist/forgejo/resource.d.ts.map +1 -0
  39. package/dist/forgejo/team-member.d.ts +39 -0
  40. package/dist/forgejo/team-member.d.ts.map +1 -0
  41. package/dist/forgejo/values.d.ts +24 -0
  42. package/dist/forgejo/values.d.ts.map +1 -0
  43. package/dist/index.d.ts +18 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +9 -0
  47. package/dist/openbao/auth-role-form.d.ts +38 -0
  48. package/dist/openbao/auth-role-form.d.ts.map +1 -0
  49. package/dist/openbao/auth-role.d.ts +20 -0
  50. package/dist/openbao/auth-role.d.ts.map +1 -0
  51. package/dist/openbao/bao-address.d.ts +72 -0
  52. package/dist/openbao/bao-address.d.ts.map +1 -0
  53. package/dist/openbao/bao-http.d.ts +71 -0
  54. package/dist/openbao/bao-http.d.ts.map +1 -0
  55. package/dist/openbao/bao-status.d.ts +74 -0
  56. package/dist/openbao/bao-status.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-group-scope.d.ts +73 -0
  58. package/dist/openbao/cloudflare-group-scope.d.ts.map +1 -0
  59. package/dist/openbao/cloudflare-parity-catalog.d.ts +28 -0
  60. package/dist/openbao/cloudflare-parity-catalog.d.ts.map +1 -0
  61. package/dist/openbao/cloudflare-parity-snapshot.d.ts +31 -0
  62. package/dist/openbao/cloudflare-parity-snapshot.d.ts.map +1 -0
  63. package/dist/openbao/cloudflare-parity.d.ts +14 -0
  64. package/dist/openbao/cloudflare-parity.d.ts.map +1 -0
  65. package/dist/openbao/cloudflare-permission-groups-solve.d.ts +85 -0
  66. package/dist/openbao/cloudflare-permission-groups-solve.d.ts.map +1 -0
  67. package/dist/openbao/cloudflare-permission-groups.d.ts +87 -0
  68. package/dist/openbao/cloudflare-permission-groups.d.ts.map +1 -0
  69. package/dist/openbao/cloudflare-policy.d.ts +65 -0
  70. package/dist/openbao/cloudflare-policy.d.ts.map +1 -0
  71. package/dist/openbao/cloudflare-role-form.d.ts +110 -0
  72. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -0
  73. package/dist/openbao/cloudflare-role-wire.d.ts +27 -0
  74. package/dist/openbao/cloudflare-role-wire.d.ts.map +1 -0
  75. package/dist/openbao/cloudflare-role.d.ts +35 -0
  76. package/dist/openbao/cloudflare-role.d.ts.map +1 -0
  77. package/dist/openbao/cloudflare-roles-config.d.ts +67 -0
  78. package/dist/openbao/cloudflare-roles-config.d.ts.map +1 -0
  79. package/dist/openbao/cloudflare-roles-expand.d.ts +76 -0
  80. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -0
  81. package/dist/openbao/digest.d.ts +24 -0
  82. package/dist/openbao/digest.d.ts.map +1 -0
  83. package/dist/openbao/fake-bao.d.ts +40 -0
  84. package/dist/openbao/fake-bao.d.ts.map +1 -0
  85. package/dist/openbao/forgejo-bootstrap-parse.d.ts +46 -0
  86. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -0
  87. package/dist/openbao/forgejo-bootstrap.d.ts +29 -0
  88. package/dist/openbao/forgejo-bootstrap.d.ts.map +1 -0
  89. package/dist/openbao/index.d.ts +18 -0
  90. package/dist/openbao/index.d.ts.map +1 -0
  91. package/dist/openbao/index.js +1158 -0
  92. package/dist/openbao/index.js.map +33 -0
  93. package/dist/openbao/mount-form.d.ts +60 -0
  94. package/dist/openbao/mount-form.d.ts.map +1 -0
  95. package/dist/openbao/mount-wire.d.ts +24 -0
  96. package/dist/openbao/mount-wire.d.ts.map +1 -0
  97. package/dist/openbao/mount.d.ts +20 -0
  98. package/dist/openbao/mount.d.ts.map +1 -0
  99. package/dist/openbao/pki-role-form.d.ts +118 -0
  100. package/dist/openbao/pki-role-form.d.ts.map +1 -0
  101. package/dist/openbao/pki-role.d.ts +50 -0
  102. package/dist/openbao/pki-role.d.ts.map +1 -0
  103. package/dist/openbao/policy-assembly.d.ts +13 -0
  104. package/dist/openbao/policy-assembly.d.ts.map +1 -0
  105. package/dist/openbao/policy-wire.d.ts +41 -0
  106. package/dist/openbao/policy-wire.d.ts.map +1 -0
  107. package/dist/openbao/policy.d.ts +83 -0
  108. package/dist/openbao/policy.d.ts.map +1 -0
  109. package/dist/openbao/proxmox-role-form.d.ts +92 -0
  110. package/dist/openbao/proxmox-role-form.d.ts.map +1 -0
  111. package/dist/openbao/proxmox-role.d.ts +47 -0
  112. package/dist/openbao/proxmox-role.d.ts.map +1 -0
  113. package/dist/openbao/ssh-role-form.d.ts +99 -0
  114. package/dist/openbao/ssh-role-form.d.ts.map +1 -0
  115. package/dist/openbao/ssh-role.d.ts +43 -0
  116. package/dist/openbao/ssh-role.d.ts.map +1 -0
  117. package/dist/proxmox/acl.d.ts +72 -0
  118. package/dist/proxmox/acl.d.ts.map +1 -0
  119. package/dist/proxmox/api-token-form.d.ts +59 -0
  120. package/dist/proxmox/api-token-form.d.ts.map +1 -0
  121. package/dist/proxmox/api-token.d.ts +159 -0
  122. package/dist/proxmox/api-token.d.ts.map +1 -0
  123. package/dist/proxmox/backup-job.d.ts +112 -0
  124. package/dist/proxmox/backup-job.d.ts.map +1 -0
  125. package/dist/proxmox/ceph-daemon-form.d.ts +70 -0
  126. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -0
  127. package/dist/proxmox/ceph-daemon.d.ts +162 -0
  128. package/dist/proxmox/ceph-daemon.d.ts.map +1 -0
  129. package/dist/proxmox/ceph-flag.d.ts +131 -0
  130. package/dist/proxmox/ceph-flag.d.ts.map +1 -0
  131. package/dist/proxmox/ceph-fs-wire.d.ts +106 -0
  132. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -0
  133. package/dist/proxmox/ceph-fs.d.ts +124 -0
  134. package/dist/proxmox/ceph-fs.d.ts.map +1 -0
  135. package/dist/proxmox/ceph-osd-tree.d.ts +154 -0
  136. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -0
  137. package/dist/proxmox/ceph-osd-write.d.ts +36 -0
  138. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -0
  139. package/dist/proxmox/ceph-osd.d.ts +102 -0
  140. package/dist/proxmox/ceph-osd.d.ts.map +1 -0
  141. package/dist/proxmox/ceph-pool-form.d.ts +217 -0
  142. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -0
  143. package/dist/proxmox/ceph-pool-settle.d.ts +39 -0
  144. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -0
  145. package/dist/proxmox/ceph-pool.d.ts +48 -0
  146. package/dist/proxmox/ceph-pool.d.ts.map +1 -0
  147. package/dist/proxmox/client.d.ts +64 -0
  148. package/dist/proxmox/client.d.ts.map +1 -0
  149. package/dist/proxmox/credentials.d.ts +153 -0
  150. package/dist/proxmox/credentials.d.ts.map +1 -0
  151. package/dist/proxmox/firewall-alias-form.d.ts +98 -0
  152. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -0
  153. package/dist/proxmox/firewall-alias.d.ts +141 -0
  154. package/dist/proxmox/firewall-alias.d.ts.map +1 -0
  155. package/dist/proxmox/generated/pbs.d.ts +388 -0
  156. package/dist/proxmox/generated/pbs.d.ts.map +1 -0
  157. package/dist/proxmox/generated/pve.d.ts +4713 -0
  158. package/dist/proxmox/generated/pve.d.ts.map +1 -0
  159. package/dist/proxmox/group.d.ts +104 -0
  160. package/dist/proxmox/group.d.ts.map +1 -0
  161. package/dist/proxmox/ha-resource.d.ts +104 -0
  162. package/dist/proxmox/ha-resource.d.ts.map +1 -0
  163. package/dist/proxmox/ha-rule-form.d.ts +103 -0
  164. package/dist/proxmox/ha-rule-form.d.ts.map +1 -0
  165. package/dist/proxmox/ha-rule.d.ts +156 -0
  166. package/dist/proxmox/ha-rule.d.ts.map +1 -0
  167. package/dist/proxmox/index.d.ts +43 -0
  168. package/dist/proxmox/index.d.ts.map +1 -0
  169. package/dist/proxmox/index.js +2322 -0
  170. package/dist/proxmox/index.js.map +70 -0
  171. package/dist/proxmox/lease-cache.d.ts +98 -0
  172. package/dist/proxmox/lease-cache.d.ts.map +1 -0
  173. package/dist/proxmox/lxc.d.ts +59 -0
  174. package/dist/proxmox/lxc.d.ts.map +1 -0
  175. package/dist/proxmox/members.d.ts +56 -0
  176. package/dist/proxmox/members.d.ts.map +1 -0
  177. package/dist/proxmox/metric-server-form.d.ts +53 -0
  178. package/dist/proxmox/metric-server-form.d.ts.map +1 -0
  179. package/dist/proxmox/metric-server-otel.d.ts +49 -0
  180. package/dist/proxmox/metric-server-otel.d.ts.map +1 -0
  181. package/dist/proxmox/metric-server.d.ts +123 -0
  182. package/dist/proxmox/metric-server.d.ts.map +1 -0
  183. package/dist/proxmox/network-apply-read.d.ts +93 -0
  184. package/dist/proxmox/network-apply-read.d.ts.map +1 -0
  185. package/dist/proxmox/network-apply.d.ts +102 -0
  186. package/dist/proxmox/network-apply.d.ts.map +1 -0
  187. package/dist/proxmox/node-network-form.d.ts +128 -0
  188. package/dist/proxmox/node-network-form.d.ts.map +1 -0
  189. package/dist/proxmox/node-network.d.ts +173 -0
  190. package/dist/proxmox/node-network.d.ts.map +1 -0
  191. package/dist/proxmox/notification-target-form.d.ts +44 -0
  192. package/dist/proxmox/notification-target-form.d.ts.map +1 -0
  193. package/dist/proxmox/notification-target.d.ts +146 -0
  194. package/dist/proxmox/notification-target.d.ts.map +1 -0
  195. package/dist/proxmox/pbs-datastore-form.d.ts +147 -0
  196. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -0
  197. package/dist/proxmox/pbs-datastore-guard.d.ts +55 -0
  198. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -0
  199. package/dist/proxmox/pbs-datastore.d.ts +140 -0
  200. package/dist/proxmox/pbs-datastore.d.ts.map +1 -0
  201. package/dist/proxmox/pbs-prune-job-form.d.ts +97 -0
  202. package/dist/proxmox/pbs-prune-job-form.d.ts.map +1 -0
  203. package/dist/proxmox/pbs-prune-job.d.ts +168 -0
  204. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -0
  205. package/dist/proxmox/pbs-sync-job-form.d.ts +83 -0
  206. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -0
  207. package/dist/proxmox/pbs-sync-job.d.ts +246 -0
  208. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -0
  209. package/dist/proxmox/pbs-verify-job-form.d.ts +114 -0
  210. package/dist/proxmox/pbs-verify-job-form.d.ts.map +1 -0
  211. package/dist/proxmox/pbs-verify-job.d.ts +145 -0
  212. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -0
  213. package/dist/proxmox/pool.d.ts +42 -0
  214. package/dist/proxmox/pool.d.ts.map +1 -0
  215. package/dist/proxmox/qemu.d.ts +46 -0
  216. package/dist/proxmox/qemu.d.ts.map +1 -0
  217. package/dist/proxmox/replication-job-form.d.ts +71 -0
  218. package/dist/proxmox/replication-job-form.d.ts.map +1 -0
  219. package/dist/proxmox/replication-job.d.ts +162 -0
  220. package/dist/proxmox/replication-job.d.ts.map +1 -0
  221. package/dist/proxmox/resource.d.ts +157 -0
  222. package/dist/proxmox/resource.d.ts.map +1 -0
  223. package/dist/proxmox/role.d.ts +97 -0
  224. package/dist/proxmox/role.d.ts.map +1 -0
  225. package/dist/proxmox/sdn-apply-read.d.ts +80 -0
  226. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -0
  227. package/dist/proxmox/sdn-apply.d.ts +63 -0
  228. package/dist/proxmox/sdn-apply.d.ts.map +1 -0
  229. package/dist/proxmox/sdn-subnet-form.d.ts +111 -0
  230. package/dist/proxmox/sdn-subnet-form.d.ts.map +1 -0
  231. package/dist/proxmox/sdn-subnet.d.ts +161 -0
  232. package/dist/proxmox/sdn-subnet.d.ts.map +1 -0
  233. package/dist/proxmox/sdn-vnet.d.ts +113 -0
  234. package/dist/proxmox/sdn-vnet.d.ts.map +1 -0
  235. package/dist/proxmox/sdn-zone.d.ts +126 -0
  236. package/dist/proxmox/sdn-zone.d.ts.map +1 -0
  237. package/dist/proxmox/storage.d.ts +120 -0
  238. package/dist/proxmox/storage.d.ts.map +1 -0
  239. package/dist/proxmox/user.d.ts +107 -0
  240. package/dist/proxmox/user.d.ts.map +1 -0
  241. package/dist/proxmox/values.d.ts +134 -0
  242. package/dist/proxmox/values.d.ts.map +1 -0
  243. package/dist/proxmox/zfs-pool-write.d.ts +76 -0
  244. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -0
  245. package/dist/proxmox/zfs-pool.d.ts +146 -0
  246. package/dist/proxmox/zfs-pool.d.ts.map +1 -0
  247. package/dist/talos/credentials.d.ts +30 -0
  248. package/dist/talos/credentials.d.ts.map +1 -0
  249. package/dist/talos/index.d.ts +15 -0
  250. package/dist/talos/index.d.ts.map +1 -0
  251. package/dist/talos/index.js +392 -0
  252. package/dist/talos/index.js.map +16 -0
  253. package/dist/talos/kubeconfig.d.ts +53 -0
  254. package/dist/talos/kubeconfig.d.ts.map +1 -0
  255. package/dist/talos/resource.d.ts +32 -0
  256. package/dist/talos/resource.d.ts.map +1 -0
  257. package/dist/talos/talos-bootstrap.d.ts +27 -0
  258. package/dist/talos/talos-bootstrap.d.ts.map +1 -0
  259. package/dist/talos/talos-cluster-health.d.ts +36 -0
  260. package/dist/talos/talos-cluster-health.d.ts.map +1 -0
  261. package/dist/talos/talos-machine-config.d.ts +41 -0
  262. package/dist/talos/talos-machine-config.d.ts.map +1 -0
  263. package/dist/talos/talosctl.d.ts +29 -0
  264. package/dist/talos/talosctl.d.ts.map +1 -0
  265. package/dist/talos/values.d.ts +20 -0
  266. package/dist/talos/values.d.ts.map +1 -0
  267. package/package.json +57 -0
  268. package/src/cloudflare/client.ts +76 -0
  269. package/src/cloudflare/index.ts +10 -0
  270. package/src/cloudflare/lock-rules.ts +60 -0
  271. package/src/cloudflare/providers.ts +23 -0
  272. package/src/cloudflare/r2-bucket-lock.ts +220 -0
  273. package/src/forgejo/branch-protection-form.ts +29 -0
  274. package/src/forgejo/branch-protection.ts +101 -0
  275. package/src/forgejo/client.ts +136 -0
  276. package/src/forgejo/index.ts +17 -0
  277. package/src/forgejo/org-actions-secrets.ts +99 -0
  278. package/src/forgejo/org-label.ts +100 -0
  279. package/src/forgejo/org-team.ts +105 -0
  280. package/src/forgejo/repo-webhook-form.ts +47 -0
  281. package/src/forgejo/repo-webhook.ts +118 -0
  282. package/src/forgejo/repository.ts +126 -0
  283. package/src/forgejo/resource.ts +171 -0
  284. package/src/forgejo/team-member.ts +97 -0
  285. package/src/forgejo/values.test.ts +48 -0
  286. package/src/forgejo/values.ts +61 -0
  287. package/src/index.ts +18 -0
  288. package/src/openbao/auth-role-form.ts +112 -0
  289. package/src/openbao/auth-role.ts +101 -0
  290. package/src/openbao/bao-address.test.ts +89 -0
  291. package/src/openbao/bao-address.ts +97 -0
  292. package/src/openbao/bao-http-gate.test.ts +88 -0
  293. package/src/openbao/bao-http-unix.test.ts +70 -0
  294. package/src/openbao/bao-http.test.ts +161 -0
  295. package/src/openbao/bao-http.ts +185 -0
  296. package/src/openbao/bao-status.test.ts +114 -0
  297. package/src/openbao/bao-status.ts +137 -0
  298. package/src/openbao/cloudflare-group-scope.test.ts +103 -0
  299. package/src/openbao/cloudflare-group-scope.ts +83 -0
  300. package/src/openbao/cloudflare-parity-catalog.ts +104 -0
  301. package/src/openbao/cloudflare-parity-snapshot.ts +84 -0
  302. package/src/openbao/cloudflare-parity.ts +152 -0
  303. package/src/openbao/cloudflare-permission-groups-solve.test.ts +171 -0
  304. package/src/openbao/cloudflare-permission-groups-solve.ts +197 -0
  305. package/src/openbao/cloudflare-permission-groups.test.ts +369 -0
  306. package/src/openbao/cloudflare-permission-groups.ts +299 -0
  307. package/src/openbao/cloudflare-policy.ts +180 -0
  308. package/src/openbao/cloudflare-role-form.test.ts +177 -0
  309. package/src/openbao/cloudflare-role-form.ts +183 -0
  310. package/src/openbao/cloudflare-role-wire.test.ts +132 -0
  311. package/src/openbao/cloudflare-role-wire.ts +41 -0
  312. package/src/openbao/cloudflare-role.ts +189 -0
  313. package/src/openbao/cloudflare-roles-config.ts +230 -0
  314. package/src/openbao/cloudflare-roles-expand-bucket.test.ts +134 -0
  315. package/src/openbao/cloudflare-roles-expand.ts +231 -0
  316. package/src/openbao/digest.ts +26 -0
  317. package/src/openbao/fake-bao.ts +84 -0
  318. package/src/openbao/forgejo-bootstrap-parse.test.ts +76 -0
  319. package/src/openbao/forgejo-bootstrap-parse.ts +90 -0
  320. package/src/openbao/forgejo-bootstrap.ts +181 -0
  321. package/src/openbao/index.ts +17 -0
  322. package/src/openbao/mount-form.ts +147 -0
  323. package/src/openbao/mount-wire.test.ts +108 -0
  324. package/src/openbao/mount-wire.ts +79 -0
  325. package/src/openbao/mount.ts +132 -0
  326. package/src/openbao/pki-role-form.ts +248 -0
  327. package/src/openbao/pki-role.ts +202 -0
  328. package/src/openbao/policy-assembly.test.ts +28 -0
  329. package/src/openbao/policy-assembly.ts +13 -0
  330. package/src/openbao/policy-wire.test.ts +90 -0
  331. package/src/openbao/policy-wire.ts +53 -0
  332. package/src/openbao/policy.ts +212 -0
  333. package/src/openbao/proxmox-role-form.ts +174 -0
  334. package/src/openbao/proxmox-role.ts +214 -0
  335. package/src/openbao/ssh-role-form.ts +248 -0
  336. package/src/openbao/ssh-role.ts +240 -0
  337. package/src/proxmox/acl.ts +267 -0
  338. package/src/proxmox/api-token-form.ts +122 -0
  339. package/src/proxmox/api-token.ts +222 -0
  340. package/src/proxmox/backup-job.ts +213 -0
  341. package/src/proxmox/ceph-daemon-form.ts +84 -0
  342. package/src/proxmox/ceph-daemon.ts +243 -0
  343. package/src/proxmox/ceph-flag.ts +249 -0
  344. package/src/proxmox/ceph-fs-wire.ts +246 -0
  345. package/src/proxmox/ceph-fs.ts +249 -0
  346. package/src/proxmox/ceph-osd-tree.ts +193 -0
  347. package/src/proxmox/ceph-osd-write.ts +76 -0
  348. package/src/proxmox/ceph-osd.ts +244 -0
  349. package/src/proxmox/ceph-pool-form.ts +249 -0
  350. package/src/proxmox/ceph-pool-settle.ts +70 -0
  351. package/src/proxmox/ceph-pool.ts +212 -0
  352. package/src/proxmox/client.test.ts +185 -0
  353. package/src/proxmox/client.ts +240 -0
  354. package/src/proxmox/credentials.test.ts +144 -0
  355. package/src/proxmox/credentials.ts +254 -0
  356. package/src/proxmox/firewall-alias-form.ts +112 -0
  357. package/src/proxmox/firewall-alias.ts +208 -0
  358. package/src/proxmox/generated/pbs.ts +461 -0
  359. package/src/proxmox/generated/pve.ts +7344 -0
  360. package/src/proxmox/group.ts +199 -0
  361. package/src/proxmox/ha-resource.ts +169 -0
  362. package/src/proxmox/ha-rule-form.ts +185 -0
  363. package/src/proxmox/ha-rule.ts +240 -0
  364. package/src/proxmox/index.ts +42 -0
  365. package/src/proxmox/lease-cache.test.ts +163 -0
  366. package/src/proxmox/lease-cache.ts +131 -0
  367. package/src/proxmox/lxc.ts +117 -0
  368. package/src/proxmox/members.test.ts +54 -0
  369. package/src/proxmox/members.ts +174 -0
  370. package/src/proxmox/metric-server-form.ts +128 -0
  371. package/src/proxmox/metric-server-otel.ts +99 -0
  372. package/src/proxmox/metric-server.ts +214 -0
  373. package/src/proxmox/network-apply-read.ts +228 -0
  374. package/src/proxmox/network-apply.ts +244 -0
  375. package/src/proxmox/node-network-form.ts +213 -0
  376. package/src/proxmox/node-network.ts +243 -0
  377. package/src/proxmox/notification-target-form.ts +104 -0
  378. package/src/proxmox/notification-target.ts +222 -0
  379. package/src/proxmox/pbs-datastore-form.ts +248 -0
  380. package/src/proxmox/pbs-datastore-guard.ts +105 -0
  381. package/src/proxmox/pbs-datastore.ts +271 -0
  382. package/src/proxmox/pbs-prune-job-form.ts +208 -0
  383. package/src/proxmox/pbs-prune-job.ts +227 -0
  384. package/src/proxmox/pbs-sync-job-form.ts +234 -0
  385. package/src/proxmox/pbs-sync-job.ts +258 -0
  386. package/src/proxmox/pbs-verify-job-form.ts +131 -0
  387. package/src/proxmox/pbs-verify-job.ts +227 -0
  388. package/src/proxmox/pool.test.ts +42 -0
  389. package/src/proxmox/pool.ts +80 -0
  390. package/src/proxmox/qemu.ts +89 -0
  391. package/src/proxmox/replication-job-form.ts +89 -0
  392. package/src/proxmox/replication-job.ts +248 -0
  393. package/src/proxmox/resource.ts +257 -0
  394. package/src/proxmox/role.ts +161 -0
  395. package/src/proxmox/sdn-apply-read.test.ts +152 -0
  396. package/src/proxmox/sdn-apply-read.ts +189 -0
  397. package/src/proxmox/sdn-apply.ts +154 -0
  398. package/src/proxmox/sdn-subnet-form.ts +156 -0
  399. package/src/proxmox/sdn-subnet.ts +238 -0
  400. package/src/proxmox/sdn-vnet.ts +228 -0
  401. package/src/proxmox/sdn-zone.ts +246 -0
  402. package/src/proxmox/storage.ts +245 -0
  403. package/src/proxmox/user.ts +214 -0
  404. package/src/proxmox/values.test.ts +193 -0
  405. package/src/proxmox/values.ts +203 -0
  406. package/src/proxmox/zfs-pool-write.ts +130 -0
  407. package/src/proxmox/zfs-pool.ts +253 -0
  408. package/src/talos/credentials.ts +136 -0
  409. package/src/talos/index.ts +14 -0
  410. package/src/talos/kubeconfig.ts +170 -0
  411. package/src/talos/resource.ts +31 -0
  412. package/src/talos/talos-bootstrap.ts +109 -0
  413. package/src/talos/talos-cluster-health.ts +119 -0
  414. package/src/talos/talos-machine-config.ts +146 -0
  415. package/src/talos/talosctl.ts +94 -0
  416. package/src/talos/values.test.ts +80 -0
  417. package/src/talos/values.ts +70 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `Proxmox.Group` — a PVE group: a name, a comment, and the set of users PVE hangs off it.
3
+ *
4
+ * ★ DECLARING `hf-mint` IS THE WHOLE POINT OF THIS FAMILY. `vaultmint@pve` holds the `HFMint` role
5
+ * ON THE PATH `/access/groups/hf-mint` — measured from `GET /access/acl`, 2026-09-13 — which is
6
+ * what confines the OpenBao proxmox engine to minting inside that one group. The group is the
7
+ * fence around the credential every other resource in this package runs on, and until now it was
8
+ * something a human typed on a node once. `user.ts` calls that account the branch it sits on;
9
+ * this is the branch's other end.
10
+ *
11
+ * ⛔ MEMBERSHIP IS NOT SETTABLE HERE, SO IT IS REPORTED AND NEVER DIFFED. Measured from the
12
+ * cluster's own schema: `POST /access/groups` and `PUT /access/groups/{groupid}` each accept
13
+ * EXACTLY `groupid` and `comment`, both with `additionalProperties: 0`. There is no members
14
+ * parameter to send. Membership is written from the other side — the `groups` field on each
15
+ * USER, which `user.ts` already owns — so `members` below is an attribute, out of `matches`,
16
+ * exactly like `pool.members` and `user.tokens`. Declared here it would be a diff that no write
17
+ * in this file could ever settle.
18
+ *
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:
21
+ * GET /access/groups -> [{"groupid":"hf-mint","comment":"…",
22
+ * "users":"hf-provision@pve,hf-read@pve"}, …]
23
+ * GET /access/groups/hf-mint -> {"comment":"…","members":["hf-provision@pve","hf-read@pve"]}
24
+ * The index says `users` and hands back a COMMA STRING; the item says `members` and hands back an
25
+ * ARRAY. A reader that looked for `users` on the item path would find nothing, report an empty
26
+ * group, and quietly show a delete as harmless.
27
+ *
28
+ * ⛔ AND THE ITEM'S ORDER IS NOT STABLE BETWEEN TWO CONSECUTIVE CALLS. MEASURED, seconds apart, on
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"]
32
+ * `read_group` builds it as `[keys %{ $data->{users} }]` — a bare Perl hash key list, whose order
33
+ * is randomised per process — while the INDEX sorts (`join(',', sort keys …)`). So the array is
34
+ * sorted on the way into `attributes` as well as being kept out of `matches`: unsorted, Alchemy's
35
+ * stored state would churn on every read even with nothing to diff.
36
+ *
37
+ * ⚠️ `groupid` IS NOT ECHOED BY THE ITEM READ. Its return schema is `additionalProperties: 0` over
38
+ * exactly `comment` and `members`, so the id comes from props — the same arrangement, and the
39
+ * same reason, as `user.ts`.
40
+ *
41
+ * ★ PRIVILEGES: NOTHING HAD TO BE WIDENED, WHICH MAKES THIS THE FIRST FAMILY HERE THAT COST
42
+ * NOTHING. Measured with `pveum user permissions`, 2026-09-13:
43
+ * · item GET checks `['perm','/access/groups',['Sys.Audit','Group.Allocate'], any => 1]`, and
44
+ * `hf-read@pve` already holds `Sys.Audit` there (propagated from `/` by `PVEAuditor`).
45
+ * So `readRole` is left at the default `read` — unlike storage/sdn-zone/sdn-vnet, whose ITEM
46
+ * reads PVE gates on an allocate privilege. See the ⛔ on `readRole` in `resource.ts`.
47
+ * · POST, PUT and DELETE each check `Group.Allocate` on `/access/groups`, and
48
+ * `hf-provision@pve` holds it via `LXCProvisioner`.
49
+ *
50
+ * ⛔ `retain` BY DEFAULT, BECAUSE DELETING A GROUP DESTROYS TWO THINGS THIS FILE CANNOT PUT BACK.
51
+ * Measured in `PVE::AccessControl` on the node:
52
+ * · The member list IS the group. `user.cfg` stores it on the GROUP line (`group:<id>:<users>:
53
+ * <comment>:`) and DERIVES each user's `groups` map from it at parse time. Deleting the group
54
+ * deletes the list, and this resource has no `members` prop to restore it from.
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
58
+ * confirmation and nothing in the plan to suggest it.
59
+ * `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists, unlike the ACL
60
+ * family's — so `.pipe(RemovalPolicy.destroy())` really removes the group. See the ★ in
61
+ * `resource.ts` for why the policy is a guard rather than a stubbed-out operation.
62
+ *
63
+ * ⛔ NO SECRET REACHES STATE. A group holds a comment and a list of userids; there is no password,
64
+ * no token and no key anywhere on these endpoints. `user.ts`'s ⛔ about unencrypted state applies
65
+ * to this package, and this family simply has nothing to trip it.
66
+ */
67
+ import { Resource } from 'alchemy';
68
+ import * as Provider from 'alchemy/Provider';
69
+ import * as Effect from 'effect/Effect';
70
+ import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
71
+ import { csv, text } from './values.ts';
72
+
73
+ export interface GroupProps extends WithTarget {
74
+ /**
75
+ * PVE's primary key for a group.
76
+ *
77
+ * ⚠️ THERE IS NO RENAME, AND THE FAILURE IS WORSE HERE THAN FOR A ROLE. Editing this makes `path`
78
+ * point at a different group, which reads as absent and is created empty — while the old group
79
+ * keeps its members and its ACL grants with nobody managing it. Two groups then both look
80
+ * right in the UI and only one of them grants anything.
81
+ */
82
+ groupid: string;
83
+ /** Free text shown in the UI — the only mutable field a group has. See `storedComment` below. */
84
+ comment?: string;
85
+ }
86
+
87
+ export interface GroupAttributes {
88
+ groupid: string;
89
+ comment: string;
90
+ /**
91
+ * Who is in the group, sorted — reported so a plan can say what a delete would strip, never
92
+ * compared, because this endpoint cannot set it. See the two ⛔s in the header.
93
+ */
94
+ members: string[];
95
+ }
96
+
97
+ export interface ProxmoxGroup extends Resource<
98
+ 'Proxmox.Group',
99
+ GroupProps,
100
+ GroupAttributes,
101
+ never,
102
+ PveRequirements
103
+ > {}
104
+
105
+ /** ★ `retain` by default — a delete takes the membership and the group's grants. See the header. */
106
+ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
107
+ defaultRemovalPolicy: 'retain',
108
+ });
109
+
110
+ /**
111
+ * The comment PVE will actually END UP HOLDING for a given declaration.
112
+ *
113
+ * ⛔ PVE CANNOT STORE THE COMMENT `'0'`, AND A PROVIDER THAT DOES NOT MODEL THAT DIFFS FOREVER.
114
+ * Three places in the shipped Perl test the comment for TRUTH rather than for definedness, and
115
+ * in Perl the one-character string `'0'` is false:
116
+ * `create_group`: `$group->{comment} = $param->{comment} if $param->{comment};`
117
+ * `write_user_config`: `my $comment = $d->{comment} ? encode_text($d->{comment}) : '';`
118
+ * the config parser: `$cfg->{groups}->{$g}->{comment} = decode_text($comment) if $comment;`
119
+ * So a declared `comment: '0'` is accepted by the API, dropped on the way to `user.cfg`, and read
120
+ * back as absent. Left alone, `matches` would be false on every plan and every deploy would
121
+ * rewrite the same value into the same hole. Normalising BOTH the comparison and the form is how
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
124
+ * vanish would have been a write, and this agent had read access only.
125
+ * ⚠️ `pool.ts` HAS THE SAME LATENT HOLE — `pool:$pool:$comment:…` is written by that same
126
+ * truthiness test and is not guarded. Not fixed from here; flagged so it is fixed on purpose.
127
+ *
128
+ * ⚠️ EVERY OTHER COMMENT ROUND-TRIPS EXACTLY, colons and newlines included: `write_user_config`
129
+ * runs it through `encode_text` and the parser through `decode_text`, so the field separator in
130
+ * `user.cfg` cannot be smuggled in.
131
+ */
132
+ const storedComment = (comment: string | undefined) => {
133
+ const value = comment ?? '';
134
+ return value === '0' ? '' : value;
135
+ };
136
+
137
+ /**
138
+ * The item read's `members` array, sorted into something two plans can agree on.
139
+ *
140
+ * ⚠️ `csv` IS REUSED RATHER THAN RE-IMPLEMENTED, AND ITS OWN ⚠️ IS EXACTLY THIS CASE: a list PVE
141
+ * does not promise to give back in the order it was handed. Going out through the joined form
142
+ * and back is the price of that reuse, and cheaper than a fourth hand-rolled sorter.
143
+ * ⚠️ THE ARRAY ONLY — THE COMMA STRING IS NOT ACCEPTED HERE, ON PURPOSE. That spelling belongs to
144
+ * the INDEX (`users`), which this provider never reads, so a branch for it would be a guess at a
145
+ * shape that cannot arrive. `user.ts` refuses the same guess for `tokens` and for the same
146
+ * reason: a wrong guess on a reported-only field surfaces in a plan as a phantom membership.
147
+ * ⚠️ NO DEDUPLICATION, UNLIKE `user.ts`'s `groupSet`. These names are Perl HASH KEYS —
148
+ * `[keys %{ $data->{users} }]` — so a duplicate is not representable. `user.groups` needs the
149
+ * dedupe because its value also arrives from a hand-written prop; this one never does.
150
+ */
151
+ const memberList = (live: unknown): string[] => {
152
+ const joined = csv((Array.isArray(live) ? live : []).map((member: unknown) => text(member)));
153
+ return joined === '' ? [] : joined.split(',');
154
+ };
155
+
156
+ const handlers = pveHandlers<GroupProps, GroupAttributes>({
157
+ attributes: (live, props) => ({
158
+ /**
159
+ * ⚠️ ABSENT IS EMPTY, NOT MISSING. `read_group` sets `comment` only `if defined(…)`, so a group
160
+ * without one answers `{"members":[…]}` and nothing else. THAT ONE IS READ OFF THE HANDLER'S
161
+ * SOURCE, not off the wire — every group on this cluster happens to carry a comment, so the
162
+ * commentless shape was not there to measure. The empty-MEMBERS shape WAS measured —
163
+ * `HomeAssistant` answers `{"comment":"…","members":[]}`. `''` is what an undeclared
164
+ * `comment` prop normalises to, so the two sides meet either way.
165
+ */
166
+ comment: text(live['comment']),
167
+ /** ⚠️ FROM PROPS: the item read's schema is `additionalProperties: 0` over comment/members. */
168
+ groupid: props.groupid,
169
+ members: memberList(live['members']),
170
+ }),
171
+ collection: () => 'access/groups',
172
+ createForm: (props) => ({ comment: storedComment(props.comment), groupid: props.groupid }),
173
+ /**
174
+ * ⚠️ `members` IS NOT COMPARED, AND NOTHING ELSE IS LEFT TO COMPARE. `comment` is the only field
175
+ * a PUT here accepts, so it is the only field whose drift this provider can repair — the rule
176
+ * `resource.ts` states once in the ⛔ on adoption: a field deliberately out of `matches` is a
177
+ * field this resource does not manage.
178
+ */
179
+ matches: (attributes, props) => attributes.comment === storedComment(props.comment),
180
+ path: (props) => `access/groups/${props.groupid}`,
181
+ /**
182
+ * ⚠️ ALWAYS SENT, EVEN EMPTY — that is how a comment is CLEARED. `update_group` assigns
183
+ * `if defined($param->{comment})`, so an omitted field leaves the old text in place and a
184
+ * declaration that dropped its comment would report an update the update could not settle. The
185
+ * empty string then falls out of `user.cfg` on the truthiness test above and reads back as
186
+ * `''`, which is what the props side normalises to. The loop closes.
187
+ */
188
+ updateForm: (props) => ({ comment: storedComment(props.comment) }),
189
+ });
190
+
191
+ /**
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
194
+ * `Administrator` on `/` to every human who logs in. Handing it to Alchemy would invite adoption,
195
+ * and therefore one day a delete that takes the whole admin group's access with it. Adoption
196
+ * stays an explicit act, here as everywhere else in this package.
197
+ */
198
+ export const ProxmoxGroupProvider = () =>
199
+ Provider.effect(ProxmoxGroup, Effect.succeed(ProxmoxGroup.Provider.of(handlers)));
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `Proxmox.HaResource` — a guest's HA membership, declared.
3
+ *
4
+ * ★ WITHOUT ONE OF THESE, EVERY GUEST THIS PACKAGE DECLARES IS PINNED TO THE NODE THE LINE NAMED
5
+ * AND DIES WITH IT. The target is a three-node quorate cluster holding zero HA resources, so
6
+ * `node` in `LxcProps` is the whole of a container's availability story: lose that node and the
7
+ * container is simply gone until a person notices. Declaring a guest without declaring its HA
8
+ * membership is half a declaration, which is why this lives beside `lxc.ts` rather than in a
9
+ * runbook somebody reads after an outage.
10
+ *
11
+ * ⚠️ `state` READS LIKE STATUS AND IS CONFIGURATION. `started | stopped | disabled | ignored` is
12
+ * the state the CRM is ASKED for; a line in the HA config file, not a reading off the cluster,
13
+ * so it is a prop and it is compared. `LxcAttributes.status` is the opposite case — reported,
14
+ * never declared — and confusing the two inverts the meaning: `state: 'stopped'` here says "HA
15
+ * should hold this guest stopped and still relocate it on node failure", not "it is stopped".
16
+ *
17
+ * ⛔ AND YET IT MOVES POWER, WHICH IS THE ONE PLACE THIS PACKAGE'S "PLANS DO NOT START THINGS" RULE
18
+ * BENDS. `lxc.ts` and `qemu.ts` refuse to own power state precisely so a deploy is not a
19
+ * maintenance window — but the CRM acts on whatever this file says. The default `started` will
20
+ * BOOT a stopped guest within seconds of the write, and `stopped` will SHUT DOWN a running one.
21
+ * There is no way to declare HA membership without declaring intent, so the intent is at least
22
+ * explicit and diffable here; read a plan that touches `state` as a plan that touches uptime.
23
+ *
24
+ * ⛔ `sid` CARRIES ITS TYPE PREFIX — `ct:101`, `vm:100` — AND READ ANSWERS IN THE PREFIXED FORM.
25
+ * POST also takes the bare `101` shortcut; taking it would leave a resource keyed on a spelling
26
+ * PVE never returns, and every plan comparing `101` against `ct:101`. So the prefixed form is
27
+ * required here and it is what goes into the path.
28
+ * ⚠️ EDITING `sid` IN PLACE ORPHANS THE OLD MEMBERSHIP. `diff` reads the NEW path, finds nothing
29
+ * and calls that drift, so reconcile creates the new entry and the old one stays under HA with
30
+ * nothing declaring it. Retire the declaration and add a second one instead of renaming.
31
+ *
32
+ * ⛔ MEASURED ON PVE 9.2: `/cluster/ha/groups` ANSWERS HTTP 500 — groups were removed in PVE 9,
33
+ * even though the published API schema still documents the endpoint AND a `group` parameter on
34
+ * this very resource. Nothing here sends `group`. The successor is `/cluster/ha/rules`
35
+ * (node-affinity and resource-affinity) and that is the next family to add; do not port the old
36
+ * groups endpoint back just because the docs still describe it.
37
+ *
38
+ * ⚠️ RECONCILE NEEDS `Sys.Console` ON `/`, WHICH `LXCProvisioner` DOES NOT HOLD. Read and diff are
39
+ * fine — `GET /cluster/ha/resources/{sid}` checks `Sys.Audit`, which the role already has — while
40
+ * 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
+ * opens a root shell on every node (`/nodes/{node}/vncshell`, `termproxy`), so granting it to the
43
+ * provision credential buys HA membership at the price of node console access. A separate role
44
+ * for HA writes is the narrower answer if that trade is not wanted.
45
+ */
46
+ import { Resource } from 'alchemy';
47
+ import * as Provider from 'alchemy/Provider';
48
+ import * as Effect from 'effect/Effect';
49
+ import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
50
+ import { num } from './values.ts';
51
+
52
+ /**
53
+ * The requested states this provider admits.
54
+ *
55
+ * ⚠️ `enabled` IS DELIBERATELY ABSENT. PVE accepts it and documents it as an alias for `started`,
56
+ * which means a resource declared as `enabled` can be read back as `started` — and `matches`
57
+ * would then report an update on every plan, forever, over a difference in spelling. One spelling
58
+ * per state is the only version of this that settles.
59
+ */
60
+ export type HaState = 'started' | 'stopped' | 'disabled' | 'ignored';
61
+
62
+ export interface HaResourceProps extends WithTarget {
63
+ /** ⛔ PREFIXED AND CLUSTER-WIDE: `ct:101`, `vm:100`. Changing it is a different object. */
64
+ sid: string;
65
+ /** What the CRM should aim for. Defaults to `started`, PVE's own default. */
66
+ state?: HaState;
67
+ /** Free text shown in the HA panel. Max 4096 characters. */
68
+ comment?: string;
69
+ /** Restart tries on the SAME node before the manager gives up and relocates. Default 1. */
70
+ max_restart?: number;
71
+ /** Relocation tries before the resource is left in `error`. Default 1. */
72
+ max_relocate?: number;
73
+ }
74
+
75
+ export interface HaResourceAttributes {
76
+ /** As PVE returns it, prefix included. */
77
+ sid: string;
78
+ /** `ct` | `vm`. Implied by the prefix, reported so a plan can say what is being protected. */
79
+ type: string;
80
+ /**
81
+ * ⚠️ TYPED `string`, NOT `HaState`, because this is whatever the config file holds — including an
82
+ * `enabled` a person wrote in the UI. Narrowing it here would be a claim about someone else's
83
+ * edit rather than a report of it.
84
+ */
85
+ state: string;
86
+ comment: string;
87
+ max_restart: number;
88
+ max_relocate: number;
89
+ }
90
+
91
+ export interface ProxmoxHaResource extends Resource<
92
+ 'Proxmox.HaResource',
93
+ HaResourceProps,
94
+ HaResourceAttributes,
95
+ never,
96
+ PveRequirements
97
+ > {}
98
+
99
+ export const ProxmoxHaResource = Resource<ProxmoxHaResource>('Proxmox.HaResource');
100
+
101
+ /** `ct:101` -> `ct`. ⚠️ `?? ''` because noUncheckedIndexedAccess types `split()[0]` as optional. */
102
+ const kind = (sid: string) => sid.split(':')[0] ?? '';
103
+
104
+ /**
105
+ * ⚠️ NO `type` IN EITHER FORM, ON PURPOSE. The prefix in `sid` already names it on create, and PUT
106
+ * does not accept `type` at all — sending it on create only would make the two shapes diverge for
107
+ * no gain, and sending it on update is a parameter-verification failure.
108
+ */
109
+ const shape = (props: HaResourceProps) => ({
110
+ comment: props.comment ?? '',
111
+ max_relocate: String(props.max_relocate ?? 1),
112
+ max_restart: String(props.max_restart ?? 1),
113
+ state: props.state ?? 'started',
114
+ });
115
+
116
+ const handlers = pveHandlers<HaResourceProps, HaResourceAttributes>({
117
+ attributes: (live, props) => ({
118
+ comment: typeof live['comment'] === 'string' ? live['comment'] : '',
119
+ max_relocate: num(live['max_relocate'], 1),
120
+ max_restart: num(live['max_restart'], 1),
121
+ sid: typeof live['sid'] === 'string' ? live['sid'] : props.sid,
122
+ state: typeof live['state'] === 'string' ? live['state'] : 'started',
123
+ type: typeof live['type'] === 'string' ? live['type'] : kind(props.sid),
124
+ }),
125
+ /**
126
+ * ⚠️ CLUSTER-SCOPED, SO THERE IS NO NODE IN THE PATH — and that is the point of the object. The
127
+ * guest's node is where it happens to run now; this resource is the statement that it may run
128
+ * somewhere else tomorrow.
129
+ */
130
+ collection: () => 'cluster/ha/resources',
131
+ createForm: (props) => ({ ...shape(props), sid: props.sid }),
132
+ /**
133
+ * ⚠️ `digest` IS NOT COMPARED AND IS NOT AN ATTRIBUTE. It changes whenever ANY line of the HA
134
+ * config changes, including one written for a different guest, so diffing it would report an
135
+ * update on this resource because somebody else edited theirs.
136
+ * ⚠️ `sid` AND `type` ARE NOT COMPARED EITHER: sid is the path, so a change there is a different
137
+ * object, and type is create-only — it is read back from the prefix, never edited.
138
+ */
139
+ matches: (attributes, props) =>
140
+ attributes.state === (props.state ?? 'started') &&
141
+ attributes.comment === (props.comment ?? '') &&
142
+ attributes.max_restart === (props.max_restart ?? 1) &&
143
+ attributes.max_relocate === (props.max_relocate ?? 1),
144
+ /**
145
+ * ⚠️ THE COLON GOES INTO THE URL RAW. RFC 3986 allows `:` inside a path segment and PVE matches
146
+ * the sid as one segment, so no `encodeURIComponent` here; percent-encoding it would make the
147
+ * request depend on a proxy normalising `%3A` back before the dispatcher sees it.
148
+ */
149
+ path: (props) => `cluster/ha/resources/${props.sid}`,
150
+ updateForm: shape,
151
+ });
152
+
153
+ /**
154
+ * ⛔ Empty for the same reason as every other resource here: adoption must be explicit.
155
+ * `GET /cluster/ha/resources` would hand back every guest an operator has ever put under
156
+ * HA, and adopting those means one day deleting them.
157
+
158
+ *
159
+ * ⛔ DELETE DEFAULTS TO `purge=1` AND THE FACTORY SENDS NO FORM, SO THAT DEFAULT APPLIES:
160
+ * PVE strips the sid out of every HA rule referencing it and DELETES a rule that had no
161
+ * other member. Destroying an HA membership can therefore take an affinity rule with it,
162
+ * which matters once the rules family lands.
163
+ * ⚠️ It does NOT stop or remove the guest. The container keeps running exactly where it is,
164
+ * only unmanaged — so removing this resource is how a guest is handed back to its node,
165
+ * not how it is decommissioned.
166
+
167
+ */
168
+ export const ProxmoxHaResourceProvider = () =>
169
+ Provider.effect(ProxmoxHaResource, Effect.succeed(ProxmoxHaResource.Provider.of(handlers)));
@@ -0,0 +1,185 @@
1
+ /**
2
+ * An HA rule's props as the form PVE wants, and the two lists PVE re-spells on the way back.
3
+ *
4
+ * ★ SPLIT OUT OF ha-rule.ts TO KEEP BOTH FILES UNDER THE 250-LINE CAP, and the seam is the same
5
+ * one metric-server-form.ts and node-network-form.ts cut: this file answers "how does a
6
+ * declaration become a PVE form, and what shape does PVE hand back", ha-rule.ts answers "what is
7
+ * an HA rule and when has it changed". Nothing here reads the cluster or decides a diff.
8
+ *
9
+ * ⚠️ THE `import type` BACK TO ha-rule.ts IS A CYCLE ON PAPER ONLY. It is type-only, so it is
10
+ * erased before anything runs, and `HaRuleProps` stays public in the file declaring the resource.
11
+ */
12
+ import type { HaRuleProps } from './ha-rule.ts';
13
+ import { csv } from './values.ts';
14
+
15
+ /**
16
+ * A `<node>[:<pri>]` list, spelled the way PVE gives it back.
17
+ *
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`
20
+ * turns the list into a HASH keyed by node name, and `encode_plugin_value` rebuilds it with
21
+ * `for my $node (sort keys %$value)` — sorted by NODE NAME, never by the token — emitting the
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
24
+ * re-spelled. Compare the raw strings and every plan reports an update, forever.
25
+ *
26
+ * ⚠️ SORTED BY NAME, NOT BY TOKEN, AND THE TWO GENUINELY DIVERGE. `csv` sorts whole tokens. Node
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
30
+ * this does too.
31
+ *
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
34
+ * is what a Map gives for free.
35
+ */
36
+ export const nodeList = (value: readonly string[] | string | undefined) => {
37
+ const ranked = new Map<string, number>();
38
+ for (const entry of typeof value === 'string' ? value.split(',') : (value ?? [])) {
39
+ const [node, priority] = entry.trim().split(':');
40
+ if (node === undefined || node === '') continue;
41
+ const rank = Number.parseInt(priority ?? '0', 10);
42
+ ranked.set(node, Number.isNaN(rank) ? 0 : rank);
43
+ }
44
+ return [...ranked.keys()]
45
+ .sort()
46
+ .map((node) => {
47
+ const rank = ranked.get(node) ?? 0;
48
+ return rank === 0 ? node : `${node}:${String(rank)}`;
49
+ })
50
+ .join(',');
51
+ };
52
+
53
+ /**
54
+ * PVE's own verdict on whether the rule can be satisfied, flattened to one reportable string.
55
+ *
56
+ * ⚠️ COMPUTED, NEVER DECLARED, AND NEVER COMPARED. `PVE::API2::HA::Rules` runs `check_feasibility`
57
+ * over the WHOLE rule set on every read and attaches whatever it finds to each rule as
58
+ * `errors => { <option> => "<message>, <message>" }`. It therefore changes when somebody else's
59
+ * rule changes, and none of it is a parameter any write accepts. It belongs in attributes so a
60
+ * plan can show that PVE considers this rule unsatisfiable, and nowhere near `matches`.
61
+ */
62
+ export const problems = (value: unknown) =>
63
+ typeof value === 'object' && value !== null
64
+ ? Object.entries(value)
65
+ .map(([option, message]) => `${option}: ${String(message)}`)
66
+ .sort()
67
+ .join('; ')
68
+ : '';
69
+
70
+ /**
71
+ * A comment as PVE will actually STORE it, which is not always the string it was handed.
72
+ *
73
+ * ⛔ PVE DECODES `%XX` OUT OF EVERY COMMENT ON THE WAY IN AND NEVER PUTS IT BACK. MEASURED:
74
+ * `PVE::SectionConfig::check_config` runs `decode_value` over each submitted field (line 1625 on
75
+ * this node), the base rule plugin's `decode_value` sends `comment` through
76
+ * `PVE::ParseUtils::decode_text` — `uri_unescape` then a utf8 decode — and `$get_api_ha_rule`
77
+ * hands the comment straight back WITHOUT re-encoding it (only `resources` and `nodes` are
78
+ * re-encoded there). So a declared `deploy%20window` is stored and returned as `deploy window`,
79
+ * and a `matches` comparing the declared string would report an update on every plan forever
80
+ * while every PUT wrote exactly what was already there.
81
+ *
82
+ * ★ SO THE COMPARISON MIRRORS THE DECODE RATHER THAN FIGHTING IT. Both sides then say what the
83
+ * cluster will really hold, which is the same move `nodeList` above makes for a re-sorted list.
84
+ * ⚠️ GROUPS OF `%XX` ARE DECODED TOGETHER so a multi-byte utf8 sequence survives; a lone `%` or a
85
+ * malformed sequence is left alone, as `uri_unescape` leaves it. The one place this differs from
86
+ * Perl is a sequence that is valid percent-encoding but invalid utf8: Perl substitutes U+FFFD,
87
+ * this leaves the escape. Nothing in a comment should be reaching for that.
88
+ */
89
+ export const commentText = (raw: string) =>
90
+ raw.replace(/(?:%[0-9a-fA-F]{2})+/g, (escaped) => {
91
+ try {
92
+ return decodeURIComponent(escaped);
93
+ } catch {
94
+ return escaped;
95
+ }
96
+ });
97
+
98
+ /**
99
+ * Every managed optional, per type: the value to send, or undefined for "clear it".
100
+ *
101
+ * ⛔ `disable=0` DOES NOT CLEAR A DISABLED RULE, AND THAT IS THE WORST TRAP IN THIS FAMILY.
102
+ * MEASURED in `/usr/share/perl5/PVE/API2/HA/Rules.pm`: both `create_rule` and `update_rule` run
103
+ * `delete $param->{disable} if !$param->{disable};` BEFORE the plugin sees the form. A PUT
104
+ * carrying `disable=0` is therefore a PUT carrying nothing — PVE answers 200, the rule stays
105
+ * disabled, `matches` reports an update on the next plan, and the loop never settles. The only
106
+ * way back on is `delete=disable`, which is why `disable` is in this table rather than in
107
+ * `required` below.
108
+ *
109
+ * ⛔ AND THE BRANCHES CANNOT BE ONE SHARED MAP. `PVE::SectionConfig::delete_from_config` dies
110
+ * `no such option '<k>'` for an option the plugin does not declare and `unable to delete
111
+ * required option '<k>'` for one it requires — so `delete=strict` against a resource-affinity
112
+ * rule is a hard failure, and `delete=nodes` or `delete=resources` would be one against either.
113
+ * Only `comment`, `disable` and (node-affinity only) `strict` are clearable at all.
114
+ *
115
+ * ⚠️ AN EMPTY `comment` IS A CLEARED ONE. PVE stores whatever string it is given, so writing `''`
116
+ * would leave the option present and empty rather than absent; `matches` reads both as `''`, but
117
+ * only the delete actually removes the line.
118
+ */
119
+ export const optional = (props: HaRuleProps): Record<string, string | undefined> => ({
120
+ comment: props.comment === undefined || props.comment === '' ? undefined : props.comment,
121
+ disable: props.disable === true ? '1' : undefined,
122
+ ...(props.type === 'node-affinity' ? { strict: props.strict === true ? '1' : undefined } : {}),
123
+ });
124
+
125
+ /**
126
+ * The fields sent on EVERY write, create and update alike.
127
+ *
128
+ * ⛔ `type` GOES OUT ON THE UPDATE TOO, AND LEAVING IT OFF BREAKS EVERY PUT. It reads like a
129
+ * create-only discriminant — the handler certainly treats it as one, taking the plugin from the
130
+ * STORED rule — but the PUT is still validated against a `oneOf` keyed on it. MEASURED in
131
+ * `PVE::JSONSchema::check_one_of` on this node: when the type property is absent the validator
132
+ * marks EVERY other key unknown and, unless the whole `oneOf` is itself optional, errors on the
133
+ * type property; its own comment says "Its type property is otherwise never optional", and
134
+ * `SectionConfig::updateSchema` builds that `oneOf` through `combine_schemas` without an
135
+ * `optional` flag. So an update that omitted it would fail parameter verification with a pile of
136
+ * "unexpected property" errors pointing at the fields it did send. It is safe to send because a
137
+ * rule of the other type is reported ABSENT by `attributes` and never reaches a PUT.
138
+ *
139
+ * ⚠️ `affinity` IS SENT UNCONDITIONALLY FOR BOTH TYPES. PVE requires it on a resource-affinity
140
+ * create and defaults it to `positive` on a node-affinity one; sending it either way means the
141
+ * read-back matches on the first plan after the create rather than relying on PVE materialising
142
+ * its own default into the file.
143
+ *
144
+ * ⚠️ THE LISTS GO OUT IN THEIR NORMALISED FORM, which is not required but is free: PVE re-sorts
145
+ * them anyway, so writing what it will hand back keeps the config file readable next to the
146
+ * declaration that produced it.
147
+ *
148
+ * ⚠️ NO `digest`. PVE accepts one on PUT as an optimistic lock, but the only digest this provider
149
+ * could send is the one from its own read moments earlier — and it is a digest of the WHOLE
150
+ * rules file, so any unrelated rule written in between would turn a correct update into a
151
+ * spurious failure. The factory's read-back guard is the check that stays.
152
+ */
153
+ const required = (props: HaRuleProps): Record<string, string> => ({
154
+ affinity: props.affinity,
155
+ resources: csv(props.resources),
156
+ type: props.type,
157
+ ...(props.type === 'node-affinity' ? { nodes: nodeList(props.nodes) } : {}),
158
+ });
159
+
160
+ export const body = (props: HaRuleProps) => {
161
+ const fields = required(props);
162
+ for (const [option, value] of Object.entries(optional(props))) {
163
+ if (value !== undefined) fields[option] = value;
164
+ }
165
+ return fields;
166
+ };
167
+
168
+ /**
169
+ * The options an update must explicitly remove.
170
+ *
171
+ * ⛔ A PUT THAT OMITS A FIELD DOES NOT CLEAR IT — `update_rule` merges the form into the stored
172
+ * rule (`$rule->{$_} = $opts->{$_} for keys $opts->%*`) — so a managed optional needs a matching
173
+ * `delete=` the moment its prop goes away. Both halves come off the one table above, which makes
174
+ * "managed but not clearable" impossible to write here.
175
+ * ⚠️ NEVER BOTH AT ONCE: `delete_from_config` dies `cannot set and delete property '<k>' at the
176
+ * same time!`. The table answers a value or `undefined`, never both, so the two lists are
177
+ * disjoint by construction.
178
+ * ⚠️ THE CREATE FORM CANNOT USE THIS. `POST /cluster/ha/rules` has no `delete` parameter at all
179
+ * (MEASURED from the cluster's own schema), and it needs none: an omitted optional on a brand
180
+ * new rule is simply absent.
181
+ */
182
+ export const clearList = (props: HaRuleProps) =>
183
+ Object.entries(optional(props))
184
+ .filter(([, value]) => value === undefined)
185
+ .map(([option]) => option);