ProxmoxResourceMapper.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. using RackPeek.Domain.Resources;
  2. using RackPeek.Domain.Resources.Servers;
  3. using RackPeek.Domain.Resources.SubResources;
  4. using RackPeek.Domain.Resources.SystemResources;
  5. namespace RackPeek.Domain.Discovery;
  6. /// <summary>
  7. /// Maps a Proxmox estate onto RackPeek's three levels. Pure.
  8. /// <para>
  9. /// A node becomes two resources, because it is two things: the machine
  10. /// (<c>kepler</c>, a Server carrying the CPU, memory and disks) and the
  11. /// hypervisor installed on it (<c>kepler-pve</c>, a System). Guests then run on
  12. /// the hypervisor, giving Hardware -> System -> System all the way down.
  13. /// </para>
  14. /// </summary>
  15. public static class ProxmoxResourceMapper {
  16. public const string Scheme = "pve";
  17. /// <summary>Label recording which cards a guest has exclusive use of.</summary>
  18. public const string GpuLabel = "gpu";
  19. /// <summary>The cap RackPeek's own validation puts on a label value.</summary>
  20. private const int _maxLabelLength = Helpers.ThrowIfInvalid.MaxLabelValueLength;
  21. public static List<Resource> ToResources(
  22. string scope,
  23. IReadOnlyList<ProxmoxNode> nodes,
  24. IReadOnlyList<ProxmoxGuest> guests) {
  25. var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  26. var resources = new List<Resource>();
  27. // Nodes first, so a guest named after its node does not take the node's name.
  28. var hypervisorNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  29. // PCI addresses repeat on every machine, so a guest can only be matched against
  30. // the cards in the node it actually runs on.
  31. var gpusByNode = nodes.ToDictionary(
  32. n => n.Name,
  33. n => n.Gpus,
  34. StringComparer.OrdinalIgnoreCase);
  35. foreach (ProxmoxNode node in nodes) {
  36. Server server = ToServer(node, scope, taken);
  37. SystemResource hypervisor = ToHypervisor(node, scope, server.Name, taken);
  38. hypervisorNames[node.Name] = hypervisor.Name;
  39. resources.Add(server);
  40. resources.Add(hypervisor);
  41. }
  42. // A vmid is unique within the scope, so two entries carrying the same one are
  43. // the same guest — which is what a migration in flight looks like, reported by
  44. // both the node it is leaving and the node it is joining. Without this they
  45. // would come out as two resources sharing an identity.
  46. resources.AddRange(guests
  47. .DistinctBy(g => g.VmId)
  48. .Select(g => ToResource(g, scope, hypervisorNames, gpusByNode, taken)));
  49. return resources;
  50. }
  51. /// <summary>The machine itself. Takes the node's own name, being the thing people point at.</summary>
  52. private static Server ToServer(ProxmoxNode node, string scope, ISet<string> taken) {
  53. var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}");
  54. return new Server {
  55. Kind = Server.KindLabel,
  56. Name = DiscoveryNaming.Unique(
  57. DiscoveryNaming.Suggest(node.Name, "server", discoveryId),
  58. discoveryId,
  59. taken),
  60. DiscoveryId = discoveryId,
  61. Ram = ToGb(node.MemoryBytes) is { } ram ? new Ram { Size = ram } : null,
  62. Cpus = ToCpus(node),
  63. Drives = ToDrives(node.Disks),
  64. // A GPU passed through to a guest is still bolted into this machine, so it
  65. // is recorded here rather than on whatever borrows it. VRAM is not something
  66. // the PCI list knows, so it is left off.
  67. Gpus = node.Gpus.Count == 0 ? null : node.Gpus.Select(g => new Gpu { Model = g.Model }).ToList()
  68. };
  69. }
  70. /// <summary>The Proxmox install running on the machine, which is what guests run on.</summary>
  71. private static SystemResource ToHypervisor(
  72. ProxmoxNode node,
  73. string scope,
  74. string serverName,
  75. ISet<string> taken) {
  76. var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}/pve");
  77. return new SystemResource {
  78. Kind = SystemResource.KindLabel,
  79. Name = DiscoveryNaming.Unique(
  80. DiscoveryNaming.Suggest($"{node.Name}-pve", "hypervisor", discoveryId),
  81. discoveryId,
  82. taken),
  83. DiscoveryId = discoveryId,
  84. Type = "hypervisor",
  85. Os = DescribeVersion(node.Version),
  86. Cores = node.Cores > 0 ? node.Cores : null,
  87. Ram = ToGb(node.MemoryBytes),
  88. RunsOn = [serverName]
  89. };
  90. }
  91. /// <summary>
  92. /// One entry per socket. Proxmox reports totals across the machine, so they are
  93. /// divided down — two sockets of an 8-core part read as 2 x 8, not 1 x 16.
  94. /// </summary>
  95. private static List<Cpu>? ToCpus(ProxmoxNode node) {
  96. if (string.IsNullOrWhiteSpace(node.CpuModel))
  97. return null;
  98. var sockets = Math.Max(1, node.Sockets);
  99. var cpu = new Cpu {
  100. Model = node.CpuModel,
  101. Cores = node.PhysicalCores > 0 ? node.PhysicalCores / sockets : null,
  102. Threads = node.Cores > 0 ? node.Cores / sockets : null
  103. };
  104. return Enumerable.Range(0, sockets).Select(_ => cpu).ToList();
  105. }
  106. private static List<Drive>? ToDrives(IReadOnlyList<ProxmoxDisk> disks) {
  107. if (disks.Count == 0)
  108. return null;
  109. return disks
  110. .Select(d => new Drive {
  111. Type = string.IsNullOrEmpty(d.Type) ? null : d.Type,
  112. Size = (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes)
  113. })
  114. .ToList();
  115. }
  116. private static SystemResource ToResource(
  117. ProxmoxGuest guest,
  118. string scope,
  119. IReadOnlyDictionary<string, string> hypervisorNames,
  120. IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode,
  121. ISet<string> taken) {
  122. // The vmid is unique within the cluster and survives a rename or a migration
  123. // between nodes, which is exactly what an identity needs to do.
  124. var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}");
  125. Dictionary<string, string> labels = PassthroughLabels(guest, gpusByNode);
  126. // The bridge to network discovery: a scan identifies this guest by one of
  127. // these, so carrying them lets the resolver land both collectors on one card.
  128. if (guest.Macs.Count > 0)
  129. labels["macs"] = string.Join(",", guest.Macs);
  130. return new SystemResource {
  131. Kind = SystemResource.KindLabel,
  132. Name = DiscoveryNaming.Unique(
  133. DiscoveryNaming.Suggest(FallbackName(guest), "system", discoveryId),
  134. discoveryId,
  135. taken),
  136. DiscoveryId = discoveryId,
  137. Type = guest.Type,
  138. Os = guest.Os,
  139. Cores = guest.Cores > 0 ? guest.Cores : null,
  140. Ram = ToGb(guest.MemoryBytes),
  141. Ip = guest.Ip,
  142. Drives = ToGuestDrives(guest),
  143. Tags = guest.Tags.ToArray(),
  144. Labels = labels,
  145. RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : []
  146. };
  147. }
  148. /// <summary>
  149. /// Every disk the config lists, falling back to the boot disk from the guest list
  150. /// when the config could not be read. The storage backend says nothing about the
  151. /// underlying medium, so the type is left off rather than guessed.
  152. /// </summary>
  153. private static List<Drive>? ToGuestDrives(ProxmoxGuest guest) {
  154. IReadOnlyList<long> sizes = guest.Disks.Count > 0
  155. ? guest.Disks
  156. : guest.DiskBytes > 0
  157. ? [guest.DiskBytes]
  158. : [];
  159. if (sizes.Count == 0)
  160. return null;
  161. return sizes
  162. .Select(bytes => new Drive { Size = (int)DiscoveryUnits.BytesToWholeGb(bytes) })
  163. .ToList();
  164. }
  165. /// <summary>
  166. /// Records the cards a guest holds. The GPU itself stays on the Server, because
  167. /// that is where it is physically installed; this is the assignment, which
  168. /// RackPeek has no first-class way to express.
  169. /// </summary>
  170. private static Dictionary<string, string> PassthroughLabels(
  171. ProxmoxGuest guest,
  172. IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode) {
  173. var labels = new Dictionary<string, string>();
  174. if (guest.PassthroughAddresses.Count == 0
  175. || !gpusByNode.TryGetValue(guest.Node, out IReadOnlyList<ProxmoxGpu>? gpus))
  176. return labels;
  177. var held = guest.PassthroughAddresses
  178. .Select(address => gpus.FirstOrDefault(g => AddressesMatch(g.Address, address)))
  179. .OfType<ProxmoxGpu>()
  180. .Select(g => g.Model)
  181. .ToList();
  182. if (held.Count == 0)
  183. return labels;
  184. var value = string.Join(", ", held);
  185. labels[GpuLabel] = value.Length <= _maxLabelLength
  186. ? value
  187. : value[.._maxLabelLength].TrimEnd(',', ' ');
  188. return labels;
  189. }
  190. /// <summary>
  191. /// The device list gives a function suffix (<c>0000:01:00.0</c>) that a guest
  192. /// config usually leaves off (<c>0000:01:00</c>), so either may be the longer.
  193. /// </summary>
  194. private static bool AddressesMatch(string deviceAddress, string configured) =>
  195. deviceAddress.StartsWith(configured, StringComparison.OrdinalIgnoreCase)
  196. || configured.StartsWith(deviceAddress, StringComparison.OrdinalIgnoreCase);
  197. /// <summary>A guest that was never named still needs one; the vmid is what people call it.</summary>
  198. private static string FallbackName(ProxmoxGuest guest) =>
  199. string.IsNullOrWhiteSpace(guest.Name)
  200. ? $"{(guest.Type == ProxmoxResponseParser.ContainerType ? "ct" : "vm")}-{guest.VmId}"
  201. : guest.Name;
  202. /// <summary><c>pve-manager/8.2.2/9355359c</c> becomes <c>Proxmox VE 8.2.2</c>.</summary>
  203. internal static string? DescribeVersion(string? pveVersion) {
  204. if (string.IsNullOrWhiteSpace(pveVersion))
  205. return null;
  206. var parts = pveVersion.Split('/');
  207. return parts.Length >= 2 ? $"Proxmox VE {parts[1]}" : pveVersion;
  208. }
  209. private static double? ToGb(long bytes) =>
  210. bytes > 0 ? DiscoveryUnits.BytesToWholeGb(bytes) : null;
  211. }