DiscoverDockerCommand.cs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. using System.ComponentModel;
  2. using RackPeek.Domain.Discovery;
  3. using RackPeek.Domain.Resources;
  4. using RackPeek.Domain.Resources.Services;
  5. using RackPeek.Domain.Resources.SystemResources;
  6. using Spectre.Console;
  7. using Spectre.Console.Cli;
  8. namespace Shared.Rcl.Commands.Discovery;
  9. public sealed class DiscoverDockerSettings : DiscoverSettings {
  10. [CommandOption("--docker-host <URI>")]
  11. [Description("Docker endpoint, e.g. unix:///var/run/docker.sock or tcp://host:2375. " +
  12. "Defaults to DOCKER_HOST, then the local socket.")]
  13. public string? DockerHost { get; init; }
  14. [CommandOption("--host <NAME>")]
  15. [Description("Name of the machine these containers run on. Defaults to its hostname.")]
  16. public string? HostName { get; init; }
  17. }
  18. /// <summary>Reads the Docker Engine API and emits each published container as a Service.</summary>
  19. public sealed class DiscoverDockerCommand(IEnumerable<ISystemProbe> probes)
  20. : AsyncCommand<DiscoverDockerSettings> {
  21. protected override async Task<int> ExecuteAsync(
  22. CommandContext context,
  23. DiscoverDockerSettings settings,
  24. CancellationToken cancellationToken) {
  25. // The host's own facts give the services a stable id seed, the address they are
  26. // reachable on, and something to hang runsOn off.
  27. SystemFacts host = await ReadHostAsync(cancellationToken);
  28. DockerApiClient client;
  29. try {
  30. client = new DockerApiClient(settings.DockerHost);
  31. }
  32. catch (UriFormatException ex) {
  33. AnsiConsole.MarkupLine(
  34. $"[red]'{Markup.Escape(settings.DockerHost ?? string.Empty)}' is not a usable Docker endpoint.[/] " +
  35. $"{Markup.Escape(ex.Message)}");
  36. return 1;
  37. }
  38. using DockerApiClient _ = client;
  39. IReadOnlyList<DockerContainer> containers;
  40. try {
  41. containers = await client.ListContainersAsync(cancellationToken);
  42. }
  43. catch (Exception ex) when (
  44. ex is HttpRequestException or IOException or TimeoutException
  45. // HttpClient reports its own timeout as a cancellation.
  46. || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
  47. AnsiConsole.MarkupLine(
  48. $"[red]Could not reach Docker at {Markup.Escape(client.Endpoint)}.[/] " +
  49. $"{Markup.Escape(ex.Message)}");
  50. return 1;
  51. }
  52. // Over TCP the machine running this command is not the machine running the
  53. // containers, so the engine is asked about itself instead of trusting the local
  54. // probe: its daemon id seeds the services' identity (the same ids from any
  55. // workstation), and its hostname is what runsOn should point at.
  56. DockerEngineInfo? engine = client.IsLocal ? null : await client.GetInfoAsync(cancellationToken);
  57. if (!client.IsLocal && engine == null)
  58. AnsiConsole.MarkupLine(
  59. "[grey]The engine does not expose /info (a restricted socket proxy blocks it by " +
  60. "default), so the endpoint itself is the identity seed — keep addressing this " +
  61. "engine the same way, and pass --host to name the machine it runs on.[/]");
  62. // Named through the same mapper the system collector uses, so runsOn always
  63. // points at exactly the resource 'rpk discover system' produces on that machine.
  64. SystemResource hostResource = SystemResourceMapper.ToResource(host, settings.HostName);
  65. var hostName = client.IsLocal
  66. ? hostResource.Name
  67. : settings.HostName ?? engine?.Hostname ?? hostResource.Name;
  68. var seed = client.IsLocal
  69. ? host.MachineId ?? host.Hostname
  70. : engine?.Id ?? client.Endpoint;
  71. // Published ports live on the engine host, so a remote service's address is the
  72. // endpoint the user dialled — the local probe's address is only the last resort.
  73. var serviceIp = client.IsLocal
  74. ? host.Ip
  75. : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip;
  76. List<Service> services = DockerServiceMapper.ToResources(containers, seed, hostName, serviceIp);
  77. var skipped = containers.Count - services.Count;
  78. if (skipped > 0)
  79. AnsiConsole.MarkupLine(
  80. $"[grey]Skipped {skipped} container(s) not reachable from outside the host.[/]");
  81. // The host System rides along so the server can line runsOn up by the host's id
  82. // even after the user has renamed it — a name alone could not be reconciled. Over
  83. // TCP the facts probed here describe this machine, not the engine's, so they stay
  84. // out; a rename there is preserved instead by the merge keeping the stored link
  85. // whenever an update's runsOn points at nothing.
  86. List<Resource> resources = client.IsLocal && services.Count > 0
  87. ? [hostResource, .. services]
  88. : [.. services];
  89. return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
  90. }
  91. private async Task<SystemFacts> ReadHostAsync(CancellationToken cancellationToken) {
  92. // Unlike `discover system`, an unsupported platform is not fatal here — the
  93. // containers can still be read; only the host's own facts fall back to basics.
  94. return await SystemProbes.TryReadHostAsync(probes, cancellationToken)
  95. ?? SystemFactsParser.Parse(new RawSystemSnapshot {
  96. Hostname = Environment.MachineName,
  97. Cores = Environment.ProcessorCount
  98. });
  99. }
  100. }