@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Cloudflare policy document as the engine stores it — resolution from names, parsing from a
|
|
3
|
+
* read, and the one comparison every Bao.CloudflareRole plan rests on.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED from the engine source (house/platform/secrets/vault/plugin/cloudflare/path_roles.go)
|
|
6
|
+
* and the 2026-09-14 snapshot of all 586 live roles:
|
|
7
|
+
* • `policies` is a STRING field holding a JSON array (path_roles.go:67-72). Each entry is
|
|
8
|
+
* `{effect, resources: map[string]string, permission_groups: [{id}]}` (:25-33).
|
|
9
|
+
* • A read hands back `json.Marshal` of the stored slice (:199-207): no whitespace, entry and
|
|
10
|
+
* group order preserved (Go slices), resource keys sorted (Go maps). Every snapshot role has
|
|
11
|
+
* exactly that shape.
|
|
12
|
+
*/
|
|
13
|
+
import { GROUP_SCOPES, groupKey, scopeOfResource } from './cloudflare-group-scope.ts';
|
|
14
|
+
import type { DeclaredPolicy } from './cloudflare-roles-expand.ts';
|
|
15
|
+
|
|
16
|
+
export interface WirePolicy {
|
|
17
|
+
readonly effect: string;
|
|
18
|
+
readonly resources: Readonly<Record<string, string>>;
|
|
19
|
+
readonly groupIds: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A declared entry with its IDs, still carrying how its group order is to be compared. */
|
|
23
|
+
export interface ResolvedPolicy extends WirePolicy {
|
|
24
|
+
readonly groupOrder: DeclaredPolicy['groupOrder'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One name at every scope — what a source that cannot see scopes can honestly offer.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ IT IS A WIDENING, AND IT IS THE ASYMMETRY BETWEEN THE TWO SOURCES. The live-roles bootstrap
|
|
31
|
+
* and cloudflare-permission-groups-solve.ts recover `name → id` from roles that carry ids and no
|
|
32
|
+
* scopes, so they cannot tell the seven two-id names apart; the id they learned is offered for
|
|
33
|
+
* whatever scope an entry asks. The engine source keys by (name, scope) and never does this.
|
|
34
|
+
*/
|
|
35
|
+
export const atEveryScope = (byName: ReadonlyMap<string, string>): ReadonlyMap<string, string> =>
|
|
36
|
+
new Map(
|
|
37
|
+
[...byName].flatMap(([name, id]) =>
|
|
38
|
+
Object.values(GROUP_SCOPES).map((scope) => [groupKey(name, scope), id] as const),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Declared names to IDs, or the names `ids` lacks.
|
|
44
|
+
*
|
|
45
|
+
* ⛔ AN ENTRY LOOKS ITS GROUPS UP AT ITS OWN SCOPE (`groupKey`), not by name: a zone entry and an
|
|
46
|
+
* account entry of the same role can name one group and mean two different ids.
|
|
47
|
+
* ★ THE ACCOUNT ENTRY IS SORTED BY ID, EXACTLY AS cfhf.py:180 DID. ⚠️ Plain `sort()`, never
|
|
48
|
+
* `localeCompare`: Python's `sorted()` on str is codepoint order, and JavaScript's default sort
|
|
49
|
+
* is UTF-16 code-unit order, which agree on the lowercase hex these IDs are made of.
|
|
50
|
+
*/
|
|
51
|
+
export const resolvePolicies = (
|
|
52
|
+
declared: readonly DeclaredPolicy[],
|
|
53
|
+
ids: ReadonlyMap<string, string>,
|
|
54
|
+
): { readonly policies: ResolvedPolicy[]; readonly missing: string[] } => {
|
|
55
|
+
const missing = new Set<string>();
|
|
56
|
+
const policies = declared.map((policy) => {
|
|
57
|
+
/**
|
|
58
|
+
* ⛔ THE ENTRY'S OWN WORD BEATS ITS RESOURCE. `scope` is roles.yaml `groups_scope:` reaching
|
|
59
|
+
* here unchanged; `scopeOfResource` is the default for the 639 of 643 references that say
|
|
60
|
+
* nothing. The four that do are `logs`/`logs-read` on both security mounts — ACCOUNT roles
|
|
61
|
+
* whose live entry carries the ZONE-scoped `Logs` ids, which is how Cloudflare writes "all
|
|
62
|
+
* zones from an account" (cf-mint-analytics-tokens.py:155-158).
|
|
63
|
+
*/
|
|
64
|
+
const scope = policy.scope ?? scopeOfResource(policy.resource);
|
|
65
|
+
const groupIds = policy.groups.flatMap((name) => {
|
|
66
|
+
const id = ids.get(groupKey(name, scope));
|
|
67
|
+
if (id === undefined) missing.add(name);
|
|
68
|
+
return id === undefined ? [] : [id];
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
effect: policy.effect,
|
|
72
|
+
groupIds: policy.groupOrder === 'by-id' ? [...groupIds].sort() : groupIds,
|
|
73
|
+
groupOrder: policy.groupOrder,
|
|
74
|
+
resources: { [policy.resource]: '*' },
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
return { missing: [...missing].sort(), policies };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
81
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
82
|
+
|
|
83
|
+
const stringMap = (value: unknown): Readonly<Record<string, string>> | undefined => {
|
|
84
|
+
if (!isRecord(value)) return undefined;
|
|
85
|
+
const out: Record<string, string> = {};
|
|
86
|
+
for (const [key, item] of Object.entries(value)) {
|
|
87
|
+
if (typeof item !== 'string') return undefined;
|
|
88
|
+
out[key] = item;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const entryOf = (entry: unknown): WirePolicy | undefined => {
|
|
94
|
+
if (!isRecord(entry) || typeof entry['effect'] !== 'string') return undefined;
|
|
95
|
+
const resources = stringMap(entry['resources']);
|
|
96
|
+
const groups = entry['permission_groups'];
|
|
97
|
+
if (resources === undefined || !Array.isArray(groups)) return undefined;
|
|
98
|
+
const groupIds = groups.flatMap((group) =>
|
|
99
|
+
isRecord(group) && typeof group['id'] === 'string' ? [group['id']] : [],
|
|
100
|
+
);
|
|
101
|
+
return groupIds.length === groups.length
|
|
102
|
+
? { effect: entry['effect'], groupIds, resources }
|
|
103
|
+
: undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The live `policies` string parsed, or undefined when it is not a policy document.
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ UNDEFINED IS NOT ABSENCE. It means the role exists and its policies could not be read, which
|
|
110
|
+
* compares unequal to every declaration — so it plans `update`, and reconcile's re-read refuses
|
|
111
|
+
* to call a write that still reads back like that a success.
|
|
112
|
+
*/
|
|
113
|
+
export const parseLivePolicies = (raw: unknown): WirePolicy[] | undefined => {
|
|
114
|
+
if (typeof raw !== 'string') return undefined;
|
|
115
|
+
let parsed: unknown;
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(raw);
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
122
|
+
const policies = parsed.map(entryOf);
|
|
123
|
+
return policies.every((policy): policy is WirePolicy => policy !== undefined)
|
|
124
|
+
? policies
|
|
125
|
+
: undefined;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The `policies` field for a write — the array cfhf.py:174-180 built, serialized.
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ NOT BYTE-IDENTICAL TO WHAT PYTHON SENT, AND IT NEED NOT BE. `json.dumps` put a space after
|
|
132
|
+
* every `:` and `,`; this emits none. The engine unmarshals the string into structs
|
|
133
|
+
* (path_roles.go:161-166) and stores those, never the text, so the stored role and the read
|
|
134
|
+
* that diff compares are the same either way.
|
|
135
|
+
*/
|
|
136
|
+
export const policiesJson = (policies: readonly WirePolicy[]) =>
|
|
137
|
+
JSON.stringify(
|
|
138
|
+
policies.map((policy) => ({
|
|
139
|
+
effect: policy.effect,
|
|
140
|
+
resources: policy.resources,
|
|
141
|
+
permission_groups: policy.groupIds.map((id) => ({ id })),
|
|
142
|
+
})),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const sameList = (a: readonly string[], b: readonly string[]) =>
|
|
146
|
+
a.length === b.length && a.every((item, i) => item === b[i]);
|
|
147
|
+
|
|
148
|
+
const sameResources = (
|
|
149
|
+
a: Readonly<Record<string, string>>,
|
|
150
|
+
b: Readonly<Record<string, string>>,
|
|
151
|
+
) => {
|
|
152
|
+
const keys = Object.keys(a);
|
|
153
|
+
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* ⛔ THE POLICY EQUALITY.
|
|
158
|
+
*
|
|
159
|
+
* • Entry count and entry ORDER must match: cfhf.py always wrote the scope entry first and the
|
|
160
|
+
* account entry second (cfhf.py:174-180).
|
|
161
|
+
* • Within an entry: effect, the resource map, and the group IDs — IN ORDER for a `declared`
|
|
162
|
+
* entry, because cfhf.py:175 kept yaml order; AS A SORTED LIST for a `by-id` entry, because
|
|
163
|
+
* cfhf.py:180 sorted it, so its order never carried meaning.
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ NOT ORDER-INSENSITIVE EVERYWHERE, ON PURPOSE. Cloudflare treats a group list as a set, so a
|
|
166
|
+
* reordered `declared` entry mints an identical token — and still plans `update` here. The
|
|
167
|
+
* Python would have rewritten it as well, and an adoption that is noop only up to reordering
|
|
168
|
+
* would not prove the port exact.
|
|
169
|
+
*/
|
|
170
|
+
export const samePolicies = (want: readonly ResolvedPolicy[], have: readonly WirePolicy[]) =>
|
|
171
|
+
want.length === have.length &&
|
|
172
|
+
want.every((w, i) => {
|
|
173
|
+
const h = have[i];
|
|
174
|
+
if (h === undefined || w.effect !== h.effect || !sameResources(w.resources, h.resources)) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return w.groupOrder === 'by-id'
|
|
178
|
+
? sameList([...w.groupIds].sort(), [...h.groupIds].sort())
|
|
179
|
+
: sameList(w.groupIds, h.groupIds);
|
|
180
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The equality Bao.CloudflareRole's diff and reconcile rest on: TTLs as seconds, description
|
|
3
|
+
* exactly, and policies with order significant in a declared entry and insignificant in the
|
|
4
|
+
* account entry the Python sorted. Plus the write body and the refusals.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import { atEveryScope, resolvePolicies } from './cloudflare-policy.ts';
|
|
9
|
+
import {
|
|
10
|
+
type BaoCloudflareRoleProps,
|
|
11
|
+
attributesOf,
|
|
12
|
+
differences,
|
|
13
|
+
liveRoleOf,
|
|
14
|
+
refusalOf,
|
|
15
|
+
writeBody,
|
|
16
|
+
} from './cloudflare-role-form.ts';
|
|
17
|
+
|
|
18
|
+
const ZONE = 'com.cloudflare.api.account.zone.z1';
|
|
19
|
+
const ACCOUNT = 'com.cloudflare.api.account.acct';
|
|
20
|
+
|
|
21
|
+
const PROPS: BaoCloudflareRoleProps = {
|
|
22
|
+
description: 'Deploy with a route. (zone: example.com)',
|
|
23
|
+
maxTtl: '2h',
|
|
24
|
+
mount: 'cloudflare-acme-platform',
|
|
25
|
+
name: 'example-com-workers-deploy-full',
|
|
26
|
+
policies: [
|
|
27
|
+
{
|
|
28
|
+
effect: 'allow',
|
|
29
|
+
groupOrder: 'declared',
|
|
30
|
+
groups: ['Routes Write', 'DNS Write'],
|
|
31
|
+
resource: ZONE,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
effect: 'allow',
|
|
35
|
+
groupOrder: 'by-id',
|
|
36
|
+
groups: ['Scripts Write', 'Containers Write'],
|
|
37
|
+
resource: ACCOUNT,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
ttl: '30m',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const IDS = new Map([
|
|
44
|
+
['Containers Write', 'c'],
|
|
45
|
+
['DNS Write', 'd'],
|
|
46
|
+
['Routes Write', 'r'],
|
|
47
|
+
['Scripts Write', 's'],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const { policies: RESOLVED } = resolvePolicies(PROPS.policies, atEveryScope(IDS));
|
|
51
|
+
|
|
52
|
+
/** A policies string in the engine's own marshalled shape. */
|
|
53
|
+
const wire = (entries: [string, string[]][]) =>
|
|
54
|
+
JSON.stringify(
|
|
55
|
+
entries.map(([resource, ids]) => ({
|
|
56
|
+
effect: 'allow',
|
|
57
|
+
resources: { [resource]: '*' },
|
|
58
|
+
permission_groups: ids.map((id) => ({ id })),
|
|
59
|
+
})),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const live = (overrides: Record<string, unknown> = {}) =>
|
|
63
|
+
liveRoleOf({
|
|
64
|
+
description: PROPS.description,
|
|
65
|
+
max_ttl: 7200,
|
|
66
|
+
name: PROPS.name,
|
|
67
|
+
policies: wire([
|
|
68
|
+
[ZONE, ['r', 'd']],
|
|
69
|
+
[ACCOUNT, ['c', 's']],
|
|
70
|
+
]),
|
|
71
|
+
ttl: 1800,
|
|
72
|
+
...overrides,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const fields = (role = live(), props: BaoCloudflareRoleProps = PROPS) =>
|
|
76
|
+
differences(props, RESOLVED, role).map((d) => d.field);
|
|
77
|
+
|
|
78
|
+
describe('differences', () => {
|
|
79
|
+
it('is empty when live matches, TTL text compared to engine seconds', () => {
|
|
80
|
+
assert.deepEqual(fields(), []);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reads the account entry as a sorted set, because the Python sorted it', () => {
|
|
84
|
+
const reordered = wire([
|
|
85
|
+
[ZONE, ['r', 'd']],
|
|
86
|
+
[ACCOUNT, ['s', 'c']],
|
|
87
|
+
]);
|
|
88
|
+
assert.deepEqual(fields(live({ policies: reordered })), []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps group order significant in a declared entry', () => {
|
|
92
|
+
const reordered = wire([
|
|
93
|
+
[ZONE, ['d', 'r']],
|
|
94
|
+
[ACCOUNT, ['c', 's']],
|
|
95
|
+
]);
|
|
96
|
+
assert.deepEqual(fields(live({ policies: reordered })), ['policies']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The drift role's shape on 2026-09-14: the same entries, account first.
|
|
100
|
+
it('keeps entry order significant', () => {
|
|
101
|
+
const swapped = wire([
|
|
102
|
+
[ACCOUNT, ['c', 's']],
|
|
103
|
+
[ZONE, ['r', 'd']],
|
|
104
|
+
]);
|
|
105
|
+
assert.deepEqual(fields(live({ policies: swapped })), ['policies']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('catches a different resource, a missing group and an extra entry', () => {
|
|
109
|
+
for (const policies of [
|
|
110
|
+
wire([
|
|
111
|
+
[`${ZONE}x`, ['r', 'd']],
|
|
112
|
+
[ACCOUNT, ['c', 's']],
|
|
113
|
+
]),
|
|
114
|
+
wire([
|
|
115
|
+
[ZONE, ['r', 'd']],
|
|
116
|
+
[ACCOUNT, ['c']],
|
|
117
|
+
]),
|
|
118
|
+
wire([
|
|
119
|
+
[ZONE, ['r', 'd']],
|
|
120
|
+
[ACCOUNT, ['c', 's']],
|
|
121
|
+
[ACCOUNT, ['c']],
|
|
122
|
+
]),
|
|
123
|
+
]) {
|
|
124
|
+
assert.deepEqual(fields(live({ policies })), ['policies'], policies);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('names description, ttl and max_ttl separately', () => {
|
|
129
|
+
const drifted = live({ description: 'widened by hand', max_ttl: 3600, ttl: 600 });
|
|
130
|
+
assert.deepEqual(fields(drifted), ['description', 'ttl', 'max_ttl']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('treats live policies that are not a policy document as different', () => {
|
|
134
|
+
assert.deepEqual(fields(live({ policies: 'not json' })), ['policies']);
|
|
135
|
+
assert.deepEqual(fields(live({ policies: '[{"effect":"allow"}]' })), ['policies']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('never equals an unparseable declared ttl, and reconcile refuses it', () => {
|
|
139
|
+
const props = { ...PROPS, ttl: '1h30m' };
|
|
140
|
+
assert.deepEqual(fields(live(), props), ['ttl']);
|
|
141
|
+
assert.match(refusalOf(props) ?? '', /unparseable duration ttl=1h30m/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('writeBody', () => {
|
|
146
|
+
it('sends the four fields apply-roles.py sent, policies in the engine shape with sorted account IDs', () => {
|
|
147
|
+
const body = writeBody(PROPS, RESOLVED);
|
|
148
|
+
assert.deepEqual(Object.keys(body).sort(), ['description', 'max_ttl', 'policies', 'ttl']);
|
|
149
|
+
assert.deepEqual([body.ttl, body.max_ttl, body.description], ['30m', '2h', PROPS.description]);
|
|
150
|
+
assert.equal(
|
|
151
|
+
body.policies,
|
|
152
|
+
wire([
|
|
153
|
+
[ZONE, ['r', 'd']],
|
|
154
|
+
[ACCOUNT, ['c', 's']],
|
|
155
|
+
]),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('refusalOf and attributesOf', () => {
|
|
161
|
+
it('refuses no policies and a policy with no groups', () => {
|
|
162
|
+
assert.equal(refusalOf(PROPS), undefined);
|
|
163
|
+
assert.equal(refusalOf({ ...PROPS, policies: [] }), 'no policy entries');
|
|
164
|
+
const empty = [
|
|
165
|
+
{ effect: 'allow' as const, groupOrder: 'declared' as const, groups: [], resource: ZONE },
|
|
166
|
+
];
|
|
167
|
+
assert.equal(refusalOf({ ...PROPS, policies: empty }), 'a policy with no groups');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('builds attributes from live only, with a stable digest', () => {
|
|
171
|
+
const attrs = attributesOf(PROPS, live({ description: 'live says this' }));
|
|
172
|
+
assert.equal(attrs.description, 'live says this');
|
|
173
|
+
assert.deepEqual([attrs.ttl, attrs.maxTtl, attrs.mount], [1800, 7200, PROPS.mount]);
|
|
174
|
+
assert.equal(attrs.digest, attributesOf(PROPS, live({ description: 'live says this' })).digest);
|
|
175
|
+
assert.notEqual(attrs.digest, attributesOf(PROPS, live()).digest);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props, attributes and field comparison for Bao.CloudflareRole — the resource is
|
|
3
|
+
* cloudflare-role.ts, the policy document rules are cloudflare-policy.ts.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED from the engine (house/platform/secrets/vault/plugin/cloudflare/path_roles.go) and the
|
|
6
|
+
* 2026-09-14 snapshot of all 586 live roles:
|
|
7
|
+
* • A role is name, description, policies, ttl and max_ttl AND NOTHING ELSE (path_roles.go:14-20
|
|
8
|
+
* stores those; :204-210 returns them). Every snapshot read carries exactly those five keys, so
|
|
9
|
+
* `differences` below covers the whole server-side object — no unmanaged field can drift
|
|
10
|
+
* behind a green plan.
|
|
11
|
+
* • ttl and max_ttl are TypeDurationSecond (:73-80) and read back as integer seconds.
|
|
12
|
+
* • ⚠️ A WRITE MERGES, IT DOES NOT REPLACE (:117-146). The handler loads the stored role and
|
|
13
|
+
* overwrites only the fields present — the opposite of the proxmox engine, where an omitted
|
|
14
|
+
* field is zeroed. writeBody sends all four anyway, so a write always lands the whole
|
|
15
|
+
* declaration and can never leave an old description behind a new policy.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NO SECRET IN PROPS OR ATTRIBUTES, AND THERE IS NONE TO LEAK. A role is permission-group IDs, a
|
|
18
|
+
* zone or account resource, a sentence and two TTLs. The parent token lives at `<mount>/config`
|
|
19
|
+
* and a minted token at `<mount>/creds/<name>`; nothing in this family reads either path. Keep
|
|
20
|
+
* it that way — Alchemy persists attributes unencrypted (see policy.ts).
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
type ResolvedPolicy,
|
|
24
|
+
type WirePolicy,
|
|
25
|
+
parseLivePolicies,
|
|
26
|
+
policiesJson,
|
|
27
|
+
samePolicies,
|
|
28
|
+
} from './cloudflare-policy.ts';
|
|
29
|
+
import type { DeclaredPolicy } from './cloudflare-roles-expand.ts';
|
|
30
|
+
import { sha256 } from './digest.ts';
|
|
31
|
+
import { mountPath, parseDuration } from './mount-form.ts';
|
|
32
|
+
|
|
33
|
+
export interface BaoCloudflareRoleProps {
|
|
34
|
+
/** Mount of the Cloudflare secrets engine, e.g. `cloudflare-<account>-dns`. */
|
|
35
|
+
mount: string;
|
|
36
|
+
/** Role name. ⛔ Consumers mint by it — see cloudflare-roles-expand.ts before changing one. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** The description as written: a zone role's carries its ` (zone: <name>)` suffix. */
|
|
39
|
+
description: string;
|
|
40
|
+
/** Default lease TTL, e.g. `5m`. */
|
|
41
|
+
ttl: string;
|
|
42
|
+
/** Maximum lease TTL, e.g. `1h`. */
|
|
43
|
+
maxTtl: string;
|
|
44
|
+
/**
|
|
45
|
+
* Policy entries, groups by NAME. The provider resolves them through CloudflarePermissionGroups
|
|
46
|
+
* at diff and reconcile time; an ID is never a prop, so a plan can never persist a guessed one.
|
|
47
|
+
*/
|
|
48
|
+
policies: DeclaredPolicy[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BaoCloudflareRoleAttributes {
|
|
52
|
+
mount: string;
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
/** Live ttl in seconds, as the engine stores it. */
|
|
56
|
+
ttl: number;
|
|
57
|
+
/** Live max_ttl in seconds. */
|
|
58
|
+
maxTtl: number;
|
|
59
|
+
/** Live `policies` JSON — group IDs and a resource, never a credential. */
|
|
60
|
+
policies: string;
|
|
61
|
+
/** SHA-256 of the fields above — safe to persist; see policy.ts. */
|
|
62
|
+
digest: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One live role, as a read returns it. */
|
|
66
|
+
export interface LiveCloudflareRole {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly description: string;
|
|
69
|
+
readonly ttl: number | undefined;
|
|
70
|
+
readonly maxTtl: number | undefined;
|
|
71
|
+
readonly policiesText: string;
|
|
72
|
+
/** Undefined when `policies` is not a policy document — see parseLivePolicies. */
|
|
73
|
+
readonly policies: readonly WirePolicy[] | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RoleDifference {
|
|
77
|
+
readonly field: 'description' | 'max_ttl' | 'policies' | 'ttl';
|
|
78
|
+
readonly want: string;
|
|
79
|
+
readonly have: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `cloudflare-<account>-dns/roles/homeflare-dev-dns-read` — read, write and delete use it. */
|
|
83
|
+
export const rolePath = (mount: string, name: string) => `${mountPath(mount)}/roles/${name}`;
|
|
84
|
+
|
|
85
|
+
const seconds = (value: unknown) =>
|
|
86
|
+
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
87
|
+
|
|
88
|
+
/** A read's `data`, shaped. Lenient on purpose: a malformed field compares unequal, loudly. */
|
|
89
|
+
export const liveRoleOf = (data: Readonly<Record<string, unknown>>): LiveCloudflareRole => ({
|
|
90
|
+
description: typeof data['description'] === 'string' ? data['description'] : '',
|
|
91
|
+
maxTtl: seconds(data['max_ttl']),
|
|
92
|
+
name: typeof data['name'] === 'string' ? data['name'] : '',
|
|
93
|
+
policies: parseLivePolicies(data['policies']),
|
|
94
|
+
policiesText: typeof data['policies'] === 'string' ? data['policies'] : '',
|
|
95
|
+
ttl: seconds(data['ttl']),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ⚠️ Every value is taken from `live`, the prop only standing in for a missing `name` — the same
|
|
100
|
+
* split the sibling families use, so a read never launders the declaration back as "current".
|
|
101
|
+
*/
|
|
102
|
+
export const attributesOf = (
|
|
103
|
+
props: Pick<BaoCloudflareRoleProps, 'mount' | 'name'>,
|
|
104
|
+
live: LiveCloudflareRole,
|
|
105
|
+
): BaoCloudflareRoleAttributes => {
|
|
106
|
+
const attrs = {
|
|
107
|
+
description: live.description,
|
|
108
|
+
maxTtl: live.maxTtl ?? 0,
|
|
109
|
+
mount: mountPath(props.mount),
|
|
110
|
+
name: live.name || props.name,
|
|
111
|
+
policies: live.policiesText,
|
|
112
|
+
ttl: live.ttl ?? 0,
|
|
113
|
+
};
|
|
114
|
+
return { ...attrs, digest: sha256(JSON.stringify(attrs)) };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Why reconcile must refuse this declaration, or undefined.
|
|
119
|
+
*
|
|
120
|
+
* ⛔ AN UNPARSEABLE TTL IS A REFUSAL, NOT A ZERO: it can never equal the live seconds, so it would
|
|
121
|
+
* plan `update` forever. ⛔ AN EMPTY POLICY LIST OR AN ENTRY WITH NO GROUPS IS A REFUSAL TOO — the
|
|
122
|
+
* engine rejects both (path_roles.go:147-149, :175-180), and saying so before the write names the
|
|
123
|
+
* declaration instead of quoting the server.
|
|
124
|
+
*/
|
|
125
|
+
export const refusalOf = (props: BaoCloudflareRoleProps): string | undefined => {
|
|
126
|
+
const bad = [
|
|
127
|
+
...(parseDuration(props.ttl) === undefined ? [`ttl=${props.ttl}`] : []),
|
|
128
|
+
...(parseDuration(props.maxTtl) === undefined ? [`maxTtl=${props.maxTtl}`] : []),
|
|
129
|
+
];
|
|
130
|
+
if (bad.length > 0) return `unparseable duration ${bad.join(', ')} (use 30s / 5m / 1h / 1d)`;
|
|
131
|
+
if (props.policies.length === 0) return 'no policy entries';
|
|
132
|
+
if (props.policies.some((policy) => policy.groups.length === 0)) return 'a policy with no groups';
|
|
133
|
+
return undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The body for `PUT <mount>/roles/<name>` — the four `k=v` pairs apply-roles.py:157-162 sent.
|
|
138
|
+
*
|
|
139
|
+
* ★ THE TTLs GO AS THE DECLARED TEXT (`5m`), which is what `bao write ttl=5m` put in the JSON body,
|
|
140
|
+
* and TypeDurationSecond parses it server-side. `policies` is a string because the field is a
|
|
141
|
+
* string (path_roles.go:67-68), not because the engine wants the whole body stringly-typed.
|
|
142
|
+
*/
|
|
143
|
+
export const writeBody = (props: BaoCloudflareRoleProps, policies: readonly WirePolicy[]) => ({
|
|
144
|
+
description: props.description,
|
|
145
|
+
max_ttl: props.maxTtl,
|
|
146
|
+
policies: policiesJson(policies),
|
|
147
|
+
ttl: props.ttl,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const sameTtl = (want: string, have: number | undefined) => {
|
|
151
|
+
const wantSeconds = parseDuration(want);
|
|
152
|
+
return wantSeconds !== undefined && wantSeconds === have;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Every field where live differs from the resolved declaration. Empty means noop.
|
|
157
|
+
*
|
|
158
|
+
* ⛔ apply-roles.py --verify COMPARED NONE OF THESE. It lists `<mount>/roles` and reports names
|
|
159
|
+
* only: MISSING when roles.yaml has a role the vault lacks, UNTRACKED for the reverse
|
|
160
|
+
* (apply-roles.py:136-141, :167-173, :179-185). It never read a role. So this equality is new: the
|
|
161
|
+
* four fields --apply WRITES (apply-roles.py:157-162), compared the way the engine reads them back
|
|
162
|
+
* — durations as seconds, description exactly, policies by the rules in samePolicies.
|
|
163
|
+
*/
|
|
164
|
+
export const differences = (
|
|
165
|
+
props: Pick<BaoCloudflareRoleProps, 'description' | 'maxTtl' | 'ttl'>,
|
|
166
|
+
policies: readonly ResolvedPolicy[],
|
|
167
|
+
live: LiveCloudflareRole,
|
|
168
|
+
): RoleDifference[] => {
|
|
169
|
+
const out: RoleDifference[] = [];
|
|
170
|
+
if (props.description !== live.description) {
|
|
171
|
+
out.push({ field: 'description', have: live.description, want: props.description });
|
|
172
|
+
}
|
|
173
|
+
if (!sameTtl(props.ttl, live.ttl)) {
|
|
174
|
+
out.push({ field: 'ttl', have: String(live.ttl), want: props.ttl });
|
|
175
|
+
}
|
|
176
|
+
if (!sameTtl(props.maxTtl, live.maxTtl)) {
|
|
177
|
+
out.push({ field: 'max_ttl', have: String(live.maxTtl), want: props.maxTtl });
|
|
178
|
+
}
|
|
179
|
+
if (live.policies === undefined || !samePolicies(policies, live.policies)) {
|
|
180
|
+
out.push({ field: 'policies', have: live.policiesText, want: policiesJson(policies) });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters:
|
|
3
|
+
* a write parses the duration text into seconds and re-marshals `policies` (path_roles.go:117-146,
|
|
4
|
+
* :199-210), a role never written is a 404, and a delete answers 204 whether or not it existed.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import { BaoError } from './bao-status.ts';
|
|
9
|
+
import { atEveryScope, resolvePolicies } from './cloudflare-policy.ts';
|
|
10
|
+
import { type BaoCloudflareRoleProps, differences, writeBody } from './cloudflare-role-form.ts';
|
|
11
|
+
import {
|
|
12
|
+
deleteCloudflareRole,
|
|
13
|
+
readCloudflareRole,
|
|
14
|
+
writeCloudflareRole,
|
|
15
|
+
} from './cloudflare-role-wire.ts';
|
|
16
|
+
import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
|
|
17
|
+
import { parseDuration } from './mount-form.ts';
|
|
18
|
+
|
|
19
|
+
const PROPS: BaoCloudflareRoleProps = {
|
|
20
|
+
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
21
|
+
maxTtl: '1h',
|
|
22
|
+
mount: 'cloudflare-acme-dns',
|
|
23
|
+
name: 'example-com-dns-read',
|
|
24
|
+
policies: [
|
|
25
|
+
{
|
|
26
|
+
effect: 'allow',
|
|
27
|
+
groupOrder: 'declared',
|
|
28
|
+
groups: ['DNS Read'],
|
|
29
|
+
resource: 'com.cloudflare.api.account.zone.z1',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
ttl: '5m',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PATH = '/v1/cloudflare-acme-dns/roles/example-com-dns-read';
|
|
36
|
+
|
|
37
|
+
/** The engine's role store, reduced to what these calls can observe. */
|
|
38
|
+
const roleStore = () => {
|
|
39
|
+
const roles = new Map<string, Record<string, unknown>>();
|
|
40
|
+
return (seen: Seen): Reply => {
|
|
41
|
+
if (seen.method === 'PUT') {
|
|
42
|
+
const body = JSON.parse(seen.body) as Record<string, string>;
|
|
43
|
+
roles.set(seen.path, {
|
|
44
|
+
description: body['description'],
|
|
45
|
+
max_ttl: parseDuration(body['max_ttl'] ?? ''),
|
|
46
|
+
name: seen.path.split('/').at(-1),
|
|
47
|
+
// ★ Re-marshalled, as json.Marshal of the stored structs would be.
|
|
48
|
+
policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
|
|
49
|
+
ttl: parseDuration(body['ttl'] ?? ''),
|
|
50
|
+
});
|
|
51
|
+
return { status: 204 };
|
|
52
|
+
}
|
|
53
|
+
if (seen.method === 'DELETE') {
|
|
54
|
+
roles.delete(seen.path);
|
|
55
|
+
return { status: 204 };
|
|
56
|
+
}
|
|
57
|
+
const role = roles.get(seen.path);
|
|
58
|
+
return role === undefined
|
|
59
|
+
? { json: { errors: [] }, status: 404 }
|
|
60
|
+
: { json: { data: role }, status: 200 };
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('Cloudflare role wire', () => {
|
|
65
|
+
it('reads a role that was never written as absent', async () => {
|
|
66
|
+
await withFake(roleStore(), async (bao) => {
|
|
67
|
+
const env = { BAO_ADDR: bao.address };
|
|
68
|
+
assert.equal(await run(env, readCloudflareRole(PROPS.mount, PROPS.name)), undefined);
|
|
69
|
+
assert.deepEqual([bao.seen[0]?.method, bao.seen[0]?.path], ['GET', PATH]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('writes the four fields and reads them back with no difference', async () => {
|
|
74
|
+
await withFake(roleStore(), async (bao) => {
|
|
75
|
+
const env = { BAO_ADDR: bao.address };
|
|
76
|
+
const { policies } = resolvePolicies(
|
|
77
|
+
PROPS.policies,
|
|
78
|
+
atEveryScope(new Map([['DNS Read', 'id-read']])),
|
|
79
|
+
);
|
|
80
|
+
await run(env, writeCloudflareRole(PROPS.mount, PROPS.name, writeBody(PROPS, policies)));
|
|
81
|
+
const put = bao.seen[0];
|
|
82
|
+
assert.deepEqual([put?.method, put?.path], ['PUT', PATH]);
|
|
83
|
+
assert.equal(put?.headers.get('content-type'), 'application/json');
|
|
84
|
+
assert.deepEqual(JSON.parse(put?.body ?? ''), {
|
|
85
|
+
description: PROPS.description,
|
|
86
|
+
max_ttl: '1h',
|
|
87
|
+
policies:
|
|
88
|
+
'[{"effect":"allow","resources":{"com.cloudflare.api.account.zone.z1":"*"},' +
|
|
89
|
+
'"permission_groups":[{"id":"id-read"}]}]',
|
|
90
|
+
ttl: '5m',
|
|
91
|
+
});
|
|
92
|
+
const live = await run(env, readCloudflareRole(PROPS.mount, PROPS.name));
|
|
93
|
+
assert.ok(live);
|
|
94
|
+
assert.deepEqual([live.ttl, live.maxTtl], [300, 3600]);
|
|
95
|
+
assert.deepEqual(differences(PROPS, policies, live), []);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('fails a refused read (403) instead of calling the role absent', async () => {
|
|
100
|
+
await withFake(
|
|
101
|
+
() => ({ json: { errors: ['permission denied'] }, status: 403 }),
|
|
102
|
+
async (bao) => {
|
|
103
|
+
const error = await runFailure({ BAO_ADDR: bao.address }, readCloudflareRole('m', 'r'));
|
|
104
|
+
assert.ok(error instanceof BaoError);
|
|
105
|
+
assert.equal(error.status, 403);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('fails a write to a mount that is not there (404), carrying OpenBao errors', async () => {
|
|
111
|
+
const errors = ['no handler for route "cloudflare-nope/roles/r". route entry not found.'];
|
|
112
|
+
await withFake(
|
|
113
|
+
() => ({ json: { errors }, status: 404 }),
|
|
114
|
+
async (bao) => {
|
|
115
|
+
const write = writeCloudflareRole('cloudflare-nope', 'r', { ttl: '5m' });
|
|
116
|
+
const error = await runFailure({ BAO_ADDR: bao.address }, write);
|
|
117
|
+
assert.ok(error instanceof BaoError);
|
|
118
|
+
assert.deepEqual(error.errors, errors);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('deletes idempotently: a role already gone is success', async () => {
|
|
124
|
+
await withFake(
|
|
125
|
+
() => ({ json: { errors: [] }, status: 404 }),
|
|
126
|
+
async (bao) => {
|
|
127
|
+
await run({ BAO_ADDR: bao.address }, deleteCloudflareRole(PROPS.mount, PROPS.name));
|
|
128
|
+
assert.deepEqual([bao.seen[0]?.method, bao.seen[0]?.path], ['DELETE', PATH]);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|