@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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/cloudflare/client.d.ts +45 -0
- package/dist/cloudflare/client.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +11 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +129 -0
- package/dist/cloudflare/index.js.map +13 -0
- package/dist/cloudflare/lock-rules.d.ts +50 -0
- package/dist/cloudflare/lock-rules.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +19 -0
- package/dist/cloudflare/providers.d.ts.map +1 -0
- package/dist/cloudflare/r2-bucket-lock.d.ts +100 -0
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -0
- package/dist/forgejo/branch-protection-form.d.ts +19 -0
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -0
- package/dist/forgejo/branch-protection.d.ts +47 -0
- package/dist/forgejo/branch-protection.d.ts.map +1 -0
- package/dist/forgejo/client.d.ts +37 -0
- package/dist/forgejo/client.d.ts.map +1 -0
- package/dist/forgejo/index.d.ts +18 -0
- package/dist/forgejo/index.d.ts.map +1 -0
- package/dist/forgejo/index.js +479 -0
- package/dist/forgejo/index.js.map +21 -0
- package/dist/forgejo/org-actions-secrets.d.ts +45 -0
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -0
- package/dist/forgejo/org-label.d.ts +46 -0
- package/dist/forgejo/org-label.d.ts.map +1 -0
- package/dist/forgejo/org-team.d.ts +42 -0
- package/dist/forgejo/org-team.d.ts.map +1 -0
- package/dist/forgejo/repo-webhook-form.d.ts +27 -0
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -0
- package/dist/forgejo/repo-webhook.d.ts +46 -0
- package/dist/forgejo/repo-webhook.d.ts.map +1 -0
- package/dist/forgejo/repository.d.ts +45 -0
- package/dist/forgejo/repository.d.ts.map +1 -0
- package/dist/forgejo/resource.d.ts +83 -0
- package/dist/forgejo/resource.d.ts.map +1 -0
- package/dist/forgejo/team-member.d.ts +39 -0
- package/dist/forgejo/team-member.d.ts.map +1 -0
- package/dist/forgejo/values.d.ts +24 -0
- package/dist/forgejo/values.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +9 -0
- package/dist/openbao/auth-role-form.d.ts +38 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts +20 -0
- package/dist/openbao/auth-role.d.ts.map +1 -0
- package/dist/openbao/bao-address.d.ts +72 -0
- package/dist/openbao/bao-address.d.ts.map +1 -0
- package/dist/openbao/bao-http.d.ts +71 -0
- package/dist/openbao/bao-http.d.ts.map +1 -0
- package/dist/openbao/bao-status.d.ts +74 -0
- package/dist/openbao/bao-status.d.ts.map +1 -0
- package/dist/openbao/cloudflare-group-scope.d.ts +73 -0
- package/dist/openbao/cloudflare-group-scope.d.ts.map +1 -0
- package/dist/openbao/cloudflare-parity-catalog.d.ts +28 -0
- package/dist/openbao/cloudflare-parity-catalog.d.ts.map +1 -0
- package/dist/openbao/cloudflare-parity-snapshot.d.ts +31 -0
- package/dist/openbao/cloudflare-parity-snapshot.d.ts.map +1 -0
- package/dist/openbao/cloudflare-parity.d.ts +14 -0
- package/dist/openbao/cloudflare-parity.d.ts.map +1 -0
- package/dist/openbao/cloudflare-permission-groups-solve.d.ts +85 -0
- package/dist/openbao/cloudflare-permission-groups-solve.d.ts.map +1 -0
- package/dist/openbao/cloudflare-permission-groups.d.ts +87 -0
- package/dist/openbao/cloudflare-permission-groups.d.ts.map +1 -0
- package/dist/openbao/cloudflare-policy.d.ts +65 -0
- package/dist/openbao/cloudflare-policy.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role-form.d.ts +110 -0
- package/dist/openbao/cloudflare-role-form.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role-wire.d.ts +27 -0
- package/dist/openbao/cloudflare-role-wire.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts +35 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-config.d.ts +67 -0
- package/dist/openbao/cloudflare-roles-config.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts +76 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -0
- package/dist/openbao/digest.d.ts +24 -0
- package/dist/openbao/digest.d.ts.map +1 -0
- package/dist/openbao/fake-bao.d.ts +40 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -0
- package/dist/openbao/forgejo-bootstrap-parse.d.ts +46 -0
- package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -0
- package/dist/openbao/forgejo-bootstrap.d.ts +29 -0
- package/dist/openbao/forgejo-bootstrap.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +18 -0
- package/dist/openbao/index.d.ts.map +1 -0
- package/dist/openbao/index.js +1158 -0
- package/dist/openbao/index.js.map +33 -0
- package/dist/openbao/mount-form.d.ts +60 -0
- package/dist/openbao/mount-form.d.ts.map +1 -0
- package/dist/openbao/mount-wire.d.ts +24 -0
- package/dist/openbao/mount-wire.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +20 -0
- package/dist/openbao/mount.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +118 -0
- package/dist/openbao/pki-role-form.d.ts.map +1 -0
- package/dist/openbao/pki-role.d.ts +50 -0
- package/dist/openbao/pki-role.d.ts.map +1 -0
- package/dist/openbao/policy-assembly.d.ts +13 -0
- package/dist/openbao/policy-assembly.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +41 -0
- package/dist/openbao/policy-wire.d.ts.map +1 -0
- package/dist/openbao/policy.d.ts +83 -0
- package/dist/openbao/policy.d.ts.map +1 -0
- package/dist/openbao/proxmox-role-form.d.ts +92 -0
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts +47 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -0
- package/dist/openbao/ssh-role-form.d.ts +99 -0
- package/dist/openbao/ssh-role-form.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts +43 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts +72 -0
- package/dist/proxmox/acl.d.ts.map +1 -0
- package/dist/proxmox/api-token-form.d.ts +59 -0
- package/dist/proxmox/api-token-form.d.ts.map +1 -0
- package/dist/proxmox/api-token.d.ts +159 -0
- package/dist/proxmox/api-token.d.ts.map +1 -0
- package/dist/proxmox/backup-job.d.ts +112 -0
- package/dist/proxmox/backup-job.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon-form.d.ts +70 -0
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +162 -0
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag.d.ts +131 -0
- package/dist/proxmox/ceph-flag.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +106 -0
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs.d.ts +124 -0
- package/dist/proxmox/ceph-fs.d.ts.map +1 -0
- package/dist/proxmox/ceph-osd-tree.d.ts +154 -0
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -0
- package/dist/proxmox/ceph-osd-write.d.ts +36 -0
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -0
- package/dist/proxmox/ceph-osd.d.ts +102 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +217 -0
- package/dist/proxmox/ceph-pool-form.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-settle.d.ts +39 -0
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +48 -0
- package/dist/proxmox/ceph-pool.d.ts.map +1 -0
- package/dist/proxmox/client.d.ts +64 -0
- package/dist/proxmox/client.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +153 -0
- package/dist/proxmox/credentials.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +98 -0
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +141 -0
- package/dist/proxmox/firewall-alias.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs.d.ts +388 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -0
- package/dist/proxmox/generated/pve.d.ts +4713 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +104 -0
- package/dist/proxmox/group.d.ts.map +1 -0
- package/dist/proxmox/ha-resource.d.ts +104 -0
- package/dist/proxmox/ha-resource.d.ts.map +1 -0
- package/dist/proxmox/ha-rule-form.d.ts +103 -0
- package/dist/proxmox/ha-rule-form.d.ts.map +1 -0
- package/dist/proxmox/ha-rule.d.ts +156 -0
- package/dist/proxmox/ha-rule.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +43 -0
- package/dist/proxmox/index.d.ts.map +1 -0
- package/dist/proxmox/index.js +2322 -0
- package/dist/proxmox/index.js.map +70 -0
- package/dist/proxmox/lease-cache.d.ts +98 -0
- package/dist/proxmox/lease-cache.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +59 -0
- package/dist/proxmox/lxc.d.ts.map +1 -0
- package/dist/proxmox/members.d.ts +56 -0
- package/dist/proxmox/members.d.ts.map +1 -0
- package/dist/proxmox/metric-server-form.d.ts +53 -0
- package/dist/proxmox/metric-server-form.d.ts.map +1 -0
- package/dist/proxmox/metric-server-otel.d.ts +49 -0
- package/dist/proxmox/metric-server-otel.d.ts.map +1 -0
- package/dist/proxmox/metric-server.d.ts +123 -0
- package/dist/proxmox/metric-server.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +93 -0
- package/dist/proxmox/network-apply-read.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +102 -0
- package/dist/proxmox/network-apply.d.ts.map +1 -0
- package/dist/proxmox/node-network-form.d.ts +128 -0
- package/dist/proxmox/node-network-form.d.ts.map +1 -0
- package/dist/proxmox/node-network.d.ts +173 -0
- package/dist/proxmox/node-network.d.ts.map +1 -0
- package/dist/proxmox/notification-target-form.d.ts +44 -0
- package/dist/proxmox/notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/notification-target.d.ts +146 -0
- package/dist/proxmox/notification-target.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore-form.d.ts +147 -0
- package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore-guard.d.ts +55 -0
- package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts +140 -0
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job-form.d.ts +97 -0
- package/dist/proxmox/pbs-prune-job-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job.d.ts +168 -0
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job-form.d.ts +83 -0
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job.d.ts +246 -0
- package/dist/proxmox/pbs-sync-job.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job-form.d.ts +114 -0
- package/dist/proxmox/pbs-verify-job-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job.d.ts +145 -0
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -0
- package/dist/proxmox/pool.d.ts +42 -0
- package/dist/proxmox/pool.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +46 -0
- package/dist/proxmox/qemu.d.ts.map +1 -0
- package/dist/proxmox/replication-job-form.d.ts +71 -0
- package/dist/proxmox/replication-job-form.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +162 -0
- package/dist/proxmox/replication-job.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +157 -0
- package/dist/proxmox/resource.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +97 -0
- package/dist/proxmox/role.d.ts.map +1 -0
- package/dist/proxmox/sdn-apply-read.d.ts +80 -0
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -0
- package/dist/proxmox/sdn-apply.d.ts +63 -0
- package/dist/proxmox/sdn-apply.d.ts.map +1 -0
- package/dist/proxmox/sdn-subnet-form.d.ts +111 -0
- package/dist/proxmox/sdn-subnet-form.d.ts.map +1 -0
- package/dist/proxmox/sdn-subnet.d.ts +161 -0
- package/dist/proxmox/sdn-subnet.d.ts.map +1 -0
- package/dist/proxmox/sdn-vnet.d.ts +113 -0
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -0
- package/dist/proxmox/sdn-zone.d.ts +126 -0
- package/dist/proxmox/sdn-zone.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +120 -0
- package/dist/proxmox/storage.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +107 -0
- package/dist/proxmox/user.d.ts.map +1 -0
- package/dist/proxmox/values.d.ts +134 -0
- package/dist/proxmox/values.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +76 -0
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +146 -0
- package/dist/proxmox/zfs-pool.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +30 -0
- package/dist/talos/credentials.d.ts.map +1 -0
- package/dist/talos/index.d.ts +15 -0
- package/dist/talos/index.d.ts.map +1 -0
- package/dist/talos/index.js +392 -0
- package/dist/talos/index.js.map +16 -0
- package/dist/talos/kubeconfig.d.ts +53 -0
- package/dist/talos/kubeconfig.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +32 -0
- package/dist/talos/resource.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +27 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -0
- package/dist/talos/talos-cluster-health.d.ts +36 -0
- package/dist/talos/talos-cluster-health.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +41 -0
- package/dist/talos/talos-machine-config.d.ts.map +1 -0
- package/dist/talos/talosctl.d.ts +29 -0
- package/dist/talos/talosctl.d.ts.map +1 -0
- package/dist/talos/values.d.ts +20 -0
- package/dist/talos/values.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/cloudflare/client.ts +76 -0
- package/src/cloudflare/index.ts +10 -0
- package/src/cloudflare/lock-rules.ts +60 -0
- package/src/cloudflare/providers.ts +23 -0
- package/src/cloudflare/r2-bucket-lock.ts +220 -0
- package/src/forgejo/branch-protection-form.ts +29 -0
- package/src/forgejo/branch-protection.ts +101 -0
- package/src/forgejo/client.ts +136 -0
- package/src/forgejo/index.ts +17 -0
- package/src/forgejo/org-actions-secrets.ts +99 -0
- package/src/forgejo/org-label.ts +100 -0
- package/src/forgejo/org-team.ts +105 -0
- package/src/forgejo/repo-webhook-form.ts +47 -0
- package/src/forgejo/repo-webhook.ts +118 -0
- package/src/forgejo/repository.ts +126 -0
- package/src/forgejo/resource.ts +171 -0
- package/src/forgejo/team-member.ts +97 -0
- package/src/forgejo/values.test.ts +48 -0
- package/src/forgejo/values.ts +61 -0
- package/src/index.ts +18 -0
- package/src/openbao/auth-role-form.ts +112 -0
- package/src/openbao/auth-role.ts +101 -0
- package/src/openbao/bao-address.test.ts +89 -0
- package/src/openbao/bao-address.ts +97 -0
- package/src/openbao/bao-http-gate.test.ts +88 -0
- package/src/openbao/bao-http-unix.test.ts +70 -0
- package/src/openbao/bao-http.test.ts +161 -0
- package/src/openbao/bao-http.ts +185 -0
- package/src/openbao/bao-status.test.ts +114 -0
- package/src/openbao/bao-status.ts +137 -0
- package/src/openbao/cloudflare-group-scope.test.ts +103 -0
- package/src/openbao/cloudflare-group-scope.ts +83 -0
- package/src/openbao/cloudflare-parity-catalog.ts +104 -0
- package/src/openbao/cloudflare-parity-snapshot.ts +84 -0
- package/src/openbao/cloudflare-parity.ts +152 -0
- package/src/openbao/cloudflare-permission-groups-solve.test.ts +171 -0
- package/src/openbao/cloudflare-permission-groups-solve.ts +197 -0
- package/src/openbao/cloudflare-permission-groups.test.ts +369 -0
- package/src/openbao/cloudflare-permission-groups.ts +299 -0
- package/src/openbao/cloudflare-policy.ts +180 -0
- package/src/openbao/cloudflare-role-form.test.ts +177 -0
- package/src/openbao/cloudflare-role-form.ts +183 -0
- package/src/openbao/cloudflare-role-wire.test.ts +132 -0
- package/src/openbao/cloudflare-role-wire.ts +41 -0
- package/src/openbao/cloudflare-role.ts +189 -0
- package/src/openbao/cloudflare-roles-config.ts +230 -0
- package/src/openbao/cloudflare-roles-expand-bucket.test.ts +134 -0
- package/src/openbao/cloudflare-roles-expand.ts +231 -0
- package/src/openbao/digest.ts +26 -0
- package/src/openbao/fake-bao.ts +84 -0
- package/src/openbao/forgejo-bootstrap-parse.test.ts +76 -0
- package/src/openbao/forgejo-bootstrap-parse.ts +90 -0
- package/src/openbao/forgejo-bootstrap.ts +181 -0
- package/src/openbao/index.ts +17 -0
- package/src/openbao/mount-form.ts +147 -0
- package/src/openbao/mount-wire.test.ts +108 -0
- package/src/openbao/mount-wire.ts +79 -0
- package/src/openbao/mount.ts +132 -0
- package/src/openbao/pki-role-form.ts +248 -0
- package/src/openbao/pki-role.ts +202 -0
- package/src/openbao/policy-assembly.test.ts +28 -0
- package/src/openbao/policy-assembly.ts +13 -0
- package/src/openbao/policy-wire.test.ts +90 -0
- package/src/openbao/policy-wire.ts +53 -0
- package/src/openbao/policy.ts +212 -0
- package/src/openbao/proxmox-role-form.ts +174 -0
- package/src/openbao/proxmox-role.ts +214 -0
- package/src/openbao/ssh-role-form.ts +248 -0
- package/src/openbao/ssh-role.ts +240 -0
- package/src/proxmox/acl.ts +267 -0
- package/src/proxmox/api-token-form.ts +122 -0
- package/src/proxmox/api-token.ts +222 -0
- package/src/proxmox/backup-job.ts +213 -0
- package/src/proxmox/ceph-daemon-form.ts +84 -0
- package/src/proxmox/ceph-daemon.ts +243 -0
- package/src/proxmox/ceph-flag.ts +249 -0
- package/src/proxmox/ceph-fs-wire.ts +246 -0
- package/src/proxmox/ceph-fs.ts +249 -0
- package/src/proxmox/ceph-osd-tree.ts +193 -0
- package/src/proxmox/ceph-osd-write.ts +76 -0
- package/src/proxmox/ceph-osd.ts +244 -0
- package/src/proxmox/ceph-pool-form.ts +249 -0
- package/src/proxmox/ceph-pool-settle.ts +70 -0
- package/src/proxmox/ceph-pool.ts +212 -0
- package/src/proxmox/client.test.ts +185 -0
- package/src/proxmox/client.ts +240 -0
- package/src/proxmox/credentials.test.ts +144 -0
- package/src/proxmox/credentials.ts +254 -0
- package/src/proxmox/firewall-alias-form.ts +112 -0
- package/src/proxmox/firewall-alias.ts +208 -0
- package/src/proxmox/generated/pbs.ts +461 -0
- package/src/proxmox/generated/pve.ts +7344 -0
- package/src/proxmox/group.ts +199 -0
- package/src/proxmox/ha-resource.ts +169 -0
- package/src/proxmox/ha-rule-form.ts +185 -0
- package/src/proxmox/ha-rule.ts +240 -0
- package/src/proxmox/index.ts +42 -0
- package/src/proxmox/lease-cache.test.ts +163 -0
- package/src/proxmox/lease-cache.ts +131 -0
- package/src/proxmox/lxc.ts +117 -0
- package/src/proxmox/members.test.ts +54 -0
- package/src/proxmox/members.ts +174 -0
- package/src/proxmox/metric-server-form.ts +128 -0
- package/src/proxmox/metric-server-otel.ts +99 -0
- package/src/proxmox/metric-server.ts +214 -0
- package/src/proxmox/network-apply-read.ts +228 -0
- package/src/proxmox/network-apply.ts +244 -0
- package/src/proxmox/node-network-form.ts +213 -0
- package/src/proxmox/node-network.ts +243 -0
- package/src/proxmox/notification-target-form.ts +104 -0
- package/src/proxmox/notification-target.ts +222 -0
- package/src/proxmox/pbs-datastore-form.ts +248 -0
- package/src/proxmox/pbs-datastore-guard.ts +105 -0
- package/src/proxmox/pbs-datastore.ts +271 -0
- package/src/proxmox/pbs-prune-job-form.ts +208 -0
- package/src/proxmox/pbs-prune-job.ts +227 -0
- package/src/proxmox/pbs-sync-job-form.ts +234 -0
- package/src/proxmox/pbs-sync-job.ts +258 -0
- package/src/proxmox/pbs-verify-job-form.ts +131 -0
- package/src/proxmox/pbs-verify-job.ts +227 -0
- package/src/proxmox/pool.test.ts +42 -0
- package/src/proxmox/pool.ts +80 -0
- package/src/proxmox/qemu.ts +89 -0
- package/src/proxmox/replication-job-form.ts +89 -0
- package/src/proxmox/replication-job.ts +248 -0
- package/src/proxmox/resource.ts +257 -0
- package/src/proxmox/role.ts +161 -0
- package/src/proxmox/sdn-apply-read.test.ts +152 -0
- package/src/proxmox/sdn-apply-read.ts +189 -0
- package/src/proxmox/sdn-apply.ts +154 -0
- package/src/proxmox/sdn-subnet-form.ts +156 -0
- package/src/proxmox/sdn-subnet.ts +238 -0
- package/src/proxmox/sdn-vnet.ts +228 -0
- package/src/proxmox/sdn-zone.ts +246 -0
- package/src/proxmox/storage.ts +245 -0
- package/src/proxmox/user.ts +214 -0
- package/src/proxmox/values.test.ts +193 -0
- package/src/proxmox/values.ts +203 -0
- package/src/proxmox/zfs-pool-write.ts +130 -0
- package/src/proxmox/zfs-pool.ts +253 -0
- package/src/talos/credentials.ts +136 -0
- package/src/talos/index.ts +14 -0
- package/src/talos/kubeconfig.ts +170 -0
- package/src/talos/resource.ts +31 -0
- package/src/talos/talos-bootstrap.ts +109 -0
- package/src/talos/talos-cluster-health.ts +119 -0
- package/src/talos/talos-machine-config.ts +146 -0
- package/src/talos/talosctl.ts +94 -0
- package/src/talos/values.test.ts +80 -0
- 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
|
+
);
|