DiscoveryToolTests.cs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. using ModelContextProtocol.Client;
  2. using RackPeek.Mcp.Tools;
  3. namespace Tests.Mcp;
  4. /// <summary>
  5. /// The discovery tools against fake engines answering on real sockets: preview
  6. /// returns reviewable YAML, apply merges it, and a second run changes nothing —
  7. /// the discovery-id identity contract, observed through the MCP surface.
  8. /// </summary>
  9. public class DiscoveryToolTests {
  10. // -- discover_docker ----------------------------------------------------------------
  11. [Fact]
  12. public async Task Docker_discovery_previews_reachable_containers_as_conformant_yaml() {
  13. await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync();
  14. using var api = new McpFixture();
  15. await using McpClient client = await api.ConnectAsync();
  16. DiscoveryResult result = await client.CallOkAsync<DiscoveryResult>(
  17. "discover_docker", new Dictionary<string, object?> {
  18. ["dockerHost"] = $"tcp://{engine.Host}",
  19. ["hostName"] = "nas01"
  20. });
  21. // The fixture holds six containers; redis publishes nothing and pgadmin only
  22. // binds loopback, so four become services.
  23. Assert.Equal(4, result.ResourceCount);
  24. Assert.Equal(2, result.Skipped);
  25. Assert.Null(result.Applied);
  26. Assert.Contains("jellyfin", result.Yaml);
  27. Assert.Contains("wireguard", result.Yaml);
  28. Assert.DoesNotContain("redis", result.Yaml);
  29. Assert.Contains("runsOn", result.Yaml);
  30. Assert.Contains("nas01", result.Yaml);
  31. SchemaAssert.ConformsToSchema(result.Yaml);
  32. // Preview writes nothing.
  33. Assert.DoesNotContain("jellyfin", api.StoredYaml);
  34. }
  35. [Fact]
  36. public async Task Applying_docker_discovery_merges_and_a_second_run_changes_nothing() {
  37. await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync();
  38. using var api = new McpFixture();
  39. await using McpClient client = await api.ConnectAsync();
  40. var args = new Dictionary<string, object?> {
  41. ["dockerHost"] = $"tcp://{engine.Host}",
  42. ["hostName"] = "nas01",
  43. ["apply"] = true
  44. };
  45. DiscoveryResult first = await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
  46. Assert.NotNull(first.Applied);
  47. Assert.Equal(4, first.Applied.Added.Count);
  48. Assert.Contains("jellyfin", api.StoredYaml);
  49. Assert.Contains("discoveryId: rpk1:docker:", api.StoredYaml);
  50. SchemaAssert.ConformsToSchema(api.StoredYaml);
  51. // Same engine, same answers: the ids line everything up, nothing duplicates.
  52. DiscoveryResult second = await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
  53. Assert.NotNull(second.Applied);
  54. Assert.Empty(second.Applied.Added);
  55. Assert.Empty(second.Applied.Updated);
  56. }
  57. [Fact]
  58. public async Task Discovered_services_survive_a_user_rename_on_the_next_apply() {
  59. // The identity contract, end to end: the user renames a discovered service,
  60. // discovery runs again, and the rename sticks because the id matches.
  61. await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync();
  62. using var api = new McpFixture();
  63. await using McpClient client = await api.ConnectAsync();
  64. var args = new Dictionary<string, object?> {
  65. ["dockerHost"] = $"tcp://{engine.Host}",
  66. ["hostName"] = "nas01",
  67. ["apply"] = true
  68. };
  69. await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
  70. await client.CallTextAsync("rename_resource", new Dictionary<string, object?> {
  71. ["name"] = "jellyfin",
  72. ["newName"] = "media-jellyfin"
  73. });
  74. DiscoveryResult again = await client.CallOkAsync<DiscoveryResult>("discover_docker", args);
  75. Assert.NotNull(again.Applied);
  76. Assert.Empty(again.Applied.Added); // not re-added under the old name
  77. Assert.Contains("media-jellyfin", api.StoredYaml);
  78. }
  79. [Fact]
  80. public async Task An_unreachable_docker_engine_is_a_clean_error_naming_the_endpoint() {
  81. using var api = new McpFixture();
  82. await using McpClient client = await api.ConnectAsync();
  83. var error = await client.CallErrorAsync("discover_docker",
  84. new Dictionary<string, object?> { ["dockerHost"] = "tcp://127.0.0.1:1" });
  85. Assert.Contains("Could not reach Docker", error);
  86. Assert.Contains("tcp://127.0.0.1:1", error);
  87. }
  88. [Fact]
  89. public async Task A_malformed_docker_endpoint_is_rejected_before_any_io() {
  90. using var api = new McpFixture();
  91. await using McpClient client = await api.ConnectAsync();
  92. var error = await client.CallErrorAsync("discover_docker",
  93. new Dictionary<string, object?> { ["dockerHost"] = "%%not-an-endpoint%%" });
  94. Assert.Contains("not a usable Docker endpoint", error);
  95. }
  96. // -- discover_proxmox ---------------------------------------------------------------
  97. private static Dictionary<string, string?> PveCredentials() => new() {
  98. ["RPK_PVE_TOKEN_ID"] = "root@pam!rackpeek",
  99. ["RPK_PVE_TOKEN_SECRET"] = "secret-uuid"
  100. };
  101. [Fact]
  102. public async Task Proxmox_discovery_previews_nodes_and_guests_wired_together() {
  103. await using FakeHttpServer pve = await FakeHttpServer.StartProxmoxAsync();
  104. using var api = new McpFixture(extraConfig: PveCredentials());
  105. await using McpClient client = await api.ConnectAsync();
  106. DiscoveryResult result = await client.CallOkAsync<DiscoveryResult>(
  107. "discover_proxmox", new Dictionary<string, object?> { ["host"] = pve.BaseUrl });
  108. Assert.Null(result.Applied);
  109. SchemaAssert.ConformsToSchema(result.Yaml);
  110. // Two fixture nodes, each a Server plus its hypervisor System...
  111. Assert.Contains("pve01", result.Yaml);
  112. Assert.Contains("pve02", result.Yaml);
  113. Assert.Contains("kind: Server", result.Yaml);
  114. Assert.Contains("hypervisor", result.Yaml);
  115. // ...and guests parented onto them, deduped by vmid even though both fake
  116. // nodes reported the same guest lists (the in-flight migration case).
  117. Assert.Contains("docker-01", result.Yaml);
  118. Assert.Contains("pihole", result.Yaml);
  119. Assert.Single(
  120. result.Yaml.Split(Environment.NewLine),
  121. l => l.Contains("name: pihole"));
  122. }
  123. [Fact]
  124. public async Task Applying_proxmox_discovery_persists_the_estate() {
  125. await using FakeHttpServer pve = await FakeHttpServer.StartProxmoxAsync();
  126. using var api = new McpFixture(extraConfig: PveCredentials());
  127. await using McpClient client = await api.ConnectAsync();
  128. DiscoveryResult result = await client.CallOkAsync<DiscoveryResult>(
  129. "discover_proxmox",
  130. new Dictionary<string, object?> { ["host"] = pve.BaseUrl, ["apply"] = true });
  131. Assert.NotNull(result.Applied);
  132. Assert.NotEmpty(result.Applied.Added);
  133. Assert.Contains("pve01", api.StoredYaml);
  134. Assert.Contains("discoveryId: rpk1:pve:", api.StoredYaml);
  135. SchemaAssert.ConformsToSchema(api.StoredYaml);
  136. }
  137. [Fact]
  138. public async Task Missing_proxmox_credentials_point_at_the_exact_settings_to_set() {
  139. using var api = new McpFixture(); // no RPK_PVE_* configured
  140. await using McpClient client = await api.ConnectAsync();
  141. var error = await client.CallErrorAsync("discover_proxmox",
  142. new Dictionary<string, object?> { ["host"] = "https://pve.lan:8006" });
  143. Assert.Contains("RPK_PVE_TOKEN_ID", error);
  144. Assert.Contains("RPK_PVE_TOKEN_SECRET", error);
  145. }
  146. [Fact]
  147. public async Task An_unreachable_proxmox_host_is_a_clean_error_naming_the_endpoint() {
  148. using var api = new McpFixture(extraConfig: PveCredentials());
  149. await using McpClient client = await api.ConnectAsync();
  150. var error = await client.CallErrorAsync("discover_proxmox",
  151. new Dictionary<string, object?> { ["host"] = "http://127.0.0.1:1" });
  152. Assert.Contains("Could not read", error);
  153. Assert.Contains("http://127.0.0.1:1", error);
  154. }
  155. }