ProxmoxGuestAddressTests.cs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. using RackPeek.Domain.Discovery;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.SystemResources;
  4. namespace Tests.Discovery;
  5. /// <summary>
  6. /// Learning where a guest actually is. Proxmox only records an address in a guest's
  7. /// config when someone set one statically, so on a DHCP estate every guest arrived
  8. /// without one — and an address is what lets a guest line up with the host a network
  9. /// sweep found. The guest itself knows, and will say so through its agent.
  10. /// <para>
  11. /// The hard part is that the agent reports every interface inside the machine,
  12. /// including the bridges Docker and Home Assistant create. Picking the wrong one
  13. /// would record 172.17.0.1 as the machine's address, which every container host
  14. /// on the estate would also report.
  15. /// </para>
  16. /// </summary>
  17. public class ProxmoxGuestAddressTests {
  18. private const string _nicMac = "bc:24:11:00:1a:01";
  19. private static List<ProxmoxGuestAddress> AgentAddresses() =>
  20. ProxmoxResponseParser.ParseAgentInterfaces(Fixture.Read("pve-agent-interfaces.json"));
  21. [Fact]
  22. public void An_agent_reports_every_routable_ipv4_it_can_see() {
  23. List<ProxmoxGuestAddress> addresses = AgentAddresses();
  24. // Loopback and the IPv6 entries are dropped; the NIC and both docker bridges stay,
  25. // because deciding between them is the caller's job, not the parser's.
  26. Assert.Equal(["192.0.2.105", "172.17.0.1", "172.18.0.1"], addresses.Select(a => a.Ip));
  27. }
  28. [Fact]
  29. public void The_guests_own_nic_wins_over_the_bridges_it_created() =>
  30. Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [_nicMac]));
  31. [Fact]
  32. // Proxmox configs upper-case them; agents vary. Both go through the same normaliser
  33. // the ARP reader uses, so a scan and this collector always agree.
  34. public void The_mac_is_matched_however_either_side_spells_it() =>
  35. Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["BC-24-11-00-1A-01"]));
  36. [Fact]
  37. // Every address on offer belongs to something the guest invented. Recording one
  38. // would be worse than recording nothing.
  39. public void Nothing_is_claimed_when_no_interface_carries_a_configured_mac() =>
  40. Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["bc:24:11:ff:ff:ff"]));
  41. [Fact]
  42. // A token that cannot read the guest config leaves no discriminator, so there is no
  43. // way to tell a NIC from a bridge.
  44. public void Nothing_is_claimed_when_the_configured_macs_are_unknown() =>
  45. Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), []));
  46. [Fact]
  47. public void An_agent_that_answers_nothing_yields_nothing() {
  48. Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":null}"""));
  49. Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":{"result":[]}}"""));
  50. }
  51. [Fact]
  52. public void A_container_reports_its_address_with_the_prefix_stripped() {
  53. List<ProxmoxGuestAddress> addresses =
  54. ProxmoxResponseParser.ParseContainerInterfaces(Fixture.Read("pve-lxc-interfaces.json"));
  55. ProxmoxGuestAddress only = Assert.Single(addresses);
  56. Assert.Equal("192.0.2.150", only.Ip);
  57. Assert.Equal("eth0", only.Interface);
  58. }
  59. [Fact]
  60. public void A_guest_that_failed_dhcp_is_not_recorded_at_its_self_assigned_address() {
  61. // 169.254 means "I could not get an address", which is not an address worth
  62. // writing into an inventory.
  63. var json = """
  64. {"data":{"result":[{"name":"ens18","hardware-address":"bc:24:11:00:1a:01",
  65. "ip-addresses":[{"ip-address":"169.254.12.7","ip-address-type":"ipv4","prefix":16}]}]}}
  66. """;
  67. Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces(json));
  68. }
  69. [Fact]
  70. public async Task A_statically_configured_address_still_wins_over_the_agent() {
  71. // The config is what the administrator asked for; the agent is what the guest
  72. // happens to report. Where both exist they agree, and where they do not the
  73. // configured one is the intent.
  74. var client = new ScriptedProxmoxClient {
  75. Guests = [Guest(100, "static-guest")],
  76. Configs = { [100] = new ProxmoxGuestConfig("Linux", "192.0.2.9", [], [], [_nicMac]) },
  77. Addresses = { [100] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] }
  78. };
  79. List<Resource> resources = await ProxmoxDiscovery.ReadAsync(client);
  80. Assert.Equal("192.0.2.9", GuestCard(resources, "static-guest").Ip);
  81. }
  82. [Fact]
  83. public async Task A_dhcp_guest_takes_the_address_its_agent_reports() {
  84. var client = new ScriptedProxmoxClient {
  85. Guests = [Guest(101, "dhcp-guest")],
  86. Configs = { [101] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) },
  87. Addresses = { [101] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] }
  88. };
  89. List<Resource> resources = await ProxmoxDiscovery.ReadAsync(client);
  90. Assert.Equal("192.0.2.105", GuestCard(resources, "dhcp-guest").Ip);
  91. }
  92. [Fact]
  93. public async Task A_stopped_guest_is_never_asked_where_it_is() {
  94. // It has no address to report, and asking costs a round trip per guest on an
  95. // estate where most guests may be off.
  96. var client = new ScriptedProxmoxClient {
  97. Guests = [Guest(102, "stopped-guest", "stopped")],
  98. Configs = { [102] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }
  99. };
  100. await ProxmoxDiscovery.ReadAsync(client);
  101. Assert.Empty(client.AddressCalls);
  102. }
  103. [Fact]
  104. public async Task The_guests_macs_reach_the_card() {
  105. // The MCP tool used to run its own copy of this orchestration which dropped the
  106. // MACs, silently costing every guest its chance of unifying with a scan.
  107. var client = new ScriptedProxmoxClient {
  108. Guests = [Guest(103, "mac-guest")],
  109. Configs = { [103] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }
  110. };
  111. List<Resource> resources = await ProxmoxDiscovery.ReadAsync(client);
  112. Assert.Equal(_nicMac, GuestCard(resources, "mac-guest").Labels["macs"]);
  113. }
  114. private static SystemResource GuestCard(List<Resource> resources, string name) =>
  115. resources.OfType<SystemResource>().Single(r => r.Name == name);
  116. private static ProxmoxGuest Guest(int vmId, string name, string status = "running") =>
  117. new() {
  118. VmId = vmId,
  119. Node = "pve01",
  120. Name = name,
  121. Type = "vm",
  122. Status = status
  123. };
  124. private sealed class ScriptedProxmoxClient : IProxmoxClient {
  125. public List<ProxmoxGuest> Guests { get; init; } = [];
  126. public Dictionary<int, ProxmoxGuestConfig> Configs { get; } = [];
  127. public Dictionary<int, List<ProxmoxGuestAddress>> Addresses { get; } = [];
  128. public List<int> AddressCalls { get; } = [];
  129. public string Endpoint => "https://pve.example.com:8006";
  130. public Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default) =>
  131. Task.FromResult("example-cluster");
  132. public Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default) =>
  133. Task.FromResult<IReadOnlyList<ProxmoxNode>>([new ProxmoxNode { Name = "pve01" }]);
  134. public Task<ProxmoxNode> EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default) =>
  135. Task.FromResult(node);
  136. public Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
  137. string node,
  138. string endpoint,
  139. CancellationToken cancellationToken = default) =>
  140. Task.FromResult<IReadOnlyList<ProxmoxGuest>>(
  141. endpoint == ProxmoxApiClient.QemuEndpoint ? Guests : []);
  142. public Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(
  143. string node,
  144. CancellationToken cancellationToken = default) =>
  145. Task.FromResult<IReadOnlyList<ProxmoxDisk>>([]);
  146. public Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(
  147. string node,
  148. CancellationToken cancellationToken = default) =>
  149. Task.FromResult<IReadOnlyList<ProxmoxGpu>>([]);
  150. public Task<ProxmoxGuestConfig> GetGuestConfigAsync(
  151. string node,
  152. string endpoint,
  153. int vmId,
  154. CancellationToken cancellationToken = default) =>
  155. Task.FromResult(Configs.TryGetValue(vmId, out ProxmoxGuestConfig? config)
  156. ? config
  157. : new ProxmoxGuestConfig(null, null, [], []));
  158. public Task<IReadOnlyList<ProxmoxGuestAddress>> GetGuestAddressesAsync(
  159. string node,
  160. string endpoint,
  161. int vmId,
  162. CancellationToken cancellationToken = default) {
  163. AddressCalls.Add(vmId);
  164. return Task.FromResult<IReadOnlyList<ProxmoxGuestAddress>>(
  165. Addresses.TryGetValue(vmId, out List<ProxmoxGuestAddress>? found) ? found : []);
  166. }
  167. }
  168. }