@homeflare/alchemy 0.38.0 → 0.40.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/README.md +32 -8
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1609 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/forgejo/index.js +4 -2
- package/dist/forgejo/index.js.map +3 -3
- package/dist/forgejo/team-member.d.ts +25 -0
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +14 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +574 -133
- package/dist/proxmox/index.js.map +33 -23
- package/dist/proxmox/qemu-form.d.ts +27 -18
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts +30 -0
- package/dist/proxmox/qemu-judge.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-net.d.ts +9 -0
- package/dist/proxmox/qemu-net.d.ts.map +1 -0
- package/dist/proxmox/qemu-props.d.ts +112 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -0
- package/dist/proxmox/qemu-volume.d.ts +27 -0
- package/dist/proxmox/qemu-volume.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +14 -27
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/storage-download-errors.d.ts +5 -0
- package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
- package/dist/proxmox/storage-download-identity.d.ts +27 -0
- package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/storage-download-props.d.ts +36 -0
- package/dist/proxmox/storage-download-props.d.ts.map +1 -0
- package/dist/proxmox/storage-download-task.d.ts +17 -0
- package/dist/proxmox/storage-download-task.d.ts.map +1 -0
- package/dist/proxmox/storage-download.d.ts +44 -0
- package/dist/proxmox/storage-download.d.ts.map +1 -0
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/telemetry/fake-collector.d.ts +36 -0
- package/dist/telemetry/fake-collector.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +223 -0
- package/dist/telemetry/index.js.map +12 -0
- package/dist/telemetry/layer.d.ts +86 -0
- package/dist/telemetry/layer.d.ts.map +1 -0
- package/dist/telemetry/redact-serialization.d.ts +21 -0
- package/dist/telemetry/redact-serialization.d.ts.map +1 -0
- package/dist/telemetry/redact.d.ts +46 -0
- package/dist/telemetry/redact.d.ts.map +1 -0
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/drift.d.ts +27 -0
- package/dist/unifi/drift.d.ts.map +1 -0
- package/dist/unifi/fake-unifi.d.ts +5 -1
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +6 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +11 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +725 -60
- package/dist/unifi/index.js.map +31 -9
- package/dist/unifi/network-drift.d.ts +26 -0
- package/dist/unifi/network-drift.d.ts.map +1 -0
- package/dist/unifi/network-form.d.ts +40 -7
- package/dist/unifi/network-form.d.ts.map +1 -1
- package/dist/unifi/network.d.ts +1 -0
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/paginate.d.ts +24 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +64 -3
- package/package.json +10 -2
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/index.ts +1 -0
- package/src/proxmox/constraints-required.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +3 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.test.ts +11 -6
- package/src/proxmox/index.ts +19 -3
- package/src/proxmox/qemu-form.ts +49 -13
- package/src/proxmox/qemu-identity.test.ts +30 -0
- package/src/proxmox/qemu-judge.test.ts +65 -0
- package/src/proxmox/qemu-judge.ts +84 -0
- package/src/proxmox/qemu-lifecycle.test.ts +78 -19
- package/src/proxmox/qemu-lifecycle.ts +131 -32
- package/src/proxmox/qemu-net.test.ts +48 -0
- package/src/proxmox/qemu-net.ts +78 -0
- package/src/proxmox/qemu-ownership.test.ts +44 -0
- package/src/proxmox/qemu-props.test.ts +87 -0
- package/src/proxmox/qemu-props.ts +157 -0
- package/src/proxmox/qemu-volume.test.ts +229 -0
- package/src/proxmox/qemu-volume.ts +178 -0
- package/src/proxmox/qemu.ts +15 -29
- package/src/proxmox/storage-download-errors.ts +7 -0
- package/src/proxmox/storage-download-identity.test.ts +54 -0
- package/src/proxmox/storage-download-identity.ts +50 -0
- package/src/proxmox/storage-download-lifecycle.ts +226 -0
- package/src/proxmox/storage-download-ownership.test.ts +50 -0
- package/src/proxmox/storage-download-props.ts +38 -0
- package/src/proxmox/storage-download-task.ts +72 -0
- package/src/proxmox/storage-download.test.ts +216 -0
- package/src/proxmox/storage-download.ts +58 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/telemetry/fake-collector.ts +59 -0
- package/src/telemetry/http-client-sealing.test.ts +82 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/layer.test.ts +204 -0
- package/src/telemetry/layer.ts +152 -0
- package/src/telemetry/redact-serialization.ts +178 -0
- package/src/telemetry/redact.test.ts +124 -0
- package/src/telemetry/redact.ts +147 -0
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/drift.test.ts +78 -0
- package/src/unifi/drift.ts +56 -0
- package/src/unifi/errors-and-secrets.test.ts +127 -0
- package/src/unifi/fake-unifi.ts +5 -1
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/firewall-zone-form.ts +31 -2
- package/src/unifi/firewall-zone.test.ts +21 -1
- package/src/unifi/firewall-zone.ts +1 -1
- package/src/unifi/get-only-guard.test.ts +165 -0
- package/src/unifi/index.ts +78 -1
- package/src/unifi/network-drift.test.ts +207 -0
- package/src/unifi/network-drift.ts +80 -0
- package/src/unifi/network-form.ts +11 -39
- package/src/unifi/network.test.ts +7 -82
- package/src/unifi/network.ts +6 -6
- package/src/unifi/paginate.test.ts +252 -0
- package/src/unifi/paginate.ts +141 -0
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/resource.ts +73 -3
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +190 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing against fixture stdout — REASONED shapes (ceph-auth-parse.ts's header), so these fixtures
|
|
3
|
+
* are this PR's stand-in for a live cluster, not a measurement.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { leaksSliceOf, trailingCommaKeyStdout, unquotedKeyStdout } from './ceph-key-leak-assert.ts';
|
|
7
|
+
import { parseAuthGet, parseAuthGetOrCreate, parseQuorumStatus } from './ceph-auth-parse.ts';
|
|
8
|
+
|
|
9
|
+
const ENTITY = 'client.k8s-rbd';
|
|
10
|
+
const CAPS = {
|
|
11
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
12
|
+
mon: 'profile rbd',
|
|
13
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
14
|
+
};
|
|
15
|
+
const KEYRING_JSON = JSON.stringify([
|
|
16
|
+
{ caps: CAPS, entity: ENTITY, key: 'fake-ceph-key-not-a-real-secret' },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
describe('parseAuthGet', () => {
|
|
20
|
+
test('present: returns caps only — the parsed value has no key field at all', () => {
|
|
21
|
+
const observed = parseAuthGet({ exitCode: 0, stderr: '', stdout: KEYRING_JSON }, ENTITY);
|
|
22
|
+
expect(observed).toEqual({ caps: CAPS, kind: 'present' });
|
|
23
|
+
expect(Object.keys(observed)).not.toContain('key');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('absent: ENOENT in stderr, never mistaken for an error', () => {
|
|
27
|
+
const observed = parseAuthGet(
|
|
28
|
+
{ exitCode: 2, stderr: 'Error ENOENT: failed to find client.k8s-rbd in keyring', stdout: '' },
|
|
29
|
+
ENTITY,
|
|
30
|
+
);
|
|
31
|
+
expect(observed).toEqual({ kind: 'absent' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a nonzero exit without ENOENT propagates as an error, never as absent', () => {
|
|
35
|
+
expect(() =>
|
|
36
|
+
parseAuthGet({ exitCode: 1, stderr: 'Error EACCES: permission denied', stdout: '' }, ENTITY),
|
|
37
|
+
).toThrow(/EACCES/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('unparseable stdout is an error, never absent', () => {
|
|
41
|
+
expect(() => parseAuthGet({ exitCode: 0, stderr: '', stdout: 'not json' }, ENTITY)).toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a JSON array with no matching entity is an error', () => {
|
|
45
|
+
const other = JSON.stringify([{ caps: CAPS, entity: 'client.k8s-other', key: 'x' }]);
|
|
46
|
+
expect(() => parseAuthGet({ exitCode: 0, stderr: '', stdout: other }, ENTITY)).toThrow(
|
|
47
|
+
/no matching entry/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an entry missing a caps field is an error', () => {
|
|
52
|
+
const bad = JSON.stringify([{ entity: ENTITY, key: 'x' }]);
|
|
53
|
+
expect(() => parseAuthGet({ exitCode: 0, stderr: '', stdout: bad }, ENTITY)).toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Decision 65 (LAND finding 5): `auth get` is read unfiltered on every reconcile, so every
|
|
57
|
+
// failure path below feeds it a key-shaped sentinel and proves no 8-character SLICE of it ever
|
|
58
|
+
// reaches the thrown error — not just the whole string. LAND red team (2026-09-26), CONFIRMED:
|
|
59
|
+
// a hyphenated sentinel and a whole-string `.not.toContain` check both missed a real leak where
|
|
60
|
+
// `JSON.parse`'s own error message quoted a prefix of an unquoted or comma-adjacent key
|
|
61
|
+
// (ceph-key-leak-assert.ts has the measured detail).
|
|
62
|
+
test('a malformed present entry never lets even a slice of the key it carried leak', () => {
|
|
63
|
+
const bad = JSON.stringify([{ entity: ENTITY, key: 'sentinel-ceph-key-must-never-leak' }]); // no caps
|
|
64
|
+
let caught: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parseAuthGet({ exitCode: 0, stderr: '', stdout: bad }, ENTITY);
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
caught = cause;
|
|
69
|
+
}
|
|
70
|
+
expect(caught).toBeInstanceOf(Error);
|
|
71
|
+
expect(leaksSliceOf(String(caught), 'sentinel-ceph-key-must-never-leak')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a nonzero exit never echoes stdout, even a slice of a key it somehow carried', () => {
|
|
75
|
+
const stdout = unquotedKeyStdout(ENTITY);
|
|
76
|
+
let caught: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parseAuthGet({ exitCode: 13, stderr: 'Error EPERM: refused', stdout }, ENTITY);
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
caught = cause;
|
|
81
|
+
}
|
|
82
|
+
expect(String(caught)).toContain('EPERM');
|
|
83
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
for (const [shape, stdout] of [
|
|
87
|
+
['a bare (unquoted) key token', unquotedKeyStdout(ENTITY)],
|
|
88
|
+
['a trailing comma after the entry', trailingCommaKeyStdout(ENTITY)],
|
|
89
|
+
] as const) {
|
|
90
|
+
test(`unparseable stdout via ${shape} never leaks a slice of the key, from parseAuthGet`, () => {
|
|
91
|
+
let caught: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parseAuthGet({ exitCode: 0, stderr: '', stdout }, ENTITY);
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
caught = cause;
|
|
96
|
+
}
|
|
97
|
+
expect(caught).toBeInstanceOf(Error);
|
|
98
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test(`unparseable stdout via ${shape} never leaks a slice of the key, from parseAuthGetOrCreate`, () => {
|
|
102
|
+
let caught: unknown;
|
|
103
|
+
try {
|
|
104
|
+
parseAuthGetOrCreate({ exitCode: 0, stderr: '', stdout }, ENTITY);
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
caught = cause;
|
|
107
|
+
}
|
|
108
|
+
expect(caught).toBeInstanceOf(Error);
|
|
109
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('parseAuthGetOrCreate', () => {
|
|
115
|
+
test('returns caps and the key, on success', () => {
|
|
116
|
+
const created = parseAuthGetOrCreate({ exitCode: 0, stderr: '', stdout: KEYRING_JSON }, ENTITY);
|
|
117
|
+
expect(created).toEqual({ caps: CAPS, key: 'fake-ceph-key-not-a-real-secret' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('never reads a nonzero exit as absent — always an error', () => {
|
|
121
|
+
expect(() =>
|
|
122
|
+
parseAuthGetOrCreate(
|
|
123
|
+
{ exitCode: 22, stderr: 'Error EINVAL: caps do not match', stdout: '' },
|
|
124
|
+
ENTITY,
|
|
125
|
+
),
|
|
126
|
+
).toThrow(/EINVAL/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('parseQuorumStatus', () => {
|
|
131
|
+
test('healthy: a non-empty quorum array', () => {
|
|
132
|
+
expect(parseQuorumStatus('{"quorum":[0,1,2],"quorum_names":["a","b","c"]}')).toEqual({
|
|
133
|
+
healthy: true,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('degraded: an empty quorum array', () => {
|
|
138
|
+
expect(parseQuorumStatus('{"quorum":[]}')).toEqual({
|
|
139
|
+
healthy: false,
|
|
140
|
+
reason: 'no non-empty `quorum` array in the quorum_status output',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('degraded: no quorum field at all', () => {
|
|
145
|
+
expect(parseQuorumStatus('{"election_epoch":5}').healthy).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('degraded: not even a JSON object', () => {
|
|
149
|
+
expect(parseQuorumStatus('[1,2,3]').healthy).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('degraded, never thrown: unparseable JSON fails closed instead of crashing the caller', () => {
|
|
153
|
+
expect(parseQuorumStatus('not json').healthy).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing for the three ceph `-f json` shapes `Ceph.AuthEntity` reads: `auth get`, `auth
|
|
3
|
+
* get-or-create` and `quorum_status`. Pure: no I/O, tested with fixture stdout
|
|
4
|
+
* (ceph-auth-parse.test.ts) — there is no live mon to capture real output from yet, so every shape
|
|
5
|
+
* below is REASONED from public Ceph documentation, not measured, and an unexpected shape is
|
|
6
|
+
* always an error, never a guess.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ `parseAuthGet`'S RETURN TYPE HAS NO `key` FIELD. Decision 65 (2026-09-26, LAND finding 5)
|
|
9
|
+
* amended the design to let the observe step read `auth get`'s unfiltered stdout on every
|
|
10
|
+
* reconcile, not only on create — but the key it carries is dropped in the same breath:
|
|
11
|
+
* `parseKeyringEntry` reads it into a local binding and `parseAuthGet` never puts it in the
|
|
12
|
+
* object it returns. Not a convention, a type: there is no field here to forget to strip.
|
|
13
|
+
* ⛔ `parseQuorumStatus` FAILS CLOSED. A JSON parse error, a missing `quorum` array or an empty one
|
|
14
|
+
* all answer `{ healthy: false }`; nothing here ever calls an unparseable answer healthy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type CephCaps = { readonly mon: string; readonly osd: string; readonly mgr: string };
|
|
18
|
+
|
|
19
|
+
export type CephExecOutcome = {
|
|
20
|
+
readonly exitCode: number;
|
|
21
|
+
readonly stdout: string;
|
|
22
|
+
readonly stderr: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
26
|
+
typeof value === 'object' && value !== null;
|
|
27
|
+
|
|
28
|
+
const parseCaps = (value: unknown, entity: string): CephCaps => {
|
|
29
|
+
if (!isRecord(value)) throw new Error(`ceph auth: ${entity}'s entry has no caps object`);
|
|
30
|
+
const { mon, osd, mgr } = value;
|
|
31
|
+
if (typeof mon !== 'string' || typeof osd !== 'string' || typeof mgr !== 'string') {
|
|
32
|
+
throw new Error(`ceph auth: ${entity}'s caps are missing a mon, osd or mgr string`);
|
|
33
|
+
}
|
|
34
|
+
return { mgr, mon, osd };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* REASONED shape (public `ceph auth get -f json` / `auth get-or-create -f json` docs): a JSON
|
|
39
|
+
* array with one object per entity, `{entity, key, caps: {mon, osd, mgr}}`.
|
|
40
|
+
*/
|
|
41
|
+
const parseKeyringEntry = (
|
|
42
|
+
stdout: string,
|
|
43
|
+
entity: string,
|
|
44
|
+
): { readonly caps: CephCaps; readonly key: string } => {
|
|
45
|
+
let parsed: unknown;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(stdout);
|
|
48
|
+
} catch {
|
|
49
|
+
// ⛔ NEVER INTERPOLATE THE CATCH VALUE. LAND red team (2026-09-26), CONFIRMED: both Bun's and
|
|
50
|
+
// Node's `JSON.parse` errors quote a slice of the offending input — a bare (unquoted) or
|
|
51
|
+
// comma-adjacent key in this stdout put a slice of a real cephx key straight into this
|
|
52
|
+
// thrown error (measured: median 32/40 chars of a 1000-key sample). `stdout` carries the
|
|
53
|
+
// key on every reconcile since decision 65, so this message names only what failed, never
|
|
54
|
+
// any part of what it was reading.
|
|
55
|
+
throw new Error(`ceph auth ${entity}: stdout did not parse as JSON`);
|
|
56
|
+
}
|
|
57
|
+
const row = Array.isArray(parsed)
|
|
58
|
+
? parsed.find((entry) => isRecord(entry) && entry['entity'] === entity)
|
|
59
|
+
: undefined;
|
|
60
|
+
if (!isRecord(row)) throw new Error(`ceph auth ${entity}: no matching entry in the JSON output`);
|
|
61
|
+
const { key } = row;
|
|
62
|
+
if (typeof key !== 'string' || key.trim() === '') {
|
|
63
|
+
throw new Error(`ceph auth ${entity}: the entry has no key field`);
|
|
64
|
+
}
|
|
65
|
+
return { caps: parseCaps(row['caps'], entity), key };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CephAuthObserved =
|
|
69
|
+
| { readonly kind: 'present'; readonly caps: CephCaps }
|
|
70
|
+
| { readonly kind: 'absent' };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ⚠️ ABSENCE IS RECOGNISED ONLY BY `ENOENT` IN STDERR (REASONED from `ceph auth get`'s documented
|
|
74
|
+
* error text). Any other nonzero exit — a permission problem, a malformed entity ceph itself
|
|
75
|
+
* refused, a mon mid-election — propagates as an error, never as "absent": the transient-read
|
|
76
|
+
* test every family in this kit carries.
|
|
77
|
+
*/
|
|
78
|
+
export const parseAuthGet = (result: CephExecOutcome, entity: string): CephAuthObserved => {
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
if (result.stderr.includes('ENOENT')) return { kind: 'absent' };
|
|
81
|
+
throw new Error(`ceph auth get ${entity} failed: ${result.stderr.trim().slice(0, 300)}`);
|
|
82
|
+
}
|
|
83
|
+
const { caps } = parseKeyringEntry(result.stdout, entity);
|
|
84
|
+
return { caps, kind: 'present' };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* ⚠️ `get-or-create` NEVER READS AS ABSENT — REASONED: it either creates, returns the existing
|
|
89
|
+
* entity unchanged, or fails outright (e.g. an existing entity with different caps). A nonzero
|
|
90
|
+
* exit here is always an error.
|
|
91
|
+
*/
|
|
92
|
+
export const parseAuthGetOrCreate = (
|
|
93
|
+
result: CephExecOutcome,
|
|
94
|
+
entity: string,
|
|
95
|
+
): { readonly caps: CephCaps; readonly key: string } => {
|
|
96
|
+
if (result.exitCode !== 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`ceph auth get-or-create ${entity} failed: ${result.stderr.trim().slice(0, 300)}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return parseKeyringEntry(result.stdout, entity);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type CephQuorum =
|
|
105
|
+
| { readonly healthy: true }
|
|
106
|
+
| { readonly healthy: false; readonly reason: string };
|
|
107
|
+
|
|
108
|
+
/** REASONED shape (public `ceph quorum_status -f json` docs): an object with a `quorum` array. */
|
|
109
|
+
export const parseQuorumStatus = (stdout: string): CephQuorum => {
|
|
110
|
+
let parsed: unknown;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(stdout);
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
return { healthy: false, reason: `stdout did not parse as JSON (${String(cause)})` };
|
|
115
|
+
}
|
|
116
|
+
if (!isRecord(parsed)) return { healthy: false, reason: 'stdout was not a JSON object' };
|
|
117
|
+
const quorum = parsed['quorum'];
|
|
118
|
+
return Array.isArray(quorum) && quorum.length > 0
|
|
119
|
+
? { healthy: true }
|
|
120
|
+
: { healthy: false, reason: 'no non-empty `quorum` array in the quorum_status output' };
|
|
121
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K-A4 finding 5 / decision 65 (2026-09-26): the design was amended to let the observe step read
|
|
3
|
+
* `auth get`'s unfiltered stdout on every reconcile, not only on create, with no node-side shell
|
|
4
|
+
* filter — accepted because the key is dropped in memory before anything is logged, returned,
|
|
5
|
+
* stored or put in an error. Split from ceph-auth-reconcile.test.ts (which already covers the
|
|
6
|
+
* happy-path create/no-op/drift secrecy checks) to keep that file under the house line cap; this
|
|
7
|
+
* file is the failure-path proof the decision's own text calls for.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THE MALFORMED STDOUT MUST ACTUALLY FAIL `JSON.parse`. LAND red team (2026-09-26), CONFIRMED:
|
|
10
|
+
* an earlier version of this file fed a WELL-FORMED JSON entry that was merely missing `caps` —
|
|
11
|
+
* a different, never-vulnerable error path (`parseCaps` never even looks at `key`) — so it never
|
|
12
|
+
* exercised the line that used to interpolate the parse failure's own message. The two shapes
|
|
13
|
+
* below (ceph-key-leak-assert.ts) are ones that genuinely throw inside `JSON.parse`.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { type Seen, run, withFake } from '../openbao/fake-bao.ts';
|
|
17
|
+
import {
|
|
18
|
+
KEY_SHAPED_SENTINEL,
|
|
19
|
+
leaksSliceOf,
|
|
20
|
+
trailingCommaKeyStdout,
|
|
21
|
+
unquotedKeyStdout,
|
|
22
|
+
} from './ceph-key-leak-assert.ts';
|
|
23
|
+
import { reconcileCephAuthEntity } from './ceph-auth-reconcile.ts';
|
|
24
|
+
import type { CephAuthEntityProps } from './ceph-auth-form.ts';
|
|
25
|
+
import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
|
|
26
|
+
|
|
27
|
+
const ENTITY = 'client.k8s-rbd';
|
|
28
|
+
const MON = 'mon-a.example.test';
|
|
29
|
+
const CAPS = {
|
|
30
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
31
|
+
mon: 'profile rbd',
|
|
32
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
33
|
+
};
|
|
34
|
+
const PROPS: CephAuthEntityProps = { caps: CAPS, entity: ENTITY, mount: 'talos-c1', nodes: [MON] };
|
|
35
|
+
const okReply = () => ({ status: 200 });
|
|
36
|
+
|
|
37
|
+
const reconcileOn = async (stdout: string) => {
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
const fake = fakeCephDial({ [MON]: [fakeCephOk(stdout)] });
|
|
40
|
+
let caught: unknown;
|
|
41
|
+
let seen: readonly Seen[] = [];
|
|
42
|
+
await withFake(okReply, async (bao) => {
|
|
43
|
+
try {
|
|
44
|
+
await run(
|
|
45
|
+
{ BAO_ADDR: bao.address },
|
|
46
|
+
reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: (l) => lines.push(l) }),
|
|
47
|
+
);
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
caught = cause;
|
|
50
|
+
}
|
|
51
|
+
seen = bao.seen;
|
|
52
|
+
});
|
|
53
|
+
return { caught, lines, writes: seen.filter((s) => s.method !== 'GET') };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('a malformed present observation', () => {
|
|
57
|
+
test('a well-formed entry missing `caps` never leaks the key it carried (parseCaps never sees it)', async () => {
|
|
58
|
+
const { caught, lines, writes } = await reconcileOn(
|
|
59
|
+
JSON.stringify([{ entity: ENTITY, key: KEY_SHAPED_SENTINEL }]),
|
|
60
|
+
);
|
|
61
|
+
expect(caught).toBeInstanceOf(Error);
|
|
62
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
63
|
+
expect(writes).toHaveLength(0);
|
|
64
|
+
expect(leaksSliceOf(lines.join('\n'))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const [shape, stdout] of [
|
|
68
|
+
['a bare (unquoted) key token', unquotedKeyStdout(ENTITY)],
|
|
69
|
+
['a trailing comma after the entry', trailingCommaKeyStdout(ENTITY)],
|
|
70
|
+
] as const) {
|
|
71
|
+
test(`unparseable stdout via ${shape} never leaks a slice of the key, through the full reconcile loop`, async () => {
|
|
72
|
+
// Decision 65 accepts reading `auth get`'s unfiltered stdout on every reconcile only
|
|
73
|
+
// because the key never survives past the parse — including when the parse ITSELF fails on
|
|
74
|
+
// input that (unlike a well-formed-but-incomplete entry) actually contains the key's bytes.
|
|
75
|
+
const { caught, lines, writes } = await reconcileOn(stdout);
|
|
76
|
+
expect(caught).toBeInstanceOf(Error);
|
|
77
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
78
|
+
expect(writes).toHaveLength(0);
|
|
79
|
+
expect(leaksSliceOf(lines.join('\n'))).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observe -> ensure -> sync loop against fakeCephDial + fakeBao — no ssh, no real vault.
|
|
3
|
+
* Mon-transport doc acceptance tests #2 (the reconcile loop itself) and #3 (secret handling). The
|
|
4
|
+
* failure-path half of #3 — a malformed `auth get` reply that still carries a key — is
|
|
5
|
+
* ceph-auth-reconcile-secrecy.test.ts, split out to keep this file under the house line cap.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { sha256 } from '../openbao/digest.ts';
|
|
9
|
+
import { type Reply, type Seen, run, withFake } from '../openbao/fake-bao.ts';
|
|
10
|
+
import { leaksSliceOf } from './ceph-key-leak-assert.ts';
|
|
11
|
+
import { reconcileCephAuthEntity } from './ceph-auth-reconcile.ts';
|
|
12
|
+
import type { CephAuthEntityAttributes, CephAuthEntityProps } from './ceph-auth-form.ts';
|
|
13
|
+
import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
|
|
14
|
+
|
|
15
|
+
const ENTITY = 'client.k8s-rbd';
|
|
16
|
+
const MON = 'mon-a.example.test';
|
|
17
|
+
const CAPS = {
|
|
18
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
19
|
+
mon: 'profile rbd',
|
|
20
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
21
|
+
};
|
|
22
|
+
const OTHER_CAPS = { ...CAPS, osd: 'profile rbd pool=other' };
|
|
23
|
+
const KEY = 'fake-ceph-key-not-a-real-secret';
|
|
24
|
+
const keyring = (caps = CAPS) => JSON.stringify([{ caps, entity: ENTITY, key: KEY }]);
|
|
25
|
+
const ABSENT = { exitCode: 2, stderr: 'Error ENOENT: not found', stdout: '' };
|
|
26
|
+
const HEALTHY_QUORUM = '{"quorum":[0,1,2]}';
|
|
27
|
+
|
|
28
|
+
const PROPS: CephAuthEntityProps = { caps: CAPS, entity: ENTITY, mount: 'talos-c1', nodes: [MON] };
|
|
29
|
+
const PRIOR: CephAuthEntityAttributes = {
|
|
30
|
+
baoPath: 'talos-c1/ceph/client.k8s-rbd',
|
|
31
|
+
caps: CAPS,
|
|
32
|
+
entity: ENTITY,
|
|
33
|
+
fingerprint: sha256(KEY),
|
|
34
|
+
node: MON,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const okReply = (): Reply => ({ status: 200 });
|
|
38
|
+
const writesOf = (seen: readonly Seen[]) => seen.filter((s) => s.method !== 'GET');
|
|
39
|
+
const argvOf = (seen: readonly { readonly argv: readonly string[] }[]) =>
|
|
40
|
+
seen.map((c) => c.argv.slice(1, 3));
|
|
41
|
+
|
|
42
|
+
describe('create of absent', () => {
|
|
43
|
+
test('a preflight write, get-or-create, the real write, one quorum check, key never in the attributes', async () => {
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
const fake = fakeCephDial({
|
|
46
|
+
[MON]: [
|
|
47
|
+
{ kind: 'result', result: ABSENT },
|
|
48
|
+
fakeCephOk(keyring()),
|
|
49
|
+
fakeCephOk(HEALTHY_QUORUM),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
await withFake(okReply, async (bao) => {
|
|
53
|
+
const attrs = await run(
|
|
54
|
+
{ BAO_ADDR: bao.address },
|
|
55
|
+
reconcileCephAuthEntity(PROPS, undefined, {
|
|
56
|
+
dial: fake.dial,
|
|
57
|
+
log: (line) => lines.push(line),
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
expect(attrs).toEqual(PRIOR);
|
|
61
|
+
expect(leaksSliceOf(JSON.stringify(attrs), KEY)).toBe(false);
|
|
62
|
+
|
|
63
|
+
// ⛔ The preflight (K-A4 finding 2) proves the path is writable BEFORE anything is minted —
|
|
64
|
+
// see ceph-auth-reconcile.ts's header. Both writes land at the same path; only the second
|
|
65
|
+
// carries the key.
|
|
66
|
+
const writes = writesOf(bao.seen);
|
|
67
|
+
expect(writes).toHaveLength(2);
|
|
68
|
+
expect(writes[0]?.path).toBe('/v1/talos-c1/data/ceph/client.k8s-rbd');
|
|
69
|
+
expect(leaksSliceOf(writes[0]?.body ?? '', KEY)).toBe(false);
|
|
70
|
+
expect(writes[1]?.path).toBe('/v1/talos-c1/data/ceph/client.k8s-rbd');
|
|
71
|
+
expect(writes[1]?.body).toContain(KEY);
|
|
72
|
+
|
|
73
|
+
expect(argvOf(fake.seen)).toEqual([
|
|
74
|
+
['auth', 'get'],
|
|
75
|
+
['auth', 'get-or-create'],
|
|
76
|
+
['quorum_status', '-f'],
|
|
77
|
+
]);
|
|
78
|
+
for (const call of fake.seen)
|
|
79
|
+
expect(leaksSliceOf(JSON.stringify(call.argv), KEY)).toBe(false);
|
|
80
|
+
// Decision 65 (LAND finding 5): the create path's own `auth get` observe also reads the
|
|
81
|
+
// keyring's unfiltered stdout — this proves the runner's log line never carries it either.
|
|
82
|
+
expect(leaksSliceOf(lines.join('\n'), KEY)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a vault write refused before anything is minted leaves the mon untouched', async () => {
|
|
87
|
+
const fake = fakeCephDial({ [MON]: [{ kind: 'result', result: ABSENT }] });
|
|
88
|
+
const refused = () => ({ json: { errors: ['permission denied'] }, status: 403 });
|
|
89
|
+
await withFake(refused, async (bao) => {
|
|
90
|
+
await expect(
|
|
91
|
+
run(
|
|
92
|
+
{ BAO_ADDR: bao.address },
|
|
93
|
+
reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
|
|
94
|
+
),
|
|
95
|
+
).rejects.toThrow();
|
|
96
|
+
// Only `auth get` (the observe) reached the mon — the preflight failed before get-or-create.
|
|
97
|
+
expect(argvOf(fake.seen)).toEqual([['auth', 'get']]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('a second reconcile, caps unchanged', () => {
|
|
103
|
+
test('zero bao writes, zero quorum checks — the observe alone decides', async () => {
|
|
104
|
+
// Decision 65 (LAND finding 5): this `auth get` reads the keyring's unfiltered stdout too —
|
|
105
|
+
// prove the key never survives into attrs or the per-call log line, same as the create path.
|
|
106
|
+
const lines: string[] = [];
|
|
107
|
+
const fake = fakeCephDial({ [MON]: [fakeCephOk(keyring())] });
|
|
108
|
+
await withFake(okReply, async (bao) => {
|
|
109
|
+
const attrs = await run(
|
|
110
|
+
{ BAO_ADDR: bao.address },
|
|
111
|
+
reconcileCephAuthEntity(PROPS, PRIOR, { dial: fake.dial, log: (line) => lines.push(line) }),
|
|
112
|
+
);
|
|
113
|
+
expect(attrs).toEqual(PRIOR);
|
|
114
|
+
expect(leaksSliceOf(JSON.stringify(attrs), KEY)).toBe(false);
|
|
115
|
+
expect(writesOf(bao.seen)).toHaveLength(0);
|
|
116
|
+
expect(fake.seen).toHaveLength(1);
|
|
117
|
+
expect(leaksSliceOf(lines.join('\n'), KEY)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('caps drift', () => {
|
|
123
|
+
test('runs exactly auth caps, never get-or-create; the fingerprint is reused, not reminted', async () => {
|
|
124
|
+
// Same decision-65 guarantee: the drifted `auth get` reply carries the key in its stdout too.
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
const drifted: CephAuthEntityAttributes = { ...PRIOR, caps: OTHER_CAPS };
|
|
127
|
+
const fake = fakeCephDial({
|
|
128
|
+
[MON]: [fakeCephOk(keyring(OTHER_CAPS)), fakeCephOk(HEALTHY_QUORUM)],
|
|
129
|
+
});
|
|
130
|
+
await withFake(okReply, async (bao) => {
|
|
131
|
+
const attrs = await run(
|
|
132
|
+
{ BAO_ADDR: bao.address },
|
|
133
|
+
reconcileCephAuthEntity(PROPS, drifted, {
|
|
134
|
+
dial: fake.dial,
|
|
135
|
+
log: (line) => lines.push(line),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
expect(attrs.caps).toEqual(CAPS);
|
|
139
|
+
expect(attrs.fingerprint).toBe(PRIOR.fingerprint);
|
|
140
|
+
expect(leaksSliceOf(JSON.stringify(attrs), KEY)).toBe(false);
|
|
141
|
+
expect(writesOf(bao.seen)).toHaveLength(0);
|
|
142
|
+
expect(argvOf(fake.seen)).toEqual([
|
|
143
|
+
['auth', 'get'],
|
|
144
|
+
['auth', 'caps'],
|
|
145
|
+
['quorum_status', '-f'],
|
|
146
|
+
]);
|
|
147
|
+
expect(leaksSliceOf(lines.join('\n'), KEY)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('a down anchor', () => {
|
|
153
|
+
test('every mon failing at the transport layer is an error, never "absent", and nothing is created', async () => {
|
|
154
|
+
const fake = fakeCephDial({});
|
|
155
|
+
await withFake(okReply, async (bao) => {
|
|
156
|
+
await expect(
|
|
157
|
+
run(
|
|
158
|
+
{ BAO_ADDR: bao.address },
|
|
159
|
+
reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
|
|
160
|
+
),
|
|
161
|
+
).rejects.toThrow(/never treated as absent/);
|
|
162
|
+
expect(writesOf(bao.seen)).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('never adopted', () => {
|
|
168
|
+
test('a live entity present with no prior state is refused outright, never taken over', async () => {
|
|
169
|
+
const fake = fakeCephDial({ [MON]: [fakeCephOk(keyring())] });
|
|
170
|
+
await withFake(okReply, async (bao) => {
|
|
171
|
+
await expect(
|
|
172
|
+
run(
|
|
173
|
+
{ BAO_ADDR: bao.address },
|
|
174
|
+
reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
|
|
175
|
+
),
|
|
176
|
+
).rejects.toThrow(/never adopted/);
|
|
177
|
+
expect(writesOf(bao.seen)).toHaveLength(0);
|
|
178
|
+
expect(fake.seen).toHaveLength(1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('a moved identity (K-A4 finding 1)', () => {
|
|
184
|
+
test('an entity edited in place is refused before any ssh call, never re-capped as a takeover', async () => {
|
|
185
|
+
const moved: CephAuthEntityProps = { ...PROPS, entity: 'client.k8s-other' };
|
|
186
|
+
const fake = fakeCephDial({ [MON]: [fakeCephOk(keyring())] });
|
|
187
|
+
await withFake(okReply, async (bao) => {
|
|
188
|
+
await expect(
|
|
189
|
+
run(
|
|
190
|
+
{ BAO_ADDR: bao.address },
|
|
191
|
+
reconcileCephAuthEntity(moved, PRIOR, { dial: fake.dial, log: () => {} }),
|
|
192
|
+
),
|
|
193
|
+
).rejects.toThrow(/identity moved/);
|
|
194
|
+
// Refused before the transport is ever touched — no `auth get`, no `auth caps`.
|
|
195
|
+
expect(fake.seen).toHaveLength(0);
|
|
196
|
+
expect(writesOf(bao.seen)).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a mount change alone is the same identity move', async () => {
|
|
201
|
+
const moved: CephAuthEntityProps = { ...PROPS, mount: 'talos-c2' };
|
|
202
|
+
const fake = fakeCephDial({});
|
|
203
|
+
await withFake(okReply, async (bao) => {
|
|
204
|
+
await expect(
|
|
205
|
+
run(
|
|
206
|
+
{ BAO_ADDR: bao.address },
|
|
207
|
+
reconcileCephAuthEntity(moved, PRIOR, { dial: fake.dial, log: () => {} }),
|
|
208
|
+
),
|
|
209
|
+
).rejects.toThrow(/identity moved/);
|
|
210
|
+
expect(fake.seen).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('a degraded quorum after a write fails the row', () => {
|
|
216
|
+
test('the create is reported as failed even though the ceph write itself landed', async () => {
|
|
217
|
+
const fake = fakeCephDial({
|
|
218
|
+
[MON]: [
|
|
219
|
+
{ kind: 'result', result: ABSENT },
|
|
220
|
+
fakeCephOk(keyring()),
|
|
221
|
+
fakeCephOk('{"quorum":[]}'),
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
await withFake(okReply, async (bao) => {
|
|
225
|
+
await expect(
|
|
226
|
+
run(
|
|
227
|
+
{ BAO_ADDR: bao.address },
|
|
228
|
+
reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
|
|
229
|
+
),
|
|
230
|
+
).rejects.toThrow(/degraded/);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|