using RackPeek.Domain.Discovery; using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.SystemResources; namespace Tests.Discovery; /// /// Over TCP the machine running the command is not the machine running the /// containers, so nothing probed locally may leak into what gets recorded: identity /// comes from the engine (GET /info), the address from the endpoint the user /// dialled, and a rename of the host is preserved by the merge rather than by the /// collector, which cannot see it. /// public class RemoteDockerDiscoveryTests { // -- GET /info -------------------------------------------------------------------- [Fact] public void The_engines_identity_is_read_from_info() { DockerEngineInfo? info = DockerEngineInfoParser.Parse(Fixture.Read("docker-info.json")); Assert.NotNull(info); Assert.Equal("e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d", info.Id); Assert.Equal("nas01", info.Hostname); } [Theory] [InlineData("{}")] [InlineData("""{"ID": "", "Name": " "}""")] [InlineData("""{"ID": 42, "Name": ["nas01"]}""")] public void Missing_or_unusable_fields_come_back_null_rather_than_empty(string json) { DockerEngineInfo? info = DockerEngineInfoParser.Parse(json); Assert.NotNull(info); Assert.Null(info.Id); Assert.Null(info.Hostname); } [Theory] [InlineData("not json at all")] [InlineData("[]")] [InlineData("\"a string\"")] public void A_broken_info_response_is_null_not_an_exception(string json) => Assert.Null(DockerEngineInfoParser.Parse(json)); // -- The endpoint ----------------------------------------------------------------- [Theory] [InlineData("tcp://nas01:2375", "nas01")] [InlineData("tcp://192.168.1.20:2375", "192.168.1.20")] [InlineData("http://nas01.lan:2376", "nas01.lan")] public void The_remote_host_is_the_host_part_of_the_endpoint(string endpoint, string expected) { using var client = new DockerApiClient(endpoint); Assert.Equal(expected, client.RemoteHost); } [Fact] public void A_local_socket_has_no_remote_host() { using var client = new DockerApiClient("unix:///var/run/docker.sock"); Assert.Null(client.RemoteHost); } [Fact] public async Task An_ipv4_endpoint_address_is_used_verbatim() => Assert.Equal("192.168.1.20", await DockerApiClient.ResolveIpv4Async("192.168.1.20")); [Fact] public async Task An_ipv6_endpoint_yields_null_because_the_schema_holds_ipv4() => Assert.Null(await DockerApiClient.ResolveIpv4Async("::1")); [Fact] public async Task A_resolvable_name_becomes_its_ipv4_address() => // localhost is the one name every test machine resolves without real DNS. Assert.Equal("127.0.0.1", await DockerApiClient.ResolveIpv4Async("localhost")); [Fact] public async Task An_unresolvable_name_is_null_rather_than_an_exception() => Assert.Null(await DockerApiClient.ResolveIpv4Async("host name with spaces!")); // -- Identity independent of the workstation --------------------------------------- [Fact] public void Two_workstations_discovering_the_same_engine_agree_on_every_id() { List containers = DockerContainerParser.Parse(Fixture.Read("docker-containers.json")); const string engineId = "e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d"; // Same engine seed; everything the workstation contributes differs. List fromLaptop = DockerServiceMapper.ToResources(containers, engineId, "nas01", "192.168.1.20"); List fromCi = DockerServiceMapper.ToResources(containers, engineId, "nas01", "10.0.0.9"); Assert.Equal( fromLaptop.Select(s => s.DiscoveryId), fromCi.Select(s => s.DiscoveryId)); } // -- Preserving the user's links when the collector cannot see the host ------------ private static SystemResource StoredHost(string name) => new() { Kind = SystemResource.KindLabel, Name = name, DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"), Type = "baremetal", Os = "Debian", Cores = 12, Ram = 63 }; private static Service ServiceRunningOn(string host, string container = "jellyfin") => new() { Kind = Service.KindLabel, Name = container, DiscoveryId = DiscoveryId.Create(DiscoveryId.DockerScheme, $"engine-a/{container}"), RunsOn = [host] }; [Fact] public void A_dangling_runs_on_keeps_the_stored_link_the_user_chose() { // The host was renamed on the server; a remote collector still sends the // hostname it sees, which no longer names anything. List existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")]; List incoming = [ServiceRunningOn("nas01")]; DiscoveryIdResolver.ResolveNames(existing, incoming); Assert.Equal(["storage-01"], incoming[0].RunsOn); } [Fact] public void A_genuine_move_to_a_documented_host_is_recorded() { List existing = [StoredHost("storage-01"), StoredHost2("pi01"), ServiceRunningOn("storage-01")]; List incoming = [ServiceRunningOn("pi01")]; DiscoveryIdResolver.ResolveNames(existing, incoming); Assert.Equal(["pi01"], incoming[0].RunsOn); } [Fact] public void A_move_to_a_host_arriving_in_the_same_payload_is_recorded() { // Local discovery sends the host along; its (possibly renamed) name anchors // the services, so nothing here should fall back to the stored link. List existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")]; List incoming = [StoredHost2("pi01"), ServiceRunningOn("pi01")]; DiscoveryIdResolver.ResolveNames(existing, incoming); Assert.Equal(["pi01"], incoming[1].RunsOn); } [Fact] public void A_service_seen_for_the_first_time_keeps_whatever_it_reports() { // Nothing stored to preserve: the dangling name is still the best available. List existing = [StoredHost("storage-01")]; List incoming = [ServiceRunningOn("nas01")]; DiscoveryIdResolver.ResolveNames(existing, incoming); Assert.Equal(["nas01"], incoming[0].RunsOn); } [Fact] public void A_stored_service_with_no_link_gains_the_reported_one() { Service unparented = ServiceRunningOn("storage-01"); unparented.RunsOn = []; List existing = [unparented]; List incoming = [ServiceRunningOn("nas01")]; DiscoveryIdResolver.ResolveNames(existing, incoming); Assert.Equal(["nas01"], incoming[0].RunsOn); } /// A second stored host with its own identity. private static SystemResource StoredHost2(string name) { SystemResource host = StoredHost(name); host.DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-b"); return host; } }