@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.StorageDownload` engine tests over a stub fetch. No live host, no live cluster.
|
|
3
|
+
* ⚠️ The exact `import/` volid segment is REASONED, not measured — see storage-download-props.ts.
|
|
4
|
+
* These fixtures spell it that way for readability only; `readDownload` matches by filename
|
|
5
|
+
* suffix, so the tests below would pass with any segment spelling PVE actually uses.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Layer from 'effect/Layer';
|
|
10
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
11
|
+
import { FAKE_TARGET, type FakePve, fakePve, withoutBao } from './fake-pve.ts';
|
|
12
|
+
import type { StorageDownloadProps } from './storage-download-props.ts';
|
|
13
|
+
import { ProxmoxStorageDownload, ProxmoxStorageDownloadProvider } from './storage-download.ts';
|
|
14
|
+
|
|
15
|
+
const NODE = 'pve1';
|
|
16
|
+
const STORAGE = 'cephfs-tb4';
|
|
17
|
+
const FILENAME = 'talos-v1.13.0-metal-amd64.qcow2';
|
|
18
|
+
const UPID = `UPID:${NODE}:00000002:0:0:imgdownload:${STORAGE}:test@pve!fake:`;
|
|
19
|
+
const row = (over: Partial<Record<string, unknown>> = {}) => ({
|
|
20
|
+
format: 'qcow2',
|
|
21
|
+
size: 123_456,
|
|
22
|
+
volid: `${STORAGE}:import/${FILENAME}`,
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const declare = (over: Partial<StorageDownloadProps> = {}): StorageDownloadProps => ({
|
|
27
|
+
checksum: 'a'.repeat(64),
|
|
28
|
+
checksumAlgorithm: 'sha256',
|
|
29
|
+
filename: FILENAME,
|
|
30
|
+
node: NODE,
|
|
31
|
+
storage: STORAGE,
|
|
32
|
+
target: FAKE_TARGET,
|
|
33
|
+
url: 'https://images.example.test/talos.qcow2',
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const engine = (fake: FakePve) =>
|
|
38
|
+
engineOver(ProxmoxStorageDownloadProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
39
|
+
|
|
40
|
+
const isTaskStatus = (call: { method: string; path: string }) =>
|
|
41
|
+
call.path.includes('/tasks/') && call.path.endsWith('/status');
|
|
42
|
+
const isList = (call: { method: string; path: string }) =>
|
|
43
|
+
call.method === 'GET' && call.path.includes('/content?');
|
|
44
|
+
const isDownload = (call: { method: string; path: string }) =>
|
|
45
|
+
call.method === 'POST' && call.path.endsWith('/download-url');
|
|
46
|
+
|
|
47
|
+
describe('creating a download', () => {
|
|
48
|
+
test('one POST to download-url, a task wait, then the next plan is noop', async () => {
|
|
49
|
+
let downloaded = false;
|
|
50
|
+
const fake = fakePve((call) => {
|
|
51
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
52
|
+
if (isList(call)) return downloaded ? [row()] : [];
|
|
53
|
+
if (isDownload(call)) {
|
|
54
|
+
downloaded = true;
|
|
55
|
+
return UPID;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
});
|
|
59
|
+
await withoutBao(async () => {
|
|
60
|
+
const stack = engine(fake);
|
|
61
|
+
expect(await stack.deploy(ProxmoxStorageDownload('image', declare()))).toEqual({
|
|
62
|
+
image: 'create',
|
|
63
|
+
});
|
|
64
|
+
expect(
|
|
65
|
+
(await stack.verify(ProxmoxStorageDownload('image', declare()), { all: true })).rows[0],
|
|
66
|
+
).toMatchObject({ diff: 'noop' });
|
|
67
|
+
});
|
|
68
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/storage/${STORAGE}/download-url`]);
|
|
69
|
+
const posted = fake.calls.find((call) => call.method === 'POST')?.form;
|
|
70
|
+
expect(posted?.['checksum']).toBe('a'.repeat(64));
|
|
71
|
+
expect(posted?.['checksum-algorithm']).toBe('sha256');
|
|
72
|
+
expect(posted?.['content']).toBe('import');
|
|
73
|
+
expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('⛔ checksum is required: an empty one is refused before any write ("checksum-pinned")', async () => {
|
|
77
|
+
const fake = fakePve((call) => (isList(call) ? [] : null));
|
|
78
|
+
await withoutBao(async () => {
|
|
79
|
+
await expect(
|
|
80
|
+
engine(fake).deploy(ProxmoxStorageDownload('image', declare({ checksum: ' ' }))),
|
|
81
|
+
).rejects.toThrow(/checksum is required/);
|
|
82
|
+
});
|
|
83
|
+
expect(fake.writes()).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a failed download task refuses the plan — the checksum-mismatch refusal', async () => {
|
|
87
|
+
const fake = fakePve((call) => {
|
|
88
|
+
if (isTaskStatus(call)) return { exitstatus: 'checksum mismatch', status: 'stopped' };
|
|
89
|
+
if (isList(call)) return [];
|
|
90
|
+
if (isDownload(call)) return UPID;
|
|
91
|
+
return null;
|
|
92
|
+
});
|
|
93
|
+
await withoutBao(async () => {
|
|
94
|
+
await expect(engine(fake).deploy(ProxmoxStorageDownload('image', declare()))).rejects.toThrow(
|
|
95
|
+
/checksum mismatch/,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('removing a download', () => {
|
|
102
|
+
test('not retain by default: dropping the declaration deletes the file and waits for its task', async () => {
|
|
103
|
+
let exists = false;
|
|
104
|
+
const fake = fakePve((call) => {
|
|
105
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
106
|
+
if (isList(call)) return exists ? [row()] : [];
|
|
107
|
+
if (isDownload(call)) {
|
|
108
|
+
exists = true;
|
|
109
|
+
return UPID;
|
|
110
|
+
}
|
|
111
|
+
if (call.method === 'DELETE') {
|
|
112
|
+
exists = false;
|
|
113
|
+
return UPID;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
await withoutBao(async () => {
|
|
118
|
+
const stack = engine(fake);
|
|
119
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
120
|
+
await stack.deploy(Effect.void);
|
|
121
|
+
});
|
|
122
|
+
expect(fake.writes()).toHaveLength(2);
|
|
123
|
+
expect(fake.writes()[0]).toBe(`POST nodes/${NODE}/storage/${STORAGE}/download-url`);
|
|
124
|
+
expect(fake.writes()[1]).toMatch(
|
|
125
|
+
new RegExp(`^DELETE nodes/${NODE}/storage/${STORAGE}/content/`),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('⛔ idempotent delete: a volume already gone by removal time sends no DELETE', async () => {
|
|
130
|
+
let created = false;
|
|
131
|
+
let vanished = false;
|
|
132
|
+
const fake = fakePve((call) => {
|
|
133
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
134
|
+
if (isList(call)) return created && !vanished ? [row()] : [];
|
|
135
|
+
if (isDownload(call)) {
|
|
136
|
+
created = true;
|
|
137
|
+
return UPID;
|
|
138
|
+
}
|
|
139
|
+
return null; // a DELETE here would fail the test below by never answering it
|
|
140
|
+
});
|
|
141
|
+
await withoutBao(async () => {
|
|
142
|
+
const stack = engine(fake);
|
|
143
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
144
|
+
vanished = true; // out-of-band: something else already removed the file
|
|
145
|
+
await stack.deploy(Effect.void);
|
|
146
|
+
});
|
|
147
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/storage/${STORAGE}/download-url`]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('identity (red-team I1/I2 on PR 297)', () => {
|
|
152
|
+
test('a changed filename replaces: the old file is deleted, the new one downloaded', async () => {
|
|
153
|
+
const NEW_FILENAME = 'talos-v1.14.0-metal-amd64.qcow2';
|
|
154
|
+
// ⚠️ ALCHEMY REPLACES CREATE-FIRST (acl.test.ts's own note): the new file exists alongside the
|
|
155
|
+
// old one for a moment, so the fixture tracks a SET of files, not one current name.
|
|
156
|
+
const files = new Set<string>();
|
|
157
|
+
const fake = fakePve((call) => {
|
|
158
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
159
|
+
if (isList(call)) return [...files].map((f) => row({ volid: `${STORAGE}:import/${f}` }));
|
|
160
|
+
if (isDownload(call)) {
|
|
161
|
+
files.add(call.form['filename'] ?? '');
|
|
162
|
+
return UPID;
|
|
163
|
+
}
|
|
164
|
+
if (call.method === 'DELETE') {
|
|
165
|
+
// ⚠️ The volid path segment may arrive percent-encoded (its own `:` and `/`) — match by
|
|
166
|
+
// decoded suffix, the same way readDownload matches a listed row (storage-download-props.ts).
|
|
167
|
+
const decoded = decodeURIComponent(call.path);
|
|
168
|
+
const gone = [...files].find((f) => decoded.endsWith(f));
|
|
169
|
+
if (gone !== undefined) files.delete(gone);
|
|
170
|
+
return UPID;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
});
|
|
174
|
+
await withoutBao(async () => {
|
|
175
|
+
const stack = engine(fake);
|
|
176
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
177
|
+
expect(
|
|
178
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare({ filename: NEW_FILENAME }))),
|
|
179
|
+
).toEqual({ image: 'replace' });
|
|
180
|
+
});
|
|
181
|
+
const [download1, download2] = fake.writes().filter((w) => w.includes('download-url'));
|
|
182
|
+
expect(download1).toBeDefined();
|
|
183
|
+
expect(download2).toBeDefined();
|
|
184
|
+
expect(fake.writes().some((w) => w.startsWith('DELETE') && w.endsWith(FILENAME))).toBe(true);
|
|
185
|
+
expect(files).toEqual(new Set([NEW_FILENAME]));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a changed checksum on the same filename is refused, never silently planned noop', async () => {
|
|
189
|
+
let downloaded = false;
|
|
190
|
+
const fake = fakePve((call) => {
|
|
191
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
192
|
+
if (isList(call)) return downloaded ? [row()] : [];
|
|
193
|
+
if (isDownload(call)) {
|
|
194
|
+
downloaded = true;
|
|
195
|
+
return UPID;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
});
|
|
199
|
+
await withoutBao(async () => {
|
|
200
|
+
const stack = engine(fake);
|
|
201
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
202
|
+
await expect(
|
|
203
|
+
stack.deploy(ProxmoxStorageDownload('image', declare({ checksum: 'b'.repeat(64) }))),
|
|
204
|
+
).rejects.toThrow(/checksum changed but filename did not/);
|
|
205
|
+
});
|
|
206
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/storage/${STORAGE}/download-url`]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('no secret prop', () => {
|
|
211
|
+
test('every declared field is a public identity or download parameter, never a credential', () => {
|
|
212
|
+
for (const key of Object.keys(declare())) {
|
|
213
|
+
expect(key).not.toMatch(/pass|secret|token|key$|credential/i);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.StorageDownload` — one `download-url` fetch of an image onto a named storage's
|
|
3
|
+
* `import` content, declared. Joined 2026-09-26 for Talos: a node needs a boot image on shared
|
|
4
|
+
* storage (`cephtb4`/`cephfs-tb4`) before `Proxmox.Vm` can reference it as a disk source.
|
|
5
|
+
*
|
|
6
|
+
* ★ A CREATE-ORIENTED FAMILY, NOT A GENERAL DOWNLOADER. `content` is fixed to `import` — never a
|
|
7
|
+
* prop — because that is the one content type a VM disk import consumes; an ISO or a container
|
|
8
|
+
* template already has its own path (`upload`, or an out-of-band copy) and does not belong here.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ `checksum` AND `checksumAlgorithm` ARE REQUIRED, NOT OPTIONAL, EVEN THOUGH PVE'S OWN SCHEMA
|
|
11
|
+
* MARKS BOTH OPTIONAL (`generated/pve/nodes-node-storage-storage.ts`). An unpinned download is
|
|
12
|
+
* not a declaration — it is a promise that whatever the URL answers today is what a Talos node
|
|
13
|
+
* boots from, silently, on every future adopt. This resource narrows the vendor's own contract
|
|
14
|
+
* on purpose; `storage-download-lifecycle.ts`'s `createRefusals` is the runtime backstop for a
|
|
15
|
+
* caller that casts around the type.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ THERE IS NO UPDATE PATH. PVE does not remember the `url` or `checksum` a volume was created
|
|
18
|
+
* from — `GET .../content` reports only `format`/`size`/`volid` — so once the file exists on
|
|
19
|
+
* `storage`, this resource has nothing left to compare a new declaration against except the
|
|
20
|
+
* PREVIOUS one (storage-download-identity.ts). A DIFFERENT `checksum`/`url` on the SAME `filename`
|
|
21
|
+
* is REFUSED, never silently written or silently `noop` (2026-09-26, red-team I2 on PR 297):
|
|
22
|
+
* change `filename` to force a new download, which plans a `replace` — the old file is deleted and
|
|
23
|
+
* the new one downloaded under its own name — exactly as an immutable artifact should be.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ NO SECRET IS A PROP. Every field here — `node`, `storage`, `filename`, `url`, the checksum
|
|
26
|
+
* pair, `compression`, `verifyCertificates` — is either an identity or a public download
|
|
27
|
+
* parameter; none is a credential, matching the "no secret prop" rule every PVE family here
|
|
28
|
+
* carries (see qemu-props.ts's `cipassword`/`machine` for the family that has one to guard).
|
|
29
|
+
*
|
|
30
|
+
* ★ NOT `retain` BY DEFAULT, UNLIKE `Proxmox.Vm`/`Proxmox.Lxc`. A downloaded image is reproducible
|
|
31
|
+
* from its own declaration (the same `url` and `checksum` rebuild it byte-for-byte); a VM's
|
|
32
|
+
* disk state or a container's data is not. Dropping this declaration deletes the file — an
|
|
33
|
+
* operator who wants to keep an unreferenced image around pins its own `RemovalPolicy.retain()`.
|
|
34
|
+
*/
|
|
35
|
+
import { Resource } from 'alchemy';
|
|
36
|
+
import * as Provider from 'alchemy/Provider';
|
|
37
|
+
import * as Effect from 'effect/Effect';
|
|
38
|
+
import type { PveRequirements } from './resource.ts';
|
|
39
|
+
import { storageDownloadHandlers } from './storage-download-lifecycle.ts';
|
|
40
|
+
import type { StorageDownloadAttributes, StorageDownloadProps } from './storage-download-props.ts';
|
|
41
|
+
|
|
42
|
+
export type { StorageDownloadAttributes, StorageDownloadProps } from './storage-download-props.ts';
|
|
43
|
+
|
|
44
|
+
export interface ProxmoxStorageDownload extends Resource<
|
|
45
|
+
'Proxmox.StorageDownload',
|
|
46
|
+
StorageDownloadProps,
|
|
47
|
+
StorageDownloadAttributes,
|
|
48
|
+
never,
|
|
49
|
+
PveRequirements
|
|
50
|
+
> {}
|
|
51
|
+
|
|
52
|
+
export const ProxmoxStorageDownload = Resource<ProxmoxStorageDownload>('Proxmox.StorageDownload');
|
|
53
|
+
|
|
54
|
+
export const ProxmoxStorageDownloadProvider = () =>
|
|
55
|
+
Provider.effect(
|
|
56
|
+
ProxmoxStorageDownload,
|
|
57
|
+
Effect.succeed(ProxmoxStorageDownload.Provider.of(storageDownloadHandlers)),
|
|
58
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `credentials-write.ts` — offline, fake `bao`, no real process ever spawns. Fixtures only.
|
|
3
|
+
*/
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
9
|
+
import { reservedTempPath, writeKvValue } from './credentials-write.ts';
|
|
10
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
11
|
+
|
|
12
|
+
const run = <A, E>(
|
|
13
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
14
|
+
handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
|
|
15
|
+
calls: FakeCall[] = [],
|
|
16
|
+
) =>
|
|
17
|
+
Effect.runPromise(
|
|
18
|
+
Effect.provideService(
|
|
19
|
+
effect,
|
|
20
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
21
|
+
fakeSpawner(handler, calls),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe('writeKvValue', () => {
|
|
26
|
+
it('sends the value on stdin, never in argv', async () => {
|
|
27
|
+
const calls: FakeCall[] = [];
|
|
28
|
+
await run(
|
|
29
|
+
writeKvValue('talos-c1', 'kubeconfig', 'kubeconfig', 'super-secret-kubeconfig-body'),
|
|
30
|
+
() => ({}),
|
|
31
|
+
calls,
|
|
32
|
+
);
|
|
33
|
+
assert.equal(calls.length, 1);
|
|
34
|
+
// ⛔ C2 (LAND red team): `=-`, never `=@-` — `@` means "read a file at this literal path",
|
|
35
|
+
// which OpenBao's own CLI rejects (or, worse, silently reads a file named `-` if one exists).
|
|
36
|
+
assert.deepEqual(calls[0]?.args, ['kv', 'put', 'talos-c1/kubeconfig', 'kubeconfig=-']);
|
|
37
|
+
assert.equal(calls[0]?.stdin, 'super-secret-kubeconfig-body');
|
|
38
|
+
// ⛔ THE POINT OF THE TEST: the value appears nowhere in argv.
|
|
39
|
+
assert.ok(!calls[0]?.args.some((arg) => arg.includes('super-secret-kubeconfig-body')));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('fails on a non-zero exit and never echoes stdin in the error', async () => {
|
|
43
|
+
await assert.rejects(
|
|
44
|
+
run(writeKvValue('talos-c1', 'kubeconfig', 'kubeconfig', 'secret-body'), () => ({
|
|
45
|
+
exitCode: 2,
|
|
46
|
+
stderr: 'permission denied',
|
|
47
|
+
})),
|
|
48
|
+
(error: unknown) =>
|
|
49
|
+
error instanceof Error &&
|
|
50
|
+
error.message.includes('permission denied') &&
|
|
51
|
+
!error.message.includes('secret-body'),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('reservedTempPath', () => {
|
|
57
|
+
it('does not pre-create the file, and cleans up if something else wrote there', async () => {
|
|
58
|
+
let path = '';
|
|
59
|
+
await Effect.runPromise(
|
|
60
|
+
Effect.scoped(
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
const reserved = yield* reservedTempPath('c1-node-kubeconfig-out');
|
|
63
|
+
path = reserved.path;
|
|
64
|
+
assert.equal(existsSync(path), false, 'nothing has written here yet');
|
|
65
|
+
yield* Effect.promise(() =>
|
|
66
|
+
Bun.write(path, 'written by the caller, like talosctl would'),
|
|
67
|
+
);
|
|
68
|
+
assert.ok(existsSync(path));
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
assert.equal(existsSync(path), false, 'must be gone once the caller scope closed');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('two calls never collide on the same path', async () => {
|
|
76
|
+
const [a, b] = await Effect.runPromise(
|
|
77
|
+
Effect.scoped(Effect.all([reservedTempPath('same-label'), reservedTempPath('same-label')])),
|
|
78
|
+
);
|
|
79
|
+
assert.notEqual(a.path, b.path);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write half of Talos's OpenBao KV access — split out of credentials.ts (2026-09-26,
|
|
3
|
+
* K-talos-first-boot) once it needed its own header, the same reason machine-config-read.ts,
|
|
4
|
+
* machine-config-poll.ts and talos-errors.ts were split out of talos-machine-config.ts before it.
|
|
5
|
+
* `credentials.ts` stays the read side (mint a talosconfig, read a KV value); this file is the one
|
|
6
|
+
* place anything in this package ever writes TO OpenBao.
|
|
7
|
+
*
|
|
8
|
+
* ★ FIRST CALLER: `Talos.Kubeconfig` (kubeconfig.ts) writing the admin kubeconfig it generates at
|
|
9
|
+
* cluster bring-up into the vault instead of an un-vaulted host `runtimePath` — see that file's
|
|
10
|
+
* own header. `Ceph.AuthEntity` (K-A4, a separate design/PR) is expected to need the same shape
|
|
11
|
+
* for its own vault-written entity keys; this is not yet shared with it on purpose — two callers
|
|
12
|
+
* is the point at which sharing stops being speculative.
|
|
13
|
+
*/
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import type * as Scope from 'effect/Scope';
|
|
16
|
+
import * as Stream from 'effect/Stream';
|
|
17
|
+
import * as ChildProcess from 'effect/unstable/process/ChildProcess';
|
|
18
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A fresh, unguessable temp path for SOME OTHER PROCESS (talosctl) to create — unlike
|
|
22
|
+
* `credentials.ts`'s `mintKvTempFile`, this does NOT pre-create the file with `wx`, because the
|
|
23
|
+
* caller here is capturing a command's OWN output rather than writing known content itself. The
|
|
24
|
+
* random UUIDv7 name is the only pre-fabrication guard (an attacker cannot pre-plant a symlink at a
|
|
25
|
+
* path it cannot predict) — first used by `kubeconfig.ts` to capture `talosctl kubeconfig`'s
|
|
26
|
+
* generated file before reading it back and writing it into the vault.
|
|
27
|
+
*/
|
|
28
|
+
export const reservedTempPath = (
|
|
29
|
+
label: string,
|
|
30
|
+
): Effect.Effect<{ readonly path: string }, never, Scope.Scope> =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const path = `${Bun.env['TMPDIR'] ?? '/tmp'}/hf-talos-${label}-${Bun.randomUUIDv7()}.yaml`;
|
|
33
|
+
yield* Effect.addFinalizer(() =>
|
|
34
|
+
Effect.tryPromise({ try: () => Bun.file(path).delete(), catch: () => undefined }).pipe(
|
|
35
|
+
Effect.orElseSucceed(() => undefined),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
return { path };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write one field of an OpenBao KV-v2 value from `value` — never argv, the mirror image of
|
|
43
|
+
* `credentials.ts`'s `readKvValue`.
|
|
44
|
+
*
|
|
45
|
+
* ⛔ C2 FIX (LAND red team, 2026-09-26) — `field=@-` IS WRONG AND WAS NEVER MEASURED. `@value` means
|
|
46
|
+
* "read a file at this path"; a literal path of `-` is not the stdin convention for `@` — it is
|
|
47
|
+
* just a file named `-`. MEASURED against the estate's OpenBao v2.6.2 binary (isolated in-memory
|
|
48
|
+
* dev server, `env -i`, scratch HOME, no estate credentials involved; server removed after):
|
|
49
|
+
* `field=@-` exits 1 with `invalid key/value pair "field=@-": error reading file: open -: no
|
|
50
|
+
* such file or directory`, and if a file literally named `-` exists in the working directory it
|
|
51
|
+
* silently stores THAT file's bytes instead. `field=-` (no `@`) is Vault/OpenBao's own stdin
|
|
52
|
+
* convention and was confirmed against the same binary to read the piped bytes correctly. Every
|
|
53
|
+
* `Talos.Kubeconfig` create failed here — AFTER `talosctl kubeconfig` had already issued a fresh
|
|
54
|
+
* admin client certificate, which cannot be revoked.
|
|
55
|
+
* ⛔ stderr ONLY ON FAILURE — same rule as `readKvValue`: never echo what was being written, on
|
|
56
|
+
* success or failure.
|
|
57
|
+
*/
|
|
58
|
+
export const writeKvValue = (
|
|
59
|
+
mount: string,
|
|
60
|
+
key: string,
|
|
61
|
+
field: string,
|
|
62
|
+
value: string,
|
|
63
|
+
): Effect.Effect<void, Error, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
66
|
+
const result = yield* ChildProcess.make('bao', ['kv', 'put', `${mount}/${key}`, `${field}=-`], {
|
|
67
|
+
detached: false,
|
|
68
|
+
extendEnv: true,
|
|
69
|
+
stderr: 'pipe',
|
|
70
|
+
stdin: Stream.fromIterable([new TextEncoder().encode(value)]),
|
|
71
|
+
stdout: 'pipe',
|
|
72
|
+
}).pipe(
|
|
73
|
+
spawner.spawn,
|
|
74
|
+
Effect.flatMap((child) =>
|
|
75
|
+
Effect.all(
|
|
76
|
+
{
|
|
77
|
+
exitCode: child.exitCode,
|
|
78
|
+
stderr: child.stderr.pipe(
|
|
79
|
+
Stream.decodeText,
|
|
80
|
+
Stream.mkString,
|
|
81
|
+
Effect.map((text) => text.trim()),
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{ concurrency: 'unbounded' },
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
Effect.scoped,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (result.exitCode !== 0) {
|
|
91
|
+
return yield* Effect.fail(
|
|
92
|
+
new Error(`bao kv put ${mount}/${key} exited ${String(result.exitCode)}: ${result.stderr}`),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The C1 lifetime fix — pins that `mintTalosconfig`'s temp file survives until the CALLER's own
|
|
3
|
+
* `Effect.scoped` closes, not until `mintTalosconfig` itself returns (the shipped defect,
|
|
4
|
+
* credentials.ts's own header). Fully offline: fake-process.ts fakes `bao`, nothing real spawns.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ FIXTURES ONLY. No real OpenBao, no real talosconfig material — every "talosconfig" body
|
|
7
|
+
* below is a plain marker string, not YAML that could be mistaken for the real shape.
|
|
8
|
+
*/
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { existsSync, statSync } from 'node:fs';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
14
|
+
import { mintKubeconfig, mintKvTempFile, mintTalosconfig, readKvValue } from './credentials.ts';
|
|
15
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
16
|
+
|
|
17
|
+
const bao =
|
|
18
|
+
(data: Record<string, unknown>, exitCode = 0) =>
|
|
19
|
+
(call: FakeCall) => {
|
|
20
|
+
assert.equal(call.command, 'bao');
|
|
21
|
+
return exitCode === 0
|
|
22
|
+
? { stdout: JSON.stringify({ data: { data } }) }
|
|
23
|
+
: { exitCode, stderr: 'permission denied' };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const run = <A, E>(
|
|
27
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
28
|
+
handler: (c: FakeCall) => ReturnType<ReturnType<typeof bao>>,
|
|
29
|
+
calls: FakeCall[] = [],
|
|
30
|
+
) =>
|
|
31
|
+
Effect.runPromise(
|
|
32
|
+
Effect.provideService(
|
|
33
|
+
effect,
|
|
34
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
35
|
+
fakeSpawner(handler, calls),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe('mintTalosconfig — C1 lifetime fix', () => {
|
|
40
|
+
it('reads talosconfig/<mount> — the FIXED default key, not the doubled data/data path', async () => {
|
|
41
|
+
const calls: FakeCall[] = [];
|
|
42
|
+
let capturedPath = '';
|
|
43
|
+
await Effect.runPromise(
|
|
44
|
+
Effect.scoped(
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const credential = yield* mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' });
|
|
47
|
+
capturedPath = credential.talosconfigPath;
|
|
48
|
+
assert.ok(existsSync(capturedPath), 'file must exist while the caller scope is open');
|
|
49
|
+
}),
|
|
50
|
+
).pipe(
|
|
51
|
+
Effect.provideService(
|
|
52
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
53
|
+
fakeSpawner(bao({ talosconfig: 'fake-talosconfig-body' }), calls),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
assert.equal(calls.length, 1);
|
|
58
|
+
assert.deepEqual(calls[0]?.args, ['kv', 'get', '-format=json', 'talos-c1/talosconfig']);
|
|
59
|
+
assert.equal(existsSync(capturedPath), false, 'must be gone once the caller scope closed');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('is gone even when the caller scope is closed by a later failure (finalizers always run)', async () => {
|
|
63
|
+
let capturedPath = '';
|
|
64
|
+
const failing = Effect.scoped(
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const credential = yield* mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' });
|
|
67
|
+
capturedPath = credential.talosconfigPath;
|
|
68
|
+
return yield* Effect.fail(new Error('downstream talosctl call failed'));
|
|
69
|
+
}),
|
|
70
|
+
).pipe(
|
|
71
|
+
Effect.provideService(
|
|
72
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
73
|
+
fakeSpawner(bao({ talosconfig: 'fake-body' })),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
await assert.rejects(Effect.runPromise(failing));
|
|
77
|
+
assert.equal(existsSync(capturedPath), false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('honors a custom talosconfigKey without adding a data/ prefix', async () => {
|
|
81
|
+
const calls: FakeCall[] = [];
|
|
82
|
+
await run(
|
|
83
|
+
Effect.scoped(
|
|
84
|
+
mintTalosconfig({ cluster: 'c1', mount: 'talos-c1', talosconfigKey: 'context/admin' }),
|
|
85
|
+
),
|
|
86
|
+
bao({ config: 'fallback-field-body' }),
|
|
87
|
+
calls,
|
|
88
|
+
);
|
|
89
|
+
assert.deepEqual(calls[0]?.args, ['kv', 'get', '-format=json', 'talos-c1/context/admin']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('fails without spawning talosctl or leaving a file when bao denies the read', async () => {
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
run(Effect.scoped(mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' })), bao({}, 1)),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('fails when the KV entry has neither a talosconfig nor a config field', async () => {
|
|
99
|
+
await assert.rejects(
|
|
100
|
+
run(
|
|
101
|
+
Effect.scoped(mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' })),
|
|
102
|
+
bao({ unrelated: 'x' }),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('mintKvTempFile', () => {
|
|
109
|
+
it('writes exactly the given content at mode 0600', async () => {
|
|
110
|
+
let path = '';
|
|
111
|
+
await Effect.runPromise(
|
|
112
|
+
Effect.scoped(
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const file = yield* mintKvTempFile('exact body\n', 'c1-node-config');
|
|
115
|
+
path = file.path;
|
|
116
|
+
const text = yield* Effect.promise(() => Bun.file(path).text());
|
|
117
|
+
assert.equal(text, 'exact body\n');
|
|
118
|
+
assert.equal(statSync(path).mode & 0o777, 0o600);
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
assert.equal(existsSync(path), false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('mintKubeconfig — the consumer-side seam, same lifetime shape as mintTalosconfig', () => {
|
|
127
|
+
it('reads the default kubeconfig key and the file survives only within the caller scope', async () => {
|
|
128
|
+
const calls: FakeCall[] = [];
|
|
129
|
+
let capturedPath = '';
|
|
130
|
+
await Effect.runPromise(
|
|
131
|
+
Effect.scoped(
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const file = yield* mintKubeconfig({ cluster: 'c1', mount: 'talos-c1' });
|
|
134
|
+
capturedPath = file.path;
|
|
135
|
+
assert.ok(existsSync(capturedPath));
|
|
136
|
+
}),
|
|
137
|
+
).pipe(
|
|
138
|
+
Effect.provideService(
|
|
139
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
140
|
+
fakeSpawner(bao({ kubeconfig: 'fake-kubeconfig-body' }), calls),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
assert.deepEqual(calls[0]?.args, ['kv', 'get', '-format=json', 'talos-c1/kubeconfig']);
|
|
145
|
+
assert.equal(existsSync(capturedPath), false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('readKvValue', () => {
|
|
150
|
+
it('tries fields in order and returns the first non-empty match', async () => {
|
|
151
|
+
const value = await run(
|
|
152
|
+
readKvValue('talos-c1', 'nodes/10001', ['config', 'legacyConfig']),
|
|
153
|
+
bao({ legacyConfig: 'second-field-body' }),
|
|
154
|
+
);
|
|
155
|
+
assert.equal(value, 'second-field-body');
|
|
156
|
+
});
|
|
157
|
+
});
|