@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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-flight cap on OpenBao exchanges (`BaoGate` in bao-http.ts), counted where the listener that
|
|
3
|
+
* died on 2026-09-14 counted it: on the server. The fake holds every request for a few milliseconds
|
|
4
|
+
* and records the most it ever held at once.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
10
|
+
import { BaoGate, MAX_IN_FLIGHT, baoRead } from './bao-http.ts';
|
|
11
|
+
import { type Reply, run, runFailure, withFake } from './fake-bao.ts';
|
|
12
|
+
|
|
13
|
+
/** A fake that holds each request `holdMs`, answering `reply`, and remembers its peak overlap. */
|
|
14
|
+
const holding = (reply: (path: string) => Reply, holdMs = 15) => {
|
|
15
|
+
const counts = { inFlight: 0, peak: 0 };
|
|
16
|
+
const answer = async (seen: { path: string }) => {
|
|
17
|
+
counts.inFlight += 1;
|
|
18
|
+
counts.peak = Math.max(counts.peak, counts.inFlight);
|
|
19
|
+
await Bun.sleep(holdMs);
|
|
20
|
+
counts.inFlight -= 1;
|
|
21
|
+
return reply(seen.path);
|
|
22
|
+
};
|
|
23
|
+
return { answer, counts };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const OK: Reply = { json: { data: {} }, status: 200 };
|
|
27
|
+
|
|
28
|
+
const burst = (address: string, calls: number, gate?: Semaphore.Semaphore) =>
|
|
29
|
+
Promise.all(
|
|
30
|
+
Array.from({ length: calls }, (_, index) => {
|
|
31
|
+
const read = baoRead(`kv/burst/${String(index)}`);
|
|
32
|
+
return run(
|
|
33
|
+
{ BAO_ADDR: address },
|
|
34
|
+
gate === undefined ? read : read.pipe(Effect.provideService(BaoGate, gate)),
|
|
35
|
+
);
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe('BaoGate', () => {
|
|
40
|
+
it('never lets more than the gate through at once, and still answers every call', async () => {
|
|
41
|
+
const fake = holding(() => OK);
|
|
42
|
+
await withFake(fake.answer, async (bao) => {
|
|
43
|
+
const results = await burst(bao.address, 40, Semaphore.makeUnsafe(4));
|
|
44
|
+
assert.equal(results.length, 40);
|
|
45
|
+
assert.equal(bao.seen.length, 40);
|
|
46
|
+
// ★ EQUAL, NOT AT-MOST: 4 proves the calls really overlapped, so the cap was what held them.
|
|
47
|
+
assert.equal(fake.counts.peak, 4);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('caps an unprovided burst at MAX_IN_FLIGHT — the gate every provider shares', async () => {
|
|
52
|
+
const fake = holding(() => OK);
|
|
53
|
+
await withFake(fake.answer, async (bao) => {
|
|
54
|
+
await burst(bao.address, MAX_IN_FLIGHT * 3);
|
|
55
|
+
assert.equal(fake.counts.peak, MAX_IN_FLIGHT);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ★ THE SENSITIVITY CHECK: the same burst through a wide gate overlaps far past 4, so the tests
|
|
60
|
+
// above would fail if the permit were dropped from baoCall.
|
|
61
|
+
it('lets the burst overlap when the gate is wide, so the cap above is doing the holding', async () => {
|
|
62
|
+
const fake = holding(() => OK);
|
|
63
|
+
await withFake(fake.answer, async (bao) => {
|
|
64
|
+
await burst(bao.address, 40, Semaphore.makeUnsafe(1000));
|
|
65
|
+
assert.ok(fake.counts.peak > 4, `peak was ${String(fake.counts.peak)}`);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('gives a permit back when OpenBao refuses the call', async () => {
|
|
70
|
+
const denied: Reply = { json: { errors: ['permission denied'] }, status: 403 };
|
|
71
|
+
const fake = holding((path) => (path.includes('/refused/') ? denied : OK), 2);
|
|
72
|
+
await withFake(fake.answer, async (bao) => {
|
|
73
|
+
const gate = Semaphore.makeUnsafe(2);
|
|
74
|
+
const env = { BAO_ADDR: bao.address };
|
|
75
|
+
const gated = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
76
|
+
effect.pipe(Effect.provideService(BaoGate, gate));
|
|
77
|
+
await Promise.all(
|
|
78
|
+
Array.from({ length: 10 }, (_, index) =>
|
|
79
|
+
runFailure(env, gated(baoRead(`kv/refused/${String(index)}`))),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
// ⛔ A LEAKED PERMIT HANGS HERE rather than failing, so the race turns a hang into a failure.
|
|
83
|
+
const after = run(env, gated(baoRead('kv/after')));
|
|
84
|
+
const hung = Bun.sleep(2000).then(() => 'hung' as const);
|
|
85
|
+
assert.notEqual(await Promise.race([after, hung]), 'hung');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unix-socket path, against a fake agent listener on Bun.serve's `unix` option.
|
|
3
|
+
*
|
|
4
|
+
* ★ An OpenBao Agent `listener "unix"` serves the API on a socket; BAO_ADDR / BAO_AGENT_ADDR name
|
|
5
|
+
* it as `unix:///path`. These prove the request really leaves over the socket — through the real
|
|
6
|
+
* FetchHttpClient layer, with no TCP server to fall back on.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { describe, it } from 'node:test';
|
|
13
|
+
import { baoDelete, baoRead } from './bao-http.ts';
|
|
14
|
+
import { fakeBao, run, withFake } from './fake-bao.ts';
|
|
15
|
+
|
|
16
|
+
const socketPath = (label: string) => {
|
|
17
|
+
const path = join(tmpdir(), `fake-bao-${label}-${String(process.pid)}.sock`);
|
|
18
|
+
rmSync(path, { force: true });
|
|
19
|
+
return path;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('a unix:// address', () => {
|
|
23
|
+
it('reads over the socket, in agent mode, with the namespace', async () => {
|
|
24
|
+
const path = socketPath('read');
|
|
25
|
+
await withFake(
|
|
26
|
+
() => ({ json: { data: { type: 'kv' } }, status: 200 }),
|
|
27
|
+
async (agent) => {
|
|
28
|
+
const env = { BAO_ADDR: agent.address, BAO_NAMESPACE: 'homeflare' };
|
|
29
|
+
assert.deepEqual(await run(env, baoRead('sys/mounts/kv')), { type: 'kv' });
|
|
30
|
+
assert.equal(agent.seen[0]?.path, '/v1/sys/mounts/kv');
|
|
31
|
+
assert.equal(agent.seen[0]?.headers.get('x-vault-namespace'), 'homeflare');
|
|
32
|
+
assert.equal(agent.seen[0]?.headers.has('x-vault-token'), false);
|
|
33
|
+
},
|
|
34
|
+
path,
|
|
35
|
+
);
|
|
36
|
+
rmSync(path, { force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('classifies over the socket the same way: 404 read absent, 404 delete success', async () => {
|
|
40
|
+
const path = socketPath('absent');
|
|
41
|
+
await withFake(
|
|
42
|
+
() => ({ json: { errors: [] }, status: 404 }),
|
|
43
|
+
async (agent) => {
|
|
44
|
+
const env = { BAO_ADDR: agent.address };
|
|
45
|
+
assert.equal(await run(env, baoRead('pki/roles/zz')), undefined);
|
|
46
|
+
await run(env, baoDelete('pki/roles/zz'));
|
|
47
|
+
assert.equal(agent.seen.length, 2);
|
|
48
|
+
},
|
|
49
|
+
path,
|
|
50
|
+
);
|
|
51
|
+
rmSync(path, { force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ★ api/client.go:761-762 — BAO_AGENT_ADDR wins. BAO_ADDR here points at a server that is gone,
|
|
55
|
+
// so the call only succeeds if it went to the agent socket.
|
|
56
|
+
it('prefers a BAO_AGENT_ADDR socket over BAO_ADDR', async () => {
|
|
57
|
+
const gone = fakeBao(() => ({ status: 200 }));
|
|
58
|
+
gone.stop();
|
|
59
|
+
const path = socketPath('agent');
|
|
60
|
+
await withFake(
|
|
61
|
+
() => ({ json: { data: { ok: true } }, status: 200 }),
|
|
62
|
+
async (agent) => {
|
|
63
|
+
const env = { BAO_ADDR: gone.address, BAO_AGENT_ADDR: agent.address };
|
|
64
|
+
assert.deepEqual(await run(env, baoRead('kv/config')), { ok: true });
|
|
65
|
+
},
|
|
66
|
+
path,
|
|
67
|
+
);
|
|
68
|
+
rmSync(path, { force: true });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP client against a fake OpenBao on Bun.serve: status classification end to end, the
|
|
3
|
+
* namespace header, the token header present with BAO_TOKEN and ABSENT in agent mode, and the
|
|
4
|
+
* token kept out of every error and every span.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Headers from 'effect/unstable/http/Headers';
|
|
10
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
11
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
12
|
+
import { BaoEnv, baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
13
|
+
import { BaoError } from './bao-status.ts';
|
|
14
|
+
import { fakeBao, run, runFailure, withFake } from './fake-bao.ts';
|
|
15
|
+
|
|
16
|
+
const TOKEN = 'hvs.FAKE-sentinel-never-a-real-token';
|
|
17
|
+
const NONE = { errors: [] };
|
|
18
|
+
|
|
19
|
+
const keepsTokenOut = (error: unknown) => {
|
|
20
|
+
assert.ok(error instanceof BaoError);
|
|
21
|
+
assert.ok(!error.message.includes(TOKEN), 'token in message');
|
|
22
|
+
assert.ok(!JSON.stringify(error).includes(TOKEN), 'token in JSON');
|
|
23
|
+
assert.ok(!Bun.inspect(error).includes(TOKEN), 'token in inspect');
|
|
24
|
+
return error;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('baoRead', () => {
|
|
28
|
+
it('returns data, sending the token, the namespace and X-Vault-Request', async () => {
|
|
29
|
+
const reply = { json: { data: { mint_user: 'hf-read@pve', ttl: 3600 } }, status: 200 };
|
|
30
|
+
await withFake(
|
|
31
|
+
() => reply,
|
|
32
|
+
async (bao) => {
|
|
33
|
+
const env = { BAO_ADDR: bao.address, BAO_NAMESPACE: 'homeflare', BAO_TOKEN: TOKEN };
|
|
34
|
+
const data = await run(env, baoRead('proxmox-tb4/roles/read'));
|
|
35
|
+
assert.deepEqual(data, { mint_user: 'hf-read@pve', ttl: 3600 });
|
|
36
|
+
const [seen] = bao.seen;
|
|
37
|
+
assert.equal(seen?.method, 'GET');
|
|
38
|
+
assert.equal(seen?.path, '/v1/proxmox-tb4/roles/read');
|
|
39
|
+
assert.equal(seen?.headers.get('x-vault-namespace'), 'homeflare');
|
|
40
|
+
assert.equal(seen?.headers.get('x-vault-token'), TOKEN);
|
|
41
|
+
assert.equal(seen?.headers.get('x-vault-request'), 'true');
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ★ AGENT MODE: the listener injects its auto-auth token only into a request that carries none.
|
|
47
|
+
it('sends no token header at all when BAO_TOKEN is unset', async () => {
|
|
48
|
+
await withFake(
|
|
49
|
+
() => ({ json: { data: {} }, status: 200 }),
|
|
50
|
+
async (bao) => {
|
|
51
|
+
await run({ BAO_ADDR: bao.address }, baoRead('kv/config'));
|
|
52
|
+
assert.equal(bao.seen[0]?.headers.has('x-vault-token'), false);
|
|
53
|
+
assert.equal(bao.seen[0]?.headers.has('x-vault-namespace'), false);
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads a 404 as absent', async () => {
|
|
59
|
+
await withFake(
|
|
60
|
+
() => ({ json: NONE, status: 404 }),
|
|
61
|
+
async (bao) => {
|
|
62
|
+
assert.equal(await run({ BAO_ADDR: bao.address }, baoRead('pki/roles/zz')), undefined);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('fails a 403 with OpenBao errors and without the token', async () => {
|
|
68
|
+
const denied = {
|
|
69
|
+
json: { errors: ['1 error occurred:\n\t* permission denied\n\n'] },
|
|
70
|
+
status: 403,
|
|
71
|
+
};
|
|
72
|
+
await withFake(
|
|
73
|
+
() => denied,
|
|
74
|
+
async (bao) => {
|
|
75
|
+
const env = { BAO_ADDR: bao.address, BAO_TOKEN: TOKEN };
|
|
76
|
+
const error = keepsTokenOut(await runFailure(env, baoRead('sys/policies/acl/x')));
|
|
77
|
+
assert.equal(error.status, 403);
|
|
78
|
+
assert.deepEqual(error.errors, denied.json.errors);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fails a sealed server (503)', async () => {
|
|
84
|
+
await withFake(
|
|
85
|
+
() => ({ json: { errors: ['Vault is sealed'] }, status: 503 }),
|
|
86
|
+
async (bao) => {
|
|
87
|
+
const env = { BAO_ADDR: bao.address, BAO_TOKEN: TOKEN };
|
|
88
|
+
assert.equal(keepsTokenOut(await runFailure(env, baoRead('kv/config'))).status, 503);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('fails a server that is not listening, as no response', async () => {
|
|
94
|
+
const gone = fakeBao(() => ({ status: 200 }));
|
|
95
|
+
gone.stop();
|
|
96
|
+
const env = { BAO_ADDR: gone.address, BAO_TOKEN: TOKEN };
|
|
97
|
+
const error = keepsTokenOut(await runFailure(env, baoRead('kv/config')));
|
|
98
|
+
assert.equal(error.status, 0);
|
|
99
|
+
assert.match(error.message, /-> no response: /);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ⛔ Effect writes request headers onto the client span; the token must be a redacted name.
|
|
103
|
+
it('adds x-vault-token to the header names Effect redacts', async () => {
|
|
104
|
+
let names: ReadonlyArray<RegExp | string> = [];
|
|
105
|
+
const recording = HttpClient.make((request, _url, _signal, fiber) => {
|
|
106
|
+
names = fiber.getRef(Headers.CurrentRedactedNames);
|
|
107
|
+
const response = new Response('{"data":{}}', { status: 200 });
|
|
108
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, response));
|
|
109
|
+
});
|
|
110
|
+
await Effect.runPromise(
|
|
111
|
+
baoRead('kv/config').pipe(
|
|
112
|
+
Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid', BAO_TOKEN: TOKEN }),
|
|
113
|
+
Effect.provideService(HttpClient.HttpClient, recording),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
assert.ok(names.includes('x-vault-token'));
|
|
117
|
+
assert.ok(names.includes('authorization'), 'the defaults are kept');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('baoDelete', () => {
|
|
122
|
+
it('succeeds on a 404 — already gone — and on a 204', async () => {
|
|
123
|
+
for (const reply of [{ json: NONE, status: 404 }, { status: 204 }]) {
|
|
124
|
+
await withFake(
|
|
125
|
+
() => reply,
|
|
126
|
+
async (bao) => {
|
|
127
|
+
await run({ BAO_ADDR: bao.address }, baoDelete('ssh/roles/zz'));
|
|
128
|
+
assert.equal(bao.seen[0]?.method, 'DELETE');
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('fails a refused delete (403) and a sealed one (503)', async () => {
|
|
135
|
+
for (const status of [403, 503]) {
|
|
136
|
+
await withFake(
|
|
137
|
+
() => ({ json: { errors: ['nope'] }, status }),
|
|
138
|
+
async (bao) => {
|
|
139
|
+
const error = await runFailure({ BAO_ADDR: bao.address }, baoDelete('ssh/roles/zz'));
|
|
140
|
+
assert.equal(keepsTokenOut(error).status, status);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('baoWrite', () => {
|
|
148
|
+
it('sends the body as JSON with its content type, and fails a 404', async () => {
|
|
149
|
+
await withFake(
|
|
150
|
+
(seen) => (seen.path === '/v1/ok/roles/x' ? { status: 204 } : { json: NONE, status: 404 }),
|
|
151
|
+
async (bao) => {
|
|
152
|
+
const env = { BAO_ADDR: bao.address };
|
|
153
|
+
await run(env, baoWrite('PUT', 'ok/roles/x', { ttl: '3600' }));
|
|
154
|
+
assert.equal(bao.seen[0]?.headers.get('content-type'), 'application/json');
|
|
155
|
+
assert.deepEqual(JSON.parse(bao.seen[0]?.body ?? ''), { ttl: '3600' });
|
|
156
|
+
const error = await runFailure(env, baoWrite('PUT', 'nope/roles/x', { ttl: '1' }));
|
|
157
|
+
assert.equal(keepsTokenOut(error).status, 404);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One call to OpenBao's HTTP API through Effect's `HttpClient` — the transport every Bao.* family
|
|
3
|
+
* uses since it stopped shelling out to `bao`.
|
|
4
|
+
*
|
|
5
|
+
* ★ `HttpClient`, THE SAME PATTERN house/proxmox and house/forgejo USE AGAINST THEIR APIs, decided
|
|
6
|
+
* 2026-09-14. It buys a typed error channel, interruption when a plan is cancelled, the runtime's
|
|
7
|
+
* tracing, and a client a test can point at a fake server — and it needs no Alchemy session, so
|
|
8
|
+
* every call here works from all four operations. (`CommandExecutor.run` was tried on Bao.Policy
|
|
9
|
+
* first and blocked read/diff: Alchemy passes a session to reconcile/delete but NOT to read/diff,
|
|
10
|
+
* Provider.ts:258-289. The ChildProcessSpawner that fixed that is gone with the CLI.)
|
|
11
|
+
* ⚠️ THE STACK MUST PROVIDE `FetchHttpClient.layer`. alchemy.run.ts does, the way
|
|
12
|
+
* house/proxmox/alchemy.run.ts does.
|
|
13
|
+
*/
|
|
14
|
+
import * as Context from 'effect/Context';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import * as Option from 'effect/Option';
|
|
17
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
18
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
19
|
+
import * as Headers from 'effect/unstable/http/Headers';
|
|
20
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
21
|
+
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
22
|
+
import { type BaoEnvironment, headersFor, resolveAddress } from './bao-address.ts';
|
|
23
|
+
import { BaoError, type BaoIntent, settle } from './bao-status.ts';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The environment a call resolves BAO_ADDR, BAO_NAMESPACE and BAO_TOKEN from — `process.env`,
|
|
27
|
+
* read at call time, unless a test provides another.
|
|
28
|
+
*/
|
|
29
|
+
export const BaoEnv = Context.Reference<BaoEnvironment>('homeflare/openbao/BaoEnv', {
|
|
30
|
+
defaultValue: () => process.env,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ⛔ AT MOST EIGHT EXCHANGES WITH OPENBAO AT ONCE — ACROSS EVERY Bao.* FAMILY, IN ONE PROCESS.
|
|
35
|
+
*
|
|
36
|
+
* 🔴 MEASURED 2026-09-14 10:41: A DEPLOY OF THIS STACK TOOK OPENBAO's API OFF THE MINI. Alchemy fans
|
|
37
|
+
* resources out with a hard-coded `concurrency: "unbounded"` (alchemy Plan.ts:476, Apply.ts:259,
|
|
38
|
+
* :263, :303 — no option reaches it), and 555 Bao.CloudflareRole resources each read, diff,
|
|
39
|
+
* reconcile and read back: well over a thousand requests at one loopback listener within seconds.
|
|
40
|
+
* OpenBao logged
|
|
41
|
+
* HTTP server (listening on 127.0.0.1:8200) exited with error: set tcp
|
|
42
|
+
* 127.0.0.1:8200->127.0.0.1:57974: setsockopt: invalid argument
|
|
43
|
+
* and KEPT RUNNING with 8200 closed. launchd saw a live process and restarted nothing. Everything
|
|
44
|
+
* on the mini that talks to 127.0.0.1:8200 was cut off, starting with the agent's templates, until
|
|
45
|
+
* a manual restart.
|
|
46
|
+
* ★ THE LISTENER DYING IS A GO-ON-DARWIN FAILURE, NOT OURS TO FIX. Traefik lost its entrypoints the
|
|
47
|
+
* same way on darwin/arm64 (traefik issue 8841). The BURST is ours. The adoption deploy an hour
|
|
48
|
+
* earlier made the same calls and survived, so a cap narrows a race; it does not prove it closed.
|
|
49
|
+
* ★ A SEMAPHORE, NOT `HttpClient.withRateLimiter`. That limits requests per window and needs a
|
|
50
|
+
* RateLimiter service. The failure is simultaneous connections, and only a cap on in-flight
|
|
51
|
+
* exchanges bounds those whatever each one's latency.
|
|
52
|
+
* ⚠️ A Context.Reference SO A TEST CAN PROVIDE ITS OWN GATE. The default is built ONCE and cached on
|
|
53
|
+
* the reference (effect Context.ts:1582-1588), so every call in the process shares one gate.
|
|
54
|
+
* ⚠️ EIGHT IS A CHOICE, NOT A MEASUREMENT OF WHAT THE LISTENER TOLERATES. Nothing here can find that
|
|
55
|
+
* number without re-running the failure against production.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_IN_FLIGHT = 8;
|
|
58
|
+
|
|
59
|
+
export const BaoGate = Context.Reference<Semaphore.Semaphore>('homeflare/openbao/BaoGate', {
|
|
60
|
+
defaultValue: () => Semaphore.makeUnsafe(MAX_IN_FLIGHT),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ⚠️ THE CLI's DEFAULT CLIENT TIMEOUT (openbao v2.6.2 api/client.go:324). A hung agent or a server
|
|
65
|
+
* that accepts and never answers would otherwise hang the plan with no line saying why.
|
|
66
|
+
*/
|
|
67
|
+
const TIMEOUT = '60 seconds';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ⛔ EFFECT RECORDS EVERY REQUEST HEADER ON THE CLIENT SPAN and redacts only the names in
|
|
71
|
+
* `Headers.CurrentRedactedNames` — by default authorization, cookie, set-cookie and x-api-key
|
|
72
|
+
* (effect Headers.ts:753-762; HttpClient.ts:842-849, with `TracerHeaderFilter` defaulting to
|
|
73
|
+
* true). `X-Vault-Token` is not on that list, so under any tracer the token would be exported as
|
|
74
|
+
* a span attribute. It is added for every call made here.
|
|
75
|
+
*/
|
|
76
|
+
const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const names = yield* Headers.CurrentRedactedNames;
|
|
79
|
+
return yield* Effect.provideService(effect, Headers.CurrentRedactedNames, [
|
|
80
|
+
...names,
|
|
81
|
+
'x-vault-token',
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Route the exchange over a unix socket when the address is one.
|
|
87
|
+
*
|
|
88
|
+
* ★ BUN's `fetch` TAKES A `unix` OPTION (bun-types globals.d.ts BunFetchRequestInit.unix), AND
|
|
89
|
+
* `FetchHttpClient.RequestInit` IS EFFECT'S DOCUMENTED WAY TO PASS FETCH OPTIONS. The fetch client
|
|
90
|
+
* spreads that service into every call (effect FetchHttpClient.ts:59-72), reading it from the
|
|
91
|
+
* CALLING fiber's context — and `layerMergedContext` merges the caller's context over the layer's
|
|
92
|
+
* (HttpClient.ts:2065-2076), so providing it around one call routes that call alone. Any defaults
|
|
93
|
+
* already provided are kept.
|
|
94
|
+
* ⛔ IT ONLY WORKS ON BUN, SO ANYWHERE ELSE IT REFUSES. Node's fetch ignores `unix` and would send
|
|
95
|
+
* the request to http://localhost:80 — a silent wrong server, which is the one outcome this
|
|
96
|
+
* package exists to prevent. It also assumes the provided `HttpClient` is the fetch one.
|
|
97
|
+
*/
|
|
98
|
+
const overSocket =
|
|
99
|
+
(socket: string | undefined, method: string, path: string) =>
|
|
100
|
+
<A, R>(effect: Effect.Effect<A, BaoError, R>): Effect.Effect<A, BaoError, R> => {
|
|
101
|
+
if (socket === undefined) return effect;
|
|
102
|
+
if (typeof Bun === 'undefined') {
|
|
103
|
+
return Effect.fail(
|
|
104
|
+
new BaoError(0, method, path, ['a unix:// address needs Bun fetch; this is not Bun']),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return Effect.flatMap(Effect.serviceOption(FetchHttpClient.RequestInit), (defaults) => {
|
|
108
|
+
const init: BunFetchRequestInit = { ...Option.getOrElse(defaults, () => ({})), unix: socket };
|
|
109
|
+
return Effect.provideService(effect, FetchHttpClient.RequestInit, init);
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** One exchange, classified. The body is the parsed JSON object, or undefined for absent / 204. */
|
|
114
|
+
export const baoCall = (
|
|
115
|
+
intent: BaoIntent,
|
|
116
|
+
method: 'DELETE' | 'GET' | 'POST' | 'PUT',
|
|
117
|
+
path: string,
|
|
118
|
+
body?: Readonly<Record<string, unknown>>,
|
|
119
|
+
) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
const env = yield* BaoEnv;
|
|
122
|
+
const address = resolveAddress(env);
|
|
123
|
+
const client = yield* HttpClient.HttpClient;
|
|
124
|
+
// ⚠️ The content type goes on `bodyText`, never in the header map: `bodyText` overwrites it.
|
|
125
|
+
// house/proxmox/src/client.ts has the scar.
|
|
126
|
+
const request = HttpClientRequest.make(method)(`${address.base}/v1/${path}`).pipe(
|
|
127
|
+
HttpClientRequest.setHeaders(headersFor(address, env)),
|
|
128
|
+
body === undefined
|
|
129
|
+
? (self) => self
|
|
130
|
+
: HttpClientRequest.bodyText(JSON.stringify(body), 'application/json'),
|
|
131
|
+
);
|
|
132
|
+
// ★ Status first, body read exactly once, as text — an error body is not reliably JSON.
|
|
133
|
+
const exchange = client.execute(request).pipe(
|
|
134
|
+
Effect.flatMap((response) =>
|
|
135
|
+
Effect.map(response.text, (text) => ({ status: response.status, text })),
|
|
136
|
+
),
|
|
137
|
+
Effect.timeout(TIMEOUT),
|
|
138
|
+
// ⛔ `.message` ONLY: the HttpClientError message is the reason, method and URL. The error
|
|
139
|
+
// object itself holds the request, whose headers hold the token.
|
|
140
|
+
Effect.mapError((cause) => new BaoError(0, method, path, [cause.message])),
|
|
141
|
+
);
|
|
142
|
+
// ★ THE PERMIT WRAPS THE TIMEOUT, NOT THE REVERSE: a call queued behind the gate is not yet
|
|
143
|
+
// talking to OpenBao, so its wait must not count against the 60 seconds.
|
|
144
|
+
const gate = yield* BaoGate;
|
|
145
|
+
const { status, text } = yield* exchange.pipe(
|
|
146
|
+
overSocket(address.socket, method, path),
|
|
147
|
+
redactingToken,
|
|
148
|
+
Semaphore.withPermits(gate, 1),
|
|
149
|
+
);
|
|
150
|
+
const outcome = settle(intent, method, path, status, text);
|
|
151
|
+
return 'error' in outcome ? yield* Effect.fail(outcome.error) : outcome.body;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The `data` of a read, or undefined when OpenBao says the object is ABSENT (404).
|
|
156
|
+
*
|
|
157
|
+
* ★ `bao read -format=json` printed this same response body; the old helper took `data` when it was
|
|
158
|
+
* an object and the whole body otherwise, and so does this.
|
|
159
|
+
*/
|
|
160
|
+
export const baoRead = (path: string) =>
|
|
161
|
+
Effect.map(baoCall('read', 'GET', path), (body) => {
|
|
162
|
+
if (body === undefined) return undefined;
|
|
163
|
+
const data = body['data'];
|
|
164
|
+
return typeof data === 'object' && data !== null
|
|
165
|
+
? (data as Record<string, unknown>)
|
|
166
|
+
: (body as Record<string, unknown>);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A write whose SUCCESS MATTERS. Any non-2xx fails, 404 included.
|
|
171
|
+
*
|
|
172
|
+
* ★ `PUT` IS WHAT `bao write` SENT (openbao v2.6.2 api/logical.go Write); `sys/mounts` enable and
|
|
173
|
+
* tune are `POST` in the same client (api/sys_mounts.go:57, :201).
|
|
174
|
+
* ⚠️ ROLE BODIES STAY ALL-STRING ON PURPOSE. `bao write k=v` put every value in the JSON body as a
|
|
175
|
+
* string — MEASURED with `-output-curl-string`, see ssh-role-form.ts — and the form files still
|
|
176
|
+
* build exactly that body, so the server parses the same request it was always sent.
|
|
177
|
+
*/
|
|
178
|
+
export const baoWrite = (
|
|
179
|
+
method: 'POST' | 'PUT',
|
|
180
|
+
path: string,
|
|
181
|
+
body: Readonly<Record<string, unknown>>,
|
|
182
|
+
) => Effect.asVoid(baoCall('write', method, path, body));
|
|
183
|
+
|
|
184
|
+
/** A delete: idempotent — already gone (404) is success — and refused (403, 5xx) is a failure. */
|
|
185
|
+
export const baoDelete = (path: string) => Effect.asVoid(baoCall('delete', 'DELETE', path));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The classification every read and every delete in this package rests on.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ BOTH DIRECTIONS ARE PINNED, BECAUSE EACH HAS ALREADY BEEN WRONG ONCE. Every non-zero exit used
|
|
5
|
+
* to read as "absent" — a bad token planned 33 updates against a vault that was fine — and one
|
|
6
|
+
* command's absence wording was assumed for another's, so a policy that did not exist yet could
|
|
7
|
+
* not be created. These replace bao-shell.test.ts's stderr cases with the status codes those
|
|
8
|
+
* stderr lines were rendering: a missing role (404), a refused policy read under the agent lane
|
|
9
|
+
* (403 — `Code: 403 … permission denied`, measured 2026-09-14), a missing policy under the admin
|
|
10
|
+
* lane (404), and a server that is not listening (no response at all, bao-http.test.ts).
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import { BaoError, settle } from './bao-status.ts';
|
|
15
|
+
|
|
16
|
+
const ROLE = 'pki/roles/does-not-exist-zzz';
|
|
17
|
+
const POLICY = 'sys/policies/acl/zz-new-policy';
|
|
18
|
+
const NONE = JSON.stringify({ errors: [] });
|
|
19
|
+
const DENIED = JSON.stringify({ errors: ['1 error occurred:\n\t* permission denied\n\n'] });
|
|
20
|
+
const SEALED = JSON.stringify({ errors: ['Vault is sealed'] });
|
|
21
|
+
|
|
22
|
+
const errorOf = (outcome: ReturnType<typeof settle>) => {
|
|
23
|
+
assert.ok('error' in outcome, 'expected a failure');
|
|
24
|
+
return outcome.error;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('settle', () => {
|
|
28
|
+
it('passes a JSON body through on success', () => {
|
|
29
|
+
assert.deepEqual(settle('read', 'GET', ROLE, 200, '{"data":{"ttl":3600}}'), {
|
|
30
|
+
body: { data: { ttl: 3600 } },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reads an OpenBao 404 as absent on a read', () => {
|
|
35
|
+
assert.deepEqual(settle('read', 'GET', ROLE, 404, NONE), { body: undefined });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ⛔ THE BUG SHAPE b3c18a33e FIXED. A policy that does not exist yet is the same 404 as any other
|
|
39
|
+
// absent object — there is no second wording left to get wrong.
|
|
40
|
+
it('reads a policy that does not exist yet as absent', () => {
|
|
41
|
+
assert.deepEqual(settle('read', 'GET', POLICY, 404, NONE), { body: undefined });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('treats a 404 as success on a delete, because a delete must be idempotent', () => {
|
|
45
|
+
assert.deepEqual(settle('delete', 'DELETE', POLICY, 404, NONE), { body: undefined });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts a 204 with no body on a write and on a delete', () => {
|
|
49
|
+
assert.deepEqual(settle('write', 'PUT', POLICY, 204, ''), { body: undefined });
|
|
50
|
+
assert.deepEqual(settle('delete', 'DELETE', POLICY, 204, ''), { body: undefined });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('fails a 404 on a write, carrying OpenBao errors', () => {
|
|
54
|
+
const errors = ['no handler for route "nope/roles/x". route entry not found.'];
|
|
55
|
+
const error = errorOf(settle('write', 'PUT', 'nope/roles/x', 404, JSON.stringify({ errors })));
|
|
56
|
+
assert.deepEqual(error.errors, errors);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ⛔ AND DENIAL IS NEVER ABSENCE, on a read or on a delete.
|
|
60
|
+
it('fails a refused read and a refused delete (403)', () => {
|
|
61
|
+
assert.equal(errorOf(settle('read', 'GET', POLICY, 403, DENIED)).status, 403);
|
|
62
|
+
assert.equal(errorOf(settle('delete', 'DELETE', POLICY, 403, DENIED)).status, 403);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('fails a sealed server (503) on every intent', () => {
|
|
66
|
+
for (const intent of ['read', 'write', 'delete'] as const) {
|
|
67
|
+
assert.equal(errorOf(settle(intent, 'GET', ROLE, 503, SEALED)).status, 503);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('fails a 400 and a 500', () => {
|
|
72
|
+
assert.equal(errorOf(settle('read', 'GET', ROLE, 400, NONE)).status, 400);
|
|
73
|
+
assert.equal(errorOf(settle('read', 'GET', ROLE, 500, NONE)).status, 500);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ⚠️ A BAO_ADDR pointed at the wrong server must not plan CREATE for everything.
|
|
77
|
+
it('does not read a 404 that is not an OpenBao body as absent', () => {
|
|
78
|
+
const error = errorOf(settle('read', 'GET', ROLE, 404, '<html>Not Found</html>'));
|
|
79
|
+
assert.match(error.message, /not an OpenBao error body/);
|
|
80
|
+
assert.ok('error' in settle('delete', 'DELETE', ROLE, 404, '<html>Not Found</html>'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fails a successful read that has no body', () => {
|
|
84
|
+
assert.equal(errorOf(settle('read', 'GET', ROLE, 200, '')).status, 200);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ⛔ A success body can hold a credential, so a malformed one is described and never quoted.
|
|
88
|
+
it('fails an unparseable success body without quoting it', () => {
|
|
89
|
+
const error = errorOf(settle('read', 'GET', 'pbs-tb4/creds/read', 200, 'secret=abc123 {'));
|
|
90
|
+
assert.ok(!error.message.includes('abc123'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ⛔ A JSON gateway's error shape is not OpenBao's, even on a 404 (grok, 2026-09-14).
|
|
94
|
+
it('does not read a 404 whose errors are objects as absent, on a read or a delete', () => {
|
|
95
|
+
const gateway = JSON.stringify({ errors: [{ code: 1000, message: 'Not Found' }] });
|
|
96
|
+
const error = errorOf(settle('read', 'GET', ROLE, 404, gateway));
|
|
97
|
+
assert.match(error.message, /not an OpenBao error body/);
|
|
98
|
+
assert.ok('error' in settle('delete', 'DELETE', ROLE, 404, gateway));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ⛔ An error page that echoes the request back must not carry the token into the message.
|
|
102
|
+
it('describes a foreign error body without quoting it', () => {
|
|
103
|
+
const echo = '<pre>X-Vault-Token: hvs.sentinel-not-a-token</pre>';
|
|
104
|
+
const error = errorOf(settle('read', 'GET', ROLE, 502, echo));
|
|
105
|
+
assert.ok(!error.message.includes('hvs.sentinel'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('names the method and path, and carries the errors array', () => {
|
|
109
|
+
const error = errorOf(settle('read', 'GET', POLICY, 403, DENIED));
|
|
110
|
+
assert.ok(error instanceof BaoError);
|
|
111
|
+
assert.match(error.message, /^OpenBao GET \/v1\/sys\/policies\/acl\/zz-new-policy -> 403: /);
|
|
112
|
+
assert.deepEqual(error.errors, ['1 error occurred:\n\t* permission denied\n\n']);
|
|
113
|
+
});
|
|
114
|
+
});
|