ProxmoxModels.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. using System.Text.Json;
  2. namespace RackPeek.Domain.Discovery;
  3. /// <summary>
  4. /// A Proxmox node: the physical machine and the hypervisor installed on it. RackPeek
  5. /// models those as two resources, so both sets of facts are gathered here.
  6. /// </summary>
  7. public sealed record ProxmoxNode {
  8. public required string Name { get; init; }
  9. /// <summary>Logical processors, which is what the hypervisor OS sees.</summary>
  10. public int Cores { get; init; }
  11. public long MemoryBytes { get; init; }
  12. /// <summary>e.g. <c>pve-manager/8.2.2/9355359cd7afbae4</c>, only from the status call.</summary>
  13. public string? Version { get; init; }
  14. // Hardware, all from the status call and all optional — a token without Sys.Audit
  15. // still gets a usable node, just without these.
  16. public string? CpuModel { get; init; }
  17. public int Sockets { get; init; }
  18. public int PhysicalCores { get; init; }
  19. public IReadOnlyList<ProxmoxDisk> Disks { get; init; } = [];
  20. /// <summary>
  21. /// Display adapters in the machine. A GPU passed through to a guest is still
  22. /// physically in the host, so this is where it belongs — the PCI address is kept
  23. /// so a guest holding it can say which card it has.
  24. /// </summary>
  25. public IReadOnlyList<ProxmoxGpu> Gpus { get; init; } = [];
  26. }
  27. /// <summary>A physical disk as Proxmox reports it, already classified by type.</summary>
  28. public sealed record ProxmoxDisk(string Type, long SizeBytes, string? Model);
  29. /// <summary>A display adapter and where it sits on the bus.</summary>
  30. public sealed record ProxmoxGpu(string Address, string Model);
  31. /// <summary>A guest on a node. QEMU and LXC differ only in the kind of system they are.</summary>
  32. public sealed record ProxmoxGuest {
  33. public required int VmId { get; init; }
  34. public required string Node { get; init; }
  35. /// <summary>Empty for a guest that has never been named; the mapper falls back to the vmid.</summary>
  36. public string Name { get; init; } = string.Empty;
  37. /// <summary><c>vm</c> or <c>container</c>, matching SystemResource.ValidSystemTypes.</summary>
  38. public required string Type { get; init; }
  39. public int Cores { get; init; }
  40. public long MemoryBytes { get; init; }
  41. /// <summary>The boot disk, from the guest list. A fallback for when the config is unreadable.</summary>
  42. public long DiskBytes { get; init; }
  43. /// <summary>Every attached disk, from the guest's config.</summary>
  44. public IReadOnlyList<long> Disks { get; init; } = [];
  45. /// <summary>PCI addresses handed exclusively to this guest, from its config.</summary>
  46. public IReadOnlyList<string> PassthroughAddresses { get; init; } = [];
  47. public IReadOnlyList<string> Tags { get; init; } = [];
  48. /// <summary>Filled in from the guest's config, which is the only place it is known.</summary>
  49. public string? Os { get; init; }
  50. public string? Ip { get; init; }
  51. /// <summary>
  52. /// The guest's NIC MACs, from its config's netN lines — the bridge that lets a
  53. /// network scan and this collector agree they are looking at the same guest.
  54. /// </summary>
  55. public IReadOnlyList<string> Macs { get; init; } = [];
  56. }
  57. /// <summary>
  58. /// Parses the Proxmox API. Every response wraps its payload in a <c>data</c> member.
  59. /// Pure, so it tests from captured responses without a Proxmox to talk to.
  60. /// </summary>
  61. public static class ProxmoxResponseParser {
  62. public const string VmType = "vm";
  63. public const string ContainerType = "container";
  64. /// <summary>
  65. /// Nodes with whatever detail the token is allowed to see. Proxmox strips
  66. /// <c>maxcpu</c> and <c>maxmem</c> from this response for a token without the
  67. /// rights to read them, rather than refusing the call, so both shapes are normal.
  68. /// </summary>
  69. public static List<ProxmoxNode> ParseNodes(string json) {
  70. return Data(json)
  71. .Where(n => !string.IsNullOrWhiteSpace(GetString(n, "node")))
  72. .Select(n => new ProxmoxNode {
  73. Name = GetString(n, "node")!,
  74. Cores = GetInt(n, "maxcpu") ?? 0,
  75. MemoryBytes = GetLong(n, "maxmem") ?? 0
  76. })
  77. .OrderBy(n => n.Name, StringComparer.Ordinal)
  78. .ToList();
  79. }
  80. public static ProxmoxNode ParseNodeStatus(string json, string nodeName) {
  81. using var document = JsonDocument.Parse(json);
  82. if (!document.RootElement.TryGetProperty("data", out JsonElement data))
  83. return new ProxmoxNode { Name = nodeName };
  84. var cores = data.TryGetProperty("cpuinfo", out JsonElement cpu)
  85. ? GetInt(cpu, "cpus") ?? GetInt(cpu, "cores") ?? 0
  86. : 0;
  87. var memory = data.TryGetProperty("memory", out JsonElement mem)
  88. ? GetLong(mem, "total") ?? 0
  89. : 0;
  90. return new ProxmoxNode {
  91. Name = nodeName,
  92. Cores = cores,
  93. MemoryBytes = memory,
  94. Version = GetString(data, "pveversion"),
  95. CpuModel = cpu.ValueKind == JsonValueKind.Object ? GetString(cpu, "model") : null,
  96. Sockets = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "sockets") ?? 0 : 0,
  97. PhysicalCores = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "cores") ?? 0 : 0
  98. };
  99. }
  100. /// <summary>
  101. /// Physical disks. Proxmox has already worked out nvme/ssd/hdd, which is better
  102. /// than the guess <c>discover system</c> has to make from a rotational flag.
  103. /// </summary>
  104. public static List<ProxmoxDisk> ParseDisks(string json) {
  105. return Data(json)
  106. .Select(d => new ProxmoxDisk(
  107. NormaliseDiskType(GetString(d, "type")),
  108. GetLong(d, "size") ?? 0,
  109. GetString(d, "model")))
  110. .Where(d => d.SizeBytes > 0)
  111. .ToList();
  112. }
  113. /// <summary>Proxmox says "unknown" for a disk it cannot classify; RackPeek omits the type.</summary>
  114. private static string NormaliseDiskType(string? type) {
  115. return type?.ToLowerInvariant() switch {
  116. "nvme" => "nvme",
  117. "ssd" => "ssd",
  118. "hdd" => "hdd",
  119. _ => string.Empty
  120. };
  121. }
  122. /// <summary>
  123. /// Display adapters from the node's PCI device list. PCI class 0x03 is the
  124. /// display-controller class, which is how a GPU is told apart from the other
  125. /// couple of dozen devices on a modern board.
  126. /// </summary>
  127. public static List<ProxmoxGpu> ParseGpus(string json) {
  128. return Data(json)
  129. .Where(d => (GetString(d, "class") ?? string.Empty).StartsWith("0x03", StringComparison.Ordinal))
  130. .Select(d => new { Address = GetString(d, "id"), Model = MarketingName(GetString(d, "device_name")) })
  131. .Where(g => !string.IsNullOrWhiteSpace(g.Address) && !string.IsNullOrWhiteSpace(g.Model))
  132. .Select(g => new ProxmoxGpu(g.Address!, g.Model!))
  133. .ToList();
  134. }
  135. /// <summary>
  136. /// PCI addresses a guest has been given exclusive use of. The config writes them
  137. /// as <c>hostpci0: 0000:01:00</c>, optionally with trailing options and sometimes
  138. /// without the function suffix the device list carries.
  139. /// </summary>
  140. public static List<string> ParsePassthrough(JsonElement config) {
  141. return config.EnumerateObject()
  142. .Where(p => p.Name.StartsWith("hostpci", StringComparison.OrdinalIgnoreCase))
  143. .Select(p => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null)
  144. .Where(v => !string.IsNullOrWhiteSpace(v))
  145. .Select(v => v!.Split(',')[0].Trim())
  146. .Where(v => v.Length > 0)
  147. .ToList();
  148. }
  149. /// <summary>
  150. /// PCI names the part and then the product: <c>GA102 [GeForce RTX 3090]</c>. The
  151. /// bracketed half is the one people would have typed, so it wins where it exists.
  152. /// </summary>
  153. public static string? MarketingName(string? deviceName) {
  154. if (string.IsNullOrWhiteSpace(deviceName))
  155. return null;
  156. var open = deviceName.IndexOf('[');
  157. var close = deviceName.IndexOf(']');
  158. return close > open && open >= 0
  159. ? deviceName[(open + 1)..close].Trim()
  160. : deviceName.Trim();
  161. }
  162. public static List<ProxmoxGuest> ParseGuests(string json, string node, string type) {
  163. return Data(json)
  164. .Select(g => ParseGuest(g, node, type))
  165. .OfType<ProxmoxGuest>()
  166. .OrderBy(g => g.VmId)
  167. .ToList();
  168. }
  169. /// <summary>
  170. /// The scope a vmid is unique within. A clustered guest can migrate between nodes,
  171. /// so the cluster name is what keeps its identity stable; a standalone host has no
  172. /// cluster entry and falls back to the node.
  173. /// </summary>
  174. public static string ParseIdentityScope(string clusterStatusJson, string fallbackNode) {
  175. JsonElement cluster = Data(clusterStatusJson)
  176. .FirstOrDefault(e => GetString(e, "type") == "cluster");
  177. var name = cluster.ValueKind == JsonValueKind.Object ? GetString(cluster, "name") : null;
  178. return string.IsNullOrWhiteSpace(name) ? fallbackNode : name;
  179. }
  180. /// <summary>
  181. /// Reads the guest's own config. This is the only place the OS is knowable, and
  182. /// for a container it carries the address too — which is why the extra call per
  183. /// guest earns its place.
  184. /// </summary>
  185. public static ProxmoxGuestConfig ParseGuestConfig(string json) {
  186. using var document = JsonDocument.Parse(json);
  187. if (!document.RootElement.TryGetProperty("data", out JsonElement data))
  188. return new ProxmoxGuestConfig(null, null, [], [], []);
  189. return new ProxmoxGuestConfig(
  190. DescribeOs(GetString(data, "ostype")),
  191. ParseStaticIp(GetString(data, "net0")),
  192. ParseDiskSizes(data),
  193. ParsePassthrough(data),
  194. ParseMacs(data));
  195. }
  196. /// <summary>
  197. /// The MACs in a guest's netN lines. QEMU spells them as the NIC model's value
  198. /// (<c>virtio=BC:24:11:…</c>), containers as <c>hwaddr=BC:24:11:…</c> — so any
  199. /// part whose value normalises to a MAC counts, and nothing else can (bridge
  200. /// names, ip=, tags never survive normalisation). Normalised by the same code
  201. /// that reads ARP tables, so a scan and this collector always agree.
  202. /// </summary>
  203. public static List<string> ParseMacs(JsonElement config) {
  204. var macs = new List<string>();
  205. foreach (JsonProperty property in config.EnumerateObject()) {
  206. if (!IsNetSlot(property.Name))
  207. continue;
  208. var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
  209. if (value == null)
  210. continue;
  211. foreach (var part in value.Split(',', StringSplitOptions.TrimEntries)) {
  212. var separator = part.IndexOf('=');
  213. if (separator <= 0)
  214. continue;
  215. var mac = ArpTableParser.NormaliseMac(part[(separator + 1)..]);
  216. if (mac != null)
  217. macs.Add(mac);
  218. }
  219. }
  220. return macs.Distinct().ToList();
  221. }
  222. private static bool IsNetSlot(string key) =>
  223. key.StartsWith("net", StringComparison.OrdinalIgnoreCase)
  224. && key.Length > 3
  225. && key[3..].All(char.IsAsciiDigit);
  226. /// <summary>
  227. /// Every disk attached to a guest. The guest list only carries <c>maxdisk</c>,
  228. /// which is the boot disk alone — a VM with a small root and a large data volume
  229. /// would otherwise be recorded at a fraction of its real size.
  230. /// </summary>
  231. public static List<long> ParseDiskSizes(JsonElement config) {
  232. var sizes = new List<long>();
  233. foreach (JsonProperty property in config.EnumerateObject()) {
  234. if (!IsDiskSlot(property.Name))
  235. continue;
  236. var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
  237. if (value == null || value.Contains("media=cdrom", StringComparison.OrdinalIgnoreCase))
  238. continue;
  239. var size = ParseSize(value);
  240. if (size > 0)
  241. sizes.Add(size);
  242. }
  243. return sizes;
  244. }
  245. /// <summary>
  246. /// Disk-bearing config keys. <c>unusedN</c> is excluded because it is a detached
  247. /// volume with no size, and the EFI and TPM state volumes because they are
  248. /// firmware scratch space of a few megabytes rather than storage anyone inventories.
  249. /// </summary>
  250. private static bool IsDiskSlot(string key) {
  251. string[] prefixes = ["scsi", "virtio", "sata", "ide", "mp"];
  252. if (key.Equals("rootfs", StringComparison.OrdinalIgnoreCase))
  253. return true;
  254. return prefixes.Any(p =>
  255. key.StartsWith(p, StringComparison.OrdinalIgnoreCase)
  256. && key.Length > p.Length
  257. && key[p.Length..].All(char.IsAsciiDigit));
  258. }
  259. /// <summary>
  260. /// Reads <c>size=64G</c> out of a volume definition, in bytes. Proxmox permits a
  261. /// fractional number (<c>size=4.5G</c>, after an odd resize) and a bare number,
  262. /// which is bytes.
  263. /// </summary>
  264. public static long ParseSize(string volume) {
  265. foreach (var part in volume.Split(',', StringSplitOptions.TrimEntries)) {
  266. if (!part.StartsWith("size=", StringComparison.OrdinalIgnoreCase))
  267. continue;
  268. var raw = part[5..].Trim();
  269. if (raw.Length == 0)
  270. return 0;
  271. var multiplier = char.ToUpperInvariant(raw[^1]) switch {
  272. 'K' => 1024L,
  273. 'M' => 1024L * 1024,
  274. 'G' => 1024L * 1024 * 1024,
  275. 'T' => 1024L * 1024 * 1024 * 1024,
  276. _ => 0L
  277. };
  278. if (multiplier == 0)
  279. return long.TryParse(raw, out var bytes) && bytes > 0 ? bytes : 0;
  280. return double.TryParse(
  281. raw[..^1],
  282. System.Globalization.NumberStyles.Float,
  283. System.Globalization.CultureInfo.InvariantCulture,
  284. out var value)
  285. && value > 0
  286. ? (long)Math.Round(value * multiplier)
  287. : 0;
  288. }
  289. return 0;
  290. }
  291. /// <summary>
  292. /// Proxmox stores an ostype code. The container ones name a real distribution and
  293. /// are worth having; the QEMU ones are coarse by nature — <c>l26</c> means any
  294. /// Linux since 2.6 — so they stay vague rather than pretending to precision.
  295. /// </summary>
  296. internal static string? DescribeOs(string? ostype) {
  297. if (string.IsNullOrWhiteSpace(ostype))
  298. return null;
  299. return ostype.ToLowerInvariant() switch {
  300. "l24" => "Linux",
  301. "l26" => "Linux",
  302. "solaris" => "Solaris",
  303. "wxp" => "Windows XP",
  304. "w2k" => "Windows 2000",
  305. "w2k3" => "Windows Server 2003",
  306. "w2k8" => "Windows Server 2008",
  307. "wvista" => "Windows Vista",
  308. "win7" => "Windows 7",
  309. "win8" => "Windows 8",
  310. "win10" => "Windows 10",
  311. "win11" => "Windows 11",
  312. "other" => null,
  313. "unmanaged" => null,
  314. // Container templates are named after the distribution itself.
  315. var distribution => char.ToUpperInvariant(distribution[0]) + distribution[1..]
  316. };
  317. }
  318. /// <summary>
  319. /// Pulls the address out of a net interface line such as
  320. /// <c>name=eth0,bridge=vmbr0,ip=192.168.1.53/24</c>. Returns null for
  321. /// <c>ip=dhcp</c> and <c>ip=manual</c>, where the config knows no more than we do.
  322. /// </summary>
  323. internal static string? ParseStaticIp(string? net) {
  324. if (string.IsNullOrWhiteSpace(net))
  325. return null;
  326. foreach (var part in net.Split(',', StringSplitOptions.TrimEntries)) {
  327. if (!part.StartsWith("ip=", StringComparison.OrdinalIgnoreCase))
  328. continue;
  329. var value = part[3..].Split('/')[0].Trim();
  330. return value.Equals("dhcp", StringComparison.OrdinalIgnoreCase)
  331. || value.Equals("manual", StringComparison.OrdinalIgnoreCase)
  332. || value.Length == 0
  333. ? null
  334. : value;
  335. }
  336. return null;
  337. }
  338. private static ProxmoxGuest? ParseGuest(JsonElement element, string node, string type) {
  339. var vmid = GetInt(element, "vmid");
  340. if (vmid == null)
  341. return null;
  342. return new ProxmoxGuest {
  343. VmId = vmid.Value,
  344. Node = node,
  345. Name = GetString(element, "name") ?? string.Empty,
  346. Type = type,
  347. Cores = GetInt(element, "cpus") ?? 0,
  348. MemoryBytes = GetLong(element, "maxmem") ?? 0,
  349. DiskBytes = GetLong(element, "maxdisk") ?? 0,
  350. Tags = ParseTags(GetString(element, "tags"))
  351. };
  352. }
  353. /// <summary>Proxmox joins guest tags with semicolons.</summary>
  354. private static List<string> ParseTags(string? tags) {
  355. if (string.IsNullOrWhiteSpace(tags))
  356. return [];
  357. return tags.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  358. .Select(DiscoveryNaming.Slug)
  359. .Where(t => t.Length > 0)
  360. .Distinct(StringComparer.OrdinalIgnoreCase)
  361. .ToList();
  362. }
  363. private static IEnumerable<JsonElement> Data(string json) {
  364. using var document = JsonDocument.Parse(json);
  365. if (!document.RootElement.TryGetProperty("data", out JsonElement data)
  366. || data.ValueKind != JsonValueKind.Array)
  367. return [];
  368. return data.EnumerateArray().Select(e => e.Clone()).ToList();
  369. }
  370. private static string? GetString(JsonElement element, string name) =>
  371. element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
  372. ? value.GetString()
  373. : null;
  374. private static int? GetInt(JsonElement element, string name) =>
  375. element.TryGetProperty(name, out JsonElement value) && value.TryGetInt32(out var result)
  376. ? result
  377. : null;
  378. private static long? GetLong(JsonElement element, string name) =>
  379. element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result)
  380. ? result
  381. : null;
  382. }
  383. /// <summary>The parts of a guest's config worth recording. Everything is optional.</summary>
  384. public sealed record ProxmoxGuestConfig(
  385. string? Os,
  386. string? Ip,
  387. IReadOnlyList<long> DiskBytes,
  388. IReadOnlyList<string> PassthroughAddresses,
  389. IReadOnlyList<string>? Macs = null);