From c854f1ca928019ee22d33f6a54d4fd45cce2f86e Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 13 Jun 2026 00:56:39 +1200 Subject: [PATCH] feat: support PCI resource-mapping passthrough (hostpci mapping=) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox only lets the real root@pam user set a raw hostpciN ("only root can set 'hostpciN' config for non-mapped devices") — API tokens are refused. A *mapped* device is settable by any token with Mapping.Use, and is node-portable. QemuHostPci now takes either Mapping (preferred, token-settable) or Host (raw, root-only); the encoder emits `mapping=` or the raw address accordingly. Verified live: created an AMD_Radeon_RX6600 mapping and attached it to a VM via token — `hostpci0=mapping=AMD_Radeon_RX6600,pcie=1,x-vga=1` — where the raw form returned HTTP 500. Part of Homelab #115 (Phase C). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ProxmoxSharp/Vm/QemuParamEncoder.cs | 14 +++++++++++--- src/ProxmoxSharp/Vm/QemuVmSpec.cs | 14 ++++++++++++-- .../ProxmoxSharp.Tests/QemuParamEncoderTests.cs | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/ProxmoxSharp/Vm/QemuParamEncoder.cs b/src/ProxmoxSharp/Vm/QemuParamEncoder.cs index c097cbe..8d173c5 100644 --- a/src/ProxmoxSharp/Vm/QemuParamEncoder.cs +++ b/src/ProxmoxSharp/Vm/QemuParamEncoder.cs @@ -125,12 +125,20 @@ public static string EncodeNet(QemuNet n) return string.Join(",", opts); } - /// hostpci0 value: "<host>[,pcie=1][,x-vga=1][,rombar=0][,romfile=…][,mdev=…]". + /// + /// hostpci0 value: "<mapping=NAME|host>[,pcie=1][,x-vga=1][,rombar=0][,romfile=…][,mdev=…]". + /// Prefers the resource-mapping form ("mapping=NAME") — the only form a token can set. + /// public static string EncodeHostPci(QemuHostPci h) { ArgumentNullException.ThrowIfNull(h); - if (string.IsNullOrEmpty(h.Host)) throw new ArgumentException($"hostpci '{h.Id}' needs a Host PCI address."); - var opts = new List { h.Host }; + var head = (h.Mapping, h.Host) switch + { + ({ } m, _) when !string.IsNullOrEmpty(m) => $"mapping={m}", + (_, { } host) when !string.IsNullOrEmpty(host) => host, + _ => throw new ArgumentException($"hostpci '{h.Id}' needs a Mapping name or a raw Host PCI address."), + }; + var opts = new List { head }; if (h.Pcie is true) opts.Add("pcie=1"); if (h.XVga is true) opts.Add("x-vga=1"); if (h.Rombar is false) opts.Add("rombar=0"); // only emit when explicitly disabled (default is on) diff --git a/src/ProxmoxSharp/Vm/QemuVmSpec.cs b/src/ProxmoxSharp/Vm/QemuVmSpec.cs index cea1708..2d43a89 100644 --- a/src/ProxmoxSharp/Vm/QemuVmSpec.cs +++ b/src/ProxmoxSharp/Vm/QemuVmSpec.cs @@ -91,11 +91,21 @@ public sealed record QemuNet public bool? Firewall { get; init; } } -/// A PCI(e) passthrough device (hostpciN) — the gaming GPU. e.g. "hostpci0". +/// +/// A PCI(e) passthrough device (hostpciN) — the gaming GPU. e.g. "hostpci0". +/// +/// Provide EITHER (a Proxmox PCI resource-mapping name) or +/// (a raw PCI address). Prefer : Proxmox only lets +/// the real root@pam user set a raw hostpciN ("only root can set 'hostpciN' +/// config for non-mapped devices"), whereas a mapped device is settable by any token with +/// Mapping.Use — and it's node-portable. +/// +/// public sealed record QemuHostPci { public required string Id { get; init; } - public required string Host { get; init; } // PCI address, e.g. "0000:09:00" + public string? Mapping { get; init; } // PCI resource-mapping name (token-settable; preferred) + public string? Host { get; init; } // raw PCI address, e.g. "0000:09:00" (root@pam only) public bool? Pcie { get; init; } public bool? XVga { get; init; } public bool? Rombar { get; init; } diff --git a/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs b/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs index 55ca240..3a3ec13 100644 --- a/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs +++ b/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs @@ -17,6 +17,23 @@ public void HostPci_encodes_the_passthrough_line_like_the_working_win_vm() Assert.Equal("0000:09:00,pcie=1,x-vga=1", v); } + [Fact] + public void HostPci_encodes_the_resource_mapping_form() + { + // The token-settable form: hostpci0: mapping=,pcie=1,x-vga=1 + var v = QemuParamEncoder.EncodeHostPci(new QemuHostPci + { + Id = "hostpci0", Mapping = "AMD_Radeon_RX6600", Pcie = true, XVga = true, + }); + Assert.Equal("mapping=AMD_Radeon_RX6600,pcie=1,x-vga=1", v); + } + + [Fact] + public void HostPci_without_mapping_or_host_throws() + { + Assert.Throws(() => QemuParamEncoder.EncodeHostPci(new QemuHostPci { Id = "hostpci0" })); + } + [Fact] public void HostPci_omits_rombar_unless_explicitly_disabled() {