@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,212 @@
1
+ /**
2
+ * `Proxmox.CephPool` — a Ceph RADOS pool, declared. The richest Ceph family and the one a stack is
3
+ * most likely to reach for, because every RBD disk in the cluster lives inside one.
4
+ *
5
+ * ⛔ IT DOES NOT USE `pveHandlers`, AND THE REASON IS MEASURED RATHER THAN STYLISTIC: THE READ AND
6
+ * THE WRITES SIT AT DIFFERENT PATHS. `GET /nodes/{node}/ceph/pool/{name}` is `poolindex` and
7
+ * answers `[{"name":"status"}]` — an INDEX OF CHILDREN, not the pool. The pool is one level
8
+ * further down, at `.../status` (`getpool`), which accepts no PUT and no DELETE. A `PveSpec`
9
+ * carries ONE `path` for read, update and delete, so this family cannot be spelled in it. `path`
10
+ * below is therefore the READ path and nothing else consumes it: `ops.reconcile` and
11
+ * `ops.destroy` go unused and the two write handlers name their own path. acl.ts is the
12
+ * precedent for hand-writing the five handlers over `pveOperations`.
13
+ *
14
+ * ⛔ A CREATE FIRED AT A POOL THAT ALREADY EXISTS IS NOT A NO-OP, IT IS A PG MERGE — the second ⛔
15
+ * in ceph-pool-form.ts has the evidence, and `confirmAbsent` below is the guard that stops it.
16
+ *
17
+ * ⛔ EVERY WRITE HERE IS A FORKED WORKER. createpool, setpool and destroypool each return a UPID
18
+ * string, so HTTP 200 means "the task started" — an immediate read-back can see a pool that does
19
+ * not exist yet, or values that have not landed. `settle` waits for the cluster to agree.
20
+ *
21
+ * ⛔ THE FOUR POOLS ON TB4 INCLUDE CEPH'S OWN, AND NONE OF THEM MAY BE ADOPTED. Measured
22
+ * 2026-09-13: `.mgr` (application `mgr`, created and owned by the manager daemons),
23
+ * `cephfs-tb4_data` and `cephfs-tb4_metadata` (the two halves of the `cephfs-tb4` filesystem —
24
+ * destroying either destroys the filesystem), and `cephtb4`, the rbd pool the guests sit on.
25
+ * `list` answers empty like every resource in this package, so adoption stays an explicit act;
26
+ * the `applications` attribute is reported so that a plan SHOWS which of those a declaration hit.
27
+ *
28
+ * ⚠️ DELETING A POOL DESTROYS EVERY OBJECT IN IT AND CEPH DOES NOT ASK TWICE. Neither `force` nor
29
+ * `remove_storages` is ever sent, so PVE's refusal while a storage on the pool still lists RBD
30
+ * images is left standing. That refusal does NOT cover an RBD user outside storage.cfg.
31
+ *
32
+ * ⚠️ PRIVILEGES, AND THE WRITE LANE IS A BIG ASK. Read is `Sys.Audit` OR `Datastore.Audit` on `/`
33
+ * (PVEAuditor holds both, so the mount's `read` role covers read, diff and the guard). Create,
34
+ * update and delete each check `Sys.Modify` on `/` — there is no `/ceph` object to scope to, so
35
+ * granting it also buys datacenter options and every other cluster-wide write. Widen
36
+ * deliberately, the way `Pool.Allocate` was widened for `Proxmox.Pool`.
37
+ */
38
+ import { Resource } from 'alchemy';
39
+ import * as Provider from 'alchemy/Provider';
40
+ import * as Effect from 'effect/Effect';
41
+ import {
42
+ type CephPoolAttributes,
43
+ type CephPoolProps,
44
+ UNSET,
45
+ applications,
46
+ collection,
47
+ createBody,
48
+ hint,
49
+ object,
50
+ same,
51
+ updateBody,
52
+ } from './ceph-pool-form.ts';
53
+ import { confirmAbsent, settle } from './ceph-pool-settle.ts';
54
+ import { pve } from './client.ts';
55
+ import { type PveRequirements, type PveSpec, pveOperations } from './resource.ts';
56
+ import { bool, int, num, text } from './values.ts';
57
+
58
+ export type { CephPoolAttributes, CephPoolProps };
59
+
60
+ export interface ProxmoxCephPool extends Resource<
61
+ 'Proxmox.CephPool',
62
+ CephPoolProps,
63
+ CephPoolAttributes,
64
+ never,
65
+ PveRequirements
66
+ > {}
67
+
68
+ /** ★ `retain` by default — a pool holding objects cannot be rebuilt. See the ★ in resource.ts. */
69
+ export const ProxmoxCephPool = Resource<ProxmoxCephPool>('Proxmox.CephPool', {
70
+ defaultRemovalPolicy: 'retain',
71
+ });
72
+
73
+ const spec: PveSpec<CephPoolProps, CephPoolAttributes> = {
74
+ /**
75
+ * ⛔ `name` IS THE PRESENCE TEST, AND IT IS ALSO HOW THE INDEX TRAP FAILS SAFE. Pointed at
76
+ * `.../pool/{name}` rather than `.../status`, the factory would hand this function an ARRAY:
77
+ * every field would read absent and the plan would report an update no write can satisfy.
78
+ * `getpool` always returns `name` for a real pool, so that shape answers "not there" instead
79
+ * — and "not there" cannot reach a create unless `confirmAbsent` agrees.
80
+ */
81
+ attributes: (live, props) => {
82
+ if (typeof live['name'] !== 'string') return undefined;
83
+ return {
84
+ applications: applications(live['application_list']),
85
+ crush_rule: text(live['crush_rule']),
86
+ id: int(live['id'], UNSET),
87
+ min_size: int(live['min_size'], UNSET),
88
+ name: props.name,
89
+ node: props.node,
90
+ nodelete: bool(live['nodelete']),
91
+ nopgchange: bool(live['nopgchange']),
92
+ nosizechange: bool(live['nosizechange']),
93
+ pg_autoscale_mode: text(live['pg_autoscale_mode']),
94
+ pg_num: int(live['pg_num'], UNSET),
95
+ pg_num_min: int(live['pg_num_min'], UNSET),
96
+ size: int(live['size'], UNSET),
97
+ target_size: int(live['target_size'], UNSET),
98
+ target_size_ratio: num(live['target_size_ratio'], UNSET),
99
+ };
100
+ },
101
+ collection,
102
+ createForm: createBody,
103
+ /**
104
+ * ⛔ DECLARING WHAT IS LIVE MUST PLAN noop, AND EVERY OMISSION HERE IS WHY. Out, each with its
105
+ * reason on the prop it belongs to: `pg_num` (the autoscaler rewrites it), `application` (the
106
+ * write only ever ADDS to a list), `target_size_ratio` (float equality), `id`, `applications`
107
+ * and the three `no*` flags (PVE returns them and accepts none of them on write), `node` and
108
+ * `name` (the address the read was made at — true by construction, never a diff). In, and each
109
+ * measured to round-trip unchanged against TB4's four pools on 2026-09-13: size 3, min_size 2,
110
+ * pg_autoscale_mode `on`, crush_rule `replicated_rule`, pg_num_min where it is set (16 on
111
+ * cephfs-tb4_metadata, absent on cephtb4), target_size in bytes — and each of the last two
112
+ * through `hint`, which drops a declared zero for the reason given on it.
113
+ */
114
+ matches: (attributes, props) =>
115
+ same(props.size, attributes.size, attributes.nosizechange) &&
116
+ same(props.min_size, attributes.min_size, attributes.nosizechange) &&
117
+ same(props.pg_autoscale_mode, attributes.pg_autoscale_mode) &&
118
+ same(hint(props.pg_num_min), attributes.pg_num_min, attributes.nopgchange) &&
119
+ same(props.crush_rule, attributes.crush_rule, attributes.crush_rule === '') &&
120
+ same(hint(props.target_size), attributes.target_size),
121
+ /**
122
+ * ⛔ THE READ PATH, AND ONLY THE READ PATH.
123
+ * ⚠️ `?verbose=1` IS WHAT MAKES `applications` VISIBLE AT ALL — without it PVE omits the tags
124
+ * entirely (MEASURED across all four pools; there is no `application` key either way, only
125
+ * `application_list`, and only when verbose). The cost is that verbose adds two unguarded mon
126
+ * commands, `df` and `osd pool application get`, so a degraded mgr can fail the read. That is
127
+ * safe here rather than merely unlucky: a failed read is "absent", and "absent" cannot reach a
128
+ * create unless `confirmAbsent` agrees.
129
+ */
130
+ path: (props) => `${object(props)}/status?verbose=1`,
131
+ updateForm: updateBody,
132
+ };
133
+
134
+ const ops = pveOperations(spec);
135
+
136
+ export const ProxmoxCephPoolProvider = () =>
137
+ Provider.effect(
138
+ ProxmoxCephPool,
139
+ Effect.succeed(
140
+ ProxmoxCephPool.Provider.of({
141
+ /** ⛔ Empty, and here it is what keeps `.mgr` and the two cephfs pools out — see the header. */
142
+ list: () => Effect.succeed([]),
143
+ read: ({ olds }) => ops.read(olds),
144
+ diff: ({ news, output }) => ops.diff(news, output),
145
+ /**
146
+ * ⚠️ NOT `ops.reconcile`: it POSTs the moment a read comes back empty, and reads back once,
147
+ * immediately. Both are wrong for a family whose writes are forked workers and whose
148
+ * create is destructive against a pool that is already there.
149
+ */
150
+ reconcile: Effect.fn(function* ({ news }) {
151
+ const live = yield* ops.read(news);
152
+ let upid: string | undefined;
153
+ if (live === undefined) {
154
+ yield* confirmAbsent(news, spec.collection(news), object(news));
155
+ upid = yield* pve<string>(
156
+ news.target,
157
+ 'provision',
158
+ 'POST',
159
+ collection(news),
160
+ createBody(news),
161
+ );
162
+ } else {
163
+ // ⚠️ An empty form is not a write — resource.ts skips one for the same reason.
164
+ const form = updateBody(news);
165
+ if (Object.keys(form).length > 0) {
166
+ upid = yield* pve<string>(news.target, 'provision', 'PUT', object(news), form);
167
+ }
168
+ }
169
+ const after = yield* settle(
170
+ news,
171
+ ops.read,
172
+ (row) => row !== undefined && spec.matches(row, news),
173
+ );
174
+ if (after === undefined || !spec.matches(after, news)) {
175
+ return yield* Effect.die(
176
+ new Error(
177
+ `${object(news)}: the write returned no error but the pool still does not match ` +
178
+ `the declaration after 60s. PVE answered with task ${upid ?? '(none)'} -- read ` +
179
+ 'its log with `pvesh get /nodes/<node>/tasks/<upid>/log` to see what Ceph ' +
180
+ 'refused.',
181
+ ),
182
+ );
183
+ }
184
+ return after;
185
+ }),
186
+ /**
187
+ * ⛔ THIS DESTROYS EVERY OBJECT IN THE POOL. No `force`, so PVE's own check — it refuses
188
+ * while a PVE storage on this pool still lists RBD images — is left standing; and no
189
+ * `remove_storages`, so a storage.cfg section pointing here survives and stays somebody's
190
+ * to remove deliberately.
191
+ * ⚠️ AND THE REFUSAL CAN ARRIVE AFTER THE RESPONSE, WHICH IS WHY THIS WAITS. The DELETE
192
+ * forks a worker and answers 200 with a UPID, so mons running with
193
+ * `mon_allow_pool_delete` false fail once the call has already returned. MEASURED on TB4
194
+ * 2026-09-13: the config-db value is false while the running mons report true, i.e. it is
195
+ * set in ceph.conf and the two sources disagree by design.
196
+ */
197
+ delete: Effect.fn(function* ({ olds }) {
198
+ yield* pve<string>(olds.target, 'provision', 'DELETE', object(olds));
199
+ const left = yield* settle(olds, ops.read, (row) => row === undefined);
200
+ if (left !== undefined) {
201
+ return yield* Effect.die(
202
+ new Error(
203
+ `${object(olds)}: the DELETE returned no error but the pool is still there after ` +
204
+ '60s. Either a storage on it still holds RBD images, or the mons are running ' +
205
+ 'with mon_allow_pool_delete false.',
206
+ ),
207
+ );
208
+ }
209
+ }),
210
+ }),
211
+ ),
212
+ );
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Cluster member failover with fake PVE servers — no live hosts, no credentials. The rule for what
3
+ * counts as pre-send is pinned separately in members.test.ts.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Layer from 'effect/Layer';
9
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
10
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
11
+ import { PveError, pveWith } from './client.ts';
12
+ import type { PveCredential, PveTarget } from './credentials.ts';
13
+ import { orderedMembers, resetLastGoodForTest } from './members.ts';
14
+
15
+ type ServeOptions = { fetch(request: Request): Response | Promise<Response>; port: number };
16
+ const { Bun } = globalThis as unknown as {
17
+ Bun: { serve(options: ServeOptions): { port: number; stop(closeActive: boolean): void } };
18
+ };
19
+
20
+ const CRED: PveCredential = {
21
+ leaseSeconds: 300,
22
+ secret: 'not-a-real-secret',
23
+ tokenId: 'hf-read@pve!fake',
24
+ };
25
+
26
+ const target = (members: readonly string[]): PveTarget => ({
27
+ members,
28
+ mount: 'proxmox-tb4-test',
29
+ scheme: 'pve',
30
+ });
31
+
32
+ /** Remap https://member:8006 to http://127.0.0.1:<port> — wired through FetchHttpClient.Fetch. */
33
+ // ⚠️ `typeof fetch` INCLUDES `preconnect` on bun's lib, which no stub implements — the
34
+ // platform fetch carries a property nothing hand-written does. Object.assign puts it
35
+ // back so the stub still satisfies the type it is standing in for.
36
+ const remapFetch = (
37
+ ports: Record<string, number>,
38
+ underlying: typeof fetch = globalThis.fetch,
39
+ ): typeof fetch =>
40
+ Object.assign(
41
+ (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
42
+ const href =
43
+ typeof input === 'string'
44
+ ? input
45
+ : input instanceof URL
46
+ ? input.href
47
+ : input instanceof Request
48
+ ? input.url
49
+ : String(input);
50
+ const url = new URL(href);
51
+ const port = ports[url.hostname];
52
+ if (port !== undefined) {
53
+ url.protocol = 'http:';
54
+ url.hostname = '127.0.0.1';
55
+ url.port = String(port);
56
+ return underlying(url, init);
57
+ }
58
+ return underlying(input, init);
59
+ },
60
+ { preconnect: globalThis.fetch.preconnect },
61
+ );
62
+
63
+ const layerFor = (ports: Record<string, number>) =>
64
+ FetchHttpClient.layer.pipe(
65
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, remapFetch(ports))),
66
+ );
67
+
68
+ const run = <A, E>(
69
+ effect: Effect.Effect<A, E, HttpClient.HttpClient>,
70
+ ports: Record<string, number> = {},
71
+ ) => Effect.runPromise(effect.pipe(Effect.provide(layerFor(ports))));
72
+
73
+ /** A fake member that counts its requests and answers `data`. */
74
+ const counting = (data: unknown) => {
75
+ let hits = 0;
76
+ const server = Bun.serve({
77
+ fetch: () => {
78
+ hits += 1;
79
+ return Response.json({ data });
80
+ },
81
+ port: 0,
82
+ });
83
+ return { hits: () => hits, port: server.port, stop: () => server.stop(true) };
84
+ };
85
+
86
+ const A = 'pve-a.test';
87
+ const B = 'pve-b.test';
88
+
89
+ describe('cluster member failover', () => {
90
+ it('skips an unresolvable first member and answers from the second', async () => {
91
+ resetLastGoodForTest();
92
+ const up = counting(['ok']);
93
+ try {
94
+ const data = await run(pveWith(target([A, B]), CRED, 'GET', 'pools'), { [B]: up.port });
95
+ assert.deepEqual(data, ['ok']);
96
+ } finally {
97
+ up.stop();
98
+ }
99
+ });
100
+
101
+ it('does not fail over on HTTP 500 from the first member', async () => {
102
+ resetLastGoodForTest();
103
+ const bad = Bun.serve({ fetch: () => new Response('fail', { status: 500 }), port: 0 });
104
+ const good = counting([]);
105
+ try {
106
+ const error = await run(pveWith(target([A, B]), CRED, 'GET', 'pools').pipe(Effect.flip), {
107
+ [A]: bad.port,
108
+ [B]: good.port,
109
+ });
110
+ assert.ok(error instanceof PveError);
111
+ assert.equal(error.status, 500);
112
+ assert.equal(good.hits(), 0);
113
+ } finally {
114
+ bad.stop(true);
115
+ good.stop();
116
+ }
117
+ });
118
+
119
+ it('tries the last-good member first on the next call', async () => {
120
+ resetLastGoodForTest();
121
+ const serverA = counting(['a']);
122
+ const serverB = counting(['b']);
123
+ try {
124
+ const t = target([A, B]);
125
+ const ports = { [A]: serverA.port, [B]: serverB.port };
126
+ await run(pveWith(t, CRED, 'GET', 'pools'), ports);
127
+ assert.equal(serverA.hits(), 1);
128
+ assert.equal(serverB.hits(), 0);
129
+ assert.deepEqual(orderedMembers(t), [A, B]);
130
+ await run(pveWith(t, CRED, 'GET', 'cluster/status'), ports);
131
+ assert.equal(serverA.hits(), 2);
132
+ assert.equal(serverB.hits(), 0);
133
+ } finally {
134
+ serverA.stop();
135
+ serverB.stop();
136
+ }
137
+ });
138
+
139
+ it('does not re-send a write after a transport timeout', async () => {
140
+ resetLastGoodForTest();
141
+ const hang = Bun.serve({ fetch: () => new Promise<Response>(() => {}), port: 0 });
142
+ const backup = counting('UPID:1');
143
+ try {
144
+ await run(
145
+ pveWith(target([A, B]), CRED, 'PUT', 'nodes/n3/network').pipe(
146
+ Effect.timeout('50 millis'),
147
+ Effect.flip,
148
+ ),
149
+ { [A]: hang.port, [B]: backup.port },
150
+ );
151
+ // ⛔ B must never receive the PUT — a post-send timeout is not pre-send failover.
152
+ assert.equal(backup.hits(), 0);
153
+ } finally {
154
+ hang.stop(true);
155
+ backup.stop();
156
+ }
157
+ });
158
+
159
+ // ⛔ AND THE OTHER HALF: a write to a member that REFUSES the connection was never sent, so it
160
+ // must reach the next member. A real Bun refusal on a closed port, not a hand-built error.
161
+ it('fails a write over when the first member refuses the connection', async () => {
162
+ resetLastGoodForTest();
163
+ const backup = counting('UPID:2');
164
+ try {
165
+ // Loopback port 1 is closed: Bun answers ConnectionRefused before a byte is written.
166
+ const data = await run(pveWith(target([A, B]), CRED, 'PUT', 'nodes/n3/network'), {
167
+ [A]: 1,
168
+ [B]: backup.port,
169
+ });
170
+ assert.equal(data, 'UPID:2');
171
+ assert.equal(backup.hits(), 1);
172
+ } finally {
173
+ backup.stop();
174
+ }
175
+ });
176
+
177
+ it('names every member when all are down', async () => {
178
+ resetLastGoodForTest();
179
+ const error = await run(pveWith(target([A, B]), CRED, 'GET', 'pools').pipe(Effect.flip));
180
+ assert.ok(error instanceof PveError);
181
+ assert.match(error.message, /all members failed/);
182
+ assert.match(error.message, new RegExp(A));
183
+ assert.match(error.message, new RegExp(B));
184
+ });
185
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * One authenticated call to the Proxmox VE API.
3
+ *
4
+ * ★ PLAIN `fetch`, NO TLS ESCAPE HATCH, AND THAT IS A MEASURED CHOICE RATHER THAN AN OMISSION.
5
+ * Proxmox is usually met with `curl -k` because a fresh install serves its own self-signed
6
+ * certificate — I reached for `-k` myself before checking. This cluster serves a real Let's
7
+ * Encrypt certificate (`CN=cluster-tb4.example.com`), and strict TLS answers 401, i.e. the
8
+ * handshake succeeds and only the credential is missing. So there is no `rejectUnauthorized`
9
+ * option here to be left switched on by accident in somebody else's estate.
10
+ *
11
+ * ⚠️ EVERY PVE ANSWER IS WRAPPED IN `{"data": ...}`, errors included — a failed call can still be
12
+ * HTTP 200 with `{"data": null}`. Unwrapping is not cosmetic; a caller that reads the envelope
13
+ * as the payload sees `undefined` and calls it "absent".
14
+ */
15
+ import * as Effect from 'effect/Effect';
16
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
17
+ import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
18
+ import {
19
+ type ApiTarget,
20
+ type PveCredential,
21
+ type PveRole,
22
+ type PveTarget,
23
+ authorization,
24
+ } from './credentials.ts';
25
+ import { leased } from './lease-cache.ts';
26
+ import { executeOnCluster } from './members.ts';
27
+
28
+ export class PveError extends Error {
29
+ constructor(
30
+ readonly status: number,
31
+ readonly method: string,
32
+ readonly path: string,
33
+ detail: string,
34
+ ) {
35
+ super(`PVE ${method} ${path} -> ${String(status)}: ${detail}`);
36
+ this.name = 'PveError';
37
+ }
38
+ }
39
+
40
+ const clusterExhausted = (
41
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
42
+ path: string,
43
+ attempts: ReadonlyArray<{ member: string; why: string }>,
44
+ ) =>
45
+ new PveError(
46
+ 0,
47
+ method,
48
+ path,
49
+ `all members failed: ${attempts.map((a) => `${a.member} (${a.why})`).join('; ')}`,
50
+ );
51
+
52
+ /**
53
+ * A form body, where a value may be a LIST.
54
+ *
55
+ * ⛔ PVE AND PBS SPELL A MULTI-VALUED FIELD DIFFERENTLY, AND THE LIST FORM IS FOR PBS. PBS is Rust:
56
+ * its schemas use `type: array` — `delete` on every update endpoint is one — and its urlencoded
57
+ * decoder builds that array from REPEATED KEYS (`delete=a&delete=b`). PVE's Perl takes a comma
58
+ * string for the same idea, which is what `withClears` in values.ts produces, and for the one
59
+ * PVE field that genuinely needs repetition it wants a NUL-joined value instead (see
60
+ * notification-target.ts). So: PVE callers pass strings and are unaffected; a PBS caller that
61
+ * needs an array passes one and gets repeated keys rather than reaching for PVE's encoding.
62
+ */
63
+ export type PveForm = Record<string, readonly string[] | string>;
64
+
65
+ /** ⚠️ `append`, NOT `set`: a repeated key is the point, and `set` would keep only the last. */
66
+ const encode = (form: PveForm): string => {
67
+ const params = new URLSearchParams();
68
+ for (const [key, value] of Object.entries(form)) {
69
+ if (typeof value === 'string') params.append(key, value);
70
+ else for (const item of value) params.append(key, item);
71
+ }
72
+ return params.toString();
73
+ };
74
+
75
+ const buildRequest = (
76
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
77
+ apiBase: string,
78
+ path: string,
79
+ credential: PveCredential,
80
+ target: ApiTarget,
81
+ form?: PveForm,
82
+ ) => {
83
+ const url = `${apiBase}/${path}`;
84
+ /**
85
+ * ⛔ THE CONTENT TYPE IS AN ARGUMENT TO `bodyText`, NOT AN ENTRY IN THE HEADER MAP, AND GETTING
86
+ * THAT WRONG MADE EVERY WRITE IN THIS PACKAGE FAIL.
87
+ *
88
+ * 🔴 THE BUG, AND HOW IT HID FOR A WHOLE DAY. The header map DID set
89
+ * `application/x-www-form-urlencoded` — and `bodyText(s)` with no second argument sets
90
+ * `text/plain` and OVERWRITES it, because it is piped AFTER `setHeaders`. PVE and PBS accept
91
+ * ONLY form encoding or JSON and reject everything else outright (measured in
92
+ * proxmox-rest-server's `get_request_parameters`), so every create, update and delete this
93
+ * package can perform was broken from the day the client was written.
94
+ * ⛔ NOTHING CAUGHT IT BECAUSE NOTHING HAD EVER WRITTEN. Ninety-eight resources were adopted
95
+ * against the live cluster and every one matched, so `reconcile` never reached a PUT. The
96
+ * identical bug in house/forgejo/src/client.ts surfaced the moment a write token existed
97
+ * and answered `422 Unsupported Content-Type`; this one is the same line, found by looking.
98
+ * ★ A PLAN THAT SAYS `noop` PROVES THE READ PATH AND NOTHING ELSE. That is the lesson worth
99
+ * more than the fix.
100
+ *
101
+ * ⚠️ PVE TAKES FORM ENCODING, NOT JSON. Sending application/json is accepted and then SILENTLY
102
+ * IGNORED on several endpoints, which reads as "the API did nothing" rather than as a wrong
103
+ * content type — so the type below is load bearing in both directions.
104
+ */
105
+ return HttpClientRequest.make(method)(url).pipe(
106
+ HttpClientRequest.setHeaders({ Authorization: authorization(credential, target) }),
107
+ form === undefined
108
+ ? (self) => self
109
+ : HttpClientRequest.bodyText(encode(form), 'application/x-www-form-urlencoded'),
110
+ );
111
+ };
112
+
113
+ const executeOne = (
114
+ target: ApiTarget,
115
+ credential: PveCredential,
116
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
117
+ path: string,
118
+ form: PveForm | undefined,
119
+ apiBase: string,
120
+ ) =>
121
+ Effect.gen(function* () {
122
+ const client = yield* HttpClient.HttpClient;
123
+ const request = buildRequest(method, apiBase, path, credential, target, form);
124
+ return yield* client
125
+ .execute(request)
126
+ .pipe(Effect.mapError((cause) => new PveError(0, method, path, String(cause))));
127
+ });
128
+
129
+ const readResponse = <T>(
130
+ response: {
131
+ status: number;
132
+ text: Effect.Effect<string, unknown>;
133
+ json: Effect.Effect<unknown, unknown>;
134
+ },
135
+ method: string,
136
+ path: string,
137
+ ) =>
138
+ Effect.gen(function* () {
139
+ /**
140
+ * ⛔ STATUS FIRST, THEN THE BODY, AND THE BODY IS READ EXACTLY ONCE. `text` and `json` both
141
+ * consume the response stream, so asking for both is a bug waiting to happen. A failure is
142
+ * read as TEXT because an error body is not reliably JSON — PVE answers HTML for some 5xx —
143
+ * and a success is read as JSON by the client rather than by a hand-rolled `JSON.parse`.
144
+ */
145
+ if (response.status < 200 || response.status >= 300) {
146
+ // ⚠️ 401 HERE USUALLY MEANS THE LEASE EXPIRED, NOT THAT THE ROLE IS WRONG. `provision` is a
147
+ // 300s non-renewable lease; a long reconcile should mint again rather than widen the role.
148
+ const detail = yield* response.text.pipe(Effect.orElseSucceed(() => ''));
149
+ return yield* Effect.fail(new PveError(response.status, method, path, detail.slice(0, 300)));
150
+ }
151
+ const body = (yield* response.json.pipe(
152
+ Effect.mapError((cause) => new PveError(0, method, path, String(cause))),
153
+ )) as { data?: T };
154
+ return body.data;
155
+ });
156
+
157
+ /** A call that already holds a credential. Used when one mint serves several calls. */
158
+ export const pveWith = <T>(
159
+ target: ApiTarget,
160
+ credential: PveCredential,
161
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
162
+ path: string,
163
+ form?: PveForm,
164
+ ) =>
165
+ Effect.gen(function* () {
166
+ const response =
167
+ target.scheme === 'pve'
168
+ ? yield* executeOnCluster(target, method, path, (apiBase) =>
169
+ buildRequest(method, apiBase, path, credential, target, form),
170
+ ).pipe(
171
+ Effect.mapError((failure) =>
172
+ failure.tag === 'cluster'
173
+ ? clusterExhausted(failure.method, failure.path, failure.attempts)
174
+ : new PveError(0, method, path, String(failure)),
175
+ ),
176
+ )
177
+ : yield* executeOne(target, credential, method, path, form, target.api);
178
+ return yield* readResponse<T>(response, method, path);
179
+ });
180
+
181
+ /**
182
+ * Like `pveWith`, but returns the full JSON body — for endpoints whose answer lives beside `data`.
183
+ *
184
+ * ⚠️ `network-apply-read.ts` is the caller today; the local fetch there should fold into this once
185
+ * the envelope shape is stable across both paths.
186
+ */
187
+ export const pveEnvelopeWith = (
188
+ target: PveTarget,
189
+ credential: PveCredential,
190
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
191
+ path: string,
192
+ ) =>
193
+ Effect.gen(function* () {
194
+ const response = yield* executeOnCluster(target, method, path, (apiBase) =>
195
+ buildRequest(method, apiBase, path, credential, target),
196
+ ).pipe(
197
+ Effect.mapError((failure) =>
198
+ failure.tag === 'cluster'
199
+ ? clusterExhausted(failure.method, failure.path, failure.attempts)
200
+ : new PveError(0, method, path, String(failure)),
201
+ ),
202
+ );
203
+ if (response.status < 200 || response.status >= 300) {
204
+ const detail = yield* response.text.pipe(Effect.orElseSucceed(() => ''));
205
+ return yield* Effect.fail(new PveError(response.status, method, path, detail.slice(0, 300)));
206
+ }
207
+ return (yield* response.json.pipe(
208
+ Effect.mapError((cause) => new PveError(0, method, path, String(cause))),
209
+ )) as Record<string, unknown>;
210
+ });
211
+
212
+ /**
213
+ * Make one call on a credential for `role`, REUSING a still-valid lease rather than minting one.
214
+ *
215
+ * 🔴 IT USED TO MINT PER CALL, AND AT 98 RESOURCES THAT STOPPED BEING FREE. One plan plus deploy
216
+ * of the TB4 stack left 760 token entries in `/etc/pve/user.cfg` — read, diff, reconcile and the
217
+ * read-back each minted their own, and `/etc/pve` is a replicated cluster filesystem every node
218
+ * has to agree on. src/lease-cache.ts carries the measurement and the safety argument.
219
+ *
220
+ * ⚠️ `pveWith` IS STILL THERE AND STILL TAKES AN EXPLICIT CREDENTIAL. network-apply.ts needs ONE
221
+ * identity across its apply and its task poll — PVE only skips the task-status privilege check
222
+ * for the task's OWNER — and it threads a lease through by hand for that. Caching makes that
223
+ * property easier to hold, never weaker, but the explicit path stays because that caller is
224
+ * reasoning about identity rather than about cost.
225
+ *
226
+ * ★ TASK POLL ON A DIFFERENT MEMBER THAN THE APPLY STILL HOLDS: the owner is the token id, not the
227
+ * node that received the PUT. Any member serves any task path; the privilege skip is keyed on
228
+ * whether the caller IS the task's owner.
229
+ */
230
+ export const pve = <T>(
231
+ target: ApiTarget,
232
+ role: PveRole,
233
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
234
+ path: string,
235
+ form?: PveForm,
236
+ ) =>
237
+ Effect.gen(function* () {
238
+ const credential = yield* leased(target, role);
239
+ return yield* pveWith<T>(target, credential, method, path, form);
240
+ });