@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,214 @@
1
+ /**
2
+ * `Proxmox.User` — a PVE account. The identity every other resource in this package borrows.
3
+ *
4
+ * ★ THIS IS THE ONE OBJECT THAT CAN CUT THE BRANCH IT SITS ON. The user the credential mount vends
5
+ * tokens for, and the group whose members are allowed to mint, are what `src/credentials.ts`
6
+ * ultimately asks OpenBao for. A cluster rebuilt without them cannot plan ANY resource here —
7
+ * including the plan that would recreate them, which needs a credential to run. So this resource
8
+ * is drift repair for an account that already exists (comment, email, enable, expire, groups),
9
+ * not a bootstrap: the first mint user is made on a node with `pveum user add`, by a human, once.
10
+ *
11
+ * ⛔ THERE IS NO `password` PROP, AND ADDING ONE WOULD BE A LEAK RATHER THAN A FEATURE. Alchemy
12
+ * writes resource state WITHOUT encryption — see the ⛔ in `credentials.ts` — so a password prop
13
+ * would sit in clear in whatever database the stack points `state` at, and in every backup of it.
14
+ * It is also unnecessary: `password` is accepted only by the CREATE call, changing one afterwards
15
+ * is `PUT /access/password` (a different endpoint, a different privilege), and an account in a
16
+ * realm such as `@pam`, `@ldap` or `@openid` has no PVE-side password at all — the realm holds
17
+ * it. Out of scope by design. `keys` (TFA) is omitted for the same reason.
18
+ *
19
+ * ⚠️ `groups` COMES BACK AS AN ARRAY AND GOES OUT AS A COMMA STRING, and the asymmetry is the whole
20
+ * trap. GET answers `["a","b"]`, POST/PUT want `a,b`, and PVE returns the list in ITS order, not
21
+ * the declared one. Compared naively, `matches` reads ['b','a'] against 'a,b' and every plan for
22
+ * the rest of time says "1 to update". Both sides are normalised to a sorted, deduplicated set
23
+ * below. Joining is a safe comparison precisely because a group id cannot contain a comma — the
24
+ * wire format is comma-separated, so PVE refuses one.
25
+ *
26
+ * ⚠️ `tokens` IS NESTED AND IS NOT SETTABLE THROUGH THIS ENDPOINT. The read hands back a map of the
27
+ * account's API tokens; `PUT /access/users/{userid}` has no parameter for them, because they are
28
+ * their own objects under `.../token/{tokenid}`. So they are reported as an attribute — a plan
29
+ * can then say what a delete would take with it — and kept OUT of `matches`, exactly like
30
+ * `pool.members`. Reported, never declared.
31
+ *
32
+ * ⚠️ RECONCILE NEEDS PRIVILEGES `LXCProvisioner` DOES NOT HOLD, so state them rather than meet a
33
+ * 403 in the middle of a deploy (pool.ts records how that went the last time):
34
+ * · `Realm.AllocateUser` on `/access/realm/<realm>` — create and delete.
35
+ * · `User.Modify` on `/access/groups`, and on `/access/groups/<group>` for EVERY group named in
36
+ * `groups` — create, update and delete. PVE checks the groups you are granting, not just the
37
+ * user, so a role wide enough to edit the account can still be refused for one group in the
38
+ * list.
39
+ * · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
40
+ * `Permissions.Modify` is NOT on this list: that governs ACL entries, which are a different PVE
41
+ * object and would be a different resource here.
42
+ *
43
+ * ⚠️ A MISSING USER IS A 500, NOT A 404 — "no such user ('x@pve')" — and the factory turns any
44
+ * failed read into "absent". A 403 therefore also reads as absent, and the honest error arrives
45
+ * one step later, from the create: "Permission check failed (/access/groups, User.Modify)". That
46
+ * string is the one to grep for when a plan insists on creating an account that plainly exists.
47
+ */
48
+ import { Resource } from 'alchemy';
49
+ import * as Provider from 'alchemy/Provider';
50
+ import * as Effect from 'effect/Effect';
51
+ import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
52
+ import { bool } from './values.ts';
53
+
54
+ export interface UserProps extends WithTarget {
55
+ /**
56
+ * ⛔ REALM-QUALIFIED, ALWAYS: `someone@pve`, `someone@pam`. The realm is part of PVE's primary
57
+ * key, and a bare name is refused with "value does not look like a valid user id".
58
+ *
59
+ * ⚠️ PVE HAS NO RENAME. Editing this prop makes the path point at a DIFFERENT account, which
60
+ * reads as absent and is then created — while the old one stays on the cluster with its tokens
61
+ * and its ACL entries. Renaming is a delete and a create, and should be declared as one.
62
+ */
63
+ userid: string;
64
+ /** Free text shown in the UI. */
65
+ comment?: string;
66
+ /** PVE accepts the empty string here; that is how an address is cleared. */
67
+ email?: string;
68
+ /** Default true, matching PVE's own `enable=1`. False disables login without deleting anything. */
69
+ enable?: boolean;
70
+ /** Seconds since the epoch. 0 — the default — means the account never expires. */
71
+ expire?: number;
72
+ /** Group memberships. Order and duplicates carry no meaning — see the ⚠️ in the header. */
73
+ groups?: string[];
74
+ firstname?: string;
75
+ lastname?: string;
76
+ }
77
+
78
+ export interface UserAttributes {
79
+ userid: string;
80
+ comment: string;
81
+ email: string;
82
+ enable: boolean;
83
+ expire: number;
84
+ /** Normalised: sorted, deduplicated, whatever shape the cluster used on the wire. */
85
+ groups: string[];
86
+ firstname: string;
87
+ lastname: string;
88
+ /**
89
+ * API token names this account owns. Reported so a plan can show what a delete would revoke;
90
+ * never compared, because this endpoint cannot set them.
91
+ */
92
+ tokens: string[];
93
+ }
94
+
95
+ export interface ProxmoxUser extends Resource<
96
+ 'Proxmox.User',
97
+ UserProps,
98
+ UserAttributes,
99
+ never,
100
+ PveRequirements
101
+ > {}
102
+
103
+ export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
104
+
105
+ const str = (value: unknown) => (typeof value === 'string' ? value : '');
106
+
107
+ /**
108
+ * ⚠️ MORE FORGIVING THAN `lxc.ts`'s `num`, ON PURPOSE. PVE's JSON is generated from a Perl schema
109
+ * and an integer field can arrive as `0` or as `"0"` depending on the release and the endpoint. A
110
+ * strict `typeof === 'number'` would silently fall back to 0 for the string form, so a declared
111
+ * `expire` would never equal the live one and the plan would report an update it cannot settle.
112
+ */
113
+ const int = (value: unknown) => {
114
+ const parsed = typeof value === 'number' ? value : Number(value ?? 0);
115
+ return Number.isFinite(parsed) ? parsed : 0;
116
+ };
117
+
118
+ /**
119
+ * An array (what the read returns), a comma string (what older releases and the write side use), or
120
+ * nothing — in; a sorted, deduplicated list out. Both sides of `matches` go through this, which is
121
+ * the only reason the diff ever settles.
122
+ */
123
+ const groupSet = (value: unknown): string[] => {
124
+ const raw: unknown[] = Array.isArray(value)
125
+ ? value
126
+ : typeof value === 'string'
127
+ ? value.split(',')
128
+ : [];
129
+ const names = raw.map((group) => str(group).trim()).filter((group) => group !== '');
130
+ return [...new Set(names)].sort();
131
+ };
132
+
133
+ /**
134
+ * ⚠️ THE SHAPE DEPENDS ON WHICH ENDPOINT YOU ASKED. Reading ONE user gives an object keyed by token
135
+ * name; the collection read gives an array of records. This provider only ever reads the single
136
+ * user path, so the map is what arrives — anything else is reported as "no tokens" rather than
137
+ * guessed at, because a wrong guess here would show up as a phantom revocation in a plan.
138
+ */
139
+ const tokenNames = (value: unknown): string[] =>
140
+ typeof value === 'object' && value !== null && !Array.isArray(value)
141
+ ? Object.keys(value).sort()
142
+ : [];
143
+
144
+ /** Everything mutable, in the form PVE wants. Create adds `userid`; update sends exactly this. */
145
+ const shape = (props: UserProps) => ({
146
+ comment: props.comment ?? '',
147
+ email: props.email ?? '',
148
+ enable: props.enable === false ? '0' : '1',
149
+ expire: String(props.expire ?? 0),
150
+ firstname: props.firstname ?? '',
151
+ /**
152
+ * ⚠️ ALWAYS SENT, EVEN EMPTY. Omitting `groups` on a PUT leaves the existing memberships in
153
+ * place, so a group removed from the declaration would never actually be removed — and
154
+ * `matches` would keep reporting an update that the update cannot fix. Same for `email` and
155
+ * `comment`: the empty string is how PVE is told to clear a field.
156
+ * ⚠️ AND `append` IS DELIBERATELY NOT SET. Its default (0) REPLACES the list; setting it to 1
157
+ * would turn every reconcile into an add-only merge, which is the same non-settling diff.
158
+ */
159
+ groups: groupSet(props.groups).join(','),
160
+ lastname: props.lastname ?? '',
161
+ });
162
+
163
+ const handlers = pveHandlers<UserProps, UserAttributes>({
164
+ attributes: (live, props) => ({
165
+ comment: str(live['comment']),
166
+ email: str(live['email']),
167
+ /**
168
+ * ⚠️ ABSENT MEANS ENABLED. PVE's schema defaults `enable` to 1 and does not always write the
169
+ * key back for an enabled account. Reading absence as `false` would report an update on every
170
+ * plan and then DISABLE the account on the deploy that "fixed" it.
171
+ */
172
+ enable: bool(live['enable'], true),
173
+ expire: int(live['expire']),
174
+ firstname: str(live['firstname']),
175
+ groups: groupSet(live['groups']),
176
+ lastname: str(live['lastname']),
177
+ tokens: tokenNames(live['tokens']),
178
+ /** ⚠️ FROM PROPS: `GET /access/users/{userid}` does not echo the userid back in its data. */
179
+ userid: props.userid,
180
+ }),
181
+ collection: () => 'access/users',
182
+ createForm: (props) => ({ ...shape(props), userid: props.userid }),
183
+ /**
184
+ * ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
185
+ * what a PUT can put back; a field that PVE reports but will not accept is drift nobody can
186
+ * fix, and diffing it would report an update on every plan forever.
187
+ */
188
+ matches: (attributes, props) =>
189
+ attributes.comment === (props.comment ?? '') &&
190
+ attributes.email === (props.email ?? '') &&
191
+ attributes.enable === (props.enable !== false) &&
192
+ attributes.expire === (props.expire ?? 0) &&
193
+ attributes.firstname === (props.firstname ?? '') &&
194
+ attributes.lastname === (props.lastname ?? '') &&
195
+ attributes.groups.join(',') === groupSet(props.groups).join(','),
196
+ path: (props) => `access/users/${props.userid}`,
197
+ updateForm: shape,
198
+ });
199
+
200
+ /**
201
+ * ⛔ EMPTY, AND MORE POINTEDLY SO HERE THAN ANYWHERE ELSE. `GET /access/users` answers with
202
+ * every account on the cluster: `root@pam`, every human who logs in, every service
203
+ * identity someone made years ago. Handing that list to Alchemy would invite it to adopt
204
+ * — and therefore one day DELETE — people. Adoption is an explicit act.
205
+
206
+ *
207
+ * ⛔ DELETING A USER TAKES ITS API TOKENS AND ITS ACL ENTRIES WITH IT, in one call and
208
+ * without a confirmation. For the identity a credential mount vends from, that revokes
209
+ * every outstanding lease at once and every plan in this package stops working — this one
210
+ * included. PVE refuses only for `root@pam`; for everyone else it simply obeys.
211
+
212
+ */
213
+ export const ProxmoxUserProvider = () =>
214
+ Provider.effect(ProxmoxUser, Effect.succeed(ProxmoxUser.Provider.of(handlers)));
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The wire coercions, tested where they are load bearing.
3
+ *
4
+ * ★ THESE ARE NOT SMOKE TESTS. Every case here is a trap `values.ts` documents in prose, and prose
5
+ * does not fail a build. Each one is written so that REVERTING the behaviour it describes turns
6
+ * this file red — that is the only property that makes a test worth its lines.
7
+ *
8
+ * ⚠️ WHY THIS FILE EXISTS AT ALL, HONESTLY: `@homeflare/proxmox` declared a `test` script and
9
+ * shipped no tests, so `bun test` exited 1 on "No tests found" and the pre-push gate refused
10
+ * every push. The fix could have been a quieter test script. It is this instead, because the
11
+ * package had just grown thirteen resources standing on one shared module with nothing checking
12
+ * it.
13
+ */
14
+ import assert from 'node:assert/strict';
15
+ import { describe, it } from 'node:test';
16
+ import {
17
+ bool,
18
+ canonicalToken,
19
+ csv,
20
+ flag,
21
+ guestList,
22
+ int,
23
+ num,
24
+ propertyString,
25
+ text,
26
+ withClears,
27
+ } from './values.ts';
28
+
29
+ describe('bool — reading a PVE boolean off the wire', () => {
30
+ it('accepts all three spellings PVE actually sends', () => {
31
+ for (const value of [1, true, '1']) assert.equal(bool(value), true, `${String(value)} is true`);
32
+ for (const value of [0, false, '0'])
33
+ assert.equal(bool(value), false, `${String(value)} is false`);
34
+ });
35
+
36
+ /**
37
+ * ⛔ THE REGRESSION THIS FILE WAS WRITTEN FOR. An unset SectionConfig flag comes back `''`. Read
38
+ * as `false`, `backup-job.enabled` and `user.enable` — the two fields in this package that
39
+ * default ON — come back disabled, `matches` reports an update nobody asked for, and the
40
+ * deploy WRITES `enabled=0` onto a live backup job. Flip `bool` back to treating `''` as false
41
+ * and this case fails.
42
+ */
43
+ it('treats the empty string as absent, not as false', () => {
44
+ assert.equal(bool('', true), true, "'' must take the caller's fallback");
45
+ assert.equal(bool('', false), false);
46
+ });
47
+
48
+ it('treats null and undefined as absent', () => {
49
+ assert.equal(bool(undefined, true), true);
50
+ assert.equal(bool(null, true), true);
51
+ });
52
+
53
+ /** ⚠️ Documented limit: only `1` is true, so a multi-state flag cannot be read here by accident. */
54
+ it('does not read an arbitrary non-zero number as true', () => {
55
+ assert.equal(bool(2), false);
56
+ });
57
+ });
58
+
59
+ describe('flag — writing a boolean into a PVE form', () => {
60
+ it('is the inverse direction of bool, and says so in its types', () => {
61
+ assert.equal(flag(true), '1');
62
+ assert.equal(flag(false), '0');
63
+ });
64
+
65
+ /** ⛔ An undeclared field must not be sent: '0' would write PVE's default over what is live. */
66
+ it('answers undefined for an undeclared field rather than 0', () => {
67
+ assert.equal(flag(undefined), undefined);
68
+ });
69
+ });
70
+
71
+ describe('int — PVE may spell the same field as a number or a string', () => {
72
+ it('reads 8086 and "8086" as the same value', () => {
73
+ assert.equal(int(8086, -1), int('8086', -1));
74
+ });
75
+
76
+ it('falls back rather than yielding NaN', () => {
77
+ assert.equal(int('not-a-port', -1), -1);
78
+ assert.equal(int(undefined, -1), -1);
79
+ });
80
+
81
+ /** ⚠️ 0 is a legitimate value, so it must survive — this is why -1 is the absent-marker. */
82
+ it('keeps a real zero', () => {
83
+ assert.equal(int(0, -1), 0);
84
+ });
85
+ });
86
+
87
+ describe('num and text', () => {
88
+ it('num takes the fallback for null, which PVE sends for an unset field', () => {
89
+ assert.equal(num(null, 7), 7);
90
+ assert.equal(num(3, 7), 3);
91
+ });
92
+
93
+ it('text defaults to the empty string', () => {
94
+ assert.equal(text(undefined), '');
95
+ assert.equal(text(42), '');
96
+ assert.equal(text(42, 'fallback'), 'fallback', 'a non-string takes the fallback');
97
+ });
98
+ });
99
+
100
+ describe('csv — a set PVE does not give back in the order it was handed', () => {
101
+ it('is order insensitive, so two orderings compare equal', () => {
102
+ assert.equal(csv(['n3', 'n2']), csv('n2,n3'));
103
+ });
104
+
105
+ it('trims and drops empties, so a trailing comma is not a phantom member', () => {
106
+ assert.equal(csv('n2, n3,'), 'n2,n3');
107
+ });
108
+ });
109
+
110
+ describe('guestList — order is not meaning, and the sort must be numeric', () => {
111
+ /** ⛔ Sorted as TEXT, '101' precedes '99'. That is the whole reason this is not `csv`. */
112
+ it('sorts numerically, not lexically', () => {
113
+ assert.equal(guestList('99,101'), '99,101');
114
+ assert.equal(guestList([101, 99]), '99,101');
115
+ });
116
+
117
+ it('reads the array and the string form identically', () => {
118
+ assert.equal(guestList([101, 99]), guestList('101, 99'));
119
+ });
120
+ });
121
+
122
+ describe('canonicalToken', () => {
123
+ it('folds the words PVE accepts onto the digits it returns', () => {
124
+ for (const yes of ['yes', 'true', 'on', '1']) assert.equal(canonicalToken(yes), '1');
125
+ for (const no of ['no', 'false', 'off', '0']) assert.equal(canonicalToken(no), '0');
126
+ });
127
+ });
128
+
129
+ describe('propertyString — one value in two shapes', () => {
130
+ /**
131
+ * ⛔ THE MEASURED FOREVER-DIFF. `fleecing` is WRITTEN as `enabled=0` and READ BACK as the object
132
+ * {"enabled":0}. If these two do not flatten to the same string, every plan reports an update
133
+ * on a job nobody touched.
134
+ */
135
+ it('flattens the written string and the read-back object to the same value', () => {
136
+ assert.equal(
137
+ propertyString('enabled=0,storage=local'),
138
+ propertyString({ enabled: 0, storage: 'local' }),
139
+ );
140
+ });
141
+
142
+ it('is order insensitive', () => {
143
+ assert.equal(
144
+ propertyString('storage=local,enabled=1'),
145
+ propertyString('enabled=1,storage=local'),
146
+ );
147
+ });
148
+
149
+ /** ⚠️ `fleecing=1` is shorthand for `fleecing=enabled=1`; unexpanded they never compare equal. */
150
+ it('expands a bare token through defaultKey', () => {
151
+ assert.equal(propertyString('1', 'enabled'), propertyString({ enabled: 1 }));
152
+ });
153
+
154
+ it('passes a bare token through unchanged when there is no defaultKey', () => {
155
+ assert.notEqual(propertyString('1'), propertyString({ enabled: 1 }));
156
+ });
157
+
158
+ it('is empty for a value that is neither a string nor an object', () => {
159
+ assert.equal(propertyString(undefined), '');
160
+ });
161
+ });
162
+
163
+ describe('withClears — PVE refuses a key that is set and deleted at once', () => {
164
+ /**
165
+ * ⛔ THE FAILURE THIS GUARDS. PVE::SectionConfig::delete_from_config line 1860 dies with
166
+ * "cannot set and delete property '$k' at the same time!" when the key is `defined` in the
167
+ * body — and `'0'` is defined. Six families built this form by hand before it was extracted.
168
+ */
169
+ it('drops a cleared key from the body rather than sending both', () => {
170
+ const form = withClears({ comment: 'x', strict: '0' }, ['strict']);
171
+ assert.equal(form['strict'], undefined, 'strict must not be SET while it is being deleted');
172
+ assert.equal(form['delete'], 'strict');
173
+ assert.equal(form['comment'], 'x');
174
+ });
175
+
176
+ it("treats the empty string as set, because PVE's test is `defined` and not truth", () => {
177
+ const form = withClears({ comment: '' }, ['comment']);
178
+ assert.equal(form['comment'], undefined);
179
+ assert.equal(form['delete'], 'comment');
180
+ });
181
+
182
+ /** ⚠️ No clears means no `delete` key at all — an empty one would be a parameter PVE rejects. */
183
+ it('omits delete entirely when nothing is cleared', () => {
184
+ const form = withClears({ comment: 'x' }, []);
185
+ assert.equal(form['delete'], undefined);
186
+ assert.deepEqual(form, { comment: 'x' });
187
+ });
188
+
189
+ /** ⚠️ An undefined value is dropped, or URLSearchParams would send the text "undefined". */
190
+ it('drops undefined values instead of stringifying them', () => {
191
+ assert.deepEqual(withClears({ a: undefined, b: 'y' }, []), { b: 'y' });
192
+ });
193
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * How PVE spells a value on the wire, and how to get it back to something comparable.
3
+ *
4
+ * ⛔ EVERY FUNCTION HERE WAS FOUND IN TWO OR MORE RESOURCE FILES, AND THE COPIES HAD DRIFTED.
5
+ * `num` was byte-identical in three files, so merging it is free. The boolean was NOT: four
6
+ * files declared `const flag` and meant four different things by it, including one that ran the
7
+ * other way round. That is the whole reason this module exists — not to save lines, but because
8
+ * a name that means four things is a bug waiting for the person who assumes it means the first
9
+ * one they read.
10
+ *
11
+ * ★ THE TWO DIRECTIONS ARE NAMED APART, DELIBERATELY. `bool` reads the wire and answers a
12
+ * TypeScript boolean; `flag` writes a form field and answers PVE's `'1'`/`'0'`. `storage.ts`
13
+ * held both under one name — `flag` there was the write direction while `flag` in three other
14
+ * files was the read direction — which typechecks in both files and is exactly the confusion
15
+ * that survives review.
16
+ *
17
+ * ⚠️ NOTHING HERE IS RESOURCE-SPECIFIC. A coercion that only one PVE object needs belongs in that
18
+ * object's file, where its reasoning sits next to the field it serves. `role.ts` keeps its own
19
+ * `canonical` for the same reason: that one sorts a privilege SET, which is a different idea
20
+ * from `canonicalToken` below however similar the names read.
21
+ */
22
+
23
+ /** ⚠️ `typeof value === 'number'` and not `Number(value)`: PVE sends `null` for an unset field. */
24
+ export const num = (value: unknown, fallback: number) =>
25
+ typeof value === 'number' ? value : fallback;
26
+
27
+ export const text = (value: unknown, fallback = '') =>
28
+ typeof value === 'string' ? value : fallback;
29
+
30
+ /**
31
+ * A PVE boolean off the wire.
32
+ *
33
+ * ⛔ THE FOUR SPELLINGS THIS REPLACES, AND WHY THIS ONE IS A SUPERSET OF ALL OF THEM. Measured in
34
+ * the files they came from: `backup-job` accepted `1 | true | '1'` with a caller's fallback;
35
+ * `sdn-vnet` the same three with no fallback; `storage` accepted only `1 | true`; `user` ran the
36
+ * value through `Number()` and asked `!== 0`. For the values PVE actually returns on these
37
+ * endpoints — `0` and `1` — all four agree, so accepting the union changes no live answer.
38
+ *
39
+ * ⚠️ THE ONE REAL DIFFERENCE IS `2`. `user.ts` would have read any non-zero number as true; this
40
+ * reads only `1`. No PVE field in this package is documented to return anything but 0 or 1, and
41
+ * a field that did would be a flag with more than two states — which is not a boolean, and
42
+ * should be read with `num` and named for what it is rather than quietly coerced here.
43
+ *
44
+ * ⚠️ `null`, `undefined` AND `''` ALL TAKE THE FALLBACK. An absent key means PVE's documented
45
+ * default for that field, which is not always `false` — so the default is the CALLER's to state,
46
+ * and `false` is only the fallback's own default for fields that really do default off.
47
+ *
48
+ * ⛔ THE EMPTY STRING IS ABSENT, NOT FALSE, AND GETTING THAT WRONG WRITES THE WRONG VALUE. A
49
+ * metric server is a SectionConfig section and can hand back `''` for an unset flag. Read as
50
+ * `false`, the two fields in this package that default ON — `backup-job.enabled` and
51
+ * `user.enable` — would come back disabled, `matches` would report an update nobody asked for,
52
+ * and the deploy would then WRITE `enabled=0` onto a live job. An empty string is not a
53
+ * boolean; it is the absence of one.
54
+ */
55
+ export const bool = (value: unknown, fallback = false) =>
56
+ value === undefined || value === null || value === ''
57
+ ? fallback
58
+ : value === 1 || value === true || value === '1';
59
+
60
+ /**
61
+ * A boolean on its way INTO a PVE form.
62
+ *
63
+ * ⚠️ `undefined` IN, `undefined` OUT — an undeclared field must not be sent at all. Sending `'0'`
64
+ * for a prop the caller never set would write PVE's default over whatever is live, turning an
65
+ * omission into an edit.
66
+ */
67
+ export const flag = (value?: boolean) => (value === undefined ? undefined : value ? '1' : '0');
68
+
69
+ /**
70
+ * A comma list PVE does not promise to give back in the order it was handed.
71
+ *
72
+ * ⚠️ `nodes` IS A SET AND PVE DOES NOT PRESERVE ITS ORDER. The zone plugin decodes the list into a
73
+ * hash and re-encodes it by joining that hash's keys, so the string you get back is not the one
74
+ * you sent. Sorting both sides is what stops a plan reporting an update because two names came
75
+ * back the other way round. `peers` gets the same treatment: it is a mesh, not a queue.
76
+ */
77
+ export const csv = (value: readonly string[] | string | undefined) =>
78
+ (typeof value === 'string' ? value.split(',') : (value ?? []))
79
+ .map((part) => part.trim())
80
+ .filter((part) => part !== '')
81
+ .sort()
82
+ .join(',');
83
+
84
+ /**
85
+ * A comma list of guest IDs, in numeric order.
86
+ *
87
+ * ⚠️ ORDER IS NOT MEANING IN A GUEST LIST, SO IT MUST NOT BE A DIFF. PVE stores `vmid` as the
88
+ * comma-separated string it was handed and gives it back the same way, so a declaration listing
89
+ * the same guests in another order would otherwise be an update that rewrites the job to say
90
+ * exactly what it already said. Both sides are sorted numerically before they meet.
91
+ *
92
+ * ⚠️ NUMERIC, NOT LEXICAL — this is why it is not `csv`. Sorted as text, `101` precedes `99`.
93
+ */
94
+ export const guestList = (value: unknown): string => {
95
+ const parts =
96
+ typeof value === 'string'
97
+ ? value.split(',')
98
+ : Array.isArray(value)
99
+ ? value.map((entry: unknown) => String(entry))
100
+ : [];
101
+ return parts
102
+ .map((part) => Number(part.trim()))
103
+ .filter((id) => Number.isFinite(id))
104
+ .sort((left, right) => left - right)
105
+ .map((id) => String(id))
106
+ .join(',');
107
+ };
108
+
109
+ /** `yes`/`true`/`on` and their opposites are the same value as 1/0 to PVE; make them one string. */
110
+ export const canonicalToken = (raw: string) => {
111
+ const lowered = raw.trim().toLowerCase();
112
+ if (['1', 'yes', 'true', 'on'].includes(lowered)) return '1';
113
+ if (['0', 'no', 'false', 'off'].includes(lowered)) return '0';
114
+ return lowered;
115
+ };
116
+
117
+ /**
118
+ * A PVE property string, flattened to one comparable form.
119
+ *
120
+ * ⚠️ A PROPERTY STRING AND ITS PARSED FORM ARE ONE VALUE IN TWO SHAPES, AND PVE HANDS BACK
121
+ * WHICHEVER IT LIKES. MEASURED: `fleecing` is WRITTEN as the property string `fleecing=enabled=0`
122
+ * and READ BACK as the nested object {"enabled":0}. Comparing raw values would report an update
123
+ * on every plan for a job nobody touched — the same forever-diff a create-only field causes. So
124
+ * both sides are flattened here, sorted, and only then compared. `prune-backups` goes through
125
+ * the same funnel: same kind of value, the normalisation is free, and guessing which shape it
126
+ * comes back in is exactly the guess that costs a permanent diff.
127
+ *
128
+ * ⚠️ `defaultKey` EXISTS BECAUSE `fleecing=1` IS SHORTHAND FOR `fleecing=enabled=1`. PVE's format
129
+ * for it is written `[enabled=]<1|0>`, so a bare token is legal on write and comes back expanded
130
+ * on read; unexpanded, the short and long spellings never compare equal. `prune-backups` has no
131
+ * default key, so a bare token there is passed through as-is rather than invented into one.
132
+ */
133
+ export const propertyString = (value: unknown, defaultKey?: string): string => {
134
+ const pairs: [string, string][] =
135
+ typeof value === 'string'
136
+ ? value.split(',').map((part) => {
137
+ const [key, ...rest] = part.split('=');
138
+ return rest.length === 0 && defaultKey !== undefined
139
+ ? [defaultKey, key ?? '']
140
+ : [key ?? '', rest.join('=')];
141
+ })
142
+ : typeof value === 'object' && value !== null
143
+ ? Object.entries(value).map(([key, entry]) => [key, String(entry)])
144
+ : [];
145
+ return pairs
146
+ .map(([key, entry]) => [key.trim(), canonicalToken(entry)] as const)
147
+ .filter(([key]) => key !== '')
148
+ .map(([key, entry]) => `${key}=${entry}`)
149
+ .sort()
150
+ .join(',');
151
+ };
152
+
153
+ /**
154
+ * An integer PVE may have spelled as a string.
155
+ *
156
+ * ⚠️ PVE MAY HAND BACK EITHER `8086` OR `"8086"`. These objects are SectionConfig sections, and the
157
+ * integer check VALIDATES the parsed string rather than converting it, so whether a number
158
+ * arrives as a number is a property of the version you are talking to. `'8086' === 8086` is
159
+ * false, and that is an update consisting of nothing, reported on every plan forever.
160
+ */
161
+ export const int = (value: unknown, fallback: number) => {
162
+ if (typeof value === 'number') return value;
163
+ const parsed = Number.parseInt(text(value), 10);
164
+ return Number.isNaN(parsed) ? fallback : parsed;
165
+ };
166
+
167
+ /**
168
+ * A PVE update form with its clear-list attached, and the two guaranteed not to overlap.
169
+ *
170
+ * ⛔ PVE DIES IF A KEY IS SET AND DELETED IN ONE CALL, AND SIX FAMILIES HERE BUILT THAT FORM BY
171
+ * HAND. Measured in the shipped Perl on n2 — `PVE::SectionConfig::delete_from_config`, line 1860:
172
+ *
173
+ * die "cannot set and delete property '$k' at the same time!\n"
174
+ * if defined($new_options->{$k});
175
+ *
176
+ * `defined` is the test, not truth, so `strict=0` or `comment=''` in the body counts as SET. The
177
+ * concrete failure: a live node-affinity rule with `strict 1` and a declaration carrying
178
+ * `strict: false`. `matches` reports an update; the clear-list has to name `strict`, because a
179
+ * PUT of `strict=0` merges a falsy value the plugin still stores; and if the body also emits
180
+ * `strict: '0'` the PUT is refused. The update never lands, the next plan reports the same
181
+ * update, and every deploy 500s — forever.
182
+ *
183
+ * ★ SO DISJOINTNESS IS ENFORCED HERE RATHER THAN REASONED ABOUT SIX TIMES. A cleared key is
184
+ * dropped from the body: CLEAR WINS. That is the right way round because a clear-list entry is
185
+ * computed from "this prop is absent, and absent means remove it", which is a deliberate
186
+ * statement, while the body's value for the same key is the coercion of that same absence —
187
+ * `flag(false)` is `'0'`, and `'0'` is exactly what must not be sent.
188
+ *
189
+ * ⚠️ UNDEFINED VALUES ARE DROPPED TOO, so a form builder may emit `undefined` for a field it does
190
+ * not set without the caller filtering first. `new URLSearchParams` would otherwise stringify it
191
+ * to the literal text "undefined".
192
+ */
193
+ export const withClears = (
194
+ fields: Record<string, string | undefined>,
195
+ clear: readonly string[],
196
+ ): Record<string, string> => {
197
+ const cleared = new Set(clear);
198
+ const kept: Record<string, string> = {};
199
+ for (const [key, value] of Object.entries(fields)) {
200
+ if (value !== undefined && !cleared.has(key)) kept[key] = value;
201
+ }
202
+ return cleared.size === 0 ? kept : { ...kept, delete: [...cleared].join(',') };
203
+ };