Просмотр исходного кода

Bring Proxmox guests onto the MAC bridge

A guest's config already names the NIC MACs Proxmox assigned it —
net0: virtio=BC:24:11:… for VMs, hwaddr=… for containers — in the very
response the collector fetches for the OS and disks, so guests join the
collector-unification bridge with zero extra API calls: the parser
lifts every netN MAC (normalised by the shared ARP normaliser), the
guest cards carry them as a macs label, and the resolver's existing
rule does the rest. A VM found by a network sweep and the same guest
reported by `rpk discover proxmox` are now one card, in either order,
with the vmid identity winning over the scan's.

Nodes stay outside the bridge (the API exposes no host MACs we read),
and Proxmox-vs-agent-inside-the-guest remains two cards by design:
vmid and machine-id are both agent-grade identities and MACs alone
never unify those.

10 new tests: netN parsing across VM/container/dhcp configs and
multi-NIC guests, non-MAC net lines yielding nothing, the macs label
on mapped guest cards, and the scan↔proxmox unification e2e through
the real server including the rescan round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 4 часов назад
Родитель
Сommit
09226b3918

+ 51 - 3
RackPeek.Domain/Discovery/ProxmoxModels.cs

@@ -66,6 +66,12 @@ public sealed record ProxmoxGuest {
     public string? Os { get; init; }
 
     public string? Ip { get; init; }
+
+    /// <summary>
+    ///     The guest's NIC MACs, from its config's netN lines — the bridge that lets a
+    ///     network scan and this collector agree they are looking at the same guest.
+    /// </summary>
+    public IReadOnlyList<string> Macs { get; init; } = [];
 }
 
 /// <summary>
@@ -218,15 +224,56 @@ public static class ProxmoxResponseParser {
         using var document = JsonDocument.Parse(json);
 
         if (!document.RootElement.TryGetProperty("data", out JsonElement data))
-            return new ProxmoxGuestConfig(null, null, [], []);
+            return new ProxmoxGuestConfig(null, null, [], [], []);
 
         return new ProxmoxGuestConfig(
             DescribeOs(GetString(data, "ostype")),
             ParseStaticIp(GetString(data, "net0")),
             ParseDiskSizes(data),
-            ParsePassthrough(data));
+            ParsePassthrough(data),
+            ParseMacs(data));
     }
 
+    /// <summary>
+    ///     The MACs in a guest's netN lines. QEMU spells them as the NIC model's value
+    ///     (<c>virtio=BC:24:11:…</c>), containers as <c>hwaddr=BC:24:11:…</c> — so any
+    ///     part whose value normalises to a MAC counts, and nothing else can (bridge
+    ///     names, ip=, tags never survive normalisation). Normalised by the same code
+    ///     that reads ARP tables, so a scan and this collector always agree.
+    /// </summary>
+    public static List<string> ParseMacs(JsonElement config) {
+        var macs = new List<string>();
+
+        foreach (JsonProperty property in config.EnumerateObject()) {
+            if (!IsNetSlot(property.Name))
+                continue;
+
+            var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+
+            if (value == null)
+                continue;
+
+            foreach (var part in value.Split(',', StringSplitOptions.TrimEntries)) {
+                var separator = part.IndexOf('=');
+
+                if (separator <= 0)
+                    continue;
+
+                var mac = ArpTableParser.NormaliseMac(part[(separator + 1)..]);
+
+                if (mac != null)
+                    macs.Add(mac);
+            }
+        }
+
+        return macs.Distinct().ToList();
+    }
+
+    private static bool IsNetSlot(string key) =>
+        key.StartsWith("net", StringComparison.OrdinalIgnoreCase)
+        && key.Length > 3
+        && key[3..].All(char.IsAsciiDigit);
+
     /// <summary>
     ///     Every disk attached to a guest. The guest list only carries <c>maxdisk</c>,
     ///     which is the boot disk alone — a VM with a small root and a large data volume
