DiscoverDockerEndpointTests.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. using System.Net;
  2. using System.Net.Sockets;
  3. using System.Text;
  4. using Tests.EndToEnd.Infra;
  5. using Xunit.Abstractions;
  6. namespace Tests.EndToEnd.DiscoveryTests;
  7. /// <summary>
  8. /// `rpk discover docker` against endpoints it cannot reach. Every case here fails
  9. /// before any container is listed, so these tests never talk to a daemon.
  10. /// </summary>
  11. [Collection("Yaml CLI tests")]
  12. public class DiscoverDockerEndpointTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
  13. : IClassFixture<TempYamlCliFixture> {
  14. private async Task<string> ExecuteAsync(params string[] args) =>
  15. await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml");
  16. [Theory]
  17. [InlineData("ssh://user@nas")]
  18. [InlineData("npipe:////./pipe/docker_engine")]
  19. public async Task an_endpoint_that_cannot_be_dialled_says_so_instead_of_crashing(string endpoint) {
  20. // `docker context` sets ssh:// for a remote host and npipe:// is the Windows
  21. // default, so both are ordinary values to find in DOCKER_HOST. They used to
  22. // reach HttpClient, which accepts the URI and throws NotSupportedException on
  23. // the first request — past the command's catch list, so the user got
  24. // "Unexpected error occurred" and a stack trace.
  25. var output = await ExecuteAsync("discover", "docker", "--docker-host", endpoint);
  26. Assert.DoesNotContain("Unexpected error", output);
  27. Assert.DoesNotContain("at RackPeek.", output);
  28. // And the message says what to do about it.
  29. Assert.Contains("ssh -L", output);
  30. }
  31. [Fact]
  32. public async Task remote_daemon_without_a_hostname_requires_an_explicit_host_and_emits_nothing() {
  33. await using var daemon = new FakeDockerDaemon("403 Forbidden", "{}");
  34. await daemon.StartAsync();
  35. var exitCode = -1;
  36. var output = await YamlCliTestHost.RunAsync(
  37. ["discover", "docker", "--docker-host", daemon.Endpoint],
  38. fs.Root,
  39. outputHelper,
  40. "config.yaml",
  41. code => exitCode = code);
  42. await daemon.Completion;
  43. Assert.Equal(1, exitCode);
  44. Assert.Contains("Could not determine the remote Docker host name", output);
  45. Assert.Contains("pass --host", output);
  46. Assert.DoesNotContain("kind: Service", output);
  47. }
  48. [Fact]
  49. public async Task remote_daemon_can_be_named_explicitly_when_info_is_blocked() {
  50. await using var daemon = new FakeDockerDaemon("403 Forbidden", "{}");
  51. await daemon.StartAsync();
  52. var exitCode = -1;
  53. var output = await YamlCliTestHost.RunAsync(
  54. ["discover", "docker", "--docker-host", daemon.Endpoint, "--host", "nas01"],
  55. fs.Root,
  56. outputHelper,
  57. "config.yaml",
  58. code => exitCode = code);
  59. await daemon.Completion;
  60. Assert.Equal(0, exitCode);
  61. Assert.Contains("name: app", output);
  62. Assert.Contains("nas01", output);
  63. }
  64. [Fact]
  65. public async Task remote_daemon_hostname_from_info_is_used_when_available() {
  66. await using var daemon = new FakeDockerDaemon("200 OK", """{"Name":"nas-from-engine","ID":"engine-1"}""");
  67. await daemon.StartAsync();
  68. var exitCode = -1;
  69. var output = await YamlCliTestHost.RunAsync(
  70. ["discover", "docker", "--docker-host", daemon.Endpoint],
  71. fs.Root,
  72. outputHelper,
  73. "config.yaml",
  74. code => exitCode = code);
  75. await daemon.Completion;
  76. Assert.Equal(0, exitCode);
  77. Assert.Contains("nas-from-engine", output);
  78. }
  79. [Fact]
  80. public async Task local_unix_socket_discovery_still_emits_the_host_and_service() {
  81. await using var daemon = new FakeUnixDockerDaemon(Path.Combine("/tmp", $"rpk-{Guid.NewGuid():N}.sock"));
  82. await daemon.StartAsync();
  83. var exitCode = -1;
  84. var output = await YamlCliTestHost.RunAsync(
  85. ["discover", "docker", "--docker-host", daemon.Endpoint],
  86. fs.Root,
  87. outputHelper,
  88. "config.yaml",
  89. code => exitCode = code);
  90. await daemon.Completion;
  91. Assert.Equal(0, exitCode);
  92. Assert.Contains("kind: System", output);
  93. Assert.Contains("kind: Service", output);
  94. Assert.Contains("runsOn:", output);
  95. }
  96. private sealed class FakeDockerDaemon(string infoStatus, string infoJson) : IAsyncDisposable {
  97. private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
  98. public string Endpoint => $"tcp://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
  99. public Task Completion { get; private set; } = Task.CompletedTask;
  100. public Task StartAsync() {
  101. _listener.Start();
  102. Completion = ServeAsync();
  103. return Task.CompletedTask;
  104. }
  105. private async Task ServeAsync() {
  106. for (var i = 0; i < 2; i++) {
  107. using TcpClient connection = await _listener.AcceptTcpClientAsync();
  108. await using NetworkStream stream = connection.GetStream();
  109. using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
  110. var request = await reader.ReadLineAsync();
  111. while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) { }
  112. (var status, var body) = request?.Contains("/info", StringComparison.Ordinal) == true
  113. ? (infoStatus, infoJson)
  114. : ("200 OK", """[{"Names":["/app"],"Image":"app:latest","Ports":[{"IP":"0.0.0.0","PublicPort":8080,"Type":"tcp"}]}]""");
  115. var bytes = Encoding.UTF8.GetBytes(body);
  116. var headers = Encoding.ASCII.GetBytes(
  117. $"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n");
  118. await stream.WriteAsync(headers);
  119. await stream.WriteAsync(bytes);
  120. }
  121. }
  122. public ValueTask DisposeAsync() {
  123. _listener.Stop();
  124. return ValueTask.CompletedTask;
  125. }
  126. }
  127. private sealed class FakeUnixDockerDaemon(string socketPath) : IAsyncDisposable {
  128. private readonly Socket _listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
  129. public string Endpoint => $"unix://{socketPath}";
  130. public Task Completion { get; private set; } = Task.CompletedTask;
  131. public Task StartAsync() {
  132. _listener.Bind(new UnixDomainSocketEndPoint(socketPath));
  133. _listener.Listen(2);
  134. Completion = ServeAsync();
  135. return Task.CompletedTask;
  136. }
  137. private async Task ServeAsync() {
  138. for (var i = 0; i < 1; i++) {
  139. using Socket connection = await _listener.AcceptAsync();
  140. await using var stream = new NetworkStream(connection, ownsSocket: false);
  141. using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
  142. var request = await reader.ReadLineAsync();
  143. while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) { }
  144. var isInfo = request?.Contains("/info", StringComparison.Ordinal) == true;
  145. var body = isInfo
  146. ? """{"Name":"local-daemon","ID":"local-engine"}"""
  147. : """[{"Names":["/app"],"Image":"app:latest","Ports":[{"IP":"0.0.0.0","PublicPort":8080,"Type":"tcp"}]}]""";
  148. var bytes = Encoding.UTF8.GetBytes(body);
  149. var headers = Encoding.ASCII.GetBytes(
  150. $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n");
  151. await stream.WriteAsync(headers);
  152. await stream.WriteAsync(bytes);
  153. }
  154. }
  155. public ValueTask DisposeAsync() {
  156. _listener.Dispose();
  157. File.Delete(socketPath);
  158. return ValueTask.CompletedTask;
  159. }
  160. }
  161. }