DiscoveryMergeTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. using RackPeek.Domain.Api;
  2. using RackPeek.Domain.Discovery;
  3. using RackPeek.Domain.Resources.Services;
  4. using RackPeek.Domain.Resources.SystemResources;
  5. namespace Tests.Discovery;
  6. /// <summary>
  7. /// The promise discovery has to keep: run it as often as you like, from a timer,
  8. /// and it updates what is already there instead of piling up duplicates — even
  9. /// after the resource has been renamed by hand.
  10. /// Every test here goes over HTTP into a real server and asserts on the stored YAML.
  11. /// </summary>
  12. public class DiscoveryMergeTests {
  13. private static SystemFacts Facts(
  14. string machineId = "machine-a",
  15. string hostname = "nas01",
  16. double ramGb = 63) {
  17. return new SystemFacts {
  18. Hostname = hostname,
  19. MachineId = machineId,
  20. Os = "Debian GNU/Linux 12 (bookworm)",
  21. Cores = 12,
  22. RamGb = ramGb,
  23. Type = "baremetal",
  24. Ip = "192.168.1.20"
  25. };
  26. }
  27. private static string SystemYaml(
  28. string machineId = "machine-a",
  29. string hostname = "nas01",
  30. double ramGb = 63) =>
  31. DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(Facts(machineId, hostname, ramGb))]);
  32. private static string IdFor(string machineId) =>
  33. DiscoveryId.Create(DiscoveryId.SystemScheme, machineId);
  34. [Fact]
  35. public async Task First_run_adds_the_machine() {
  36. using var api = new DiscoveryApiFixture();
  37. ImportYamlResponse response = await api.PublishAsync(SystemYaml());
  38. Assert.Equal(["nas01"], response.Added);
  39. Assert.Contains($"discoveryId: {IdFor("machine-a")}", api.StoredYaml);
  40. }
  41. [Fact]
  42. public async Task A_v3_config_still_imports_and_is_saved_as_v4() {
  43. using var api = new DiscoveryApiFixture();
  44. // discoveryId arrived with schema v4; a pre-discovery v3 file must keep working.
  45. ImportYamlResponse response = await api.PublishAsync("""
  46. version: 3
  47. resources:
  48. - kind: System
  49. name: nas01
  50. type: baremetal
  51. os: Debian
  52. cores: 12
  53. ram: 63
  54. """);
  55. Assert.Equal(["nas01"], response.Added);
  56. Assert.StartsWith("version: 4", api.StoredYaml);
  57. }
  58. [Fact]
  59. public async Task Running_again_updates_rather_than_duplicating() {
  60. using var api = new DiscoveryApiFixture();
  61. await api.PublishAsync(SystemYaml());
  62. ImportYamlResponse second = await api.PublishAsync(SystemYaml(ramGb: 127));
  63. Assert.Empty(second.Added);
  64. Assert.Equal(["nas01"], second.Updated);
  65. Assert.Equal(1, Count(api.StoredYaml, "name: nas01"));
  66. Assert.Contains("ram: 127", api.StoredYaml);
  67. }
  68. [Fact]
  69. public async Task A_machine_renamed_by_hand_is_still_found_by_its_id() {
  70. using var api = new DiscoveryApiFixture();
  71. // What the inventory looks like after the user renamed it in the web UI.
  72. await api.PublishAsync($"""
  73. version: 3
  74. resources:
  75. - kind: System
  76. name: storage-01
  77. type: baremetal
  78. os: Debian GNU/Linux 12 (bookworm)
  79. cores: 12
  80. ram: 63
  81. discoveryId: {IdFor("machine-a")}
  82. """);
  83. // The machine itself still reports its hostname.
  84. ImportYamlResponse response = await api.PublishAsync(SystemYaml(ramGb: 127));
  85. Assert.Empty(response.Added);
  86. Assert.Equal(["storage-01"], response.Updated);
  87. Assert.DoesNotContain("nas01", api.StoredYaml);
  88. Assert.Contains("ram: 127", api.StoredYaml);
  89. }
  90. [Fact]
  91. public async Task A_hand_written_resource_of_the_same_name_is_adopted_not_duplicated() {
  92. using var api = new DiscoveryApiFixture();
  93. await api.PublishAsync("""
  94. version: 3
  95. resources:
  96. - kind: System
  97. name: nas01
  98. type: baremetal
  99. os: Debian
  100. cores: 12
  101. ram: 63
  102. notes: bought in 2019
  103. """);
  104. ImportYamlResponse response = await api.PublishAsync(SystemYaml());
  105. Assert.Empty(response.Added);
  106. Assert.Equal(["nas01"], response.Updated);
  107. Assert.Equal(1, Count(api.StoredYaml, "name: nas01"));
  108. // Adoption keeps what the user wrote, and the resource gains an identity.
  109. Assert.Contains("2019", api.StoredYaml);
  110. Assert.Contains($"discoveryId: {IdFor("machine-a")}", api.StoredYaml);
  111. }
  112. [Fact]
  113. public async Task A_second_machine_with_the_same_hostname_does_not_hijack_the_first() {
  114. using var api = new DiscoveryApiFixture();
  115. await api.PublishAsync(SystemYaml("machine-a"));
  116. ImportYamlResponse response = await api.PublishAsync(SystemYaml("machine-b"));
  117. // The newcomer stands aside rather than overwriting, and both are kept.
  118. Assert.Single(response.Added);
  119. Assert.StartsWith("nas01-", response.Added[0]);
  120. Assert.Equal(1, Count(api.StoredYaml, "name: nas01\n"));
  121. Assert.Contains($"name: {response.Added[0]}", api.StoredYaml);
  122. }
  123. [Fact]
  124. public async Task Services_follow_the_host_when_it_has_been_renamed() {
  125. using var api = new DiscoveryApiFixture();
  126. await api.PublishAsync($"""
  127. version: 3
  128. resources:
  129. - kind: System
  130. name: storage-01
  131. type: baremetal
  132. os: Debian
  133. cores: 12
  134. ram: 63
  135. discoveryId: {IdFor("machine-a")}
  136. """);
  137. // Docker discovery on that machine still knows it only by its hostname. The
  138. // payload below — host System first, then its services — is exactly what
  139. // DiscoverDockerCommand sends for a local engine; the host rides along because
  140. // this rename could not be reconciled from the services' bare runsOn names.
  141. SystemResource host = SystemResourceMapper.ToResource(Facts());
  142. List<Service> services = DockerServiceMapper.ToResources(
  143. DockerContainerParser.Parse(Fixture.Read("docker-containers.json")),
  144. "machine-a",
  145. host.Name,
  146. "192.168.1.20");
  147. await api.PublishAsync(DiscoveryDocument.ToYaml([host, .. services]));
  148. // runsOn was rewritten to the name the user chose, so the tree is not broken.
  149. Assert.Contains("- storage-01", api.StoredYaml);
  150. Assert.DoesNotContain("nas01", api.StoredYaml);
  151. }
  152. [Fact]
  153. public async Task A_remote_engines_services_follow_a_rename_they_cannot_see() {
  154. // A remote collector sends services only — no host System rides along, because
  155. // the facts it probes locally describe the wrong machine. What the inventory
  156. // looks like after the user documented the host and renamed it:
  157. using var api = new DiscoveryApiFixture($"""
  158. version: 4
  159. resources:
  160. - kind: System
  161. name: storage-01
  162. type: baremetal
  163. os: Debian
  164. cores: 12
  165. ram: 63
  166. discoveryId: {IdFor("machine-a")}
  167. - kind: Service
  168. name: jellyfin
  169. discoveryId: {DiscoveryId.Create(DiscoveryId.DockerScheme, "engine-a/jellyfin")}
  170. network:
  171. ip: 192.168.1.20
  172. port: 8096
  173. protocol: TCP
  174. runsOn:
  175. - storage-01
  176. """);
  177. // The engine still reports its hostname, which no longer names anything here.
  178. Service jellyfin = DockerServiceMapper.ToResources(
  179. DockerContainerParser.Parse(Fixture.Read("docker-containers.json")),
  180. "engine-a",
  181. "nas01",
  182. "192.168.1.20")
  183. .Single(s => s.Name == "jellyfin");
  184. await api.PublishAsync(DiscoveryDocument.ToYaml([jellyfin]));
  185. // The user's link survived the re-discovery; the stale hostname did not land.
  186. Assert.Contains("- storage-01", api.StoredYaml);
  187. Assert.Equal(1, Count(api.StoredYaml, "name: jellyfin"));
  188. Assert.DoesNotContain("- nas01", api.StoredYaml);
  189. }
  190. [Fact]
  191. public async Task A_dry_run_reports_the_change_without_making_it() {
  192. using var api = new DiscoveryApiFixture();
  193. ImportYamlResponse response = await api.PublishAsync(SystemYaml(), true);
  194. Assert.Equal(["nas01"], response.Added);
  195. Assert.DoesNotContain("nas01", api.StoredYaml);
  196. }
  197. [Fact]
  198. public async Task Machines_sharing_a_cloned_machine_id_are_rejected_rather_than_silently_merged() {
  199. using var api = new DiscoveryApiFixture();
  200. var yaml = DiscoveryDocument.ToYaml([
  201. SystemResourceMapper.ToResource(Facts("clone", "vm-a")),
  202. SystemResourceMapper.ToResource(Facts("clone", "vm-b"))
  203. ]);
  204. InvalidOperationException error =
  205. await Assert.ThrowsAsync<InvalidOperationException>(() => api.PublishAsync(yaml));
  206. Assert.Contains("machine-id", error.Message);
  207. }
  208. private static int Count(string haystack, string needle) {
  209. var count = 0;
  210. var index = haystack.IndexOf(needle, StringComparison.Ordinal);
  211. while (index >= 0) {
  212. count++;
  213. index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal);
  214. }
  215. return count;
  216. }
  217. [Fact]
  218. public async Task Discovering_a_machine_does_not_destroy_hardware_documented_under_the_same_name() {
  219. using var api = new DiscoveryApiFixture();
  220. // A very ordinary starting point: the box was documented as hardware by hand.
  221. await api.PublishAsync("""
  222. version: 3
  223. resources:
  224. - kind: Server
  225. name: nas01
  226. notes: 4U chassis, bought 2019
  227. ram:
  228. size: 64
  229. """);
  230. ImportYamlResponse response = await api.PublishAsync(SystemYaml());
  231. // The hardware is untouched...
  232. Assert.Contains("kind: Server", api.StoredYaml);
  233. Assert.Contains("4U chassis", api.StoredYaml);
  234. // ...and the operating system is recorded alongside it rather than instead of it.
  235. Assert.Single(response.Added);
  236. Assert.StartsWith("nas01-", response.Added[0]);
  237. Assert.Contains("kind: System", api.StoredYaml);
  238. }
  239. [Fact]
  240. public async Task Many_machines_pushing_at_once_do_not_lose_each_other() {
  241. // The realistic shape of this feature: a fleet on the same nightly timer, all
  242. // arriving within the same second. A read-modify-write that is not serialised
  243. // would silently drop most of them.
  244. using var api = new DiscoveryApiFixture();
  245. const int machines = 12;
  246. await Task.WhenAll(Enumerable.Range(0, machines)
  247. .Select(i => api.PublishAsync(SystemYaml($"machine-{i}", $"box-{i:00}"))));
  248. var stored = api.StoredYaml;
  249. for (var i = 0; i < machines; i++) {
  250. Assert.Contains($"name: box-{i:00}", stored);
  251. Assert.Contains($"discoveryId: {IdFor($"machine-{i}")}", stored);
  252. }
  253. Assert.Equal(machines, Count(stored, "kind: System"));
  254. }
  255. [Fact]
  256. public async Task Repeated_runs_converge_rather_than_accumulating() {
  257. using var api = new DiscoveryApiFixture();
  258. for (var i = 0; i < 10; i++)
  259. await api.PublishAsync(SystemYaml(ramGb: 60 + i));
  260. Assert.Equal(1, Count(api.StoredYaml, "kind: System"));
  261. Assert.Contains("ram: 69", api.StoredYaml);
  262. }
  263. private static string ProxmoxYaml(string guestName = "docker-01") {
  264. ProxmoxNode node = new() {
  265. Name = "pve01",
  266. Cores = 12,
  267. MemoryBytes = 67438305280,
  268. Version = "pve-manager/8.2.2/x"
  269. };
  270. ProxmoxGuest guest = new() {
  271. VmId = 104,
  272. Node = "pve01",
  273. Name = guestName,
  274. Type = "vm",
  275. Cores = 4,
  276. MemoryBytes = 8589934592,
  277. Os = "Linux"
  278. };
  279. return DiscoveryDocument.ToYaml(ProxmoxResourceMapper.ToResources("homelab", [node], [guest]));
  280. }
  281. [Fact]
  282. public async Task A_proxmox_estate_arrives_with_its_tree_intact() {
  283. using var api = new DiscoveryApiFixture();
  284. ImportYamlResponse response = await api.PublishAsync(ProxmoxYaml());
  285. // The machine, the hypervisor on it, and the guest on that.
  286. Assert.Equal(["pve01", "pve01-pve", "docker-01"], response.Added);
  287. // The relationships are the tedious part to type, so they have to survive.
  288. Assert.Contains("kind: Server", api.StoredYaml);
  289. Assert.Contains("- pve01\n", api.StoredYaml);
  290. Assert.Contains("- pve01-pve", api.StoredYaml);
  291. }
  292. [Fact]
  293. public async Task A_guest_renamed_in_proxmox_updates_rather_than_duplicating() {
  294. using var api = new DiscoveryApiFixture();
  295. await api.PublishAsync(ProxmoxYaml());
  296. ImportYamlResponse response = await api.PublishAsync(ProxmoxYaml("docker-renamed"));
  297. // The vmid is the identity, so renaming the guest in Proxmox does not make a
  298. // second resource — and the name the user sees in RackPeek is left alone.
  299. Assert.Empty(response.Added);
  300. Assert.Equal(1, Count(api.StoredYaml, "type: vm"));
  301. Assert.DoesNotContain("docker-renamed", api.StoredYaml);
  302. }
  303. [Fact]
  304. public async Task Known_limitation_a_guest_discovered_twice_over_is_two_resources() {
  305. // Proxmox identifies a guest by vmid; the guest identifies itself by machine-id.
  306. // Neither can derive the other, so running both collectors over the same machine
  307. // produces two resources. Documented rather than silently surprising: the second
  308. // one is reported as an addition with a suffixed name, not merged into the first.
  309. using var api = new DiscoveryApiFixture();
  310. await api.PublishAsync(ProxmoxYaml());
  311. ImportYamlResponse response = await api.PublishAsync(SystemYaml(hostname: "docker-01"));
  312. Assert.Single(response.Added);
  313. Assert.StartsWith("docker-01-", response.Added[0]);
  314. }
  315. }