@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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for `judgeIdentity` — red-team I1/I2 on PR 297. No network, no fake PVE.
|
|
3
|
+
* ⛔ WHAT THESE PIN: a changed `filename`/`storage` is a `replace`, never a silent `update` that
|
|
4
|
+
* orphans the old file; a changed pin (`checksum`/`url`/…) on the SAME `filename` is named for a
|
|
5
|
+
* refusal, never treated as a matching declaration; nothing changing is `same`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { FAKE_TARGET } from './fake-pve.ts';
|
|
9
|
+
import { judgeIdentity } from './storage-download-identity.ts';
|
|
10
|
+
import type { StorageDownloadProps } from './storage-download-props.ts';
|
|
11
|
+
|
|
12
|
+
const base: StorageDownloadProps = {
|
|
13
|
+
checksum: 'a'.repeat(64),
|
|
14
|
+
checksumAlgorithm: 'sha256',
|
|
15
|
+
filename: 'talos-v1.13.0-metal-amd64.qcow2',
|
|
16
|
+
node: 'pve1',
|
|
17
|
+
storage: 'cephfs-tb4',
|
|
18
|
+
target: FAKE_TARGET,
|
|
19
|
+
url: 'https://images.example.test/talos.qcow2',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('judgeIdentity', () => {
|
|
23
|
+
test('no prior declaration: always "same" -- a fresh create has nothing to compare', () => {
|
|
24
|
+
expect(judgeIdentity(base, undefined)).toEqual({ kind: 'same' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('nothing changed: "same"', () => {
|
|
28
|
+
expect(judgeIdentity(base, { ...base })).toEqual({ kind: 'same' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a changed filename is a "replace": the measured I1 regression (silent orphan)', () => {
|
|
32
|
+
expect(judgeIdentity({ ...base, filename: 'talos-v1.14.0-metal-amd64.qcow2' }, base)).toEqual({
|
|
33
|
+
kind: 'replace',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a changed storage is a "replace" too', () => {
|
|
38
|
+
expect(judgeIdentity({ ...base, storage: 'cephtb4' }, base)).toEqual({ kind: 'replace' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a changed checksum on the same filename is "pin-changed": the measured I2 regression', () => {
|
|
42
|
+
const verdict = judgeIdentity({ ...base, checksum: 'b'.repeat(64) }, base);
|
|
43
|
+
expect(verdict.kind).toBe('pin-changed');
|
|
44
|
+
expect((verdict as { refuse: string }).refuse).toMatch(/checksum changed but filename did not/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a changed url on the same filename is "pin-changed" too', () => {
|
|
48
|
+
const verdict = judgeIdentity(
|
|
49
|
+
{ ...base, url: 'https://images.example.test/other.qcow2' },
|
|
50
|
+
base,
|
|
51
|
+
);
|
|
52
|
+
expect(verdict.kind).toBe('pin-changed');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes a `Proxmox.StorageDownload` a DIFFERENT file, and what a changed pin on the SAME
|
|
3
|
+
* file means. (2026-09-26, red-team I1/I2 on PR 297.)
|
|
4
|
+
*
|
|
5
|
+
* ★ `filename` (WITH `storage`) IS THE IDENTITY. PVE does not record what a volume was downloaded
|
|
6
|
+
* from — `GET .../content` reports only `format`/`size`/`volid` (storage-download-props.ts) — so
|
|
7
|
+
* once the file exists there is nothing left to compare a new `url`/`checksum` against except the
|
|
8
|
+
* PREVIOUS DECLARATION. `storage-download-lifecycle.ts`'s `diff` asks `judgeIdentity` before
|
|
9
|
+
* touching the file; a genuine identity change plans `replace` (delete the old file, download the
|
|
10
|
+
* new one under its own name) rather than the silent `update` that used to orphan the old file
|
|
11
|
+
* forever, and a changed pin on the SAME filename refuses rather than silently planning `noop`.
|
|
12
|
+
*/
|
|
13
|
+
import type { StorageDownloadProps } from './storage-download-props.ts';
|
|
14
|
+
|
|
15
|
+
/** The fields PVE never stores back, so a change in them is only visible against `olds`. */
|
|
16
|
+
const PINNED: readonly (keyof StorageDownloadProps)[] = [
|
|
17
|
+
'checksum',
|
|
18
|
+
'checksumAlgorithm',
|
|
19
|
+
'compression',
|
|
20
|
+
'url',
|
|
21
|
+
'verifyCertificates',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export type IdentityVerdict =
|
|
25
|
+
| { readonly kind: 'same' }
|
|
26
|
+
| { readonly kind: 'replace' }
|
|
27
|
+
| { readonly kind: 'pin-changed'; readonly refuse: string };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `olds` is undefined for a resource with no prior declaration (a fresh create, or the recovery
|
|
31
|
+
* read for an interrupted one) — neither case has a pin to compare, so both are `same`.
|
|
32
|
+
*/
|
|
33
|
+
export const judgeIdentity = (
|
|
34
|
+
news: StorageDownloadProps,
|
|
35
|
+
olds: StorageDownloadProps | undefined,
|
|
36
|
+
): IdentityVerdict => {
|
|
37
|
+
if (olds === undefined) return { kind: 'same' };
|
|
38
|
+
if (news.filename !== olds.filename || news.storage !== olds.storage) {
|
|
39
|
+
return { kind: 'replace' };
|
|
40
|
+
}
|
|
41
|
+
const changed = PINNED.filter((key) => news[key] !== olds[key]);
|
|
42
|
+
if (changed.length === 0) return { kind: 'same' };
|
|
43
|
+
return {
|
|
44
|
+
kind: 'pin-changed',
|
|
45
|
+
refuse:
|
|
46
|
+
`${news.filename}: ${changed.join(', ')} changed but filename did not. filename is the ` +
|
|
47
|
+
'identity -- PVE does not record what a volume was downloaded from, so this declaration ' +
|
|
48
|
+
'cannot be verified or rewritten in place. Change filename to force a new download.',
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StorageDownload lifecycle. Read finds a content row by filename; create downloads and polls;
|
|
3
|
+
* delete is idempotent — a volume already gone is success, not a refusal.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ 2026-09-26, red-team C2/I1/I2/I3 on PR 297: `read` and `reconcile` now go through
|
|
6
|
+
* `ownedRead`/`refuseTakeover` (C2) so a pre-existing file is never silently adopted — and then
|
|
7
|
+
* deleted, since this family is not retain-by-default; `diff` asks `storage-download-identity.ts`
|
|
8
|
+
* (I1/I2) before touching the file; the content list read uses the `read` role, not `provision`
|
|
9
|
+
* (I3, `credentials.ts`: "read for read/diff, provision for reconcile/delete").
|
|
10
|
+
*/
|
|
11
|
+
import { isResolved } from 'alchemy/Diff';
|
|
12
|
+
import type { Input } from 'alchemy/Input';
|
|
13
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
16
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
17
|
+
import { formViolations } from './constraint-guard.ts';
|
|
18
|
+
import { runPve } from './distilled-pve.ts';
|
|
19
|
+
import { judgeIdentity } from './storage-download-identity.ts';
|
|
20
|
+
import { StorageDownloadRefusedError } from './storage-download-errors.ts';
|
|
21
|
+
import type { StorageDownloadAttributes, StorageDownloadProps } from './storage-download-props.ts';
|
|
22
|
+
import { storageDownloadTask } from './storage-download-task.ts';
|
|
23
|
+
import { int, text } from './values.ts';
|
|
24
|
+
|
|
25
|
+
const CREATE_POLLS = 600; // an image download can run far longer than a config write
|
|
26
|
+
const DELETE_POLLS = 60;
|
|
27
|
+
|
|
28
|
+
/** ⚠️ Named as a literal so `codegen/constraints.ts`'s text scan tables it (that file's own header). */
|
|
29
|
+
const DOWNLOAD_ENDPOINT = 'pve:POST /nodes/{node}/storage/{storage}/download-url';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The wire form, PVE-spelled (`checksum-algorithm`, `verify-certificates`) — what the generated
|
|
33
|
+
* vendor table keys by, and what `formViolations` checks. ⚠️ NOT re-spelled for the SDK call:
|
|
34
|
+
* `nodes.downloadNodesStorageUrl` never declares these two hyphenated names as typed properties
|
|
35
|
+
* either (its schema names them `checksum_algorithm`/`verify_certificates`), so passing this
|
|
36
|
+
* object straight through relies on protocol-http.ts's own documented "unknown keys pass through
|
|
37
|
+
* as body fields" rule — the same spec-drift path qemu-props.ts leans on for `scsi0`/`net0`, and
|
|
38
|
+
* unlike storage-wire.ts's `underscored()` this family has no reason to translate: the schema's
|
|
39
|
+
* OWN property names would just be a second spelling of the same two fields.
|
|
40
|
+
*/
|
|
41
|
+
export const downloadForm = (props: StorageDownloadProps) => ({
|
|
42
|
+
checksum: props.checksum,
|
|
43
|
+
'checksum-algorithm': props.checksumAlgorithm,
|
|
44
|
+
...(props.compression === undefined ? {} : { compression: props.compression }),
|
|
45
|
+
content: 'import',
|
|
46
|
+
filename: props.filename,
|
|
47
|
+
node: props.node,
|
|
48
|
+
storage: props.storage,
|
|
49
|
+
url: props.url,
|
|
50
|
+
...(props.verifyCertificates === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: { 'verify-certificates': props.verifyCertificates ? '1' : '0' }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Why this declaration cannot be sent, checked before every diff and every reconcile.
|
|
57
|
+
* ⛔ `checksum` REQUIRED, EVEN THOUGH THE TYPE ALREADY SAYS SO — a JS caller or a cast can still
|
|
58
|
+
* leave it `''`; this is the runtime backstop storage-download.ts's header promises.
|
|
59
|
+
*/
|
|
60
|
+
export const createRefusals = (props: StorageDownloadProps): string[] => {
|
|
61
|
+
const refuse: string[] = [];
|
|
62
|
+
if (props.checksum.trim() === '') {
|
|
63
|
+
refuse.push(
|
|
64
|
+
`${props.filename}: checksum is required — an unpinned download is not a declaration.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return refuse.concat(
|
|
68
|
+
formViolations(DOWNLOAD_ENDPOINT, downloadForm(props), true).map(
|
|
69
|
+
(why) => `${props.filename}: ${why}`,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `GET .../content?content=import`, matched by filename suffix — see storage-download-props.ts's
|
|
76
|
+
* `volid` doc for why a suffix match, not an assumed prefix. `content` is already server-filtered.
|
|
77
|
+
* ⛔ I3: THE `read` ROLE, NOT `provision` — this is a plain GET, and every caller (read/diff AND
|
|
78
|
+
* reconcile/delete, which mint their own `provision` credential for the actual write) may use the
|
|
79
|
+
* cheaper, longer-lived one, exactly as `qemu-read.ts`'s `readVm` already does for its own GET.
|
|
80
|
+
*/
|
|
81
|
+
export const readDownload = (props: StorageDownloadProps) =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const rows = yield* runPve(
|
|
84
|
+
props.target,
|
|
85
|
+
'read',
|
|
86
|
+
false,
|
|
87
|
+
nodes.listNodeStorageContent({
|
|
88
|
+
content: 'import',
|
|
89
|
+
node: props.node,
|
|
90
|
+
storage: props.storage,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
if (!Array.isArray(rows)) {
|
|
94
|
+
return yield* Effect.fail(
|
|
95
|
+
new StorageDownloadRefusedError(
|
|
96
|
+
`${props.storage}: the content list did not come back as a list.`,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const found = rows.find((row) => row.volid.endsWith(`/${props.filename}`));
|
|
101
|
+
if (found === undefined) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
filename: props.filename,
|
|
104
|
+
format: text(found.format, 'raw'),
|
|
105
|
+
node: props.node,
|
|
106
|
+
size: int(found.size, 0),
|
|
107
|
+
storage: props.storage,
|
|
108
|
+
volid: text(found.volid, ''),
|
|
109
|
+
} satisfies StorageDownloadAttributes;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** ⛔ IDEMPOTENT DELETE (distilled doctrine): a volume already gone is success, never a refusal. */
|
|
113
|
+
export const destroyDownload = (props: StorageDownloadProps) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const live = yield* readDownload(props);
|
|
116
|
+
if (live === undefined) return;
|
|
117
|
+
yield* storageDownloadTask(
|
|
118
|
+
props,
|
|
119
|
+
nodes.deleteNodeStorageContent({
|
|
120
|
+
node: props.node,
|
|
121
|
+
storage: props.storage,
|
|
122
|
+
volume: live.volid,
|
|
123
|
+
}),
|
|
124
|
+
`delete ${live.volid}`,
|
|
125
|
+
DELETE_POLLS,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const storageDownloadHandlers = {
|
|
130
|
+
list: () => Effect.succeed([]),
|
|
131
|
+
/**
|
|
132
|
+
* ⛔ C2: `Unowned` unless state already vouches for it. With no attributes this is the adoption
|
|
133
|
+
* probe or the recovery read for an interrupted download; `filename` (with `storage`) is the
|
|
134
|
+
* whole identity this family has (storage-download-identity.ts), so once the row's own instance
|
|
135
|
+
* is proven to hold the WHOLE declaration (`ownership/rows.ts`, inside `ownedRead`), a matching
|
|
136
|
+
* file is ours -- there is no further per-key drift to prove, unlike a config-bearing resource.
|
|
137
|
+
*/
|
|
138
|
+
read: Effect.fn(function* ({
|
|
139
|
+
fqn,
|
|
140
|
+
instanceId,
|
|
141
|
+
olds,
|
|
142
|
+
output,
|
|
143
|
+
}: {
|
|
144
|
+
fqn: string;
|
|
145
|
+
instanceId: string;
|
|
146
|
+
olds: StorageDownloadProps;
|
|
147
|
+
output: StorageDownloadAttributes | undefined;
|
|
148
|
+
}) {
|
|
149
|
+
const found = yield* readDownload(olds);
|
|
150
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, Effect.succeed(true));
|
|
151
|
+
}),
|
|
152
|
+
diff: Effect.fn(function* ({
|
|
153
|
+
news,
|
|
154
|
+
olds,
|
|
155
|
+
output,
|
|
156
|
+
}: {
|
|
157
|
+
news: Input<StorageDownloadProps>;
|
|
158
|
+
olds: StorageDownloadProps;
|
|
159
|
+
output: StorageDownloadAttributes | undefined;
|
|
160
|
+
}) {
|
|
161
|
+
if (!isResolved(news)) return undefined;
|
|
162
|
+
const refused = createRefusals(news);
|
|
163
|
+
if (refused.length > 0) {
|
|
164
|
+
return yield* Effect.fail(new StorageDownloadRefusedError(refused.join('\n')));
|
|
165
|
+
}
|
|
166
|
+
if (output === undefined) return undefined;
|
|
167
|
+
// ⛔ I1: a changed filename/storage is a DIFFERENT file -- plan a replace (delete the old one,
|
|
168
|
+
// download the new one under its own name) rather than the `update` that used to leave the
|
|
169
|
+
// old file orphaned forever with no state pointing at it.
|
|
170
|
+
const identity = judgeIdentity(news, olds);
|
|
171
|
+
if (identity.kind === 'replace') return { action: 'replace' } as const;
|
|
172
|
+
const live = yield* readDownload(news);
|
|
173
|
+
// ⛔ I2: a changed url/checksum/etc on the SAME filename is refused, never silently `noop` --
|
|
174
|
+
// PVE does not record what a volume was downloaded from, so this declaration cannot be
|
|
175
|
+
// verified or rewritten in place. Only when the file is actually there: if it already vanished
|
|
176
|
+
// out of band, the pin change is simply what the next download uses.
|
|
177
|
+
if (identity.kind === 'pin-changed' && live !== undefined) {
|
|
178
|
+
return yield* Effect.fail(new StorageDownloadRefusedError(identity.refuse));
|
|
179
|
+
}
|
|
180
|
+
return { action: live === undefined ? 'update' : 'noop' } as const;
|
|
181
|
+
}),
|
|
182
|
+
reconcile: Effect.fn(function* ({
|
|
183
|
+
fqn,
|
|
184
|
+
instanceId,
|
|
185
|
+
news,
|
|
186
|
+
output,
|
|
187
|
+
}: {
|
|
188
|
+
fqn: string;
|
|
189
|
+
instanceId: string;
|
|
190
|
+
news: StorageDownloadProps;
|
|
191
|
+
output: StorageDownloadAttributes | undefined;
|
|
192
|
+
}) {
|
|
193
|
+
const refused = createRefusals(news);
|
|
194
|
+
if (refused.length > 0) {
|
|
195
|
+
return yield* Effect.fail(new StorageDownloadRefusedError(refused.join('\n')));
|
|
196
|
+
}
|
|
197
|
+
const live = yield* readDownload(news);
|
|
198
|
+
if (live === undefined) {
|
|
199
|
+
yield* storageDownloadTask(
|
|
200
|
+
news,
|
|
201
|
+
nodes.downloadNodesStorageUrl(downloadForm(news)),
|
|
202
|
+
`download ${news.filename} onto ${news.storage}`,
|
|
203
|
+
CREATE_POLLS,
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
// ⛔ C2: a file already at this filename that this stack holds no state for is refused unless
|
|
207
|
+
// adoption is on. Without this, dropping the declaration later DELETES a file this stack
|
|
208
|
+
// never created — this family is not retain-by-default (storage-download.ts's header).
|
|
209
|
+
yield* refuseTakeover(
|
|
210
|
+
{ fqn, instanceId, output },
|
|
211
|
+
`Proxmox.StorageDownload ${news.filename} on ${news.storage}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const after = yield* readDownload(news);
|
|
215
|
+
if (after === undefined) {
|
|
216
|
+
return yield* Effect.fail(
|
|
217
|
+
new StorageDownloadRefusedError(
|
|
218
|
+
`${news.filename}: the write returned no error but ${news.storage} still has no ` +
|
|
219
|
+
'matching content — PVE may have normalised the filename; read it back by hand.',
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return after;
|
|
224
|
+
}),
|
|
225
|
+
delete: ({ olds }: { olds: StorageDownloadProps }) => destroyDownload(olds),
|
|
226
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose file it is — docs/ownership.md applied to `Proxmox.StorageDownload` (red-team C2 on PR
|
|
3
|
+
* 297), through the real engine over a stub fetch (fake-pve.ts).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHAT THIS PINS: a pre-existing file at the declared filename, that this stack holds no state
|
|
6
|
+
* for, is never adopted silently. The measured regression was worse than a pointless write-back:
|
|
7
|
+
* this family is not retain-by-default, so a silent adoption followed by dropping the declaration
|
|
8
|
+
* later DELETES a file this stack never created. `adopt(false)` wins over the test harness's own
|
|
9
|
+
* forced `AdoptPolicy: true` (fake-engine.ts), same as qemu-ownership.test.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
15
|
+
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
16
|
+
import type { StorageDownloadProps } from './storage-download-props.ts';
|
|
17
|
+
import { ProxmoxStorageDownload, ProxmoxStorageDownloadProvider } from './storage-download.ts';
|
|
18
|
+
|
|
19
|
+
const NODE = 'pve1';
|
|
20
|
+
const STORAGE = 'cephfs-tb4';
|
|
21
|
+
const FILENAME = 'talos-v1.13.0-metal-amd64.qcow2';
|
|
22
|
+
const row = { format: 'qcow2', size: 1, volid: `${STORAGE}:import/${FILENAME}` };
|
|
23
|
+
const declare = (): StorageDownloadProps => ({
|
|
24
|
+
checksum: 'a'.repeat(64),
|
|
25
|
+
checksumAlgorithm: 'sha256',
|
|
26
|
+
filename: FILENAME,
|
|
27
|
+
node: NODE,
|
|
28
|
+
storage: STORAGE,
|
|
29
|
+
target: FAKE_TARGET,
|
|
30
|
+
url: 'https://images.example.test/talos.qcow2',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const isList = (call: { method: string; path: string }) =>
|
|
34
|
+
call.method === 'GET' && call.path.includes('/content?');
|
|
35
|
+
|
|
36
|
+
const engine = (fake: ReturnType<typeof fakePve>) =>
|
|
37
|
+
engineOver(ProxmoxStorageDownloadProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
38
|
+
|
|
39
|
+
describe('Proxmox.StorageDownload ownership', () => {
|
|
40
|
+
test('adopt(false): a pre-existing file is refused, and nothing is downloaded or deleted', async () => {
|
|
41
|
+
const fake = fakePve((call) => (isList(call) ? [row] : null));
|
|
42
|
+
await withoutBao(async () => {
|
|
43
|
+
const declared = ProxmoxStorageDownload('image', declare()).pipe(adopt(false));
|
|
44
|
+
await expect(engine(fake).deploy(declared)).rejects.toThrow(
|
|
45
|
+
/adopt|exists, and this stack holds no state/i,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
expect(fake.writes()).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** What a `Proxmox.StorageDownload` declaration says, and what its state keeps. See storage-download.ts's header. */
|
|
2
|
+
import type { WithTarget } from './resource.ts';
|
|
3
|
+
|
|
4
|
+
export type ChecksumAlgorithm = 'md5' | 'sha1' | 'sha224' | 'sha256' | 'sha384' | 'sha512';
|
|
5
|
+
|
|
6
|
+
export interface StorageDownloadProps extends WithTarget {
|
|
7
|
+
/** The node this download runs on. Content lands on `storage`, shared or not — see the header. */
|
|
8
|
+
node: string;
|
|
9
|
+
storage: string;
|
|
10
|
+
/** The name PVE gives the file under `storage`'s `import` content. Normalized by PVE on write. */
|
|
11
|
+
filename: string;
|
|
12
|
+
/** Where to fetch the image from. ⚠️ Not re-verified after create — PVE does not store it. */
|
|
13
|
+
url: string;
|
|
14
|
+
/** ⛔ Required. See storage-download.ts's header on why this resource narrows the vendor's own optional field. */
|
|
15
|
+
checksum: string;
|
|
16
|
+
checksumAlgorithm: ChecksumAlgorithm;
|
|
17
|
+
/** Decompress the downloaded file with this algorithm before it lands on `storage`. */
|
|
18
|
+
compression?: string;
|
|
19
|
+
/** Verify the source's TLS certificate. PVE defaults this on; declare `false` for a self-signed mirror. */
|
|
20
|
+
verifyCertificates?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StorageDownloadAttributes {
|
|
24
|
+
node: string;
|
|
25
|
+
storage: string;
|
|
26
|
+
filename: string;
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ REASONED, NOT MEASURED LIVE. PVE's file-based content types spell a volid
|
|
29
|
+
* `<storage>:<segment>/<filename>` — this codebase already relies on that shape for `iso` and
|
|
30
|
+
* `vztmpl` (lxc-create-form.ts's `existingVolume`, lxc-create.test.ts's own fixture) — but the
|
|
31
|
+
* exact `<segment>` spelling for `import` content is not independently confirmed against a
|
|
32
|
+
* live cluster. `storage-download-lifecycle.ts`'s `readDownload` does not depend on knowing
|
|
33
|
+
* it: it matches by filename SUFFIX within a list already server-filtered to `content=import`.
|
|
34
|
+
*/
|
|
35
|
+
volid: string;
|
|
36
|
+
size: number;
|
|
37
|
+
format: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `download-url` and the content DELETE both answer a UPID (protocol.ts's "three 200-traps",
|
|
3
|
+
* trap (a)) — bounded task observation on the exact credential that started the worker.
|
|
4
|
+
* ★ THE SAME SHAPE AS qemu-task.ts/lxc-task.ts. Not merged with either: a fourth near-identical
|
|
5
|
+
* copy would be the moment to extract a shared poller, not this one (⚠️ flagged, not fixed here
|
|
6
|
+
* — see the PR description).
|
|
7
|
+
*/
|
|
8
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
9
|
+
import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { runPveWith } from './distilled-pve.ts';
|
|
12
|
+
import { mint } from './mint.ts';
|
|
13
|
+
import { StorageDownloadRefusedError } from './storage-download-errors.ts';
|
|
14
|
+
import { text } from './values.ts';
|
|
15
|
+
import type { PveTarget } from './credentials.ts';
|
|
16
|
+
|
|
17
|
+
export interface StorageDownloadWhere {
|
|
18
|
+
readonly node: string;
|
|
19
|
+
readonly target: PveTarget;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⛔ A CHECKSUM MISMATCH SURFACES HERE, NOT AS A DISTINCT TYPED ERROR. `download-url`'s POST
|
|
24
|
+
* answers 200 with a UPID regardless of whether the checksum will match — PVE validates the
|
|
25
|
+
* download itself, INSIDE the task, and reports the mismatch as that task's `exitstatus`. So
|
|
26
|
+
* the refusal below (`exit !== 'OK'`) IS the checksum-mismatch refusal: this poller fails the
|
|
27
|
+
* plan whenever the task did not report exactly `"OK"`, whatever the reason, without needing a
|
|
28
|
+
* new SDK tag to name it.
|
|
29
|
+
*/
|
|
30
|
+
export const storageDownloadTask = <E>(
|
|
31
|
+
where: StorageDownloadWhere,
|
|
32
|
+
operation: Effect.Effect<string, E, ProxmoxOpContext>,
|
|
33
|
+
what: string,
|
|
34
|
+
polls: number,
|
|
35
|
+
) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const credential = yield* mint(where.target, 'provision');
|
|
38
|
+
const upid = text(yield* runPveWith(where.target, credential, true, operation));
|
|
39
|
+
if (upid === '') {
|
|
40
|
+
return yield* Effect.fail(
|
|
41
|
+
new StorageDownloadRefusedError(
|
|
42
|
+
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
let exit = 'still running when this provider stopped waiting';
|
|
47
|
+
for (let attempt = 0; attempt < polls; attempt++) {
|
|
48
|
+
const status = yield* runPveWith(
|
|
49
|
+
where.target,
|
|
50
|
+
credential,
|
|
51
|
+
false,
|
|
52
|
+
nodes.getNodeTaskStatus({ node: where.node, upid }),
|
|
53
|
+
).pipe(Effect.orElseSucceed(() => undefined));
|
|
54
|
+
// ⚠️ Matches qemu-task.ts's own note: only exactly "stopped" ends the poll.
|
|
55
|
+
if (status === undefined || status.status === undefined) {
|
|
56
|
+
exit = 'the task could not be read';
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (status.status === 'stopped') {
|
|
60
|
+
exit = text(status.exitstatus, 'stopped with no exitstatus');
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
yield* Effect.sleep('1 second');
|
|
64
|
+
}
|
|
65
|
+
if (exit !== 'OK') {
|
|
66
|
+
return yield* Effect.fail(
|
|
67
|
+
new StorageDownloadRefusedError(
|
|
68
|
+
`${what}: task ${upid} ended "${exit}". Read the task log before running the deploy again.`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|