using System.Net;
using System.Net.Sockets;
using System.Text;
using Tests.EndToEnd.Infra;
using Xunit.Abstractions;
namespace Tests.EndToEnd.DiscoveryTests;
///
/// `rpk discover docker` against endpoints it cannot reach. Every case here fails
/// before any container is listed, so these tests never talk to a daemon.
///
[Collection("Yaml CLI tests")]
public class DiscoverDockerEndpointTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
: IClassFixture {
private async Task ExecuteAsync(params string[] args) =>
await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml");
[Theory]
[InlineData("ssh://user@nas")]
[InlineData("npipe:////./pipe/docker_engine")]
public async Task an_endpoint_that_cannot_be_dialled_says_so_instead_of_crashing(string endpoint) {
// `docker context` sets ssh:// for a remote host and npipe:// is the Windows
// default, so both are ordinary values to find in DOCKER_HOST. They used to
// reach HttpClient, which accepts the URI and throws NotSupportedException on
// the first request — past the command's catch list, so the user got
// "Unexpected error occurred" and a stack trace.
var output = await ExecuteAsync("discover", "docker", "--docker-host", endpoint);
Assert.DoesNotContain("Unexpected error", output);
Assert.DoesNotContain("at RackPeek.", output);
// And the message says what to do about it.
Assert.Contains("ssh -L", output);
}
[Fact]
public async Task remote_daemon_without_a_hostname_requires_an_explicit_host_and_emits_nothing() {
await using var daemon = new FakeDockerDaemon("403 Forbidden", "{}");
await daemon.StartAsync();
var exitCode = -1;
var output = await YamlCliTestHost.RunAsync(
["discover", "docker", "--docker-host", daemon.Endpoint],
fs.Root,
outputHelper,
"config.yaml",
code => exitCode = code);
await daemon.Completion;
Assert.Equal(1, exitCode);
Assert.Contains("Could not determine the remote Docker host name", output);
Assert.Contains("pass --host", output);
Assert.DoesNotContain("kind: Service", output);
}
[Fact]
public async Task remote_daemon_can_be_named_explicitly_when_info_is_blocked() {
await using var daemon = new FakeDockerDaemon("403 Forbidden", "{}");
await daemon.StartAsync();
var exitCode = -1;
var output = await YamlCliTestHost.RunAsync(
["discover", "docker", "--docker-host", daemon.Endpoint, "--host", "nas01"],
fs.Root,
outputHelper,
"config.yaml",
code => exitCode = code);
await daemon.Completion;
Assert.Equal(0, exitCode);
Assert.Contains("name: app", output);
Assert.Contains("nas01", output);
}
[Fact]
public async Task remote_daemon_hostname_from_info_is_used_when_available() {
await using var daemon = new FakeDockerDaemon("200 OK", """{"Name":"nas-from-engine","ID":"engine-1"}""");
await daemon.StartAsync();
var exitCode = -1;
var output = await YamlCliTestHost.RunAsync(
["discover", "docker", "--docker-host", daemon.Endpoint],
fs.Root,
outputHelper,
"config.yaml",
code => exitCode = code);
await daemon.Completion;
Assert.Equal(0, exitCode);
Assert.Contains("nas-from-engine", output);
}
[Fact]
public async Task local_unix_socket_discovery_still_emits_the_host_and_service() {
await using var daemon = new FakeUnixDockerDaemon(Path.Combine("/tmp", $"rpk-{Guid.NewGuid():N}.sock"));
await daemon.StartAsync();
var exitCode = -1;
var output = await YamlCliTestHost.RunAsync(
["discover", "docker", "--docker-host", daemon.Endpoint],
fs.Root,
outputHelper,
"config.yaml",
code => exitCode = code);
await daemon.Completion;
Assert.Equal(0, exitCode);
Assert.Contains("kind: System", output);
Assert.Contains("kind: Service", output);
Assert.Contains("runsOn:", output);
}
private sealed class FakeDockerDaemon(string infoStatus, string infoJson) : IAsyncDisposable {
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
public string Endpoint => $"tcp://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
public Task Completion { get; private set; } = Task.CompletedTask;
public Task StartAsync() {
_listener.Start();
Completion = ServeAsync();
return Task.CompletedTask;
}
private async Task ServeAsync() {
for (var i = 0; i < 2; i++) {
using TcpClient connection = await _listener.AcceptTcpClientAsync();
await using NetworkStream stream = connection.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
var request = await reader.ReadLineAsync();
while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) { }
(var status, var body) = request?.Contains("/info", StringComparison.Ordinal) == true
? (infoStatus, infoJson)
: ("200 OK", """[{"Names":["/app"],"Image":"app:latest","Ports":[{"IP":"0.0.0.0","PublicPort":8080,"Type":"tcp"}]}]""");
var bytes = Encoding.UTF8.GetBytes(body);
var headers = Encoding.ASCII.GetBytes(
$"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(headers);
await stream.WriteAsync(bytes);
}
}
public ValueTask DisposeAsync() {
_listener.Stop();
return ValueTask.CompletedTask;
}
}
private sealed class FakeUnixDockerDaemon(string socketPath) : IAsyncDisposable {
private readonly Socket _listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
public string Endpoint => $"unix://{socketPath}";
public Task Completion { get; private set; } = Task.CompletedTask;
public Task StartAsync() {
_listener.Bind(new UnixDomainSocketEndPoint(socketPath));
_listener.Listen(2);
Completion = ServeAsync();
return Task.CompletedTask;
}
private async Task ServeAsync() {
for (var i = 0; i < 1; i++) {
using Socket connection = await _listener.AcceptAsync();
await using var stream = new NetworkStream(connection, ownsSocket: false);
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
var request = await reader.ReadLineAsync();
while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) { }
var isInfo = request?.Contains("/info", StringComparison.Ordinal) == true;
var body = isInfo
? """{"Name":"local-daemon","ID":"local-engine"}"""
: """[{"Names":["/app"],"Image":"app:latest","Ports":[{"IP":"0.0.0.0","PublicPort":8080,"Type":"tcp"}]}]""";
var bytes = Encoding.UTF8.GetBytes(body);
var headers = Encoding.ASCII.GetBytes(
$"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(headers);
await stream.WriteAsync(bytes);
}
}
public ValueTask DisposeAsync() {
_listener.Dispose();
File.Delete(socketPath);
return ValueTask.CompletedTask;
}
}
}