@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
|
@@ -5,7 +5,7 @@ import * as Effect from 'effect/Effect';
|
|
|
5
5
|
import * as Layer from 'effect/Layer';
|
|
6
6
|
import { engineOver } from '../verify/fake-engine.ts';
|
|
7
7
|
import { FAKE_TARGET, type FakePve, fakePve, withoutBao } from './fake-pve.ts';
|
|
8
|
-
import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
|
|
8
|
+
import { ProxmoxVm, ProxmoxVmProvider, type VmProps } from './qemu.ts';
|
|
9
9
|
|
|
10
10
|
const NODE = 'pve1';
|
|
11
11
|
const VMID = 150;
|
|
@@ -42,24 +42,27 @@ describe('Proxmox.Vm distilled transport', () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test('absence creates on the collection route, waits, then settles', async () => {
|
|
45
|
-
let
|
|
45
|
+
let posted: Record<string, string> | undefined;
|
|
46
46
|
const fake = fakePve((call) => {
|
|
47
47
|
if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
|
|
48
48
|
return { status: 'stopped', exitstatus: 'OK' };
|
|
49
49
|
}
|
|
50
50
|
if (isIndex(call)) return [];
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
// ⚠️ `net0` IS NOW A MANAGED, COMPARED KEY (qemu-props.ts) — the old fixture answered the
|
|
52
|
+
// SAME static `live` regardless of what was posted, which only worked because the old
|
|
53
|
+
// `matches` never looked at `net0` at all. Echoing the create form back is what proves
|
|
54
|
+
// the declared value is what a real PVE config would report.
|
|
55
|
+
if (call.method === 'GET') return posted === undefined ? missing() : { ...live, ...posted };
|
|
56
|
+
posted = call.form;
|
|
53
57
|
return UPID;
|
|
54
58
|
});
|
|
59
|
+
const declared = { ...props, net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0' };
|
|
55
60
|
await withoutBao(async () => {
|
|
56
61
|
const stack = engine(fake);
|
|
57
|
-
await stack.deploy(
|
|
58
|
-
|
|
62
|
+
await stack.deploy(ProxmoxVm('row', declared));
|
|
63
|
+
expect((await stack.verify(ProxmoxVm('row', declared), { all: true })).rows[0]).toMatchObject(
|
|
64
|
+
{ diff: 'noop' },
|
|
59
65
|
);
|
|
60
|
-
expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
|
|
61
|
-
diff: 'noop',
|
|
62
|
-
});
|
|
63
66
|
});
|
|
64
67
|
expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
|
|
65
68
|
expect(fake.calls.find((call) => call.method === 'POST')?.form['net0']).toBe(
|
|
@@ -68,6 +71,41 @@ describe('Proxmox.Vm distilled transport', () => {
|
|
|
68
71
|
expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(true);
|
|
69
72
|
});
|
|
70
73
|
|
|
74
|
+
test('C1: a "fresh" disk/cloudinit and a MAC-less NIC do not re-drift after PVE allocates them', async () => {
|
|
75
|
+
// ⛔ THE MEASURED REGRESSION: before qemu-judge.ts, every deploy after the create re-sent
|
|
76
|
+
// scsi0/ide2/net0 in their DECLARED (fresh) spelling, because a plain string compare never
|
|
77
|
+
// equals what PVE reports back for an allocated volume or a generated MAC -- reallocating the
|
|
78
|
+
// disk and handing out a new MAC on every single deploy.
|
|
79
|
+
const declared = {
|
|
80
|
+
...props,
|
|
81
|
+
ide2: 'cephtb4:cloudinit',
|
|
82
|
+
net0: 'virtio,bridge=vmbr0,tag=20',
|
|
83
|
+
scsi0: 'cephtb4:32',
|
|
84
|
+
};
|
|
85
|
+
const allocated = {
|
|
86
|
+
ide2: 'cephtb4:vm-150-cloudinit,media=cdrom',
|
|
87
|
+
net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=20',
|
|
88
|
+
scsi0: 'cephtb4:vm-150-disk-0,size=32G',
|
|
89
|
+
};
|
|
90
|
+
let created = false;
|
|
91
|
+
const fake = fakePve((call) => {
|
|
92
|
+
if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
|
|
93
|
+
return { status: 'stopped', exitstatus: 'OK' };
|
|
94
|
+
}
|
|
95
|
+
if (isIndex(call)) return [];
|
|
96
|
+
if (call.method === 'GET') return created ? { ...live, ...allocated } : missing();
|
|
97
|
+
created = true;
|
|
98
|
+
return UPID;
|
|
99
|
+
});
|
|
100
|
+
await withoutBao(async () => {
|
|
101
|
+
const stack = engine(fake);
|
|
102
|
+
await stack.deploy(ProxmoxVm('row', declared));
|
|
103
|
+
await stack.deploy(ProxmoxVm('row', declared));
|
|
104
|
+
await stack.deploy(ProxmoxVm('row', declared));
|
|
105
|
+
});
|
|
106
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
|
|
107
|
+
});
|
|
108
|
+
|
|
71
109
|
test('a transitional task status keeps polling instead of aborting the create', async () => {
|
|
72
110
|
// ⚠️ `GetNodeTaskStatusResponseStatus` types `status` as the closed union
|
|
73
111
|
// "running" | "stopped", but the runtime validator is `S.String` (distilled-proxmox's
|
|
@@ -113,24 +151,45 @@ describe('Proxmox.Vm distilled transport', () => {
|
|
|
113
151
|
expect(fake.writes()).toEqual([]);
|
|
114
152
|
});
|
|
115
153
|
|
|
116
|
-
test('drift updates the config route and
|
|
117
|
-
|
|
154
|
+
test('drift on a declared field updates the config route, and only that field', async () => {
|
|
155
|
+
// ⛔ THE "5-DEFAULT PUT" REGRESSION TEST. `name` is deliberately left UNDECLARED and deliberately
|
|
156
|
+
// NOT what qemu-form.ts's old default would have produced (`vm150`) -- the old `shape()` sent
|
|
157
|
+
// it (and `cores`/`sockets`/`onboot`) on every write regardless, silently resetting an
|
|
158
|
+
// undeclared field to its factory value. Only `memory` is declared here, so only `memory`
|
|
159
|
+
// may appear in the PUT body.
|
|
160
|
+
const declared = { ...props, memory: 512 };
|
|
161
|
+
let current: unknown = { ...live, memory: '256', name: 'a-name-nobody-declared' };
|
|
118
162
|
const fake = fakePve((call) => {
|
|
119
163
|
if (call.method === 'GET') return current;
|
|
120
|
-
current =
|
|
164
|
+
current = { ...(current as Record<string, unknown>), memory: '512' };
|
|
121
165
|
return null;
|
|
122
166
|
});
|
|
123
167
|
await withoutBao(async () => {
|
|
124
168
|
const stack = engine(fake);
|
|
125
|
-
expect((await stack.verify(ProxmoxVm('row',
|
|
126
|
-
diff: 'update',
|
|
127
|
-
|
|
128
|
-
await stack.deploy(ProxmoxVm('row',
|
|
129
|
-
expect((await stack.verify(ProxmoxVm('row',
|
|
130
|
-
diff: 'noop',
|
|
131
|
-
|
|
169
|
+
expect((await stack.verify(ProxmoxVm('row', declared), { all: true })).rows[0]).toMatchObject(
|
|
170
|
+
{ diff: 'update' },
|
|
171
|
+
);
|
|
172
|
+
await stack.deploy(ProxmoxVm('row', declared));
|
|
173
|
+
expect((await stack.verify(ProxmoxVm('row', declared), { all: true })).rows[0]).toMatchObject(
|
|
174
|
+
{ diff: 'noop' },
|
|
175
|
+
);
|
|
132
176
|
});
|
|
133
177
|
expect(fake.writes()).toEqual([`PUT nodes/${NODE}/qemu/${String(VMID)}/config`]);
|
|
178
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
179
|
+
expect(put?.form).toEqual({ memory: '512' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('an unmanaged key smuggled past the types is refused before any write', async () => {
|
|
183
|
+
const fake = fakePve((call) => (call.method === 'GET' ? live : null));
|
|
184
|
+
// ⛔ `cipassword` IS TYPED `never` (qemu-props.ts): a cast is the only way in, exactly the
|
|
185
|
+
// scenario the type cannot guard against by itself. `formRefusals` is the runtime backstop.
|
|
186
|
+
const smuggled = { ...props, cipassword: 'not-a-real-secret' } as unknown as VmProps;
|
|
187
|
+
await withoutBao(async () => {
|
|
188
|
+
await expect(engine(fake).deploy(ProxmoxVm('row', smuggled))).rejects.toThrow(
|
|
189
|
+
/cipassword: not a config key this resource manages/,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
expect(fake.writes()).toEqual([]);
|
|
134
193
|
});
|
|
135
194
|
|
|
136
195
|
test('unrelated read failures and a digest-less body do not write', async () => {
|
|
@@ -3,50 +3,56 @@
|
|
|
3
3
|
* ⛔ GET and PUT use `/nodes/{node}/qemu/{vmid}/config`. DELETE uses `/nodes/{node}/qemu/{vmid}`.
|
|
4
4
|
* qemu-server Qemu.pm names that second route `destroy_vm`. Sending DELETE to the config
|
|
5
5
|
* route does not destroy the guest.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ WRITES GO THROUGH `qemu-judge.ts`, NOT A WHOLE-FORM PUT (2026-09-26, red-team C1/C2/I1 on
|
|
8
|
+
* PR 297). `judge` decides per key, on the live volume id and live MAC (qemu-volume.ts,
|
|
9
|
+
* qemu-net.ts); only drifted keys are sent. `ownedRead`/`refuseTakeover` (C2) refuse to adopt or
|
|
10
|
+
* write a live VM this stack holds no state for, unless `--adopt` / `adopt(true)` says so —
|
|
11
|
+
* ownership.md's rule, ported here the way openbao/policy.ts already does it. `identityRefusal`
|
|
12
|
+
* (I1) refuses a changed vmid rather than building a second VM.
|
|
6
13
|
*/
|
|
7
14
|
import { isResolved } from 'alchemy/Diff';
|
|
8
15
|
import type { Input } from 'alchemy/Input';
|
|
9
16
|
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
10
17
|
import * as Effect from 'effect/Effect';
|
|
18
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
19
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
11
20
|
import { runPve } from './distilled-pve.ts';
|
|
12
|
-
import { createForm,
|
|
21
|
+
import { createForm, formRefusals, updateForm } from './qemu-form.ts';
|
|
13
22
|
import { QemuRefusedError } from './qemu-errors.ts';
|
|
23
|
+
import { identityRefusal, judge } from './qemu-judge.ts';
|
|
24
|
+
import {
|
|
25
|
+
type VmAttributes,
|
|
26
|
+
type VmProps,
|
|
27
|
+
declaredKeys,
|
|
28
|
+
declaredValue,
|
|
29
|
+
isManagedKey,
|
|
30
|
+
storedConfig,
|
|
31
|
+
wireValue,
|
|
32
|
+
} from './qemu-props.ts';
|
|
14
33
|
import { readVm } from './qemu-read.ts';
|
|
15
34
|
import { qemuTask } from './qemu-task.ts';
|
|
16
|
-
import type { VmAttributes, VmProps } from './qemu.ts';
|
|
17
35
|
import type { PveSpec } from './resource-spec.ts';
|
|
18
36
|
import { specGuards } from './resource-guard.ts';
|
|
19
|
-
import { formToSend } from './update-guard.ts';
|
|
20
|
-
import { int } from './values.ts';
|
|
21
37
|
|
|
22
38
|
const CREATE_POLLS = 180;
|
|
23
39
|
const DELETE_POLLS = 60;
|
|
24
40
|
|
|
25
|
-
const attributesOf = (
|
|
26
|
-
|
|
27
|
-
cores?: unknown;
|
|
28
|
-
memory?: unknown;
|
|
29
|
-
name?: unknown;
|
|
30
|
-
onboot?: unknown;
|
|
31
|
-
sockets?: unknown;
|
|
32
|
-
},
|
|
33
|
-
props: VmProps,
|
|
34
|
-
): VmAttributes => ({
|
|
35
|
-
cores: int(live.cores, 1),
|
|
36
|
-
memory: int(live.memory, 512),
|
|
37
|
-
name: typeof live.name === 'string' ? live.name : '',
|
|
41
|
+
const attributesOf = (live: Record<string, unknown>, props: VmProps): VmAttributes => ({
|
|
42
|
+
config: storedConfig(live),
|
|
38
43
|
node: props.node,
|
|
39
|
-
onboot: live.onboot === 1 || live.onboot === true,
|
|
40
|
-
sockets: int(live.sockets, 1),
|
|
41
44
|
vmid: props.vmid,
|
|
42
45
|
});
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* ⚠️ KEPT ONLY TO SATISFY `PveSpec`'S REQUIRED FIELD — `qemu-judge.ts`'s `judge` is what diff and
|
|
49
|
+
* reconcile actually decide by, since a plain string compare is exactly the bug C1 found (it
|
|
50
|
+
* never matches the "new disk" or MAC-less spellings a declaration may use).
|
|
51
|
+
*/
|
|
44
52
|
const matches = (attributes: VmAttributes, props: VmProps) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
attributes.sockets === (props.sockets ?? 1) &&
|
|
49
|
-
attributes.onboot === (props.onboot === true);
|
|
53
|
+
declaredKeys(props)
|
|
54
|
+
.filter(isManagedKey)
|
|
55
|
+
.every((key) => attributes.config[key] === wireValue(declaredValue(props, key)));
|
|
50
56
|
|
|
51
57
|
const spec = {
|
|
52
58
|
attributes: attributesOf,
|
|
@@ -58,14 +64,24 @@ const spec = {
|
|
|
58
64
|
},
|
|
59
65
|
matches,
|
|
60
66
|
path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
|
|
61
|
-
updateForm
|
|
67
|
+
updateForm,
|
|
62
68
|
} satisfies PveSpec<VmProps, VmAttributes>;
|
|
63
69
|
|
|
64
70
|
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
65
71
|
|
|
66
72
|
export const readQemu = (props: VmProps) =>
|
|
67
73
|
readVm(props).pipe(
|
|
68
|
-
Effect.map((live) =>
|
|
74
|
+
Effect.map((live) =>
|
|
75
|
+
live === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: // ⚠️ WIDENED ON PURPOSE. `GetNodeQemuConfigResponse` types only the keys pve-manager's
|
|
78
|
+
// apidoc names as SCALARS; it cannot express `scsi[n]`/`net[n]`/`ipconfig[n]` at all
|
|
79
|
+
// (qemu-props.ts's header), so it carries no index signature. `storedConfig` reads
|
|
80
|
+
// those keys anyway, because the ACTUAL wire response has them (protocol-http.ts's
|
|
81
|
+
// `mapKeys` passes an unrecognised key through undecoded, both directions) — this
|
|
82
|
+
// cast only tells TypeScript what runtime already knows.
|
|
83
|
+
attributesOf(live as unknown as Record<string, unknown>, props),
|
|
84
|
+
),
|
|
69
85
|
);
|
|
70
86
|
|
|
71
87
|
/** No purge, no skiplock, no destroy-unreferenced-disks. A missing config is already gone. */
|
|
@@ -77,28 +93,95 @@ export const deleteQemu = (props: VmProps) =>
|
|
|
77
93
|
DELETE_POLLS,
|
|
78
94
|
).pipe(Effect.catchTag('QemuConfigNotFound', () => Effect.void));
|
|
79
95
|
|
|
96
|
+
/**
|
|
97
|
+
* ★ ONE PLACE, RUN BEFORE EVERY DIFF AND EVERY RECONCILE. A key smuggled past `VmProps`'s types
|
|
98
|
+
* (a cast, a JS caller: `cipassword`, `machine`, `hookscript`) is refused here rather than
|
|
99
|
+
* silently dropped from the form or silently sent — the same rule `lxc-create-form.ts` enforces
|
|
100
|
+
* for `Proxmox.Lxc`, and the check `qemu-props.ts`'s `cipassword?: never`/`machine?: never`
|
|
101
|
+
* cannot make by itself once a caller casts around the type.
|
|
102
|
+
*/
|
|
103
|
+
const refuseUnmanaged = (props: VmProps) => {
|
|
104
|
+
const refused = formRefusals(props);
|
|
105
|
+
return refused.length === 0 ? Effect.void : Effect.fail(new QemuRefusedError(refused.join('\n')));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const refuseChange = (change: { readonly refuse: readonly string[] }) =>
|
|
109
|
+
change.refuse.length === 0
|
|
110
|
+
? Effect.void
|
|
111
|
+
: Effect.fail(new QemuRefusedError(change.refuse.join('\n')));
|
|
112
|
+
|
|
80
113
|
export const qemuHandlers = {
|
|
81
114
|
list: () => Effect.succeed([]),
|
|
82
|
-
|
|
115
|
+
/**
|
|
116
|
+
* ⛔ `Unowned` UNLESS STATE ALREADY VOUCHES FOR IT (C2). With no attributes this is Alchemy's
|
|
117
|
+
* adoption probe or the recovery read for an interrupted create (ownership/probe.ts); `settled`
|
|
118
|
+
* is the same `judge` reconcile uses, so "proven ours" and "reconcile would write nothing" are
|
|
119
|
+
* the same question asked once.
|
|
120
|
+
*/
|
|
121
|
+
read: Effect.fn(function* ({
|
|
122
|
+
fqn,
|
|
123
|
+
instanceId,
|
|
124
|
+
olds,
|
|
125
|
+
output,
|
|
126
|
+
}: {
|
|
127
|
+
fqn: string;
|
|
128
|
+
instanceId: string;
|
|
129
|
+
olds: VmProps;
|
|
130
|
+
output: VmAttributes | undefined;
|
|
131
|
+
}) {
|
|
132
|
+
const found = yield* readQemu(olds);
|
|
133
|
+
const settled = Effect.sync(
|
|
134
|
+
() => found !== undefined && judge(olds, found.config).drift.length === 0,
|
|
135
|
+
);
|
|
136
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, settled);
|
|
137
|
+
}),
|
|
83
138
|
diff: Effect.fn(function* ({
|
|
84
139
|
news,
|
|
140
|
+
olds,
|
|
85
141
|
output,
|
|
86
142
|
}: {
|
|
87
143
|
news: Input<VmProps>;
|
|
144
|
+
olds: VmProps;
|
|
88
145
|
output: VmAttributes | undefined;
|
|
89
146
|
}) {
|
|
90
147
|
if (!isResolved(news)) return undefined;
|
|
148
|
+
const identity = identityRefusal(news.vmid, output?.vmid ?? olds.vmid);
|
|
149
|
+
if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
|
|
150
|
+
yield* refuseUnmanaged(news);
|
|
91
151
|
yield* guardCreate(news, output === undefined);
|
|
92
152
|
yield* guardUpdate(news);
|
|
93
153
|
if (output === undefined) return undefined;
|
|
94
154
|
const live = yield* readQemu(news);
|
|
95
155
|
if (live === undefined) {
|
|
156
|
+
// ⚠️ `update`, not `create`: state exists and the VM does not. reconcile rebuilds it from the
|
|
157
|
+
// declaration -- said here too, so an operator reading the plan sees it before the deploy.
|
|
158
|
+
yield* Effect.logWarning(
|
|
159
|
+
`Proxmox.Vm ${news.node}/${String(news.vmid)}: the cluster lists no VM with this vmid -- ` +
|
|
160
|
+
'a deploy CREATES it again, with new, empty volumes.',
|
|
161
|
+
);
|
|
96
162
|
yield* guardCreate(news, true);
|
|
97
163
|
return { action: 'update' } as const;
|
|
98
164
|
}
|
|
99
|
-
|
|
165
|
+
const change = judge(news, live.config);
|
|
166
|
+
yield* refuseChange(change);
|
|
167
|
+
return { action: change.drift.length === 0 ? 'noop' : 'update' } as const;
|
|
100
168
|
}),
|
|
101
|
-
reconcile: Effect.fn(function* ({
|
|
169
|
+
reconcile: Effect.fn(function* ({
|
|
170
|
+
fqn,
|
|
171
|
+
instanceId,
|
|
172
|
+
news,
|
|
173
|
+
olds,
|
|
174
|
+
output,
|
|
175
|
+
}: {
|
|
176
|
+
fqn: string;
|
|
177
|
+
instanceId: string;
|
|
178
|
+
news: VmProps;
|
|
179
|
+
olds: VmProps | undefined;
|
|
180
|
+
output: VmAttributes | undefined;
|
|
181
|
+
}) {
|
|
182
|
+
const identity = identityRefusal(news.vmid, output?.vmid ?? olds?.vmid);
|
|
183
|
+
if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
|
|
184
|
+
yield* refuseUnmanaged(news);
|
|
102
185
|
const live = yield* readQemu(news);
|
|
103
186
|
yield* guardCreate(news, live === undefined);
|
|
104
187
|
yield* guardUpdate(news);
|
|
@@ -110,13 +193,20 @@ export const qemuHandlers = {
|
|
|
110
193
|
CREATE_POLLS,
|
|
111
194
|
);
|
|
112
195
|
} else {
|
|
113
|
-
|
|
114
|
-
|
|
196
|
+
// ⛔ C2: a live VM this stack holds no state for is refused unless adoption is on — dies
|
|
197
|
+
// (never returns) for a fresh create/replace generation; a no-op otherwise (adopt.ts).
|
|
198
|
+
yield* refuseTakeover(
|
|
199
|
+
{ fqn, instanceId, output },
|
|
200
|
+
`Proxmox.Vm VM ${String(news.vmid)} on ${news.node}`,
|
|
201
|
+
);
|
|
202
|
+
const change = judge(news, live.config);
|
|
203
|
+
yield* refuseChange(change);
|
|
204
|
+
if (Object.keys(change.put).length > 0) {
|
|
115
205
|
yield* runPve(
|
|
116
206
|
news.target,
|
|
117
207
|
'provision',
|
|
118
208
|
true,
|
|
119
|
-
nodes.putNodeQemuConfig({ ...
|
|
209
|
+
nodes.putNodeQemuConfig({ ...change.put, node: news.node, vmid: String(news.vmid) }),
|
|
120
210
|
);
|
|
121
211
|
}
|
|
122
212
|
}
|
|
@@ -128,6 +218,15 @@ export const qemuHandlers = {
|
|
|
128
218
|
),
|
|
129
219
|
);
|
|
130
220
|
}
|
|
221
|
+
const left = judge(news, after.config);
|
|
222
|
+
if (left.drift.length > 0) {
|
|
223
|
+
return yield* Effect.fail(
|
|
224
|
+
new QemuRefusedError(
|
|
225
|
+
`${spec.path(news)}: after the write, ${left.drift.join(', ')} still differ from the ` +
|
|
226
|
+
'declaration. PVE may have normalised the value -- declare what it stores.',
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
131
230
|
return after;
|
|
132
231
|
}),
|
|
133
232
|
delete: ({ olds }: { olds: VmProps }) => deleteQemu(olds),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for `sameNet`/`netWrite` — the red-team C1 fix on PR 297. No network, no fake PVE.
|
|
3
|
+
* ⛔ WHAT THESE PIN: a MAC-less (or bare-model) declaration matches a live NIC with any MAC, and
|
|
4
|
+
* never generates a new one when written; an explicit MAC is compared and written as declared; a
|
|
5
|
+
* real drift elsewhere on the line (VLAN tag, bridge) still shows up.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { netWrite, sameNet } from './qemu-net.ts';
|
|
9
|
+
|
|
10
|
+
const LIVE = 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=20';
|
|
11
|
+
|
|
12
|
+
describe('a MAC-less declaration', () => {
|
|
13
|
+
test('bare model token matches a live NIC with any MAC: the measured C1 regression', () => {
|
|
14
|
+
expect(sameNet('virtio,bridge=vmbr0,tag=20', LIVE)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('empty-value spelling matches too', () => {
|
|
18
|
+
expect(sameNet('virtio=,bridge=vmbr0,tag=20', LIVE)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('writing it carries the LIVE mac forward, never regenerating one', () => {
|
|
22
|
+
expect(netWrite('virtio,bridge=vmbr0,tag=21', LIVE)).toBe(
|
|
23
|
+
'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=21',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('an explicit MAC', () => {
|
|
29
|
+
test('a matching MAC is a noop', () => {
|
|
30
|
+
expect(sameNet(LIVE, LIVE)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a different MAC is real drift and is written as declared', () => {
|
|
34
|
+
const declared = 'virtio=11:22:33:44:55:66,bridge=vmbr0,tag=20';
|
|
35
|
+
expect(sameNet(declared, LIVE)).toBe(false);
|
|
36
|
+
expect(netWrite(declared, LIVE)).toBe(declared);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('a real change elsewhere on the line', () => {
|
|
41
|
+
test('a different VLAN tag is drift even with the MAC substituted', () => {
|
|
42
|
+
expect(sameNet('virtio,bridge=vmbr0,tag=42', LIVE)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a different bridge is drift', () => {
|
|
46
|
+
expect(sameNet('virtio,bridge=vmbr1,tag=20', LIVE)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `netN`: comparing a declared NIC against a live one without regenerating its MAC every deploy.
|
|
3
|
+
*
|
|
4
|
+
* ★ PORTED FROM lxc-wire.ts's `netMaps`/`netWrite` (2026-09-26, red-team C1 on PR 297), adapted for
|
|
5
|
+
* QEMU's spelling. A container's `net0` carries a `hwaddr=` sub-key; a VM's carries the MAC as the
|
|
6
|
+
* VALUE of whichever NIC-model key is present — `virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0` — with the
|
|
7
|
+
* model itself (`e1000`, `virtio`, …) chosen by pve-qemu-server's own model list, not a fixed key
|
|
8
|
+
* name. A declaration with no MAC spells the model bare (`virtio,bridge=vmbr0,tag=20`) or with an
|
|
9
|
+
* empty value (`virtio=,bridge=vmbr0`); PVE generates one on create either way.
|
|
10
|
+
* ⛔ WITHOUT THIS, A PUT OF AN UNCHANGED NIC GENERATES A NEW MAC — a new DHCP lease and a new IPv6
|
|
11
|
+
* link-local address for a guest that only had, say, its VLAN tag edited. Measured by the
|
|
12
|
+
* builder's red team against a MAC-less `net0` declaration.
|
|
13
|
+
* ⚠️ OTHER SUB-KEYS (`firewall`, `link_down`, `mtu`, `queues`, `rate`, `tag`, `trunks`) COMPARE
|
|
14
|
+
* RAW, NO DEFAULTS DROPPED — that table is unverified here, unlike lxc-wire.ts's `DEFAULTS`. A
|
|
15
|
+
* difference in one is real drift and gets written, exactly as the whole-string comparison this
|
|
16
|
+
* replaces already did; only the MAC is special-cased, the minimum that fixes the measured bug.
|
|
17
|
+
*/
|
|
18
|
+
/** pve-qemu-server's own `$nic_model_list` — whichever of these is present is the MAC-bearing key. */
|
|
19
|
+
const NIC_MODELS = [
|
|
20
|
+
'e1000',
|
|
21
|
+
'e1000e',
|
|
22
|
+
'i82551',
|
|
23
|
+
'i82557b',
|
|
24
|
+
'i82559er',
|
|
25
|
+
'i82562',
|
|
26
|
+
'i82801',
|
|
27
|
+
'ne2k_isa',
|
|
28
|
+
'ne2k_pci',
|
|
29
|
+
'pcnet',
|
|
30
|
+
'rtl8139',
|
|
31
|
+
'virtio',
|
|
32
|
+
'vmxnet3',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/** `a=1,b=2` or a bare `a` (→ `['a', '']`) — the one-off parser this file needs, not lxc-wire's. */
|
|
36
|
+
const netPairs = (value: string): Map<string, string> =>
|
|
37
|
+
new Map(
|
|
38
|
+
value
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((part) => part.trim())
|
|
41
|
+
.filter((part) => part !== '')
|
|
42
|
+
.map((part): [string, string] => {
|
|
43
|
+
const at = part.indexOf('=');
|
|
44
|
+
return at === -1 ? [part, ''] : [part.slice(0, at), part.slice(at + 1)];
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const modelKey = (map: ReadonlyMap<string, string>): string | undefined =>
|
|
49
|
+
NIC_MODELS.find((model) => map.has(model));
|
|
50
|
+
|
|
51
|
+
/** The MAC-substituted comparison maps: an undeclared/blank MAC is PVE's, not drift. */
|
|
52
|
+
export const netMaps = (declared: string, live: string) => {
|
|
53
|
+
const want = netPairs(declared);
|
|
54
|
+
const have = netPairs(live);
|
|
55
|
+
const wantModel = modelKey(want);
|
|
56
|
+
const haveModel = modelKey(have);
|
|
57
|
+
if (wantModel !== undefined && (want.get(wantModel) ?? '') === '' && haveModel !== undefined) {
|
|
58
|
+
want.set(wantModel, have.get(haveModel) ?? '');
|
|
59
|
+
}
|
|
60
|
+
return { have, want };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const sameNet = (declared: string, live: string): boolean => {
|
|
64
|
+
const { have, want } = netMaps(declared, live);
|
|
65
|
+
return have.size === want.size && [...want].every(([k, v]) => have.get(k) === v);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The value to PUT: the declaration, plus the live MAC when it names none. */
|
|
69
|
+
export const netWrite = (declared: string, live: string): string => {
|
|
70
|
+
const want = netPairs(declared);
|
|
71
|
+
const wantModel = modelKey(want);
|
|
72
|
+
const have = netPairs(live);
|
|
73
|
+
const haveModel = modelKey(have);
|
|
74
|
+
if (wantModel !== undefined && (want.get(wantModel) ?? '') === '' && haveModel !== undefined) {
|
|
75
|
+
want.set(wantModel, have.get(haveModel) ?? '');
|
|
76
|
+
}
|
|
77
|
+
return [...want].map(([k, v]) => (v === '' ? k : `${k}=${v}`)).join(',');
|
|
78
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose VM it is — docs/ownership.md applied to `Proxmox.Vm` (red-team C2 on PR 297), through the
|
|
3
|
+
* real engine over a stub fetch (fake-pve.ts).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHAT THIS PINS: a live VM this stack holds no state for is never adopted or written silently
|
|
6
|
+
* (the measured regression: `adopt(false)` on an existing VM still planned `adopted` and PUT it).
|
|
7
|
+
* `adopt(false)` on the declaration wins over the test harness's own forced `AdoptPolicy: true`
|
|
8
|
+
* (fake-engine.ts) — a resource-scoped `adopt(...)` overrides the ambient policy both ways
|
|
9
|
+
* (ownership/adopt.ts), which is what lets this test observe the refusal at all.
|
|
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 { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
|
|
17
|
+
|
|
18
|
+
const NODE = 'pve1';
|
|
19
|
+
const VMID = 150;
|
|
20
|
+
const props = { node: NODE, target: FAKE_TARGET, vmid: VMID };
|
|
21
|
+
const live = { digest: 'a'.repeat(40), memory: '512' };
|
|
22
|
+
|
|
23
|
+
const engine = (fake: ReturnType<typeof fakePve>) =>
|
|
24
|
+
engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
25
|
+
|
|
26
|
+
describe('Proxmox.Vm ownership', () => {
|
|
27
|
+
test('adopt(false): a live VM with no state is refused, and nothing is written', async () => {
|
|
28
|
+
const fake = fakePve((call) => (call.method === 'GET' ? live : null));
|
|
29
|
+
await withoutBao(async () => {
|
|
30
|
+
await expect(engine(fake).deploy(ProxmoxVm('row', props).pipe(adopt(false)))).rejects.toThrow(
|
|
31
|
+
/adopt|exists, and this stack holds no state/i,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
expect(fake.writes()).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the harness default (adoption on) still adopts with no write — unchanged behaviour', async () => {
|
|
38
|
+
const fake = fakePve((call) => (call.method === 'GET' ? live : null));
|
|
39
|
+
await withoutBao(async () => {
|
|
40
|
+
expect(await engine(fake).deploy(ProxmoxVm('row', props))).toEqual({ row: 'adopted' });
|
|
41
|
+
});
|
|
42
|
+
expect(fake.writes()).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for the declared-keys model: no network, no fake PVE. The engine-level proof
|
|
3
|
+
* that an unmanaged key is refused before any write lives in qemu-lifecycle.test.ts, alongside
|
|
4
|
+
* the node-pinned-refusal and 5-default-PUT regression tests.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { formRefusals } from './qemu-form.ts';
|
|
8
|
+
import {
|
|
9
|
+
type VmProps,
|
|
10
|
+
declaredKeys,
|
|
11
|
+
indexedKey,
|
|
12
|
+
isManagedKey,
|
|
13
|
+
storedConfig,
|
|
14
|
+
wireValue,
|
|
15
|
+
} from './qemu-props.ts';
|
|
16
|
+
import { FAKE_TARGET } from './fake-pve.ts';
|
|
17
|
+
|
|
18
|
+
const base = { node: 'pve1', target: FAKE_TARGET, vmid: 150 } as const;
|
|
19
|
+
|
|
20
|
+
describe('declaredKeys', () => {
|
|
21
|
+
test('excludes identity fields and undeclared ones, and sorts what is left', () => {
|
|
22
|
+
const props: VmProps = { ...base, cores: 2, memory: 4096 };
|
|
23
|
+
expect(declaredKeys(props)).toEqual(['cores', 'memory']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('an empty declaration manages nothing — the base for every "adopt as-is" test', () => {
|
|
27
|
+
expect(declaredKeys(base as unknown as VmProps)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('isManagedKey / indexedKey', () => {
|
|
32
|
+
test('the scalar and indexed families Talos needs are all managed', () => {
|
|
33
|
+
for (const key of [
|
|
34
|
+
'agent',
|
|
35
|
+
'boot',
|
|
36
|
+
'cpu',
|
|
37
|
+
'memory',
|
|
38
|
+
'scsihw',
|
|
39
|
+
'scsi0',
|
|
40
|
+
'net0',
|
|
41
|
+
'ide2',
|
|
42
|
+
'ipconfig0',
|
|
43
|
+
'serial0',
|
|
44
|
+
]) {
|
|
45
|
+
expect(isManagedKey(key)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('⛔ no secret prop exists: cipassword and machine are never managed keys', () => {
|
|
50
|
+
expect(isManagedKey('cipassword')).toBe(false);
|
|
51
|
+
expect(isManagedKey('machine')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('an out-of-range index is not a PVE key this resource recognises', () => {
|
|
55
|
+
expect(indexedKey('scsi31')).toBeUndefined(); // pve-qemu-server's own 0-30
|
|
56
|
+
expect(indexedKey('ide4')).toBeUndefined(); // 0-3
|
|
57
|
+
expect(indexedKey('scsi03')).toBeUndefined(); // not how PVE spells it
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('wireValue / storedConfig', () => {
|
|
62
|
+
test('booleans go as 1/0, everything else as String()', () => {
|
|
63
|
+
expect(wireValue(true)).toBe('1');
|
|
64
|
+
expect(wireValue(false)).toBe('0');
|
|
65
|
+
expect(wireValue(4096)).toBe('4096');
|
|
66
|
+
expect(wireValue('local-zfs:32')).toBe('local-zfs:32');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('storedConfig is an allowlist: an unmanaged live key never reaches state', () => {
|
|
70
|
+
const live = { cipassword: 'leaked', cores: 2, digest: 'a'.repeat(40), scsi0: 'local:32' };
|
|
71
|
+
expect(storedConfig(live)).toEqual({ cores: '2', scsi0: 'local:32' });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('formRefusals — the runtime backstop behind the never-typed props', () => {
|
|
76
|
+
test('a clean declaration is refused nothing', () => {
|
|
77
|
+
expect(formRefusals({ ...base, agent: true, net0: 'bridge=vmbr0,tag=42' })).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a key smuggled past the types is named, not silently dropped or silently sent', () => {
|
|
81
|
+
const smuggled = { ...base, cipassword: 'x', machine: 'q35' } as unknown as VmProps;
|
|
82
|
+
expect(formRefusals(smuggled)).toEqual([
|
|
83
|
+
'cipassword: not a config key this resource manages (vmid 150).',
|
|
84
|
+
'machine: not a config key this resource manages (vmid 150).',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|