ProxmoxDiscoveryTests.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. using System.Text.Json;
  2. using RackPeek.Domain.Discovery;
  3. using RackPeek.Domain.Resources;
  4. using RackPeek.Domain.Resources.Servers;
  5. using RackPeek.Domain.Resources.SubResources;
  6. using RackPeek.Domain.Resources.SystemResources;
  7. namespace Tests.Discovery;
  8. /// <summary>
  9. /// Captured Proxmox API responses in, RackPeek resources out. A node becomes two
  10. /// things — the machine and the hypervisor on it — so most of these are about the
  11. /// tree rather than the fields.
  12. /// </summary>
  13. public class ProxmoxDiscoveryTests {
  14. private const string _scope = "homelab";
  15. private static List<Resource> Discover() {
  16. ProxmoxNode node = ProxmoxResponseParser.ParseNodeStatus(Fixture.Read("pve-node-status.json"), "pve01")
  17. with {
  18. Disks = ProxmoxResponseParser.ParseDisks(Fixture.Read("pve-disks.json")),
  19. Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
  20. };
  21. List<ProxmoxGuest> qemu = ProxmoxResponseParser.ParseGuests(
  22. Fixture.Read("pve-qemu.json"), "pve01", ProxmoxResponseParser.VmType);
  23. List<ProxmoxGuest> lxc = ProxmoxResponseParser.ParseGuests(
  24. Fixture.Read("pve-lxc.json"), "pve01", ProxmoxResponseParser.ContainerType);
  25. // The command reads each guest's config; here the same two are applied by hand.
  26. ProxmoxGuestConfig vmConfig = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-qemu-config.json"));
  27. ProxmoxGuestConfig ctConfig = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-lxc-config.json"));
  28. List<ProxmoxGuest> guests = [
  29. ..qemu.Select(g => g with { Os = vmConfig.Os, Disks = vmConfig.DiskBytes }),
  30. ..lxc.Select(g => g with { Os = ctConfig.Os, Ip = ctConfig.Ip, Disks = ctConfig.DiskBytes })
  31. ];
  32. return ProxmoxResourceMapper.ToResources(_scope, [node], guests);
  33. }
  34. private static SystemResource System(string name) =>
  35. Discover().OfType<SystemResource>().Single(r => r.Name == name);
  36. // ---------------------------------------------------------------- the tree
  37. [Fact]
  38. public void A_node_becomes_both_the_machine_and_the_hypervisor_on_it() {
  39. List<Resource> resources = Discover();
  40. Server server = Assert.Single(resources.OfType<Server>());
  41. SystemResource hypervisor = resources.OfType<SystemResource>().Single(r => r.Type == "hypervisor");
  42. Assert.Equal("pve01", server.Name);
  43. Assert.Equal("pve01-pve", hypervisor.Name);
  44. // Hardware -> System, which is the relationship RackPeek is built around.
  45. Assert.Equal(["pve01"], hypervisor.RunsOn);
  46. }
  47. [Fact]
  48. public void Guests_run_on_the_hypervisor_rather_than_straight_on_the_metal() {
  49. var guests = Discover()
  50. .OfType<SystemResource>()
  51. .Where(r => r.Type != "hypervisor")
  52. .ToList();
  53. Assert.NotEmpty(guests);
  54. Assert.All(guests, g => Assert.Equal(["pve01-pve"], g.RunsOn));
  55. }
  56. // ------------------------------------------------------------- the machine
  57. [Fact]
  58. public void The_machine_carries_its_processor() {
  59. Server server = Discover().OfType<Server>().Single();
  60. Cpu cpu = Assert.Single(server.Cpus!);
  61. Assert.Equal("AMD Ryzen 5 5600G with Radeon Graphics", cpu.Model);
  62. Assert.Equal(6, cpu.Cores);
  63. Assert.Equal(12, cpu.Threads);
  64. }
  65. [Fact]
  66. public void A_two_socket_machine_reports_each_socket_separately() {
  67. // Proxmox reports totals across the machine, so 2 x 8-core reads as cores=16.
  68. ProxmoxNode node = new() {
  69. Name = "dual",
  70. CpuModel = "Intel Xeon Silver 4208",
  71. Sockets = 2,
  72. PhysicalCores = 16,
  73. Cores = 32
  74. };
  75. Server server = ProxmoxResourceMapper.ToResources(_scope, [node], []).OfType<Server>().Single();
  76. Assert.Equal(2, server.Cpus!.Count);
  77. Assert.All(server.Cpus, c => {
  78. Assert.Equal(8, c.Cores);
  79. Assert.Equal(16, c.Threads);
  80. });
  81. }
  82. [Fact]
  83. public void The_machine_carries_its_physical_disks_already_classified() {
  84. Server server = Discover().OfType<Server>().Single();
  85. List<Drive> drives = server.Drives!;
  86. Assert.Equal(["nvme", "hdd", "ssd"], drives.Select(d => d.Type));
  87. Assert.Equal(932, drives[0].Size);
  88. }
  89. [Fact]
  90. public void A_disk_proxmox_cannot_classify_is_left_untyped_rather_than_guessed() {
  91. List<ProxmoxDisk> disks = ProxmoxResponseParser.ParseDisks(Fixture.Read("pve-disks.json"));
  92. // The zero-size unknown entry is dropped entirely; a real one would keep its size.
  93. Assert.Equal(3, disks.Count);
  94. Assert.DoesNotContain(disks, d => d.Type == "unknown");
  95. }
  96. [Fact]
  97. public void The_machine_carries_the_gpus_bolted_into_it() {
  98. // Including ones passed through to a guest: the card is still in this machine.
  99. Server server = Discover().OfType<Server>().Single();
  100. Assert.Equal(
  101. ["GeForce RTX 3090", "GeForce RTX 3090", "Raphael"],
  102. server.Gpus!.Select(g => g.Model));
  103. }
  104. [Fact]
  105. public void Only_display_adapters_count_as_gpus() =>
  106. // The same PCI list carries an IOMMU and an SMBus controller.
  107. Assert.Equal(3, ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json")).Count);
  108. [Theory]
  109. [InlineData("GA102 [GeForce RTX 3090]", "GeForce RTX 3090")]
  110. [InlineData("AD102 [GeForce RTX 4090]", "GeForce RTX 4090")]
  111. [InlineData("Raphael", "Raphael")]
  112. [InlineData("", null)]
  113. public void A_pci_name_is_reduced_to_the_product_people_know(string input, string? expected) =>
  114. Assert.Equal(expected, ProxmoxResponseParser.MarketingName(input));
  115. [Fact]
  116. public void A_machine_with_no_gpu_says_nothing_rather_than_an_empty_list() {
  117. ProxmoxNode node = new() { Name = "headless" };
  118. Assert.Null(ProxmoxResourceMapper.ToResources(_scope, [node], []).OfType<Server>().Single().Gpus);
  119. }
  120. [Fact]
  121. public void The_machine_carries_its_memory() =>
  122. Assert.Equal(63, Discover().OfType<Server>().Single().Ram?.Size);
  123. // ---------------------------------------------------------- the hypervisor
  124. [Fact]
  125. public void The_hypervisor_reports_what_it_is_running() {
  126. SystemResource hypervisor = System("pve01-pve");
  127. Assert.Equal("Proxmox VE 8.2.2", hypervisor.Os);
  128. Assert.Equal(12, hypervisor.Cores);
  129. Assert.Equal(63, hypervisor.Ram);
  130. }
  131. // --------------------------------------------------------------- the guests
  132. [Fact]
  133. public void Qemu_guests_are_vms_and_lxc_guests_are_containers() {
  134. Assert.Equal("vm", System("docker-01").Type);
  135. Assert.Equal("container", System("pihole").Type);
  136. }
  137. [Fact]
  138. public void A_stopped_guest_is_still_part_of_the_inventory() =>
  139. // Unlike a stopped container, a stopped VM is a real system with real resources.
  140. Assert.Contains(Discover(), r => r.Name == "windows-vm");
  141. [Fact]
  142. public void A_guest_carries_its_allocation() {
  143. SystemResource vm = System("docker-01");
  144. Assert.Equal(4, vm.Cores);
  145. Assert.Equal(8, vm.Ram);
  146. Assert.Equal("Linux", vm.Os);
  147. }
  148. [Fact]
  149. public void Every_disk_on_a_guest_is_recorded_not_just_the_boot_one() {
  150. // maxdisk in the guest list is the boot disk alone, so a VM with a small root
  151. // and a large data volume would otherwise be recorded at a fraction of its size.
  152. List<Drive> drives = System("docker-01").Drives!;
  153. Assert.Equal([64, 2048], drives.Select(d => d.Size));
  154. }
  155. [Fact]
  156. public void Container_mount_points_count_as_disks_too() {
  157. List<Drive> drives = System("pihole").Drives!;
  158. Assert.Equal([8, 100], drives.Select(d => d.Size));
  159. }
  160. [Theory]
  161. [InlineData("local-lvm:vm-104-disk-1,iothread=1,size=64G", 68719476736L)]
  162. [InlineData("tank:vm-104-disk-0,backup=0,size=2T", 2199023255552L)]
  163. [InlineData("local-lvm:vm-104-disk-0,efitype=4m,size=4M", 4194304L)]
  164. [InlineData("local-lvm:vm-104-disk-9", 0L)]
  165. public void A_volume_definition_yields_its_size(string volume, long expected) =>
  166. Assert.Equal(expected, ProxmoxResponseParser.ParseSize(volume));
  167. [Fact]
  168. public void Firmware_scratch_and_install_media_are_not_storage() {
  169. // efidisk0 is a few megabytes of EFI variables and ide2 is a mounted ISO;
  170. // neither belongs on an inventory, and the detached unused0 has no size at all.
  171. List<Drive> drives = System("docker-01").Drives!;
  172. Assert.DoesNotContain(drives, d => d.Size < 8);
  173. Assert.Equal(2, drives.Count);
  174. }
  175. [Fact]
  176. public void A_container_with_a_static_address_keeps_it() {
  177. SystemResource container = System("pihole");
  178. Assert.Equal("192.168.1.53", container.Ip);
  179. Assert.Equal("Debian", container.Os);
  180. }
  181. [Fact]
  182. public void A_dhcp_container_reports_no_address_rather_than_a_wrong_one() {
  183. ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-lxc-config-dhcp.json"));
  184. Assert.Null(config.Ip);
  185. Assert.Equal("Alpine", config.Os);
  186. }
  187. [Fact]
  188. public void Proxmox_tags_carry_across() {
  189. Assert.Equal(["production", "web"], System("docker-01").Tags);
  190. Assert.Equal(["dns"], System("pihole").Tags);
  191. }
  192. [Fact]
  193. public void A_guest_name_with_spaces_becomes_a_usable_one() =>
  194. Assert.Contains(Discover(), r => r.Name == "paperless-stack");
  195. [Fact]
  196. public void An_unnamed_guest_is_identified_by_its_vmid() =>
  197. Assert.Contains(Discover(), r => r.Name == "vm-110");
  198. // ------------------------------------------------------------------ identity
  199. [Fact]
  200. public void Identity_survives_a_rename_and_a_migration_between_nodes() {
  201. ProxmoxGuest onFirstNode = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
  202. ProxmoxGuest afterMoving = new() { VmId = 104, Node = "pve02", Name = "renamed", Type = "vm" };
  203. var first = ProxmoxResourceMapper.ToResources(_scope, [], [onFirstNode]).Single().DiscoveryId;
  204. var second = ProxmoxResourceMapper.ToResources(_scope, [], [afterMoving]).Single().DiscoveryId;
  205. // The vmid is unique cluster-wide, so neither the node nor the name is part of it.
  206. Assert.Equal(first, second);
  207. }
  208. [Fact]
  209. public void The_same_vmid_in_a_different_cluster_is_a_different_machine() {
  210. ProxmoxGuest guest = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
  211. Assert.NotEqual(
  212. ProxmoxResourceMapper.ToResources("homelab", [], [guest]).Single().DiscoveryId,
  213. ProxmoxResourceMapper.ToResources("office", [], [guest]).Single().DiscoveryId);
  214. }
  215. [Fact]
  216. public void The_machine_and_the_hypervisor_on_it_are_separate_identities() {
  217. List<Resource> resources = Discover();
  218. Assert.NotEqual(
  219. resources.OfType<Server>().Single().DiscoveryId,
  220. resources.OfType<SystemResource>().Single(r => r.Type == "hypervisor").DiscoveryId);
  221. }
  222. [Fact]
  223. public void A_guest_reported_by_two_nodes_at_once_is_still_one_resource() {
  224. ProxmoxGuest leaving = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
  225. ProxmoxGuest arriving = new() { VmId = 104, Node = "pve02", Name = "docker-01", Type = "vm" };
  226. Assert.Single(ProxmoxResourceMapper.ToResources(_scope, [], [leaving, arriving]));
  227. }
  228. [Fact]
  229. public void No_two_resources_ever_share_an_identity() {
  230. List<Resource> resources = Discover();
  231. Assert.Equal(resources.Select(r => r.DiscoveryId).Distinct().Count(), resources.Count);
  232. }
  233. [Fact]
  234. public void A_guest_named_after_its_node_does_not_take_the_node_name() {
  235. ProxmoxNode node = new() { Name = "pve01", Cores = 4, MemoryBytes = 8589934592, Version = "pve-manager/8.2.2/x" };
  236. ProxmoxGuest guest = new() { VmId = 104, Node = "pve01", Name = "pve01", Type = "vm" };
  237. List<Resource> resources = ProxmoxResourceMapper.ToResources(_scope, [node], [guest]);
  238. Assert.Equal("pve01", resources[0].Name);
  239. Assert.Equal(3, resources.Count);
  240. Assert.Equal(3, resources.Select(r => r.Name).Distinct().Count());
  241. }
  242. // -------------------------------------------------------- restricted tokens
  243. [Fact]
  244. public void A_restricted_token_still_yields_the_node_list() {
  245. // What the real API returns for a token without the rights to read node detail.
  246. List<ProxmoxNode> nodes = ProxmoxResponseParser.ParseNodes(Fixture.Read("pve-nodes.json"));
  247. ProxmoxNode node = Assert.Single(nodes);
  248. Assert.Equal("kepler", node.Name);
  249. Assert.Equal(0, node.Cores);
  250. }
  251. [Fact]
  252. public void A_permitted_token_gets_the_node_sizing_from_the_same_call() {
  253. List<ProxmoxNode> nodes = ProxmoxResponseParser.ParseNodes(Fixture.Read("pve-nodes-full.json"));
  254. Assert.Equal(["pve01", "pve02"], nodes.Select(n => n.Name));
  255. Assert.Equal(12, nodes[0].Cores);
  256. Assert.Equal(67438305280, nodes[0].MemoryBytes);
  257. }
  258. [Fact]
  259. public void A_node_with_no_readable_detail_still_gives_a_usable_tree() {
  260. ProxmoxNode bare = new() { Name = "kepler" };
  261. ProxmoxGuest guest = new() { VmId = 104, Node = "kepler", Name = "docker-01", Type = "vm", Os = "Linux" };
  262. List<Resource> resources = ProxmoxResourceMapper.ToResources(_scope, [bare], [guest]);
  263. Server server = resources.OfType<Server>().Single();
  264. Assert.Equal("kepler", server.Name);
  265. Assert.Null(server.Cpus);
  266. Assert.Null(server.Drives);
  267. // The guest still hangs off the hypervisor, which is what makes the run worth it.
  268. Assert.Equal(["kepler-pve"], resources.OfType<SystemResource>().Single(r => r.Type == "vm").RunsOn);
  269. }
  270. // --------------------------------------------------------------- conformance
  271. [Fact]
  272. public void Types_are_ones_the_schema_accepts() =>
  273. Assert.All(
  274. Discover().OfType<SystemResource>(),
  275. r => Assert.Contains(r.Type, SystemResource.ValidSystemTypes));
  276. [Fact]
  277. public void A_cluster_names_the_scope_and_a_standalone_host_falls_back_to_its_node() {
  278. Assert.Equal("homelab",
  279. ProxmoxResponseParser.ParseIdentityScope(Fixture.Read("pve-cluster-status.json"), "pve01"));
  280. Assert.Equal("pve01",
  281. ProxmoxResponseParser.ParseIdentityScope(
  282. Fixture.Read("pve-cluster-status-standalone.json"), "pve01"));
  283. }
  284. [Fact]
  285. public void Output_conforms_to_the_published_schema() =>
  286. Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(Discover()));
  287. // ------------------------------------------------- passthrough assignment
  288. private static List<Resource> DiscoverWithPassthrough() {
  289. ProxmoxNode node = new() {
  290. Name = "kepler",
  291. Cores = 32,
  292. MemoryBytes = 100_000_000_000,
  293. Version = "pve-manager/9.2.2/x",
  294. Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
  295. };
  296. ProxmoxGuestConfig config =
  297. ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-qemu-config-passthrough.json"));
  298. ProxmoxGuest guest = new() {
  299. VmId = 100,
  300. Node = "kepler",
  301. Name = "ai",
  302. Type = "vm",
  303. Cores = 8,
  304. MemoryBytes = 34_359_738_368,
  305. Os = config.Os,
  306. Disks = config.DiskBytes,
  307. PassthroughAddresses = config.PassthroughAddresses
  308. };
  309. return ProxmoxResourceMapper.ToResources(_scope, [node], [guest]);
  310. }
  311. [Fact]
  312. public void A_guest_records_the_cards_it_has_been_given() {
  313. SystemResource guest = DiscoverWithPassthrough().OfType<SystemResource>().Single(r => r.Type == "vm");
  314. Assert.Equal(
  315. "GeForce RTX 3090, GeForce RTX 3090",
  316. guest.Labels[ProxmoxResourceMapper.GpuLabel]);
  317. }
  318. [Fact]
  319. public void The_card_itself_still_belongs_to_the_machine_it_is_installed_in() {
  320. List<Resource> resources = DiscoverWithPassthrough();
  321. // The guest records the assignment; the Server records the hardware.
  322. Server server = resources.OfType<Server>().Single();
  323. Assert.Equal(3, server.Gpus!.Count);
  324. Assert.DoesNotContain(server.Labels, l => l.Key == ProxmoxResourceMapper.GpuLabel);
  325. }
  326. [Fact]
  327. public void A_config_address_without_a_function_suffix_still_matches_the_device() {
  328. // The config writes 0000:01:00; the PCI list reports 0000:01:00.0.
  329. IReadOnlyList<string> addresses = ProxmoxResponseParser.ParseGuestConfig(
  330. Fixture.Read("pve-qemu-config-passthrough.json")).PassthroughAddresses;
  331. Assert.Equal(["0000:01:00", "0000:02:00"], addresses);
  332. }
  333. [Fact]
  334. public void Options_after_the_address_are_not_part_of_it() {
  335. using var document = JsonDocument.Parse(
  336. """{"hostpci0":"0000:01:00,pcie=1,x-vga=1"}""");
  337. Assert.Equal(["0000:01:00"], ProxmoxResponseParser.ParsePassthrough(document.RootElement));
  338. }
  339. [Fact]
  340. public void A_guest_holding_nothing_carries_no_label() =>
  341. Assert.All(Discover().OfType<SystemResource>(), g => Assert.Empty(g.Labels));
  342. [Fact]
  343. public void Passthrough_of_something_that_is_not_a_display_adapter_is_ignored() {
  344. ProxmoxNode node = new() {
  345. Name = "kepler",
  346. Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
  347. };
  348. // 0000:00:14.0 is the SMBus controller in the same fixture.
  349. ProxmoxGuest guest = new() {
  350. VmId = 100,
  351. Node = "kepler",
  352. Name = "hba",
  353. Type = "vm",
  354. PassthroughAddresses = ["0000:00:14.0"]
  355. };
  356. SystemResource mapped = ProxmoxResourceMapper.ToResources(_scope, [node], [guest])
  357. .OfType<SystemResource>().Single(r => r.Type == "vm");
  358. Assert.Empty(mapped.Labels);
  359. }
  360. [Fact]
  361. public void The_same_address_on_a_different_node_is_a_different_card() {
  362. ProxmoxNode withCards = new() {
  363. Name = "kepler",
  364. Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
  365. };
  366. ProxmoxNode headless = new() { Name = "nebula" };
  367. // Every machine has a 0000:01:00; a guest on the headless node holds nothing.
  368. ProxmoxGuest guest = new() {
  369. VmId = 100,
  370. Node = "nebula",
  371. Name = "elsewhere",
  372. Type = "vm",
  373. PassthroughAddresses = ["0000:01:00"]
  374. };
  375. SystemResource mapped = ProxmoxResourceMapper
  376. .ToResources(_scope, [withCards, headless], [guest])
  377. .OfType<SystemResource>().Single(r => r.Type == "vm");
  378. Assert.Empty(mapped.Labels);
  379. }
  380. [Fact]
  381. public void A_guest_holding_more_cards_than_a_label_can_hold_is_truncated() {
  382. var many = Enumerable.Range(0, 20)
  383. .Select(i => new ProxmoxGpu($"0000:{i:00}:00.0", "NVIDIA RTX A6000 Ada Generation"))
  384. .ToList();
  385. ProxmoxNode node = new() { Name = "dense", Gpus = many };
  386. ProxmoxGuest guest = new() {
  387. VmId = 100,
  388. Node = "dense",
  389. Name = "trainer",
  390. Type = "vm",
  391. PassthroughAddresses = many.Select(g => g.Address).ToList()
  392. };
  393. var label = ProxmoxResourceMapper.ToResources(_scope, [node], [guest])
  394. .OfType<SystemResource>().Single(r => r.Type == "vm")
  395. .Labels[ProxmoxResourceMapper.GpuLabel];
  396. // RackPeek caps a label value at 200 characters.
  397. Assert.True(label.Length <= 200, $"len={label.Length}");
  398. Assert.False(label.EndsWith(','));
  399. }
  400. [Fact]
  401. public void Output_with_passthrough_conforms_to_the_published_schema() =>
  402. Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(DiscoverWithPassthrough()));
  403. }