RemoteDockerDiscoveryTests.cs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. using RackPeek.Domain.Discovery;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.Services;
  4. using RackPeek.Domain.Resources.SystemResources;
  5. namespace Tests.Discovery;
  6. /// <summary>
  7. /// Over TCP the machine running the command is not the machine running the
  8. /// containers, so nothing probed locally may leak into what gets recorded: identity
  9. /// comes from the engine (<c>GET /info</c>), the address from the endpoint the user
  10. /// dialled, and a rename of the host is preserved by the merge rather than by the
  11. /// collector, which cannot see it.
  12. /// </summary>
  13. public class RemoteDockerDiscoveryTests {
  14. // -- GET /info --------------------------------------------------------------------
  15. [Fact]
  16. public void The_engines_identity_is_read_from_info() {
  17. DockerEngineInfo? info = DockerEngineInfoParser.Parse(Fixture.Read("docker-info.json"));
  18. Assert.NotNull(info);
  19. Assert.Equal("e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d", info.Id);
  20. Assert.Equal("nas01", info.Hostname);
  21. }
  22. [Theory]
  23. [InlineData("{}")]
  24. [InlineData("""{"ID": "", "Name": " "}""")]
  25. [InlineData("""{"ID": 42, "Name": ["nas01"]}""")]
  26. public void Missing_or_unusable_fields_come_back_null_rather_than_empty(string json) {
  27. DockerEngineInfo? info = DockerEngineInfoParser.Parse(json);
  28. Assert.NotNull(info);
  29. Assert.Null(info.Id);
  30. Assert.Null(info.Hostname);
  31. }
  32. [Theory]
  33. [InlineData("not json at all")]
  34. [InlineData("[]")]
  35. [InlineData("\"a string\"")]
  36. public void A_broken_info_response_is_null_not_an_exception(string json) =>
  37. Assert.Null(DockerEngineInfoParser.Parse(json));
  38. // -- The endpoint -----------------------------------------------------------------
  39. [Theory]
  40. [InlineData("tcp://nas01:2375", "nas01")]
  41. [InlineData("tcp://192.168.1.20:2375", "192.168.1.20")]
  42. [InlineData("http://nas01.lan:2376", "nas01.lan")]
  43. public void The_remote_host_is_the_host_part_of_the_endpoint(string endpoint, string expected) {
  44. using var client = new DockerApiClient(endpoint);
  45. Assert.Equal(expected, client.RemoteHost);
  46. }
  47. [Fact]
  48. public void A_local_socket_has_no_remote_host() {
  49. using var client = new DockerApiClient("unix:///var/run/docker.sock");
  50. Assert.Null(client.RemoteHost);
  51. }
  52. [Fact]
  53. public async Task An_ipv4_endpoint_address_is_used_verbatim() =>
  54. Assert.Equal("192.168.1.20", await DockerApiClient.ResolveIpv4Async("192.168.1.20"));
  55. [Fact]
  56. public async Task An_ipv6_endpoint_yields_null_because_the_schema_holds_ipv4() =>
  57. Assert.Null(await DockerApiClient.ResolveIpv4Async("::1"));
  58. [Fact]
  59. public async Task A_resolvable_name_becomes_its_ipv4_address() =>
  60. // localhost is the one name every test machine resolves without real DNS.
  61. Assert.Equal("127.0.0.1", await DockerApiClient.ResolveIpv4Async("localhost"));
  62. [Fact]
  63. public async Task An_unresolvable_name_is_null_rather_than_an_exception() =>
  64. Assert.Null(await DockerApiClient.ResolveIpv4Async("host name with spaces!"));
  65. // -- Identity independent of the workstation ---------------------------------------
  66. [Fact]
  67. public void Two_workstations_discovering_the_same_engine_agree_on_every_id() {
  68. List<DockerContainer> containers = DockerContainerParser.Parse(Fixture.Read("docker-containers.json"));
  69. const string engineId = "e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d";
  70. // Same engine seed; everything the workstation contributes differs.
  71. List<Service> fromLaptop = DockerServiceMapper.ToResources(containers, engineId, "nas01", "192.168.1.20");
  72. List<Service> fromCi = DockerServiceMapper.ToResources(containers, engineId, "nas01", "10.0.0.9");
  73. Assert.Equal(
  74. fromLaptop.Select(s => s.DiscoveryId),
  75. fromCi.Select(s => s.DiscoveryId));
  76. }
  77. // -- Preserving the user's links when the collector cannot see the host ------------
  78. private static SystemResource StoredHost(string name) => new() {
  79. Kind = SystemResource.KindLabel,
  80. Name = name,
  81. DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"),
  82. Type = "baremetal",
  83. Os = "Debian",
  84. Cores = 12,
  85. Ram = 63
  86. };
  87. private static Service ServiceRunningOn(string host, string container = "jellyfin") => new() {
  88. Kind = Service.KindLabel,
  89. Name = container,
  90. DiscoveryId = DiscoveryId.Create(DiscoveryId.DockerScheme, $"engine-a/{container}"),
  91. RunsOn = [host]
  92. };
  93. [Fact]
  94. public void A_dangling_runs_on_keeps_the_stored_link_the_user_chose() {
  95. // The host was renamed on the server; a remote collector still sends the
  96. // hostname it sees, which no longer names anything.
  97. List<Resource> existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")];
  98. List<Resource> incoming = [ServiceRunningOn("nas01")];
  99. DiscoveryIdResolver.ResolveNames(existing, incoming);
  100. Assert.Equal(["storage-01"], incoming[0].RunsOn);
  101. }
  102. [Fact]
  103. public void A_genuine_move_to_a_documented_host_is_recorded() {
  104. List<Resource> existing = [StoredHost("storage-01"), StoredHost2("pi01"), ServiceRunningOn("storage-01")];
  105. List<Resource> incoming = [ServiceRunningOn("pi01")];
  106. DiscoveryIdResolver.ResolveNames(existing, incoming);
  107. Assert.Equal(["pi01"], incoming[0].RunsOn);
  108. }
  109. [Fact]
  110. public void A_move_to_a_host_arriving_in_the_same_payload_is_recorded() {
  111. // Local discovery sends the host along; its (possibly renamed) name anchors
  112. // the services, so nothing here should fall back to the stored link.
  113. List<Resource> existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")];
  114. List<Resource> incoming = [StoredHost2("pi01"), ServiceRunningOn("pi01")];
  115. DiscoveryIdResolver.ResolveNames(existing, incoming);
  116. Assert.Equal(["pi01"], incoming[1].RunsOn);
  117. }
  118. [Fact]
  119. public void A_service_seen_for_the_first_time_keeps_whatever_it_reports() {
  120. // Nothing stored to preserve: the dangling name is still the best available.
  121. List<Resource> existing = [StoredHost("storage-01")];
  122. List<Resource> incoming = [ServiceRunningOn("nas01")];
  123. DiscoveryIdResolver.ResolveNames(existing, incoming);
  124. Assert.Equal(["nas01"], incoming[0].RunsOn);
  125. }
  126. [Fact]
  127. public void A_stored_service_with_no_link_gains_the_reported_one() {
  128. Service unparented = ServiceRunningOn("storage-01");
  129. unparented.RunsOn = [];
  130. List<Resource> existing = [unparented];
  131. List<Resource> incoming = [ServiceRunningOn("nas01")];
  132. DiscoveryIdResolver.ResolveNames(existing, incoming);
  133. Assert.Equal(["nas01"], incoming[0].RunsOn);
  134. }
  135. /// <summary>A second stored host with its own identity.</summary>
  136. private static SystemResource StoredHost2(string name) {
  137. SystemResource host = StoredHost(name);
  138. host.DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-b");
  139. return host;
  140. }
  141. }