@@ -424,4 +471,5 @@ public sealed record ProxmoxGuestConfig(
     string? Os,
     string? Ip,
     IReadOnlyList<long> DiskBytes,
-    IReadOnlyList<string> PassthroughAddresses);
+    IReadOnlyList<string> PassthroughAddresses,
+    IReadOnlyList<string>? Macs = null);

+ 8 - 1
RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs

@@ -146,6 +146,13 @@ public static class ProxmoxResourceMapper {
         // between nodes, which is exactly what an identity needs to do.
         var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}");
 
+        Dictionary<string, string> labels = PassthroughLabels(guest, gpusByNode);
+
+        // The bridge to network discovery: a scan identifies this guest by one of
+        // these, so carrying them lets the resolver land both collectors on one card.
+        if (guest.Macs.Count > 0)
+            labels["macs"] = string.Join(",", guest.Macs);
+
         return new SystemResource {
             Kind = SystemResource.KindLabel,
             Name = DiscoveryNaming.Unique(
@@ -160,7 +167,7 @@ public static class ProxmoxResourceMapper {
             Ip = guest.Ip,
             Drives = ToGuestDrives(guest),
             Tags = guest.Tags.ToArray(),
-            Labels = PassthroughLabels(guest, gpusByNode),
+            Labels = labels,
             RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : []
         };
     }

+ 2 - 1
Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs

@@ -131,7 +131,8 @@ public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSetting
                         Os = configs[i].Os,
                         Ip = configs[i].Ip,
                         Disks = configs[i].DiskBytes,
-                        PassthroughAddresses = configs[i].PassthroughAddresses
+                        PassthroughAddresses = configs[i].PassthroughAddresses,
+                        Macs = configs[i].Macs ?? []
                     });
             }
         }

+ 6 - 2
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -390,8 +390,12 @@ cards — and agent-grade identities never unify with each other on a MAC alone
 VMs can share one; that is what machine-ids are for). The machine running the sweep
 finds itself, and unifies with its own `rpk discover system` card the same way.
 
-Proxmox remains the exception: its API view carries no host MACs, so the known
-limitation above still applies between `discover proxmox` and the other collectors.
+`rpk discover proxmox` joins the bridge for **guests**: a guest's config names the
+NIC MACs Proxmox assigned it, so a VM or container found by a sweep and the same guest
+reported by the Proxmox collector become one card too. Nodes stay outside the bridge
+(the API exposes no host MACs we read), and a guest documented both by Proxmox and by
+`rpk discover system` *inside* it remains two cards — vmid and machine-id are both
+agent-grade identities, and MACs alone never unify those.
 
 ### Being a good citizen
 

+ 121 - 0
Tests.Discovery/ProxmoxMacBridgeTests.cs

