@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a `Proxmox.Vm` declaration says, and what its state keeps.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE SAME "DECLARED KEYS" MODEL AS `Proxmox.Lxc` (lxc-props.ts, joined 2026-09-21): an
|
|
5
|
+
* undeclared key is UNMANAGED — never sent, never compared, never overwritten. Adopting a live
|
|
6
|
+
* VM is then a paste of its config, not a translation of it, and the wire spelling is PVE's own
|
|
7
|
+
* (`scsi0`, `net0`, `ipconfig0`, as `pvesh get .../qemu/{vmid}/config` prints them).
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THIS REPLACES THE "5-DEFAULT PUT". Before 2026-09-26, `qemu-form.ts`'s `shape()` sent
|
|
10
|
+
* `cores`/`memory`/`name`/`onboot`/`sockets` on EVERY write with a hard default substituted for
|
|
11
|
+
* whichever of the five was left undeclared, so declaring only `memory: 4096` on an adopted VM
|
|
12
|
+
* still PUT `name: 'vm101'` and `sockets: '1'` over whatever the guest actually had. Harmless
|
|
13
|
+
* while `ProxmoxVm` stayed Provider-only (nothing declared it), load-bearing the moment Talos
|
|
14
|
+
* does. `declaredKeys` below is the fix: a field absent from the declaration is never read,
|
|
15
|
+
* never compared and never written — see qemu-form.ts's `form()`.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NO SECRET IS A PROP. `cipassword` (cloud-init's password) is write-only and Alchemy persists
|
|
18
|
+
* props in plaintext (this estate's state store is dumped nightly) — typed `never`, exactly
|
|
19
|
+
* like lxc-props.ts's `password`. A VM that needs a login uses `sshkeys`-free image defaults or
|
|
20
|
+
* an out-of-band secret; this resource never carries one.
|
|
21
|
+
* ⛔ `machine` IS NEVER A PROP EITHER. It pins the emulated chipset/firmware revision a guest boots
|
|
22
|
+
* against; PVE already resolves it from the node's installed QEMU version, and declaring it here
|
|
23
|
+
* would fight every future node upgrade instead of tracking it. Typed `never` for the same
|
|
24
|
+
* reason `cipassword` is: a cast is the only way in, and this file's helpers below never read
|
|
25
|
+
* an unmanaged key onto the wire regardless.
|
|
26
|
+
*/
|
|
27
|
+
import type { WithTarget } from './resource.ts';
|
|
28
|
+
|
|
29
|
+
/** A PVE boolean as pvesh prints it (`1`) or as TypeScript writes it (`true`). The same value. */
|
|
30
|
+
export type PveFlag = boolean | 0 | 1;
|
|
31
|
+
|
|
32
|
+
export interface VmProps extends WithTarget {
|
|
33
|
+
/** ⛔ This resource never migrates: qemu-read.ts refuses a VM found on another node. */
|
|
34
|
+
node: string;
|
|
35
|
+
/** ⛔ Cluster-wide, and shared with LXC. See qemu.ts's header. */
|
|
36
|
+
vmid: number;
|
|
37
|
+
name?: string;
|
|
38
|
+
/** MiB. */
|
|
39
|
+
memory?: number;
|
|
40
|
+
cores?: number;
|
|
41
|
+
sockets?: number;
|
|
42
|
+
/** Emulated CPU type, e.g. `host`. */
|
|
43
|
+
cpu?: string;
|
|
44
|
+
onboot?: PveFlag;
|
|
45
|
+
/** The QEMU guest agent. Talos reports its addresses through it once this is enabled. */
|
|
46
|
+
agent?: PveFlag;
|
|
47
|
+
/** `order=scsi0;ide2;net0`. */
|
|
48
|
+
boot?: string;
|
|
49
|
+
/** e.g. `virtio-scsi-single`. */
|
|
50
|
+
scsihw?: string;
|
|
51
|
+
/**
|
|
52
|
+
* `local-zfs:32` to create a new volume, or the live volume string as adopted —
|
|
53
|
+
* `cephtb4:vm-101-disk-0,size=32G` (n is 0 to 30, pve-qemu-server's own `$MAX_SCSI_DISKS`).
|
|
54
|
+
*/
|
|
55
|
+
[scsi: `scsi${number}`]: string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* `virtio=<mac>,bridge=vmbr0,tag=<vlan>` — a VLAN-aware bridge with an 802.1q tag is a bridge
|
|
58
|
+
* plus this one sub-property; PVE's SDN stays out of it entirely (n is 0 to 31).
|
|
59
|
+
*/
|
|
60
|
+
[nic: `net${number}`]: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The cloud-init drive, e.g. `cephtb4:cloudinit`. PVE's own web UI puts it on `ide2`, but any
|
|
63
|
+
* free `ide`/`sata`/`scsi` slot works; declaring one is what gives `ipconfig0` below somewhere
|
|
64
|
+
* to write the generated seed image (n is 0 to 3).
|
|
65
|
+
*/
|
|
66
|
+
[cloudinit: `ide${number}`]: string | undefined;
|
|
67
|
+
/** cloud-init network config: `ip=dhcp`, or `ip=<cidr>,gw=<gw>` (n is 0 to 31). */
|
|
68
|
+
[ipconfig: `ipconfig${number}`]: string | undefined;
|
|
69
|
+
/** `socket` — a serial console, the way Talos exposes its own (n is 0 to 3). */
|
|
70
|
+
[serial: `serial${number}`]: string | undefined;
|
|
71
|
+
/** ⛔ A secret. See the header. */
|
|
72
|
+
cipassword?: never;
|
|
73
|
+
/** ⛔ Machine config. See the header. */
|
|
74
|
+
machine?: never;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VmAttributes {
|
|
78
|
+
node: string;
|
|
79
|
+
vmid: number;
|
|
80
|
+
/** The live config, MANAGED keys only — see `storedConfig`. An allowlist, not a mirror. */
|
|
81
|
+
config: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Props that identify the object rather than configure it: never sent, never compared. */
|
|
85
|
+
const NOT_CONFIG: ReadonlySet<string> = new Set(['node', 'target', 'vmid']);
|
|
86
|
+
|
|
87
|
+
/** The indexed families this resource manages, with pve-qemu-server's own published range. */
|
|
88
|
+
export const INDEXED = {
|
|
89
|
+
ide: 4,
|
|
90
|
+
ipconfig: 32,
|
|
91
|
+
net: 32,
|
|
92
|
+
scsi: 31,
|
|
93
|
+
serial: 4,
|
|
94
|
+
} as const;
|
|
95
|
+
export type IndexedFamily = keyof typeof INDEXED;
|
|
96
|
+
|
|
97
|
+
/** `scsi3` → `['scsi', 3]`; anything else, including an out-of-range index, → undefined. */
|
|
98
|
+
export const indexedKey = (key: string): readonly [IndexedFamily, number] | undefined => {
|
|
99
|
+
const match = /^(ide|ipconfig|net|scsi|serial)(0|[1-9]\d*)$/.exec(key);
|
|
100
|
+
if (match === null) return undefined;
|
|
101
|
+
const family = match[1] as IndexedFamily;
|
|
102
|
+
const index = Number(match[2]);
|
|
103
|
+
return index < INDEXED[family] ? [family, index] : undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The scalar (non-indexed) keys this resource manages. */
|
|
107
|
+
const SCALAR_KEYS: ReadonlySet<string> = new Set([
|
|
108
|
+
'agent',
|
|
109
|
+
'boot',
|
|
110
|
+
'cores',
|
|
111
|
+
'cpu',
|
|
112
|
+
'memory',
|
|
113
|
+
'name',
|
|
114
|
+
'onboot',
|
|
115
|
+
'scsihw',
|
|
116
|
+
'sockets',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A key this resource compares and may write. ⛔ EVERYTHING ELSE IS REFUSED, NEVER SILENTLY
|
|
121
|
+
* DROPPED OR SILENTLY SENT — `qemu-form.ts`'s `formRefusals` is what a declared key smuggled
|
|
122
|
+
* past the types (a cast, a JS caller: `cipassword`, `machine`, `hookscript`) meets before any
|
|
123
|
+
* write, the same rule `lxc-create-form.ts` enforces for `Proxmox.Lxc`.
|
|
124
|
+
*/
|
|
125
|
+
export const isManagedKey = (key: string): boolean =>
|
|
126
|
+
SCALAR_KEYS.has(key) || indexedKey(key) !== undefined;
|
|
127
|
+
|
|
128
|
+
/** A declaration read by key. ⚠️ The cast is the one place a template-literal index meets a plain string. */
|
|
129
|
+
export const declaredValue = (props: VmProps, key: string): unknown =>
|
|
130
|
+
(props as unknown as Record<string, unknown>)[key];
|
|
131
|
+
|
|
132
|
+
/** The declared keys with a value, in a stable order — never `node`/`target`/`vmid`. */
|
|
133
|
+
export const declaredKeys = (props: VmProps): string[] =>
|
|
134
|
+
Object.keys(props)
|
|
135
|
+
.filter((key) => !NOT_CONFIG.has(key) && declaredValue(props, key) !== undefined)
|
|
136
|
+
.sort();
|
|
137
|
+
|
|
138
|
+
/** One declared value as PVE's wire string. Booleans go as `1`/`0`, numbers as their decimal. */
|
|
139
|
+
export const wireValue = (value: unknown): string =>
|
|
140
|
+
value === true ? '1' : value === false ? '0' : String(value);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The live config as strings: managed keys only.
|
|
144
|
+
* ⛔ AN ALLOWLIST, NOT "EVERYTHING BUT A DENYLIST" — same reasoning as `lxc-props.ts`'s
|
|
145
|
+
* `storedConfig`. `digest` covers the whole file, `lock`/`pending` are transient, and a future
|
|
146
|
+
* PVE release's new key does not land in this estate's unencrypted state store until this
|
|
147
|
+
* resource is taught to manage it.
|
|
148
|
+
*/
|
|
149
|
+
export const storedConfig = (live: Record<string, unknown>): Record<string, string> => {
|
|
150
|
+
const out: Record<string, string> = {};
|
|
151
|
+
for (const [key, value] of Object.entries(live)) {
|
|
152
|
+
if (!isManagedKey(key)) continue;
|
|
153
|
+
if (value === null || value === undefined || typeof value === 'object') continue;
|
|
154
|
+
out[key] = wireValue(value);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for `judgeDisk` — the red-team C1 fix on PR 297. No network, no fake PVE.
|
|
3
|
+
* ⛔ WHAT THESE PIN: a "fresh" declaration (new-disk GiB, `storage:cloudinit`, or
|
|
4
|
+
* `storage:0,import-from=...`) never re-drifts against an existing live volume in the same slot;
|
|
5
|
+
* a genuine volume/storage swap is refused, never written; a size change is refused (no resize
|
|
6
|
+
* call exists here); an option-only change is written on the LIVE volume id and LIVE size.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { judgeDisk, parseDisk } from './qemu-volume.ts';
|
|
10
|
+
|
|
11
|
+
describe('a "fresh" declaration matches any existing volume in its slot', () => {
|
|
12
|
+
test('new-disk GiB spelling: the measured C1 regression', () => {
|
|
13
|
+
expect(judgeDisk('scsi0', 'cephtb4:32', 'cephtb4:vm-101-disk-0,size=32G')).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('cloud-init spelling, QEMU-only: no LXC equivalent', () => {
|
|
17
|
+
expect(judgeDisk('ide2', 'cephtb4:cloudinit', 'cephtb4:vm-101-cloudinit,media=cdrom')).toEqual(
|
|
18
|
+
{},
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The disk-import convergence bug homeflare-proxmox PR 84's red team found (2026-09-26, real
|
|
25
|
+
* fake-PVE engine): `judgeDisk` never recognized `<storage>:0,import-from=<volid>` as a new-disk
|
|
26
|
+
* spelling, so it compared the literal volname `"0"` against PVE's live read-back of the real
|
|
27
|
+
* `vm-<vmid>-disk-<n>` it allocated on import and refused -- on the very deploy that created the
|
|
28
|
+
* disk, and on every plan after (Talos's `declareTalos` declares the import spelling every time,
|
|
29
|
+
* never switches to the resolved volname). Each `judgeDisk` call below stands in for one PVE read
|
|
30
|
+
* a real Talos deploy makes; none of them are a live PVE call.
|
|
31
|
+
*/
|
|
32
|
+
describe('the import-from create spelling converges (PR 84 red team)', () => {
|
|
33
|
+
const IMPORT_DECLARED = 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw';
|
|
34
|
+
|
|
35
|
+
test('create: post-write verification reads back the real volume PVE allocated', () => {
|
|
36
|
+
// The deploy that creates the VM re-reads the config right after the PUT to verify it landed.
|
|
37
|
+
// Before the fix this was refused (declared volname "0" vs live "vm-10000-disk-0").
|
|
38
|
+
expect(judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=2G')).toEqual({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('re-plan: the same declared import spelling against the now-adopted live disk', () => {
|
|
42
|
+
// A later `bun run plan` fabricates the same live read as create did; declared is unchanged
|
|
43
|
+
// (declareTalos always emits import-from, never the resolved volname). Must still be a no-op.
|
|
44
|
+
const verdict = judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=2G');
|
|
45
|
+
expect(verdict.refuse).toBeUndefined();
|
|
46
|
+
expect(verdict.put).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('second deploy: PVE has since filled in defaults the fresh check must not treat as drift', () => {
|
|
50
|
+
// pve-qemu-server fills in options nobody declared (ssd/discard/etc.). A "fresh" declaration
|
|
51
|
+
// skips option comparison entirely, so this must be a no-op too -- not a PUT trying to "fix"
|
|
52
|
+
// options that were never wrong, and not a resize of the placeholder "0" against the real size.
|
|
53
|
+
const verdict = judgeDisk(
|
|
54
|
+
'scsi0',
|
|
55
|
+
IMPORT_DECLARED,
|
|
56
|
+
'cephtb4:vm-10000-disk-0,size=2G,ssd=1,discard=on',
|
|
57
|
+
);
|
|
58
|
+
expect(verdict).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('no resize is ever attempted for an import-from disk', () => {
|
|
62
|
+
// The live size (2G, from the imported image) never appears in a `put` -- there is no `put` at
|
|
63
|
+
// all, because `want.kind === 'fresh'` returns before size or options are ever inspected.
|
|
64
|
+
expect(judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=200G')).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a volid with its own colon (storage:path) still parses as one fresh spelling', () => {
|
|
68
|
+
// import-from points at cephfs-tb4:import/talos-factory.raw -- a second colon inside the value,
|
|
69
|
+
// which a colon-anchored regex could truncate. Confirms the whole declared string is consumed.
|
|
70
|
+
expect(
|
|
71
|
+
judgeDisk(
|
|
72
|
+
'scsi0',
|
|
73
|
+
'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw',
|
|
74
|
+
'cephtb4:vm-10042-disk-0,size=2G',
|
|
75
|
+
),
|
|
76
|
+
).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a genuine size mismatch on an already-adopted volume is still refused', () => {
|
|
80
|
+
// Not an import-from declaration: a plain adopted volume whose declared size disagrees with the
|
|
81
|
+
// live one. Must still hit the existing no-resize refusal, unchanged by the new spelling.
|
|
82
|
+
const verdict = judgeDisk(
|
|
83
|
+
'scsi0',
|
|
84
|
+
'cephtb4:vm-10000-disk-0,size=4G',
|
|
85
|
+
'cephtb4:vm-10000-disk-0,size=2G',
|
|
86
|
+
);
|
|
87
|
+
expect(verdict.refuse).toMatch(/does not resize a disk/);
|
|
88
|
+
expect(verdict.put).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('volname "0" without import-from is not swept into the new "fresh" spelling', () => {
|
|
92
|
+
// Guards NEW_IMPORT's literal ",import-from=" requirement: a coincidental "storage:0,k=v" (no
|
|
93
|
+
// import-from) must still be judged as a real volume named "0", not treated as always-matching.
|
|
94
|
+
const verdict = judgeDisk('scsi0', 'cephtb4:0,ssd=1', 'cephtb4:vm-10000-disk-0,size=2G');
|
|
95
|
+
expect(verdict.refuse).toMatch(/is not the live vm-10000-disk-0/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a mismatched storage on an import-from declaration is still refused, not silently matched', () => {
|
|
99
|
+
// "fresh" only skips the volname/size/options checks -- the storage check above it still runs.
|
|
100
|
+
const verdict = judgeDisk(
|
|
101
|
+
'scsi0',
|
|
102
|
+
'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw',
|
|
103
|
+
'local-zfs:vm-10000-disk-0,size=2G',
|
|
104
|
+
);
|
|
105
|
+
expect(verdict.refuse).toMatch(/the volume is on local-zfs, declared on cephtb4/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The LAND red team's Important finding on this same PR (2026-09-26): options declared alongside
|
|
111
|
+
* `import-from` were either silently dropped (declared after it -- `.+` swallowed them uncompared)
|
|
112
|
+
* or stranded the disk forever (declared before it -- missed the old regex, fell to the `volume`
|
|
113
|
+
* branch, and got refused against the real live volname just like the bug this file fixes). Fixed by
|
|
114
|
+
* detecting `import-from` after the general comma split instead of a position-anchored regex.
|
|
115
|
+
*/
|
|
116
|
+
describe('options declared alongside import-from are enforced, in either order (LAND red team)', () => {
|
|
117
|
+
const IMPORT = 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw';
|
|
118
|
+
|
|
119
|
+
test('an option after import-from that already matches live is a noop', () => {
|
|
120
|
+
const verdict = judgeDisk(
|
|
121
|
+
'scsi0',
|
|
122
|
+
`${IMPORT},iothread=1`,
|
|
123
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G',
|
|
124
|
+
);
|
|
125
|
+
expect(verdict).toEqual({});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('an option after import-from that disagrees with live is written, on the live volume id', () => {
|
|
129
|
+
// Scenario B from the red team: iothread was declared and honored at create; discard/ssd are
|
|
130
|
+
// added later. Before this fix, verify said `noop` and the deploy wrote nothing.
|
|
131
|
+
const verdict = judgeDisk(
|
|
132
|
+
'scsi0',
|
|
133
|
+
`${IMPORT},iothread=1,discard=on,ssd=1`,
|
|
134
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G',
|
|
135
|
+
);
|
|
136
|
+
expect(verdict.put).toBe('cephtb4:vm-150-disk-0,iothread=1,discard=on,ssd=1,size=2G');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('an option BEFORE import-from is still recognized as fresh, not a stranded volume', () => {
|
|
140
|
+
// Scenario C from the red team: `cephtb4:0,iothread=1,import-from=...` missed the old
|
|
141
|
+
// position-anchored regex entirely and was refused forever as a volname "0" vs the live one.
|
|
142
|
+
const verdict = judgeDisk(
|
|
143
|
+
'scsi0',
|
|
144
|
+
'cephtb4:0,iothread=1,import-from=cephfs-tb4:import/talos-factory.raw',
|
|
145
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G',
|
|
146
|
+
);
|
|
147
|
+
expect(verdict).toEqual({});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a live-only default the caller never declared is not treated as drift', () => {
|
|
151
|
+
// Only iothread was declared; ssd/discard are PVE's own fill-in. A full-map compare (sameMap)
|
|
152
|
+
// would call this drift and rewrite the disk on every plan -- the subset check must not.
|
|
153
|
+
const verdict = judgeDisk(
|
|
154
|
+
'scsi0',
|
|
155
|
+
`${IMPORT},iothread=1`,
|
|
156
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G,ssd=1,discard=on',
|
|
157
|
+
);
|
|
158
|
+
expect(verdict).toEqual({});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('an existing-volume declaration', () => {
|
|
163
|
+
const live = 'cephtb4:vm-101-disk-0,size=32G,ssd=1';
|
|
164
|
+
|
|
165
|
+
test('matching options is a noop', () => {
|
|
166
|
+
expect(judgeDisk('scsi0', live, live)).toEqual({});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a different storage is refused, never written', () => {
|
|
170
|
+
const verdict = judgeDisk('scsi0', 'other:vm-101-disk-0,size=32G,ssd=1', live);
|
|
171
|
+
expect(verdict.refuse).toMatch(/the volume is on cephtb4, declared on other/);
|
|
172
|
+
expect(verdict.put).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a different volume on the same storage is refused: it would detach the live one', () => {
|
|
176
|
+
const verdict = judgeDisk('scsi0', 'cephtb4:vm-101-disk-9,size=32G', live);
|
|
177
|
+
expect(verdict.refuse).toMatch(/detach the live volume to unusedN/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a declared size difference is refused: this resource does not resize', () => {
|
|
181
|
+
const verdict = judgeDisk('scsi0', 'cephtb4:vm-101-disk-0,size=64G,ssd=1', live);
|
|
182
|
+
expect(verdict.refuse).toMatch(/does not resize a disk/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('an option-only change writes the LIVE volume id and LIVE size, not the declared ones', () => {
|
|
186
|
+
const verdict = judgeDisk('scsi0', 'cephtb4:vm-101-disk-0,size=32G,ssd=0', live);
|
|
187
|
+
expect(verdict.put).toBe('cephtb4:vm-101-disk-0,ssd=0,size=32G');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the cloud-init slot has no size to carry: the PUT omits it rather than writing size=', () => {
|
|
191
|
+
const cloudinitLive = 'cephtb4:vm-101-cloudinit,media=cdrom';
|
|
192
|
+
const verdict = judgeDisk(
|
|
193
|
+
'ide2',
|
|
194
|
+
'cephtb4:vm-101-cloudinit,media=cdrom,readonly=1',
|
|
195
|
+
cloudinitLive,
|
|
196
|
+
);
|
|
197
|
+
expect(verdict.put).toBe('cephtb4:vm-101-cloudinit,media=cdrom,readonly=1');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('parseDisk', () => {
|
|
202
|
+
test('a bare new-disk spelling has no volname and no size stored as an option', () => {
|
|
203
|
+
expect(parseDisk('cephtb4:32')).toEqual({
|
|
204
|
+
kind: 'fresh',
|
|
205
|
+
options: new Map(),
|
|
206
|
+
size: undefined,
|
|
207
|
+
source: 'cephtb4',
|
|
208
|
+
volname: '',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an existing volume splits size out of the options map', () => {
|
|
213
|
+
const disk = parseDisk('cephtb4:vm-101-disk-0,size=32G,ssd=1');
|
|
214
|
+
expect(disk.kind).toBe('volume');
|
|
215
|
+
expect(disk.volname).toBe('vm-101-disk-0');
|
|
216
|
+
expect(disk.size).toBe('32G');
|
|
217
|
+
expect(disk.options).toEqual(new Map([['ssd', '1']]));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('the import-from spelling parses as fresh, same shape as the other two', () => {
|
|
221
|
+
expect(parseDisk('cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw')).toEqual({
|
|
222
|
+
kind: 'fresh',
|
|
223
|
+
options: new Map(),
|
|
224
|
+
size: undefined,
|
|
225
|
+
source: 'cephtb4',
|
|
226
|
+
volname: '',
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scsiN` and `ideN`: what a declared disk may change on a live VM, and how.
|
|
3
|
+
*
|
|
4
|
+
* ★ PORTED FROM lxc-volume.ts (2026-09-26, red-team C1 on PR 297): pve-qemu-server shares the same
|
|
5
|
+
* "new-disk spelling" rule pve-container has. `storage:GiB` in a PUT means "allocate a fresh
|
|
6
|
+
* volume" — so a declared `cephtb4:32` that already has a live `cephtb4:vm-101-disk-0,size=32G`
|
|
7
|
+
* in that slot must compare EQUAL, never be re-sent. Measured by the builder's red team: resending
|
|
8
|
+
* it allocated a second volume every deploy and parked the guest's real disk at `unusedN` (or left
|
|
9
|
+
* the swap pending on a running boot disk until the next reboot).
|
|
10
|
+
* ⚠️ `ideN` HAS A SECOND NEW-VOLUME SPELLING QEMU DOES NOT SHARE WITH LXC: `<storage>:cloudinit`
|
|
11
|
+
* creates a fresh cloud-init seed drive; the live slot then reads back
|
|
12
|
+
* `<storage>:vm-<vmid>-cloudinit,media=cdrom`. Both spellings compare equal to an existing volume
|
|
13
|
+
* already in the slot, for the same reason `storage:GiB` does.
|
|
14
|
+
* ⛔ A THIRD NEW-VOLUME SPELLING, FOUND BY homeflare-proxmox PR 84's RED TEAM (2026-09-26, real
|
|
15
|
+
* fake-PVE engine): `<storage>:0,import-from=<volid>` imports a disk from another volume or a
|
|
16
|
+
* downloaded image at create time. Before this fix it fell through to the `volume` branch below
|
|
17
|
+
* (it has a comma, so `NEW_DISK` never matched it), so `want.volname` stayed the literal `"0"`
|
|
18
|
+
* forever while PVE's live read-back reported the real `vm-<vmid>-disk-<n>` it allocated on
|
|
19
|
+
* import -- a volname mismatch, REFUSED. That refusal fired on the very deploy that created the
|
|
20
|
+
* disk (post-write verification reads the config right back) and on every plan after, because
|
|
21
|
+
* the declared value is `import-from=...` again each time (Talos's `declareTalos` recomputes it
|
|
22
|
+
* from the download resource, never switches to the resolved volname). Fixed the same way as the
|
|
23
|
+
* other two: an `import-from` disk also counts as `kind: 'fresh'`, which already matches whatever
|
|
24
|
+
* is live unconditionally when nothing else is declared (see `want.kind === 'fresh'` below) -- no
|
|
25
|
+
* special-casing of the live side needed.
|
|
26
|
+
* ⛔ IMPORT-FROM IMPORTS ONLY AT CREATE. Changing just the `import-from=` *source* (e.g. bumping a
|
|
27
|
+
* factory image path) on an already-created disk still reads as `noop`: PVE does not re-import an
|
|
28
|
+
* existing volume, and this resource has no rebuild-the-disk path. That is correct for Talos, which
|
|
29
|
+
* upgrades in place through `talosctl`, not by recreating its disk -- a caller that DOES want a
|
|
30
|
+
* fresh import on image change must delete and recreate the row itself.
|
|
31
|
+
* ⛔ LAND RED TEAM (2026-09-26): OPTIONS DECLARED ALONGSIDE `import-from` MUST STILL BE ENFORCED, AND
|
|
32
|
+
* MUST BE RECOGNIZED REGARDLESS OF WHERE `import-from` SITS IN THE LIST. The first cut matched
|
|
33
|
+
* `import-from` only when it came immediately after `:0,`, via a regex whose `.+` then swallowed
|
|
34
|
+
* every option after it uncompared (silently never written) while any option declared BEFORE it
|
|
35
|
+
* (`cephtb4:0,iothread=1,import-from=...`) missed the regex entirely and fell to the `volume`
|
|
36
|
+
* branch, comparing literal volname `"0"` against the live real one forever -- REFUSED, same
|
|
37
|
+
* stranded-disk failure this file exists to fix, just for a different option order. Fixed by
|
|
38
|
+
* folding the import check into the general `storage:volname,k=v,...` parse below: ANY declaration
|
|
39
|
+
* whose volname is the literal `"0"` and whose options include an `import-from` key is `fresh`,
|
|
40
|
+
* with `import-from` itself dropped and every other declared option carried through for
|
|
41
|
+
* `judgeDisk` to enforce (see its `want.kind === 'fresh'` branch) -- independent of option order.
|
|
42
|
+
* ⛔ NO RESIZE IS MADE HERE. lxc-volume.ts drives a second endpoint (`PUT .../resize`) this resource
|
|
43
|
+
* does not call — Talos's disks are declared once at their create size. A size difference between
|
|
44
|
+
* two already-adopted volume declarations is REFUSED with the `qm resize` to run by hand, never
|
|
45
|
+
* guessed at or silently written.
|
|
46
|
+
* ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY, FOR AN ADOPTED `volume` -- BUT AS
|
|
47
|
+
* A SUBSET FOR A `fresh` IMPORT. Unlike lxc-volume.ts, this file does not model pve-qemu-server's
|
|
48
|
+
* per-option defaults (`ssd`, `discard`, `iothread`, …) — that table is unverified here. For an
|
|
49
|
+
* already-adopted volume, any options difference from live is real drift and gets written in full,
|
|
50
|
+
* exactly as the whole-string comparison this replaces already did. An `import-from` disk cannot
|
|
51
|
+
* use that same whole-map comparison: nobody declares PVE's post-import defaults, so comparing the
|
|
52
|
+
* full map would treat every default PVE fills in as drift and rewrite the disk on every plan (the
|
|
53
|
+
* builder's "second deploy" test below pins this). So a `fresh` import instead checks only the
|
|
54
|
+
* options the caller actually declared against live, ignoring any live-only keys, and PUTs the
|
|
55
|
+
* declared options (not merged with what live already has) when one of them disagrees.
|
|
56
|
+
*/
|
|
57
|
+
import { sameMap } from './lxc-wire.ts';
|
|
58
|
+
|
|
59
|
+
/** PVE's own new-disk spelling: `storage:SIZE`, SIZE in GiB, fractional allowed. */
|
|
60
|
+
const NEW_DISK = /^([^:\s]+):(\d+(?:\.\d+)?)$/;
|
|
61
|
+
/** The `ideN` cloud-init-drive spelling — QEMU only, no GiB, no LXC equivalent. */
|
|
62
|
+
const NEW_CLOUDINIT = /^([^:\s]+):cloudinit$/;
|
|
63
|
+
|
|
64
|
+
export type Disk = {
|
|
65
|
+
/** `fresh`: a new-disk spelling (`storage:GiB`, `storage:cloudinit`, or `storage:0,import-from=...`
|
|
66
|
+
* possibly with more options). `volume`: an existing volume, `storage:volname,...`. */
|
|
67
|
+
readonly kind: 'fresh' | 'volume';
|
|
68
|
+
readonly source: string;
|
|
69
|
+
readonly volname: string;
|
|
70
|
+
readonly size: string | undefined;
|
|
71
|
+
readonly options: Map<string, string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `storage:volname,k=v,...` (or one of the "fresh" spellings) into its comparable parts.
|
|
76
|
+
* ⚠️ THE IMPORT SPELLING IS DETECTED AFTER THE GENERAL SPLIT, NOT BY ITS OWN REGEX: `import-from`'s
|
|
77
|
+
* value can itself contain a colon (e.g. `cephfs-tb4:import/talos-factory.raw`) and other options
|
|
78
|
+
* can come before or after it, so a regex anchored on `:0,import-from=` right after the colon (as
|
|
79
|
+
* the first cut had) misses that ordering and swallows everything after it into one opaque match.
|
|
80
|
+
* Splitting on commas like any `volume` spelling first, THEN checking for a literal `"0"` volname
|
|
81
|
+
* plus an `import-from` key among the parsed options, catches every ordering and keeps every other
|
|
82
|
+
* declared option (`iothread=1`, `discard=on`, …) visible to `judgeDisk` instead of swallowed.
|
|
83
|
+
*/
|
|
84
|
+
export const parseDisk = (text: string): Disk => {
|
|
85
|
+
const bare = NEW_DISK.exec(text) ?? NEW_CLOUDINIT.exec(text);
|
|
86
|
+
if (bare !== null) {
|
|
87
|
+
return {
|
|
88
|
+
kind: 'fresh',
|
|
89
|
+
options: new Map(),
|
|
90
|
+
size: undefined,
|
|
91
|
+
source: bare[1] ?? '',
|
|
92
|
+
volname: '',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const [first, ...rest] = text.split(',');
|
|
96
|
+
const at = (first ?? '').indexOf(':');
|
|
97
|
+
const source = at === -1 ? (first ?? '') : (first ?? '').slice(0, at);
|
|
98
|
+
const volname = at === -1 ? '' : (first ?? '').slice(at + 1);
|
|
99
|
+
const options = new Map(
|
|
100
|
+
rest.map((part): [string, string] => {
|
|
101
|
+
const eq = part.indexOf('=');
|
|
102
|
+
return eq === -1 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const size = options.get('size');
|
|
106
|
+
options.delete('size');
|
|
107
|
+
if (volname === '0' && options.has('import-from')) {
|
|
108
|
+
options.delete('import-from');
|
|
109
|
+
return { kind: 'fresh', options, size: undefined, source, volname: '' };
|
|
110
|
+
}
|
|
111
|
+
return { kind: 'volume', options, size, source, volname };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type DiskVerdict = {
|
|
115
|
+
readonly refuse?: string;
|
|
116
|
+
/** The value to PUT, built on the LIVE volume id and LIVE size — never the declared spelling. */
|
|
117
|
+
readonly put?: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A map back to `key=value,...` — mirrors lxc-wire.ts's `printMap`.
|
|
122
|
+
* ⚠️ `size` OMITTED WHEN THE LIVE SLOT HAS NONE (the cloud-init drive: no GiB figure to carry).
|
|
123
|
+
* Writing `size=` empty is not a value PVE's own parser accepts.
|
|
124
|
+
*/
|
|
125
|
+
const printOptions = (
|
|
126
|
+
source: string,
|
|
127
|
+
volname: string,
|
|
128
|
+
options: Map<string, string>,
|
|
129
|
+
size: string | undefined,
|
|
130
|
+
) =>
|
|
131
|
+
[
|
|
132
|
+
`${source}:${volname}`,
|
|
133
|
+
...[...options].map(([k, v]) => `${k}=${v}`),
|
|
134
|
+
...(size === undefined ? [] : [`size=${size}`]),
|
|
135
|
+
].join(',');
|
|
136
|
+
|
|
137
|
+
export const judgeDisk = (key: string, declared: string, live: string): DiskVerdict => {
|
|
138
|
+
const want = parseDisk(declared);
|
|
139
|
+
const have = parseDisk(live);
|
|
140
|
+
if (want.source !== have.source) {
|
|
141
|
+
return {
|
|
142
|
+
refuse:
|
|
143
|
+
`${key}: the volume is on ${have.source}, declared on ${want.source}. Moving a disk ` +
|
|
144
|
+
'between storages is not an update this resource makes -- move it by hand, then declare it.',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// ⛔ A "fresh" declaration never re-triggers create: PVE holds a real volume in this slot already,
|
|
148
|
+
// and re-sending storage:GiB / storage:cloudinit / storage:0,import-from=... as the PUT value
|
|
149
|
+
// would allocate a SECOND volume and park the live one at unusedN (the header's ⛔, ported from
|
|
150
|
+
// lxc-volume.ts's own). So a `fresh` PUT, when one is needed, is always built from the LIVE
|
|
151
|
+
// volume id (`have.volname`/`have.size`), never the declared placeholder -- same as `volume`.
|
|
152
|
+
// ⚠️ ONLY THE OPTIONS THE CALLER ACTUALLY DECLARED ARE CHECKED (a subset, not `sameMap`): nobody
|
|
153
|
+
// declares PVE's post-import defaults, so an empty `want.options` (plain import-from, nothing
|
|
154
|
+
// else declared) is always a noop no matter what live has filled in, and a non-empty one is
|
|
155
|
+
// compared key-by-key against live, ignoring any key live has that the caller never declared.
|
|
156
|
+
if (want.kind === 'fresh') {
|
|
157
|
+
if (want.options.size === 0) return {};
|
|
158
|
+
if ([...want.options].every(([k, v]) => have.options.get(k) === v)) return {};
|
|
159
|
+
return { put: printOptions(have.source, have.volname, want.options, have.size) };
|
|
160
|
+
}
|
|
161
|
+
if (want.volname !== have.volname) {
|
|
162
|
+
return {
|
|
163
|
+
refuse:
|
|
164
|
+
`${key}: declared volume ${want.volname} is not the live ${have.volname}. Writing it ` +
|
|
165
|
+
'would detach the live volume to unusedN; attach or swap volumes by hand.',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (want.size !== undefined && have.size !== undefined && want.size !== have.size) {
|
|
169
|
+
return {
|
|
170
|
+
refuse:
|
|
171
|
+
`${key}: declared size ${want.size} differs from the live ${have.size}. This resource does ` +
|
|
172
|
+
'not resize a disk -- grow it by hand (`qm resize`) and declare the size it then reports.',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (sameMap(want.options, have.options)) return {};
|
|
176
|
+
// ⚠️ THE LIVE SIZE, NOT THE DECLARED ONE — see the header's ⛔ on why no resize happens here.
|
|
177
|
+
return { put: printOptions(have.source, have.volname, want.options, have.size) };
|
|
178
|
+
};
|
package/src/proxmox/qemu.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.Vm` — a QEMU virtual machine, declared.
|
|
2
|
+
* `Proxmox.Vm` — a QEMU virtual machine, declared. Exported (not just Provider-only) since
|
|
3
|
+
* 2026-09-26 for Talos: see qemu-props.ts for what a declaration may say and why `cipassword`
|
|
4
|
+
* and `machine` may never be one of them.
|
|
3
5
|
*
|
|
4
6
|
* ⚠️ CREATE is `POST /nodes/{node}/qemu`. GET and PUT stay on `.../qemu/{vmid}/config`.
|
|
5
7
|
* ⛔ DELETE is `DELETE /nodes/{node}/qemu/{vmid}` (`destroy_vm` in qemu-server Qemu.pm). The
|
|
@@ -10,39 +12,22 @@
|
|
|
10
12
|
* collision Alchemy can see -- they are different resource types -- and PVE refuses the second
|
|
11
13
|
* with "already exists". Pick ids per cluster, not per kind.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
* is
|
|
15
|
-
*
|
|
15
|
+
* ⛔ THIS RESOURCE NEVER MIGRATES. `qemu-read.ts` refuses the plan outright when the declared vmid
|
|
16
|
+
* is found running on a DIFFERENT node than the one declared ("A migration is not an update") --
|
|
17
|
+
* unchanged by the 2026-09-26 export, and exactly what keeps a node-pinned Talos VM pinned.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ POWER STATE IS NEITHER DECLARED NOR REPORTED. There is no `running` prop and `VmAttributes`
|
|
20
|
+
* carries no `status` field (qemu-props.ts) — starting and stopping a VM from a plan would make a
|
|
21
|
+
* deploy a maintenance window, and the estate has one of those already.
|
|
16
22
|
*/
|
|
17
23
|
import { Resource } from 'alchemy';
|
|
18
24
|
import * as Provider from 'alchemy/Provider';
|
|
19
25
|
import * as Effect from 'effect/Effect';
|
|
20
26
|
import { qemuHandlers } from './qemu-lifecycle.ts';
|
|
21
|
-
import type { PveRequirements
|
|
22
|
-
|
|
23
|
-
export interface VmProps extends WithTarget {
|
|
24
|
-
node: string;
|
|
25
|
-
/** ⛔ Cluster-wide, and shared with LXC. See the ⛔ above. */
|
|
26
|
-
vmid: number;
|
|
27
|
-
name?: string;
|
|
28
|
-
/** MiB. */
|
|
29
|
-
memory?: number;
|
|
30
|
-
cores?: number;
|
|
31
|
-
sockets?: number;
|
|
32
|
-
/** e.g. `virtio=<mac>,bridge=vmbr0`. */
|
|
33
|
-
net0?: string;
|
|
34
|
-
onboot?: boolean;
|
|
35
|
-
}
|
|
27
|
+
import type { PveRequirements } from './resource.ts';
|
|
28
|
+
import type { VmAttributes, VmProps } from './qemu-props.ts';
|
|
36
29
|
|
|
37
|
-
export
|
|
38
|
-
vmid: number;
|
|
39
|
-
node: string;
|
|
40
|
-
name: string;
|
|
41
|
-
memory: number;
|
|
42
|
-
cores: number;
|
|
43
|
-
sockets: number;
|
|
44
|
-
onboot: boolean;
|
|
45
|
-
}
|
|
30
|
+
export type { VmAttributes, VmProps } from './qemu-props.ts';
|
|
46
31
|
|
|
47
32
|
export interface ProxmoxVm extends Resource<
|
|
48
33
|
'Proxmox.Vm',
|
|
@@ -52,7 +37,8 @@ export interface ProxmoxVm extends Resource<
|
|
|
52
37
|
PveRequirements
|
|
53
38
|
> {}
|
|
54
39
|
|
|
55
|
-
|
|
40
|
+
/** ★ `retain` by default — a VM's disks cannot be rebuilt from a line. See resource.ts / lxc.ts. */
|
|
41
|
+
export const ProxmoxVm = Resource<ProxmoxVm>('Proxmox.Vm', { defaultRemovalPolicy: 'retain' });
|
|
56
42
|
|
|
57
43
|
export { createForm } from './qemu-form.ts';
|
|
58
44
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A `Proxmox.StorageDownload` write or observation this provider will not turn into a claim of success. */
|
|
2
|
+
export class StorageDownloadRefusedError extends Error {
|
|
3
|
+
constructor(message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'StorageDownloadRefusedError';
|
|
6
|
+
}
|
|
7
|
+
}
|