| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- using System.Text.Json;
- using RackPeek.Domain.Discovery;
- using RackPeek.Domain.Resources;
- using RackPeek.Domain.Resources.Servers;
- using RackPeek.Domain.Resources.SubResources;
- using RackPeek.Domain.Resources.SystemResources;
- namespace Tests.Discovery;
- /// <summary>
- /// Captured Proxmox API responses in, RackPeek resources out. A node becomes two
- /// things — the machine and the hypervisor on it — so most of these are about the
- /// tree rather than the fields.
- /// </summary>
- public class ProxmoxDiscoveryTests {
- private const string _scope = "homelab";
- private static List<Resource> Discover() {
- ProxmoxNode node = ProxmoxResponseParser.ParseNodeStatus(Fixture.Read("pve-node-status.json"), "pve01")
- with {
- Disks = ProxmoxResponseParser.ParseDisks(Fixture.Read("pve-disks.json")),
- Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
- };
- List<ProxmoxGuest> qemu = ProxmoxResponseParser.ParseGuests(
- Fixture.Read("pve-qemu.json"), "pve01", ProxmoxResponseParser.VmType);
- List<ProxmoxGuest> lxc = ProxmoxResponseParser.ParseGuests(
- Fixture.Read("pve-lxc.json"), "pve01", ProxmoxResponseParser.ContainerType);
- // The command reads each guest's config; here the same two are applied by hand.
- ProxmoxGuestConfig vmConfig = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-qemu-config.json"));
- ProxmoxGuestConfig ctConfig = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-lxc-config.json"));
- List<ProxmoxGuest> guests = [
- ..qemu.Select(g => g with { Os = vmConfig.Os, Disks = vmConfig.DiskBytes }),
- ..lxc.Select(g => g with { Os = ctConfig.Os, Ip = ctConfig.Ip, Disks = ctConfig.DiskBytes })
- ];
- return ProxmoxResourceMapper.ToResources(_scope, [node], guests);
- }
- private static SystemResource System(string name) =>
- Discover().OfType<SystemResource>().Single(r => r.Name == name);
- // ---------------------------------------------------------------- the tree
- [Fact]
- public void A_node_becomes_both_the_machine_and_the_hypervisor_on_it() {
- List<Resource> resources = Discover();
- Server server = Assert.Single(resources.OfType<Server>());
- SystemResource hypervisor = resources.OfType<SystemResource>().Single(r => r.Type == "hypervisor");
- Assert.Equal("pve01", server.Name);
- Assert.Equal("pve01-pve", hypervisor.Name);
- // Hardware -> System, which is the relationship RackPeek is built around.
- Assert.Equal(["pve01"], hypervisor.RunsOn);
- }
- [Fact]
- public void Guests_run_on_the_hypervisor_rather_than_straight_on_the_metal() {
- var guests = Discover()
- .OfType<SystemResource>()
- .Where(r => r.Type != "hypervisor")
- .ToList();
- Assert.NotEmpty(guests);
- Assert.All(guests, g => Assert.Equal(["pve01-pve"], g.RunsOn));
- }
- // ------------------------------------------------------------- the machine
- [Fact]
- public void The_machine_carries_its_processor() {
- Server server = Discover().OfType<Server>().Single();
- Cpu cpu = Assert.Single(server.Cpus!);
- Assert.Equal("AMD Ryzen 5 5600G with Radeon Graphics", cpu.Model);
- Assert.Equal(6, cpu.Cores);
- Assert.Equal(12, cpu.Threads);
- }
- [Fact]
- public void A_two_socket_machine_reports_each_socket_separately() {
- // Proxmox reports totals across the machine, so 2 x 8-core reads as cores=16.
- ProxmoxNode node = new() {
- Name = "dual",
- CpuModel = "Intel Xeon Silver 4208",
- Sockets = 2,
- PhysicalCores = 16,
- Cores = 32
- };
- Server server = ProxmoxResourceMapper.ToResources(_scope, [node], []).OfType<Server>().Single();
- Assert.Equal(2, server.Cpus!.Count);
- Assert.All(server.Cpus, c => {
- Assert.Equal(8, c.Cores);
- Assert.Equal(16, c.Threads);
- });
- }
- [Fact]
- public void The_machine_carries_its_physical_disks_already_classified() {
- Server server = Discover().OfType<Server>().Single();
- List<Drive> drives = server.Drives!;
- Assert.Equal(["nvme", "hdd", "ssd"], drives.Select(d => d.Type));
- Assert.Equal(932, drives[0].Size);
- }
- [Fact]
- public void A_disk_proxmox_cannot_classify_is_left_untyped_rather_than_guessed() {
- List<ProxmoxDisk> disks = ProxmoxResponseParser.ParseDisks(Fixture.Read("pve-disks.json"));
- // The zero-size unknown entry is dropped entirely; a real one would keep its size.
- Assert.Equal(3, disks.Count);
- Assert.DoesNotContain(disks, d => d.Type == "unknown");
- }
- [Fact]
- public void The_machine_carries_the_gpus_bolted_into_it() {
- // Including ones passed through to a guest: the card is still in this machine.
- Server server = Discover().OfType<Server>().Single();
- Assert.Equal(
- ["GeForce RTX 3090", "GeForce RTX 3090", "Raphael"],
- server.Gpus!.Select(g => g.Model));
- }
- [Fact]
- public void Only_display_adapters_count_as_gpus() =>
- // The same PCI list carries an IOMMU and an SMBus controller.
- Assert.Equal(3, ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json")).Count);
- [Theory]
- [InlineData("GA102 [GeForce RTX 3090]", "GeForce RTX 3090")]
- [InlineData("AD102 [GeForce RTX 4090]", "GeForce RTX 4090")]
- [InlineData("Raphael", "Raphael")]
- [InlineData("", null)]
- public void A_pci_name_is_reduced_to_the_product_people_know(string input, string? expected) =>
- Assert.Equal(expected, ProxmoxResponseParser.MarketingName(input));
- [Fact]
- public void A_machine_with_no_gpu_says_nothing_rather_than_an_empty_list() {
- ProxmoxNode node = new() { Name = "headless" };
- Assert.Null(ProxmoxResourceMapper.ToResources(_scope, [node], []).OfType<Server>().Single().Gpus);
- }
- [Fact]
- public void The_machine_carries_its_memory() =>
- Assert.Equal(63, Discover().OfType<Server>().Single().Ram?.Size);
- // ---------------------------------------------------------- the hypervisor
- [Fact]
- public void The_hypervisor_reports_what_it_is_running() {
- SystemResource hypervisor = System("pve01-pve");
- Assert.Equal("Proxmox VE 8.2.2", hypervisor.Os);
- Assert.Equal(12, hypervisor.Cores);
- Assert.Equal(63, hypervisor.Ram);
- }
- // --------------------------------------------------------------- the guests
- [Fact]
- public void Qemu_guests_are_vms_and_lxc_guests_are_containers() {
- Assert.Equal("vm", System("docker-01").Type);
- Assert.Equal("container", System("pihole").Type);
- }
- [Fact]
- public void A_stopped_guest_is_still_part_of_the_inventory() =>
- // Unlike a stopped container, a stopped VM is a real system with real resources.
- Assert.Contains(Discover(), r => r.Name == "windows-vm");
- [Fact]
- public void A_guest_carries_its_allocation() {
- SystemResource vm = System("docker-01");
- Assert.Equal(4, vm.Cores);
- Assert.Equal(8, vm.Ram);
- Assert.Equal("Linux", vm.Os);
- }
- [Fact]
- public void Every_disk_on_a_guest_is_recorded_not_just_the_boot_one() {
- // maxdisk in the guest list is the boot disk alone, so a VM with a small root
- // and a large data volume would otherwise be recorded at a fraction of its size.
- List<Drive> drives = System("docker-01").Drives!;
- Assert.Equal([64, 2048], drives.Select(d => d.Size));
- }
- [Fact]
- public void Container_mount_points_count_as_disks_too() {
- List<Drive> drives = System("pihole").Drives!;
- Assert.Equal([8, 100], drives.Select(d => d.Size));
- }
- [Theory]
- [InlineData("local-lvm:vm-104-disk-1,iothread=1,size=64G", 68719476736L)]
- [InlineData("tank:vm-104-disk-0,backup=0,size=2T", 2199023255552L)]
- [InlineData("local-lvm:vm-104-disk-0,efitype=4m,size=4M", 4194304L)]
- [InlineData("local-lvm:vm-104-disk-9", 0L)]
- public void A_volume_definition_yields_its_size(string volume, long expected) =>
- Assert.Equal(expected, ProxmoxResponseParser.ParseSize(volume));
- [Fact]
- public void Firmware_scratch_and_install_media_are_not_storage() {
- // efidisk0 is a few megabytes of EFI variables and ide2 is a mounted ISO;
- // neither belongs on an inventory, and the detached unused0 has no size at all.
- List<Drive> drives = System("docker-01").Drives!;
- Assert.DoesNotContain(drives, d => d.Size < 8);
- Assert.Equal(2, drives.Count);
- }
- [Fact]
- public void A_container_with_a_static_address_keeps_it() {
- SystemResource container = System("pihole");
- Assert.Equal("192.168.1.53", container.Ip);
- Assert.Equal("Debian", container.Os);
- }
- [Fact]
- public void A_dhcp_container_reports_no_address_rather_than_a_wrong_one() {
- ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-lxc-config-dhcp.json"));
- Assert.Null(config.Ip);
- Assert.Equal("Alpine", config.Os);
- }
- [Fact]
- public void Proxmox_tags_carry_across() {
- Assert.Equal(["production", "web"], System("docker-01").Tags);
- Assert.Equal(["dns"], System("pihole").Tags);
- }
- [Fact]
- public void A_guest_name_with_spaces_becomes_a_usable_one() =>
- Assert.Contains(Discover(), r => r.Name == "paperless-stack");
- [Fact]
- public void An_unnamed_guest_is_identified_by_its_vmid() =>
- Assert.Contains(Discover(), r => r.Name == "vm-110");
- // ------------------------------------------------------------------ identity
- [Fact]
- public void Identity_survives_a_rename_and_a_migration_between_nodes() {
- ProxmoxGuest onFirstNode = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
- ProxmoxGuest afterMoving = new() { VmId = 104, Node = "pve02", Name = "renamed", Type = "vm" };
- var first = ProxmoxResourceMapper.ToResources(_scope, [], [onFirstNode]).Single().DiscoveryId;
- var second = ProxmoxResourceMapper.ToResources(_scope, [], [afterMoving]).Single().DiscoveryId;
- // The vmid is unique cluster-wide, so neither the node nor the name is part of it.
- Assert.Equal(first, second);
- }
- [Fact]
- public void The_same_vmid_in_a_different_cluster_is_a_different_machine() {
- ProxmoxGuest guest = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
- Assert.NotEqual(
- ProxmoxResourceMapper.ToResources("homelab", [], [guest]).Single().DiscoveryId,
- ProxmoxResourceMapper.ToResources("office", [], [guest]).Single().DiscoveryId);
- }
- [Fact]
- public void The_machine_and_the_hypervisor_on_it_are_separate_identities() {
- List<Resource> resources = Discover();
- Assert.NotEqual(
- resources.OfType<Server>().Single().DiscoveryId,
- resources.OfType<SystemResource>().Single(r => r.Type == "hypervisor").DiscoveryId);
- }
- [Fact]
- public void A_guest_reported_by_two_nodes_at_once_is_still_one_resource() {
- ProxmoxGuest leaving = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
- ProxmoxGuest arriving = new() { VmId = 104, Node = "pve02", Name = "docker-01", Type = "vm" };
- Assert.Single(ProxmoxResourceMapper.ToResources(_scope, [], [leaving, arriving]));
- }
- [Fact]
- public void No_two_resources_ever_share_an_identity() {
- List<Resource> resources = Discover();
- Assert.Equal(resources.Select(r => r.DiscoveryId).Distinct().Count(), resources.Count);
- }
- [Fact]
- public void A_guest_named_after_its_node_does_not_take_the_node_name() {
- ProxmoxNode node = new() { Name = "pve01", Cores = 4, MemoryBytes = 8589934592, Version = "pve-manager/8.2.2/x" };
- ProxmoxGuest guest = new() { VmId = 104, Node = "pve01", Name = "pve01", Type = "vm" };
- List<Resource> resources = ProxmoxResourceMapper.ToResources(_scope, [node], [guest]);
- Assert.Equal("pve01", resources[0].Name);
- Assert.Equal(3, resources.Count);
- Assert.Equal(3, resources.Select(r => r.Name).Distinct().Count());
- }
- // -------------------------------------------------------- restricted tokens
- [Fact]
- public void A_restricted_token_still_yields_the_node_list() {
- // What the real API returns for a token without the rights to read node detail.
- List<ProxmoxNode> nodes = ProxmoxResponseParser.ParseNodes(Fixture.Read("pve-nodes.json"));
- ProxmoxNode node = Assert.Single(nodes);
- Assert.Equal("kepler", node.Name);
- Assert.Equal(0, node.Cores);
- }
- [Fact]
- public void A_permitted_token_gets_the_node_sizing_from_the_same_call() {
- List<ProxmoxNode> nodes = ProxmoxResponseParser.ParseNodes(Fixture.Read("pve-nodes-full.json"));
- Assert.Equal(["pve01", "pve02"], nodes.Select(n => n.Name));
- Assert.Equal(12, nodes[0].Cores);
- Assert.Equal(67438305280, nodes[0].MemoryBytes);
- }
- [Fact]
- public void A_node_with_no_readable_detail_still_gives_a_usable_tree() {
- ProxmoxNode bare = new() { Name = "kepler" };
- ProxmoxGuest guest = new() { VmId = 104, Node = "kepler", Name = "docker-01", Type = "vm", Os = "Linux" };
- List<Resource> resources = ProxmoxResourceMapper.ToResources(_scope, [bare], [guest]);
- Server server = resources.OfType<Server>().Single();
- Assert.Equal("kepler", server.Name);
- Assert.Null(server.Cpus);
- Assert.Null(server.Drives);
- // The guest still hangs off the hypervisor, which is what makes the run worth it.
- Assert.Equal(["kepler-pve"], resources.OfType<SystemResource>().Single(r => r.Type == "vm").RunsOn);
- }
- // --------------------------------------------------------------- conformance
- [Fact]
- public void Types_are_ones_the_schema_accepts() =>
- Assert.All(
- Discover().OfType<SystemResource>(),
- r => Assert.Contains(r.Type, SystemResource.ValidSystemTypes));
- [Fact]
- public void A_cluster_names_the_scope_and_a_standalone_host_falls_back_to_its_node() {
- Assert.Equal("homelab",
- ProxmoxResponseParser.ParseIdentityScope(Fixture.Read("pve-cluster-status.json"), "pve01"));
- Assert.Equal("pve01",
- ProxmoxResponseParser.ParseIdentityScope(
- Fixture.Read("pve-cluster-status-standalone.json"), "pve01"));
- }
- [Fact]
- public void Output_conforms_to_the_published_schema() =>
- Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(Discover()));
- // ------------------------------------------------- passthrough assignment
- private static List<Resource> DiscoverWithPassthrough() {
- ProxmoxNode node = new() {
- Name = "kepler",
- Cores = 32,
- MemoryBytes = 100_000_000_000,
- Version = "pve-manager/9.2.2/x",
- Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
- };
- ProxmoxGuestConfig config =
- ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-qemu-config-passthrough.json"));
- ProxmoxGuest guest = new() {
- VmId = 100,
- Node = "kepler",
- Name = "ai",
- Type = "vm",
- Cores = 8,
- MemoryBytes = 34_359_738_368,
- Os = config.Os,
- Disks = config.DiskBytes,
- PassthroughAddresses = config.PassthroughAddresses
- };
- return ProxmoxResourceMapper.ToResources(_scope, [node], [guest]);
- }
- [Fact]
- public void A_guest_records_the_cards_it_has_been_given() {
- SystemResource guest = DiscoverWithPassthrough().OfType<SystemResource>().Single(r => r.Type == "vm");
- Assert.Equal(
- "GeForce RTX 3090, GeForce RTX 3090",
- guest.Labels[ProxmoxResourceMapper.GpuLabel]);
- }
- [Fact]
- public void The_card_itself_still_belongs_to_the_machine_it_is_installed_in() {
- List<Resource> resources = DiscoverWithPassthrough();
- // The guest records the assignment; the Server records the hardware.
- Server server = resources.OfType<Server>().Single();
- Assert.Equal(3, server.Gpus!.Count);
- Assert.DoesNotContain(server.Labels, l => l.Key == ProxmoxResourceMapper.GpuLabel);
- }
- [Fact]
- public void A_config_address_without_a_function_suffix_still_matches_the_device() {
- // The config writes 0000:01:00; the PCI list reports 0000:01:00.0.
- IReadOnlyList<string> addresses = ProxmoxResponseParser.ParseGuestConfig(
- Fixture.Read("pve-qemu-config-passthrough.json")).PassthroughAddresses;
- Assert.Equal(["0000:01:00", "0000:02:00"], addresses);
- }
- [Fact]
- public void Options_after_the_address_are_not_part_of_it() {
- using var document = JsonDocument.Parse(
- """{"hostpci0":"0000:01:00,pcie=1,x-vga=1"}""");
- Assert.Equal(["0000:01:00"], ProxmoxResponseParser.ParsePassthrough(document.RootElement));
- }
- [Fact]
- public void A_guest_holding_nothing_carries_no_label() =>
- Assert.All(Discover().OfType<SystemResource>(), g => Assert.Empty(g.Labels));
- [Fact]
- public void Passthrough_of_something_that_is_not_a_display_adapter_is_ignored() {
- ProxmoxNode node = new() {
- Name = "kepler",
- Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
- };
- // 0000:00:14.0 is the SMBus controller in the same fixture.
- ProxmoxGuest guest = new() {
- VmId = 100,
- Node = "kepler",
- Name = "hba",
- Type = "vm",
- PassthroughAddresses = ["0000:00:14.0"]
- };
- SystemResource mapped = ProxmoxResourceMapper.ToResources(_scope, [node], [guest])
- .OfType<SystemResource>().Single(r => r.Type == "vm");
- Assert.Empty(mapped.Labels);
- }
- [Fact]
- public void The_same_address_on_a_different_node_is_a_different_card() {
- ProxmoxNode withCards = new() {
- Name = "kepler",
- Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
- };
- ProxmoxNode headless = new() { Name = "nebula" };
- // Every machine has a 0000:01:00; a guest on the headless node holds nothing.
- ProxmoxGuest guest = new() {
- VmId = 100,
- Node = "nebula",
- Name = "elsewhere",
- Type = "vm",
- PassthroughAddresses = ["0000:01:00"]
- };
- SystemResource mapped = ProxmoxResourceMapper
- .ToResources(_scope, [withCards, headless], [guest])
- .OfType<SystemResource>().Single(r => r.Type == "vm");
- Assert.Empty(mapped.Labels);
- }
- [Fact]
- public void A_guest_holding_more_cards_than_a_label_can_hold_is_truncated() {
- var many = Enumerable.Range(0, 20)
- .Select(i => new ProxmoxGpu($"0000:{i:00}:00.0", "NVIDIA RTX A6000 Ada Generation"))
- .ToList();
- ProxmoxNode node = new() { Name = "dense", Gpus = many };
- ProxmoxGuest guest = new() {
- VmId = 100,
- Node = "dense",
- Name = "trainer",
- Type = "vm",
- PassthroughAddresses = many.Select(g => g.Address).ToList()
- };
- var label = ProxmoxResourceMapper.ToResources(_scope, [node], [guest])
- .OfType<SystemResource>().Single(r => r.Type == "vm")
- .Labels[ProxmoxResourceMapper.GpuLabel];
- // RackPeek caps a label value at 200 characters.
- Assert.True(label.Length <= 200, $"len={label.Length}");
- Assert.False(label.EndsWith(','));
- }
- [Fact]
- public void Output_with_passthrough_conforms_to_the_published_schema() =>
- Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(DiscoverWithPassthrough()));
- }
|