@@ -0,0 +1,121 @@
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Proxmox's side of the MAC bridge: guest configs carry the NIC MACs Proxmox
+///     assigned, a network scan sees exactly those MACs on the wire, and the resolver
+///     lands both collectors on one card — the same contract the system collector has.
+/// </summary>
+public class ProxmoxMacBridgeTests {
+    // -- parsing ------------------------------------------------------------------------
+
+    [Theory]
+    [InlineData("pve-qemu-config.json", "bc:24:11:12:34:56")] // virtio=BC:24:11:…
+    [InlineData("pve-lxc-config.json", "bc:24:11:aa:bb:cc")] // hwaddr=BC:24:11:…
+    [InlineData("pve-lxc-config-dhcp.json", "bc:24:11:dd:ee:ff")] // dhcp still has a MAC
+    public void A_guests_config_yields_its_normalised_mac(string fixture, string expected) {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read(fixture));
+
+        Assert.Equal([expected], config.Macs);
+    }
+
+    [Theory]
+    [InlineData("""{"data":{}}""")]
+    [InlineData("""{"data":{"ostype":"l26","scsi0":"local-lvm:vm-1-disk-0,size=64G"}}""")]
+    [InlineData("""{"data":{"net0":"bridge=vmbr0,firewall=1"}}""")] // a net line with no MAC
+    [InlineData("""{"data":{"network":"virtio=BC:24:11:12:34:56"}}""")] // not a netN slot
+    public void A_config_without_nic_macs_yields_none(string json) {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(json);
+
+        Assert.Empty(config.Macs ?? []);
+    }
+
+    [Fact]
+    public void Every_nic_of_a_multi_homed_guest_is_recorded() {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(
+            """
+            {"data":{
+              "net0":"virtio=BC:24:11:12:34:56,bridge=vmbr0",
+              "net1":"e1000=BC:24:11:99:88:77,bridge=vmbr1,tag=50"
+            }}
+            """);
+
+        Assert.Equal(["bc:24:11:12:34:56", "bc:24:11:99:88:77"], config.Macs);
+    }
+
+    // -- mapping ------------------------------------------------------------------------
+
+    [Fact]
+    public void A_guest_card_carries_its_macs_label() {
+        List<Resource> resources = ProxmoxResourceMapper.ToResources(
+            "homelab",
+            [new ProxmoxNode { Name = "pve01" }],
+            [
+                new ProxmoxGuest {
+                    VmId = 104,
+                    Node = "pve01",
+                    Name = "docker-01",
+                    Type = "vm",
+                    Macs = ["bc:24:11:12:34:56"]
+                }
+            ]);
+
+        SystemResource guest = resources.OfType<SystemResource>().Single(r => r.Name == "docker-01");
+        Assert.Equal("bc:24:11:12:34:56", guest.Labels["macs"]);
+    }
+
+    // -- the bridge, end to end through the real server ----------------------------------
+
+    [Fact]
+    public async Task A_scanned_guest_and_its_proxmox_card_become_one() {
+        // The sweep found the VM on the LAN first — by the very MAC Proxmox assigned it.
+        var scanned = DiscoveryDocument.ToYaml(NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.168.1.178", "bc:24:11:12:34:56", null, true, [])
+        ]));
+
+        using var api = new DiscoveryApiFixture(scanned);
+
+        // Now `rpk discover proxmox` reports the estate, including that guest.
+        List<Resource> estate = ProxmoxResourceMapper.ToResources(
+            "homelab",
+            [new ProxmoxNode { Name = "pve01" }],
+            [
+                new ProxmoxGuest {
+                    VmId = 104,
+                    Node = "pve01",
+                    Name = "docker-01",
+                    Type = "vm",
+                    Cores = 4,
+                    Os = "Linux",
+                    Macs = ["bc:24:11:12:34:56"]
+                }
+            ]);
+
+        ImportYamlResponse response = await api.PublishAsync(DiscoveryDocument.ToYaml(estate));
+
+        var stored = api.StoredYaml;
+
+        // The guest landed on the scan's card: identity upgraded to the vmid-based id,
+        // the scan's address kept, no duplicate for the same machine.
+        Assert.DoesNotContain("rpk1:net:", stored);
+        Assert.Contains("rpk1:pve:", stored);
+        Assert.Contains("ip: 192.168.1.178", stored);
+        Assert.Contains("os: Linux", stored);
+        Assert.DoesNotContain(response.Added, name => name.StartsWith("docker-01"));
+        Fixture.AssertConformsToSchema(stored);
+
+        // And a rescan afterwards still lands on that same card via the MAC.
+        ImportYamlResponse rescan = await api.PublishAsync(DiscoveryDocument.ToYaml(
+            NetworkScanMapper.ToResources([
+                new NetworkHostFact("192.168.1.179", "bc:24:11:12:34:56", null, true, [])
+            ])));
+
+        Assert.Empty(rescan.Added);
+        Assert.Contains("rpk1:pve:", api.StoredYaml);
+        Assert.Contains("ip: 192.168.1.179", api.StoredYaml);
+    }
+}