DiscoverProxmoxCommand.cs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. using System.ComponentModel;
  2. using RackPeek.Domain.Discovery;
  3. using RackPeek.Domain.Resources;
  4. using Spectre.Console;
  5. using Spectre.Console.Cli;
  6. namespace Shared.Rcl.Commands.Discovery;
  7. public sealed class DiscoverProxmoxSettings : DiscoverSettings {
  8. [CommandOption("--host <URL>")]
  9. [Description("Proxmox host, e.g. https://pve.lan:8006. A bare host name gets https and :8006.")]
  10. public string? Host { get; init; }
  11. [CommandOption("--token-id <ID>")]
  12. [Description("API token id, e.g. root@pam!rackpeek. Defaults to RPK_PVE_TOKEN_ID.")]
  13. public string? TokenId { get; init; }
  14. [CommandOption("--token-secret <SECRET>")]
  15. [Description("API token secret. Defaults to RPK_PVE_TOKEN_SECRET.")]
  16. public string? TokenSecret { get; init; }
  17. [CommandOption("--insecure")]
  18. [Description("Accept a self-signed certificate, which Proxmox ships with by default.")]
  19. public bool Insecure { get; init; }
  20. public string? ResolvedTokenId =>
  21. DiscoveryPublisher.Resolve(TokenId, ProxmoxApiClient.TokenIdEnvironmentVariable);
  22. public string? ResolvedTokenSecret =>
  23. DiscoveryPublisher.Resolve(TokenSecret, ProxmoxApiClient.TokenSecretEnvironmentVariable);
  24. public override ValidationResult Validate() {
  25. if (string.IsNullOrWhiteSpace(Host))
  26. return ValidationResult.Error("Pass --host, e.g. --host https://pve.lan:8006");
  27. if (string.IsNullOrWhiteSpace(ResolvedTokenId))
  28. return ValidationResult.Error(
  29. $"No API token id. Pass --token-id or set {ProxmoxApiClient.TokenIdEnvironmentVariable}.");
  30. if (string.IsNullOrWhiteSpace(ResolvedTokenSecret))
  31. return ValidationResult.Error(
  32. $"No API token secret. Pass --token-secret or set {ProxmoxApiClient.TokenSecretEnvironmentVariable}.");
  33. return base.Validate();
  34. }
  35. }
  36. /// <summary>
  37. /// Reads a Proxmox estate and emits its nodes and guests as Systems, already wired
  38. /// together — which is the part that is tedious to type by hand.
  39. /// </summary>
  40. public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSettings> {
  41. protected override async Task<int> ExecuteAsync(
  42. CommandContext context,
  43. DiscoverProxmoxSettings settings,
  44. CancellationToken cancellationToken) {
  45. ProxmoxApiClient client;
  46. try {
  47. client = new ProxmoxApiClient(
  48. settings.Host!,
  49. settings.ResolvedTokenId!,
  50. settings.ResolvedTokenSecret!,
  51. settings.Insecure);
  52. }
  53. catch (UriFormatException ex) {
  54. AnsiConsole.MarkupLine(
  55. $"[red]'{Markup.Escape(settings.Host!)}' is not a usable host.[/] {Markup.Escape(ex.Message)}");
  56. return 1;
  57. }
  58. List<Resource> resources;
  59. try {
  60. resources = await ReadAsync(client, cancellationToken);
  61. }
  62. catch (HttpRequestException ex) {
  63. AnsiConsole.MarkupLine(
  64. $"[red]Could not read {Markup.Escape(client.Endpoint)}.[/] {Markup.Escape(ex.Message)}");
  65. if (!settings.Insecure && ex.InnerException is System.Security.Authentication.AuthenticationException)
  66. AnsiConsole.MarkupLine(
  67. "[yellow]Proxmox uses a self-signed certificate by default — try --insecure.[/]");
  68. return 1;
  69. }
  70. catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) {
  71. // HttpClient reports its timeout as a cancellation.
  72. AnsiConsole.MarkupLine(
  73. $"[red]{Markup.Escape(client.Endpoint)} did not answer within the timeout.[/]");
  74. return 1;
  75. }
  76. finally {
  77. client.Dispose();
  78. }
  79. return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
  80. }
  81. private static async Task<List<Resource>> ReadAsync(
  82. IProxmoxClient client,
  83. CancellationToken cancellationToken) {
  84. var scope = await client.GetIdentityScopeAsync(cancellationToken);
  85. IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
  86. var nodes = new List<ProxmoxNode>();
  87. var guests = new List<ProxmoxGuest>();
  88. foreach (ProxmoxNode listedNode in listed) {
  89. // Node detail needs a broader permission than listing guests does, so it is
  90. // enrichment rather than a requirement — a read-only token still gets a tree.
  91. ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
  92. nodes.Add(node);
  93. var nodeName = node.Name;
  94. foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
  95. IReadOnlyList<ProxmoxGuest> listedGuests =
  96. await client.GetGuestsAsync(nodeName, endpoint, cancellationToken);
  97. // The list call knows nothing about the OS, and for a container it does
  98. // not know the address either. Both live in the guest's own config — one
  99. // call per guest, so they run concurrently rather than one at a time.
  100. ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
  101. client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken)));
  102. for (var i = 0; i < listedGuests.Count; i++)
  103. guests.Add(listedGuests[i] with {
  104. Os = configs[i].Os,
  105. Ip = configs[i].Ip,
  106. Disks = configs[i].DiskBytes,
  107. PassthroughAddresses = configs[i].PassthroughAddresses
  108. });
  109. }
  110. }
  111. return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
  112. }
  113. }