@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,161 @@
1
+ /**
2
+ * `Proxmox.Role` — a PVE role: one name, one set of privileges. The object every other resource
3
+ * in this package stands on, because a role is what the provision credential actually holds.
4
+ *
5
+ * ★ THIS IS WHERE pool.ts's ⚠️ STOPS BEING A MEMORY. That file records, in prose, that the
6
+ * provision role lacked `Pool.Allocate`, that reconcile answered "Permission check failed", and
7
+ * that a human widened the role over SSH with `pveum role modify`. Prose cannot be planned
8
+ * against: it goes stale the first time somebody edits the role in the UI and tells nobody.
9
+ * Declared here, the same fact is a diff — the privileges the role is SUPPOSED to hold are read
10
+ * off the cluster on every plan, and a hand edit shows up as `1 to update` rather than as a 403
11
+ * six weeks later in the middle of something else.
12
+ *
13
+ * ⛔ THE INDEX AND THE ITEM DISAGREE ABOUT WHAT `privs` IS, AND MISSING THAT COSTS A DIFF THAT
14
+ * NEVER CONVERGES. `GET /access/roles` (the index) reports each role's privileges as a COMMA
15
+ * STRING. `GET /access/roles/{roleid}` — the path this resource reads — returns a privilege MAP
16
+ * instead, `{"VM.Allocate":1,"Sys.Audit":1,…}`, and its key order is a Perl hash's order, so it
17
+ * is not stable between two calls to the same endpoint. Compare the raw string, or the raw
18
+ * object, or the keys in the order they arrived, and every plan reports an update forever.
19
+ * `canonical` below is the whole answer: both sides become a sorted, de-duplicated set before
20
+ * anything is compared, and `attributes` STORES the sorted form so Alchemy's state does not
21
+ * churn either.
22
+ *
23
+ * ⚠️ A DECLARATION REPLACES THE PRIVILEGE SET; IT DOES NOT ADD TO IT. `PUT /access/roles/{roleid}`
24
+ * accepts an `append` flag and this resource deliberately never sends it — with append a role
25
+ * could only ever grow, so `matches` would report an update forever whenever props were a subset
26
+ * of live, which is the same perpetual diff by a different road. The consequence is the one that
27
+ * bites: anything a human added by hand is REVOKED on the next deploy unless it is in `privs`.
28
+ * Copy the live set out of `pveum role list` before declaring a role that already exists; do not
29
+ * type it from memory.
30
+ *
31
+ * ⛔ A ROLE CAN LOCK ITS OWN PROVIDER OUT, AND NOTHING IN THIS PACKAGE CAN UNDO IT. Reconcile runs
32
+ * as the provision credential, and that credential holds a role. Declare THAT role without the
33
+ * privileges the provider needs — `Sys.Modify` on `/access` above all — and the write succeeds,
34
+ * after which every later plan reads 403 and the repair has to happen out of band as `root@pam`
35
+ * over SSH. It is a one-way door, which is why the privileges a reconcile needs are written
36
+ * down at the bottom of this comment rather than left to be rediscovered from an error message.
37
+ *
38
+ * ⚠️ PVE REFUSES TO EDIT ITS OWN BUILT-IN ROLES (`Administrator`, `NoAccess`, the `PVE*` set), and
39
+ * the item endpoint gives no sign of which those are — only the index carries the `special`
40
+ * flag. A declaration aimed at a built-in role therefore READS BACK CLEANLY and fails at write
41
+ * time, which reads like a broken provider rather than like a refusal. Declare roles you own.
42
+ *
43
+ * ★ PRIVILEGES A RECONCILE NEEDS: `Sys.Audit` on `/access` to read and diff, `Sys.Modify` on
44
+ * `/access` to create, update or delete. Not `Permissions.Modify` — binding a role to a user is
45
+ * `/access/acl`, a different object, and nothing here writes it. An auditor-shaped credential
46
+ * can already PLAN this resource and cannot DEPLOY it, which is the honest failure: the plan is
47
+ * true and the deploy says exactly which privilege is missing.
48
+ */
49
+ import { Resource } from 'alchemy';
50
+ import * as Provider from 'alchemy/Provider';
51
+ import * as Effect from 'effect/Effect';
52
+ import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
53
+
54
+ export interface RoleProps extends WithTarget {
55
+ /**
56
+ * PVE's primary key for a role.
57
+ *
58
+ * ⚠️ THERE IS NO RENAME. `path` is built from this, so changing it makes the factory read
59
+ * nothing live, create a role under the new name, and leave the old one on the cluster with
60
+ * nobody managing it. Delete the resource and declare a new one rather than editing this.
61
+ */
62
+ roleid: string;
63
+ /**
64
+ * The COMPLETE privilege set, e.g. `['VM.Allocate', 'VM.Audit', 'Sys.Audit']`. Order and
65
+ * duplicates do not matter — see the ⛔ in the header — but completeness does: this list is the
66
+ * role, not an addition to it.
67
+ *
68
+ * ⚠️ DECLARE AT LEAST ONE. A role with no privileges grants nothing and reads back as an empty
69
+ * document, which is the one shape the factory cannot tell apart from an object that is not
70
+ * there — reconcile would then refuse with its "the write returned no error but the object is
71
+ * still absent" message, which is true of the read and misleading about the cause.
72
+ */
73
+ privs: string[];
74
+ }
75
+
76
+ export interface RoleAttributes {
77
+ roleid: string;
78
+ /** Sorted and de-duplicated on the way in, so persisted state is identical across plans. */
79
+ privs: string[];
80
+ }
81
+
82
+ export interface ProxmoxRole extends Resource<
83
+ 'Proxmox.Role',
84
+ RoleProps,
85
+ RoleAttributes,
86
+ never,
87
+ PveRequirements
88
+ > {}
89
+
90
+ export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
91
+
92
+ /**
93
+ * The only shape two privilege sets may be compared in: sorted, de-duplicated, blanks dropped.
94
+ *
95
+ * ⚠️ THE TRIM AND THE EMPTY FILTER ARE NOT DECORATION. `'A,B,'.split(',')` yields a trailing `''`,
96
+ * and a hand-written list is quite likely to carry a stray space after a comma. Either one turns
97
+ * into a phantom member that no live answer can contain, so `matches` would be false on every
98
+ * plan and the deploy would rewrite the role to exactly what it already was.
99
+ */
100
+ const canonical = (privs: readonly string[]): string[] =>
101
+ [...new Set(privs.map((priv) => priv.trim()).filter((priv) => priv.length > 0))].sort();
102
+
103
+ /**
104
+ * The privileges in a live answer, from EITHER shape PVE uses for them.
105
+ *
106
+ * ⚠️ THE MAP BRANCH IS THE ONE THAT RUNS HERE — the item endpoint answers `{"VM.Allocate":1,…}`,
107
+ * so the privilege names are the KEYS and the `1`s carry no information. The string branch
108
+ * exists because the index answers `{"privs":"VM.Allocate,Sys.Audit",…}` for that same role, and
109
+ * a reader that assumed the map shape for an index entry would compare the words `privs`,
110
+ * `roleid` and `special` against real privileges and report drift forever.
111
+ *
112
+ * ⚠️ A KEY WITH NO DOT IS NOT A PRIVILEGE. Every PVE privilege is `Category.Name` — VM.Allocate,
113
+ * Sys.Modify, Datastore.AllocateSpace, Pool.Audit — while the metadata PVE mixes into role
114
+ * answers (`special`, marking a built-in) is a bare word. Filtering on the dot keeps a flag from
115
+ * being diffed as though somebody had granted it.
116
+ */
117
+ const livePrivs = (live: Record<string, unknown>): string[] => {
118
+ const listed = live['privs'];
119
+ if (typeof listed === 'string') return canonical(listed.split(','));
120
+ return canonical(Object.keys(live).filter((key) => key.includes('.')));
121
+ };
122
+
123
+ const handlers = pveHandlers<RoleProps, RoleAttributes>({
124
+ attributes: (live, props) => ({ privs: livePrivs(live), roleid: props.roleid }),
125
+ collection: () => 'access/roles',
126
+ /** ⚠️ PVE wants ONE comma string here, not a repeated field — `privs=A,B,C`. */
127
+ createForm: (props) => ({ privs: canonical(props.privs).join(','), roleid: props.roleid }),
128
+ /**
129
+ * ⛔ NORMALISED ON BOTH SIDES, even though `attributes` already arrives sorted. This is a
130
+ * predicate, not a fast path: the cost of trusting one side's order is not a slow plan, it is
131
+ * a plan that reports an update every single time and a deploy that writes the same role back
132
+ * forever. Cheap insurance against the exact trap named in the header.
133
+ */
134
+ matches: (attributes, props) =>
135
+ canonical(attributes.privs).join(',') === canonical(props.privs).join(','),
136
+ path: (props) => `access/roles/${props.roleid}`,
137
+ /**
138
+ * ⚠️ NO `append` FIELD, DELIBERATELY. Sending `append=1` would make every update additive, so a
139
+ * privilege could be granted from here but never taken away — and a role that cannot narrow
140
+ * is not a declaration. See the ⚠️ in the header for what that means for hand edits.
141
+ */
142
+ updateForm: (props) => ({ privs: canonical(props.privs).join(',') }),
143
+ });
144
+
145
+ /**
146
+ * ⛔ AN EMPTY LIST, AND NOWHERE DOES IT MATTER MORE. `GET /access/roles` returns every
147
+ * role on the cluster, PVE's own built-ins included. Returning them would invite Alchemy
148
+ * to adopt `Administrator` — and therefore one day to narrow or delete it. Adoption is
149
+ * an explicit act, here as everywhere else in this package.
150
+
151
+ *
152
+ * ⛔ DO NOT CARRY THE FACTORY'S REASSURANCE OVER TO THIS ONE. `destroy` there notes that
153
+ * PVE refuses to delete things still in use — a pool holding guests, a storage with
154
+ * volumes — and a role is NOT protected that way. Whoever is bound to the role loses
155
+ * those privileges the moment it goes, and the credential this provider runs with is
156
+ * bound through exactly such a binding. Checking who holds a role before removing it is
157
+ * an operator's job, and it is not done here on their behalf while they read a diff.
158
+
159
+ */
160
+ export const ProxmoxRoleProvider = () =>
161
+ Provider.effect(ProxmoxRole, Effect.succeed(ProxmoxRole.Provider.of(handlers)));
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The fabric comparison, which is what decides whether `Proxmox.SdnApply` publishes.
3
+ *
4
+ * ⛔ IT IS TESTED HERE RATHER THAN AGAINST THE CLUSTER BECAUSE STAGING A REAL FABRIC CHANGE MEANS
5
+ * TOUCHING CEPH'S CLUSTER NETWORK. TB4's OSPF fabric pins 10.100.0.102/103/104 to en05 and en06,
6
+ * and those are the addresses `cluster_network` runs over. The staged-zone path IS proven end to
7
+ * end against the live cluster (plan noop -> stage a zone -> plan update -> remove -> plan noop);
8
+ * the fabric path is proven by construction, with the two ways it could lie pinned below.
9
+ *
10
+ * ★ THE TWO WAYS IT COULD LIE, AND BOTH ARE FALSE POSITIVES. `?pending=1` carries a `digest` and
11
+ * `?running=1` does not, and the node list comes back in a different order between the two reads
12
+ * — MEASURED. Either would make the comparison report a difference on a cluster where nothing has
13
+ * changed, which is a forever-`update` on a resource whose update reloads the network on three
14
+ * nodes at once. That is worse than the bug this file was written to fix.
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { describe, it } from 'node:test';
18
+ import { PveError } from './client.ts';
19
+ import { UNDIFFABLE, canonical, fabricStaged, subsystemAbsent } from './sdn-apply-read.ts';
20
+
21
+ /** The shape `cluster/sdn/fabrics/all` actually returns, trimmed to what matters. */
22
+ const N2 = {
23
+ fabric_id: 'tb4',
24
+ interfaces: ['name=en05', 'name=en06'],
25
+ ip: '10.100.0.102',
26
+ node_id: 'n2',
27
+ };
28
+ const N3 = {
29
+ fabric_id: 'tb4',
30
+ interfaces: ['name=en05', 'name=en06'],
31
+ ip: '10.100.0.103',
32
+ node_id: 'n3',
33
+ };
34
+
35
+ const running = {
36
+ fabrics: [{ area: '1', id: 'tb4', ip_prefix: '10.100.0.0/24', protocol: 'ospf' }],
37
+ nodes: [N2, N3],
38
+ };
39
+
40
+ describe('fabric canonicalisation', () => {
41
+ it('ignores the digest the pending view carries and the running view does not', () => {
42
+ const pending = {
43
+ fabrics: [
44
+ {
45
+ area: '1',
46
+ digest: '7b94d363ff69',
47
+ id: 'tb4',
48
+ ip_prefix: '10.100.0.0/24',
49
+ protocol: 'ospf',
50
+ },
51
+ ],
52
+ nodes: running.nodes.map((n) => ({ ...n, digest: '7b94d363ff69' })),
53
+ };
54
+ assert.equal(canonical(pending), canonical(running));
55
+ });
56
+
57
+ it('ignores node order, which is not stable between the two reads', () => {
58
+ const reordered = { ...running, nodes: [...running.nodes].reverse() };
59
+ assert.equal(canonical(reordered), canonical(running));
60
+ });
61
+
62
+ it('ignores key order within a node', () => {
63
+ const shuffled = {
64
+ ...running,
65
+ nodes: running.nodes.map((n) => ({
66
+ node_id: n.node_id,
67
+ ip: n.ip,
68
+ interfaces: n.interfaces,
69
+ fabric_id: n.fabric_id,
70
+ })),
71
+ };
72
+ assert.equal(canonical(shuffled), canonical(running));
73
+ });
74
+
75
+ // ⛔ AND THE DIRECTION THAT MATTERS: a real change must NOT be canonicalised away.
76
+ it('sees a changed node address', () => {
77
+ const moved = {
78
+ ...running,
79
+ nodes: [{ ...N2, ip: '10.100.0.199' }, N3],
80
+ };
81
+ assert.notEqual(canonical(moved), canonical(running));
82
+ });
83
+
84
+ it('sees an added node — the shape of a fabric gaining a member', () => {
85
+ const added = {
86
+ ...running,
87
+ nodes: [
88
+ ...running.nodes,
89
+ { fabric_id: 'tb4', interfaces: ['name=en05'], ip: '10.100.0.104', node_id: 'n4' },
90
+ ],
91
+ };
92
+ assert.notEqual(canonical(added), canonical(running));
93
+ });
94
+
95
+ it('sees a changed interface list, which is what decides where the address lands', () => {
96
+ const rewired = {
97
+ ...running,
98
+ nodes: [{ ...N2, interfaces: ['name=en05'] }, N3],
99
+ };
100
+ assert.notEqual(canonical(rewired), canonical(running));
101
+ });
102
+ });
103
+
104
+ describe('the stated blind spot', () => {
105
+ // ⚠️ ipams and dns answer HTTP 400 to ?pending=1 — there is no way to ask them what is staged.
106
+ // The list exists so the gap is NAMED rather than silently folded to zero, which is what the
107
+ // old `orElseSucceed(() => 0)` would have done if anyone had added them to the counted set.
108
+ it('names the collections that cannot be diffed at all', () => {
109
+ assert.deepEqual([...UNDIFFABLE], ['cluster/sdn/ipams', 'cluster/sdn/dns']);
110
+ });
111
+ });
112
+
113
+ /**
114
+ * ⛔ WHAT A FAILED READ IS ALLOWED TO MEAN. Every failure used to read as "nothing staged", so an
115
+ * expired read lease answered `noop` — and one failed fabric view, compared with a real one, read
116
+ * as a DIFFERENCE, which is `PUT /cluster/sdn` and a network reload carrying Ceph's cluster
117
+ * network on all three nodes. Found in review 2026-09-14. A failure other than 501 now fails the
118
+ * read outright; that path runs through `pve` and is proven by the live plan, not here.
119
+ */
120
+ describe('what a failed read is allowed to mean', () => {
121
+ it('reads only a 501 as an absent subsystem', () => {
122
+ assert.equal(
123
+ subsystemAbsent(new PveError(501, 'GET', 'cluster/sdn/zones?pending=1', '')),
124
+ true,
125
+ );
126
+ for (const status of [0, 400, 401, 403, 500, 502, 596]) {
127
+ const failure = new PveError(status, 'GET', 'cluster/sdn/zones?pending=1', '');
128
+ assert.equal(subsystemAbsent(failure), false, `status ${String(status)}`);
129
+ }
130
+ });
131
+
132
+ it('never reads something that is not a PVE answer as an absent subsystem', () => {
133
+ assert.equal(subsystemAbsent(new Error('501')), false);
134
+ assert.equal(subsystemAbsent({ status: 501 }), false);
135
+ });
136
+
137
+ it('counts an unchanged fabric as nothing staged and a moved node as staged', () => {
138
+ const moved = { ...running, nodes: [{ ...N2, ip: '10.100.0.199' }, N3] };
139
+ assert.equal(fabricStaged(canonical(running), canonical(running)), 0);
140
+ assert.equal(fabricStaged(canonical(moved), canonical(running)), 1);
141
+ });
142
+
143
+ it('counts a fabric subsystem absent from both views as nothing staged', () => {
144
+ assert.equal(fabricStaged(undefined, undefined), 0);
145
+ });
146
+
147
+ // ⛔ THE CEPH CASE: one view absent is refused, never counted as a difference.
148
+ it('refuses one absent view rather than calling it a difference', () => {
149
+ assert.equal(fabricStaged(undefined, canonical(running)), 'inconsistent');
150
+ assert.equal(fabricStaged(canonical(running), undefined), 'inconsistent');
151
+ });
152
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * What is staged on the SDN, across the WHOLE surface the apply publishes — not just the two
3
+ * collections this package declares resources for.
4
+ *
5
+ * 🔴 THE GAP THIS FILE CLOSES, AND IT WAS FOUND BY ALMOST DECLARING THE APPLY. `pendingCount` in
6
+ * sdn-apply.ts counted `cluster/sdn/zones` and `cluster/sdn/vnets`. TB4 has zero of each — and a
7
+ * staged OSPF FABRIC that nothing counted. MEASURED 2026-09-13 on n2:
8
+ *
9
+ * /etc/pve/sdn/fabrics.cfg ospf_fabric: tb4, area 1, ip_prefix 10.100.0.0/24
10
+ * ospf_node: tb4_n2/n3/n4, interfaces en05 + en06,
11
+ * ip 10.100.0.102 / .103 / .104
12
+ * /etc/network/interfaces.d/sdn the file PVE GENERATES from it, carrying those /32s
13
+ *
14
+ * Those addresses are Ceph's `cluster_network`. So `diff` would have answered `noop` for ever
15
+ * while `PUT /cluster/sdn` republished the fabric and reloaded networking on all three nodes at
16
+ * once — the simultaneity the NetworkApply chain exists to prevent, arriving through the door
17
+ * nobody was watching.
18
+ *
19
+ * ⛔ THE SURFACE IS NOT UNIFORM, AND ASSUMING IT WAS IS WHAT MADE THE GAP INVISIBLE. Measured, one
20
+ * request per collection, per query:
21
+ *
22
+ * zones, vnets, controllers, prefix-lists, route-maps accept ?pending=1 and ?running=1,
23
+ * and mark a staged row with `state`
24
+ * fabrics accepts both, and its rows carry NO
25
+ * `state` marker at all
26
+ * ipams, dns REJECT both with HTTP 400,
27
+ * "property is not defined in schema"
28
+ *
29
+ * ⛔ SO A `state`-ONLY TEST IS WRONG FOR FABRICS, AND `orElseSucceed(() => 0)` IS WORSE THAN WRONG
30
+ * FOR ipams AND dns. The old code folded an unreadable collection to zero — right for a cluster
31
+ * with no SDN at all, and indistinguishable from "this collection rejects the question". Adding
32
+ * ipams and dns to the old list would have counted them as 0 for ever and read as coverage.
33
+ */
34
+ import * as Effect from 'effect/Effect';
35
+ import { PveError, pve } from './client.ts';
36
+ import type { PveTarget } from './credentials.ts';
37
+
38
+ /** A staged row. `state` is present only under `?pending=1`, and only when it differs from running. */
39
+ type PendingRow = { state?: string };
40
+
41
+ /**
42
+ * Collections that answer `?pending=1` AND mark a staged row with `state`.
43
+ *
44
+ * ⚠️ THIS LIST IS WIDER THAN THE RESOURCES THIS PACKAGE SHIPS, ON PURPOSE, AND THAT REVERSES THE
45
+ * OLD COMMENT. It used to say counting a collection nothing declares would turn somebody else's
46
+ * hand-staged object into a permanent `update`. True — and the alternative is publishing that
47
+ * object without ever showing it in a plan. An apply that is cluster-wide must be honest about
48
+ * the whole cluster: a controller staged by hand in the UI is a thing this resource WILL push,
49
+ * so it has to be a thing this resource ADMITS to. A permanent `update` on somebody's abandoned
50
+ * half-edit is a loud, safe failure; publishing it silently is not.
51
+ */
52
+ const MARKED = [
53
+ 'cluster/sdn/zones',
54
+ 'cluster/sdn/vnets',
55
+ 'cluster/sdn/controllers',
56
+ 'cluster/sdn/prefix-lists',
57
+ 'cluster/sdn/route-maps',
58
+ ] as const;
59
+
60
+ /**
61
+ * Collections with NO pending view at all.
62
+ *
63
+ * ⚠️ NAMED RATHER THAN SILENTLY SKIPPED. `ipams` and `dns` answer HTTP 400 to both `?pending=1` and
64
+ * `?running=1`, so there is no way to ask them what is staged. They are a REAL blind spot: an
65
+ * ipam edited by hand is published by the apply and cannot be seen from here. TB4 has one ipam
66
+ * (`pve`, the built-in) and zero dns entries, so the blind spot is currently empty — which is a
67
+ * fact about today, not a property of the design.
68
+ */
69
+ export const UNDIFFABLE = ['cluster/sdn/ipams', 'cluster/sdn/dns'] as const;
70
+
71
+ /**
72
+ * Whether a failed read means "this SDN subsystem does not exist here" — the ONE failure that may
73
+ * read as nothing staged.
74
+ *
75
+ * 🔴 IT USED TO BE EVERY FAILURE. `orElseSucceed` caught 401, 403, 5xx and a dropped connection
76
+ * along with the 501 it was written for, so an expired read lease or a missing grant answered
77
+ * "nothing staged": `diff` said noop, and the read-back after a real apply said settled while
78
+ * objects were still staged. The fabric views were worse because they are COMPARED — one view
79
+ * failing while the other succeeded read as a DIFFERENCE, which is `PUT /cluster/sdn` and a
80
+ * network reload carrying Ceph's cluster network on all three nodes, over a timeout. Found in
81
+ * review on 2026-09-14 and confirmed by reading.
82
+ * ⛔ SO ANYTHING ELSE FAILS THE READ. A plan that stops on an unreadable SDN is loud and safe; a
83
+ * plan that guesses is neither.
84
+ */
85
+ export const subsystemAbsent = (error: unknown): boolean =>
86
+ error instanceof PveError && error.status === 501;
87
+
88
+ /** Staged rows in the collections that mark them. */
89
+ const markedPending = (target: PveTarget) =>
90
+ Effect.all(
91
+ MARKED.map((collection) =>
92
+ pve<PendingRow[]>(target, 'read', 'GET', `${collection}?pending=1`).pipe(
93
+ Effect.map((rows) => (rows ?? []).filter((row) => row.state !== undefined).length),
94
+ /**
95
+ * ⚠️ AN ABSENT COLLECTION COUNTS AS ZERO, AND THAT IS STILL RIGHT HERE — but ONLY an absent
96
+ * one. Some versions answer 501 for an SDN subsystem that has never been configured, and
97
+ * failing the whole plan over it would make this resource undeclarable on a cluster with
98
+ * no SDN. The collections that REJECT the question are handled by not asking them — see
99
+ * UNDIFFABLE — and every other failure is a failure; see `subsystemAbsent`.
100
+ */
101
+ Effect.catchIf(subsystemAbsent, () => Effect.succeed(0)),
102
+ ),
103
+ ),
104
+ { concurrency: 'unbounded' },
105
+ ).pipe(Effect.map((counts) => counts.reduce((total, count) => total + count, 0)));
106
+
107
+ /**
108
+ * Staged fabric changes from the two canonical views, where `undefined` is a view whose subsystem
109
+ * answered 501.
110
+ *
111
+ * ⛔ ONE VIEW ABSENT AND ONE PRESENT IS NOT A DIFFERENCE. It is one endpoint answering two
112
+ * questions inconsistently, and a difference is exactly what triggers the publish — so it is
113
+ * refused rather than counted.
114
+ */
115
+ export const fabricStaged = (
116
+ staged: string | undefined,
117
+ running: string | undefined,
118
+ ): number | 'inconsistent' => {
119
+ if (staged === undefined && running === undefined) return 0;
120
+ if (staged === undefined || running === undefined) return 'inconsistent';
121
+ return staged === running ? 0 : 1;
122
+ };
123
+
124
+ /**
125
+ * Whether the fabric config differs between staged and applied.
126
+ *
127
+ * ★ COMPARING THE TWO VIEWS IS THE TEST, BECAUSE THERE IS NO `state` TO READ. `?pending=1` is the
128
+ * staged config and `?running=1` is what the nodes are actually running; equal means nothing to
129
+ * publish. `cluster/sdn/fabrics/all` returns both fabrics and nodes in ONE call, which is why it
130
+ * is asked rather than the two subdirectories.
131
+ *
132
+ * ⚠️ `digest` IS STRIPPED BEFORE COMPARING. The pending view carries one and the running view does
133
+ * not — MEASURED — so comparing raw bodies reports a difference on a cluster where nothing has
134
+ * changed, which is exactly the forever-diff this package has fixed three times elsewhere.
135
+ * ⚠️ AND THE ORDER OF THE NODE LIST IS NOT STABLE between the two reads, so both sides are
136
+ * canonically sorted. Order is not meaning in a set of fabric nodes.
137
+ */
138
+ const fabricPending = (target: PveTarget) =>
139
+ Effect.all(
140
+ ['pending', 'running'].map((view) =>
141
+ pve<unknown>(target, 'read', 'GET', `cluster/sdn/fabrics/all?${view}=1`).pipe(
142
+ Effect.map(canonical),
143
+ Effect.catchIf(subsystemAbsent, () => Effect.succeed(undefined)),
144
+ ),
145
+ ),
146
+ { concurrency: 'unbounded' },
147
+ ).pipe(
148
+ Effect.flatMap(([staged, running]) => {
149
+ const verdict = fabricStaged(staged, running);
150
+ return verdict === 'inconsistent'
151
+ ? Effect.die(
152
+ new Error(
153
+ 'cluster/sdn/fabrics/all answered 501 for one of ?pending=1 and ?running=1 and not ' +
154
+ 'the other. Refusing to read that as a staged fabric change.',
155
+ ),
156
+ )
157
+ : Effect.succeed(verdict);
158
+ }),
159
+ );
160
+
161
+ /** Stable JSON with `digest` removed and every array sorted, so only real differences show. */
162
+ export const canonical = (value: unknown): string => JSON.stringify(sortDeep(value));
163
+
164
+ const sortDeep = (value: unknown): unknown => {
165
+ if (Array.isArray(value)) {
166
+ return value
167
+ .map(sortDeep)
168
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
169
+ }
170
+ if (value !== null && typeof value === 'object') {
171
+ const entries = Object.entries(value as Record<string, unknown>)
172
+ .filter(([key]) => key !== 'digest')
173
+ .sort(([left], [right]) => left.localeCompare(right));
174
+ return Object.fromEntries(entries.map(([key, inner]) => [key, sortDeep(inner)]));
175
+ }
176
+ return value;
177
+ };
178
+
179
+ /**
180
+ * How many staged changes the apply would publish, across everything it is capable of publishing.
181
+ *
182
+ * ⛔ ZERO HERE IS THE ONLY THING THAT MAKES DECLARING THE APPLY SAFE. `reconcile` runs on CREATE —
183
+ * Alchemy always calls it the first time a resource appears — and `PUT /cluster/sdn` regenerates
184
+ * `/etc/network/interfaces.d/sdn` on every node. On TB4 that file carries Ceph's cluster network.
185
+ */
186
+ export const sdnPendingCount = (target: PveTarget) =>
187
+ Effect.all([markedPending(target), fabricPending(target)], { concurrency: 'unbounded' }).pipe(
188
+ Effect.map(([marked, fabric]) => marked + fabric),
189
+ );
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `Proxmox.SdnApply` — the call that makes a declared network real.
3
+ *
4
+ * ⛔ WITHOUT THIS RESOURCE, EVERY SDN DEPLOY IS A GREEN PLAN THAT CHANGES NO NETWORK. Writes under
5
+ * `cluster/sdn/*` edit files in `/etc/pve/sdn/` and touch nothing on any node. `PUT /cluster/sdn`
6
+ * is the apply, and it is CLUSTER-WIDE — it publishes every staged object at once, not the one
7
+ * that happened to reconcile.
8
+ *
9
+ * ★ MEASURED ON TB4 ON 2026-09-13, NOT INFERRED. A probe zone was created, inspected and removed:
10
+ *
11
+ * pvesh create /cluster/sdn/zones --zone zzprobe --type simple
12
+ * GET /cluster/sdn/zones/zzprobe -> {"digest":"c438…","type":"simple","zone":"zzprobe"}
13
+ * GET /cluster/sdn/zones/zzprobe?pending=1 -> {"digest":null,"pending":{},"state":"new",…}
14
+ * GET /cluster/sdn/zones?running=1 -> [] ⬅ THE APPLIED CONFIG WAS EMPTY
15
+ *
16
+ * So the plain read — the one `pveOperations` performs — answers from the STAGED file and cannot
17
+ * tell staged from applied. `state` appears only under `?pending=1`, and `?running=1` is the
18
+ * applied view. That is the whole basis for this resource.
19
+ *
20
+ * ★ IT IS A RESOURCE RATHER THAN A HOOK SO THAT THE GRAPH ORDERS IT. Pass the zones and vnets it
21
+ * should follow in `after`; Alchemy orders by data flow, so reading their attributes is what
22
+ * puts the apply last. A stack-level hook would fire on every run, including runs where no SDN
23
+ * object changed, and would be invisible in `plan`.
24
+ *
25
+ * ⚠️ ONE APPLY PER STACK. Two of these would each publish the other's staged half, so whichever
26
+ * ran first would apply a zone its own declaration had not finished writing. Declare one.
27
+ *
28
+ * ⚠️ THE COLLECTION READ IS DELIBERATE, AND IT IS WHAT LETS THE READ LEASE SEE ANYTHING AT ALL.
29
+ * `GET /cluster/sdn/zones/{zone}` is checked against SDN.Allocate — read the ⛔ in sdn-zone.ts —
30
+ * but `GET /cluster/sdn/zones` is "list entries where you have SDN.Audit or SDN.Allocate", so
31
+ * the collection answers an auditor-shaped credential. This resource only ever reads collections.
32
+ */
33
+ import { Resource } from 'alchemy';
34
+ import { isResolved } from 'alchemy/Diff';
35
+ import type { Input } from 'alchemy/Input';
36
+ import * as Provider from 'alchemy/Provider';
37
+ import * as Effect from 'effect/Effect';
38
+ import { pve } from './client.ts';
39
+ import type { PveRequirements } from './resource.ts';
40
+ import type { WithTarget } from './resource.ts';
41
+ import { sdnPendingCount } from './sdn-apply-read.ts';
42
+
43
+ export interface SdnApplyProps extends WithTarget {
44
+ /**
45
+ * The staged objects this apply publishes.
46
+ *
47
+ * ⚠️ IT IS NEVER READ, AND IT IS NOT DECORATION. Alchemy orders resources by DATA FLOW, so the
48
+ * only way to say "after the zones" is to consume something the zones produced. Pass their
49
+ * attributes here — `after: [zone.zone, vnet.vnet]` — and the apply lands last. Leave it out
50
+ * and the apply may run BEFORE the zone it was meant to publish, which plans green and
51
+ * changes nothing.
52
+ */
53
+ after?: readonly unknown[];
54
+ }
55
+
56
+ export interface SdnApplyAttributes {
57
+ /**
58
+ * How many SDN objects are still staged.
59
+ *
60
+ * ⚠️ ZERO IS THE ONLY SETTLED VALUE. Anything above it means the running config and the staged
61
+ * config disagree — which is drift whether this stack caused it or somebody staged a zone in
62
+ * the UI and walked away.
63
+ */
64
+ pending: number;
65
+ }
66
+
67
+ export interface ProxmoxSdnApply extends Resource<
68
+ 'Proxmox.SdnApply',
69
+ SdnApplyProps,
70
+ SdnApplyAttributes,
71
+ never,
72
+ PveRequirements
73
+ > {}
74
+
75
+ export const ProxmoxSdnApply = Resource<ProxmoxSdnApply>('Proxmox.SdnApply');
76
+
77
+ const read = (props: SdnApplyProps) =>
78
+ sdnPendingCount(props.target).pipe(Effect.map((pending) => ({ pending })));
79
+
80
+ export const ProxmoxSdnApplyProvider = () =>
81
+ Provider.effect(
82
+ ProxmoxSdnApply,
83
+ Effect.succeed(
84
+ ProxmoxSdnApply.Provider.of({
85
+ /** ⛔ Empty for the same reason as every other resource here: adoption must be explicit. */
86
+ list: () => Effect.succeed([]),
87
+
88
+ read: Effect.fn(function* ({ olds }) {
89
+ return yield* read(olds);
90
+ }),
91
+
92
+ /**
93
+ * ⚠️ THE DIFF IS ABOUT THE CLUSTER, NOT ABOUT THE PROPS. This resource has no settable
94
+ * field — `after` exists only to create an ordering edge — so comparing props to props
95
+ * would report `noop` forever and the apply would run exactly once, ever. What decides
96
+ * it is whether anything is staged right now.
97
+ */
98
+ diff: Effect.fn(function* ({ news }: { news: Input<SdnApplyProps> }) {
99
+ if (!isResolved(news)) return undefined;
100
+ const { pending } = yield* read(news);
101
+ return pending === 0 ? ({ action: 'noop' } as const) : ({ action: 'update' } as const);
102
+ }),
103
+
104
+ reconcile: Effect.fn(function* ({ news }) {
105
+ /**
106
+ * ⛔ NOTHING STAGED MEANS NOTHING TO PUBLISH, AND THIS BRANCH IS A SAFETY PROPERTY RATHER
107
+ * THAN AN OPTIMISATION — the same one network-apply.ts has carried all along, and the
108
+ * absence of which is why this resource could not be declared at all.
109
+ *
110
+ * 🔴 WHAT IT PREVENTS. `reconcile` runs on CREATE, the first time this resource appears
111
+ * in a stack, before `diff` has ever been consulted. `PUT /cluster/sdn` regenerates
112
+ * `/etc/network/interfaces.d/sdn` on EVERY node at once — and on TB4 that file carries
113
+ * the OSPF fabric holding Ceph's `cluster_network` on en05/en06. Without this branch,
114
+ * adding one line to a stack file reloads networking on all three nodes simultaneously,
115
+ * which is precisely what the serialised NetworkApply chain exists to prevent.
116
+ *
117
+ * ⚠️ IT DEPENDS ON sdn-apply-read.ts BEING HONEST ABOUT THE WHOLE SURFACE. A count that
118
+ * only looks at zones and vnets answers zero on a cluster with a staged fabric, and
119
+ * this branch would then wave through the exact publish it is here to stop.
120
+ */
121
+ const staged = yield* read(news);
122
+ if (staged.pending === 0) return staged;
123
+
124
+ yield* pve(news.target, 'provision', 'PUT', 'cluster/sdn');
125
+ /**
126
+ * ⛔ READ BACK, FOR THE SAME REASON THE FACTORY DOES. PVE answers 200 on writes that did
127
+ * nothing, and here "nothing" is indistinguishable from success by status code alone.
128
+ * If objects are still staged after an apply, the network does NOT match the
129
+ * declaration and saying otherwise is the exact lie this resource was added to stop.
130
+ */
131
+ const after = yield* read(news);
132
+ if (after.pending !== 0) {
133
+ return yield* Effect.die(
134
+ new Error(
135
+ `PUT /cluster/sdn returned no error but ${String(after.pending)} SDN object(s) ` +
136
+ 'are still staged. The running config does not match the declared one -- check ' +
137
+ '`pvesh get /cluster/sdn/zones --pending 1` for the objects PVE refused.',
138
+ ),
139
+ );
140
+ }
141
+ return after;
142
+ }),
143
+
144
+ /**
145
+ * ⛔ DELETING THIS RESOURCE APPLIES NOTHING AND UNDOES NOTHING, AND THAT IS CORRECT. There
146
+ * is no un-apply in PVE: the running config is already published on every node, and the
147
+ * way to remove a network is to delete the zone or vnet and apply AGAIN. Making this
148
+ * destroy call `PUT /cluster/sdn` would publish whatever happened to be staged at the
149
+ * moment somebody removed a line from a stack file, which is the worst possible time.
150
+ */
151
+ delete: () => Effect.void,
152
+ }),
153
+ ),
154
+ );