| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- using RackPeek.Domain.Api;
- using RackPeek.Domain.Discovery;
- using RackPeek.Domain.Resources.Services;
- using RackPeek.Domain.Resources.SystemResources;
- namespace Tests.Discovery;
- /// <summary>
- /// The promise discovery has to keep: run it as often as you like, from a timer,
- /// and it updates what is already there instead of piling up duplicates — even
- /// after the resource has been renamed by hand.
- /// Every test here goes over HTTP into a real server and asserts on the stored YAML.
- /// </summary>
- public class DiscoveryMergeTests {
- private static SystemFacts Facts(
- string machineId = "machine-a",
- string hostname = "nas01",
- double ramGb = 63) {
- return new SystemFacts {
- Hostname = hostname,
- MachineId = machineId,
- Os = "Debian GNU/Linux 12 (bookworm)",
- Cores = 12,
- RamGb = ramGb,
- Type = "baremetal",
- Ip = "192.168.1.20"
- };
- }
- private static string SystemYaml(
- string machineId = "machine-a",
- string hostname = "nas01",
- double ramGb = 63) =>
- DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(Facts(machineId, hostname, ramGb))]);
- private static string IdFor(string machineId) =>
- DiscoveryId.Create(DiscoveryId.SystemScheme, machineId);
- [Fact]
- public async Task First_run_adds_the_machine() {
- using var api = new DiscoveryApiFixture();
- ImportYamlResponse response = await api.PublishAsync(SystemYaml());
- Assert.Equal(["nas01"], response.Added);
- Assert.Contains($"discoveryId: {IdFor("machine-a")}", api.StoredYaml);
- }
- [Fact]
- public async Task A_v3_config_still_imports_and_is_saved_as_v4() {
- using var api = new DiscoveryApiFixture();
- // discoveryId arrived with schema v4; a pre-discovery v3 file must keep working.
- ImportYamlResponse response = await api.PublishAsync("""
- version: 3
- resources:
- - kind: System
- name: nas01
- type: baremetal
- os: Debian
- cores: 12
- ram: 63
- """);
- Assert.Equal(["nas01"], response.Added);
- Assert.StartsWith("version: 4", api.StoredYaml);
- }
- [Fact]
- public async Task Running_again_updates_rather_than_duplicating() {
- using var api = new DiscoveryApiFixture();
- await api.PublishAsync(SystemYaml());
- ImportYamlResponse second = await api.PublishAsync(SystemYaml(ramGb: 127));
- Assert.Empty(second.Added);
- Assert.Equal(["nas01"], second.Updated);
- Assert.Equal(1, Count(api.StoredYaml, "name: nas01"));
- Assert.Contains("ram: 127", api.StoredYaml);
- }
- [Fact]
- public async Task A_machine_renamed_by_hand_is_still_found_by_its_id() {
- using var api = new DiscoveryApiFixture();
- // What the inventory looks like after the user renamed it in the web UI.
- await api.PublishAsync($"""
- version: 3
- resources:
- - kind: System
- name: storage-01
- type: baremetal
- os: Debian GNU/Linux 12 (bookworm)
- cores: 12
- ram: 63
- discoveryId: {IdFor("machine-a")}
- """);
- // The machine itself still reports its hostname.
- ImportYamlResponse response = await api.PublishAsync(SystemYaml(ramGb: 127));
- Assert.Empty(response.Added);
- Assert.Equal(["storage-01"], response.Updated);
- Assert.DoesNotContain("nas01", api.StoredYaml);
- Assert.Contains("ram: 127", api.StoredYaml);
- }
- [Fact]
- public async Task A_hand_written_resource_of_the_same_name_is_adopted_not_duplicated() {
- using var api = new DiscoveryApiFixture();
- await api.PublishAsync("""
- version: 3
- resources:
- - kind: System
- name: nas01
- type: baremetal
- os: Debian
- cores: 12
- ram: 63
- notes: bought in 2019
- """);
- ImportYamlResponse response = await api.PublishAsync(SystemYaml());
- Assert.Empty(response.Added);
- Assert.Equal(["nas01"], response.Updated);
- Assert.Equal(1, Count(api.StoredYaml, "name: nas01"));
- // Adoption keeps what the user wrote, and the resource gains an identity.
- Assert.Contains("2019", api.StoredYaml);
- Assert.Contains($"discoveryId: {IdFor("machine-a")}", api.StoredYaml);
- }
- [Fact]
- public async Task A_second_machine_with_the_same_hostname_does_not_hijack_the_first() {
- using var api = new DiscoveryApiFixture();
- await api.PublishAsync(SystemYaml("machine-a"));
- ImportYamlResponse response = await api.PublishAsync(SystemYaml("machine-b"));
- // The newcomer stands aside rather than overwriting, and both are kept.
- Assert.Single(response.Added);
- Assert.StartsWith("nas01-", response.Added[0]);
- Assert.Equal(1, Count(api.StoredYaml, "name: nas01\n"));
- Assert.Contains($"name: {response.Added[0]}", api.StoredYaml);
- }
- [Fact]
- public async Task Services_follow_the_host_when_it_has_been_renamed() {
- using var api = new DiscoveryApiFixture();
- await api.PublishAsync($"""
- version: 3
- resources:
- - kind: System
- name: storage-01
- type: baremetal
- os: Debian
- cores: 12
- ram: 63
- discoveryId: {IdFor("machine-a")}
- """);
- // Docker discovery on that machine still knows it only by its hostname. The
- // payload below — host System first, then its services — is exactly what
- // DiscoverDockerCommand sends for a local engine; the host rides along because
- // this rename could not be reconciled from the services' bare runsOn names.
- SystemResource host = SystemResourceMapper.ToResource(Facts());
- List<Service> services = DockerServiceMapper.ToResources(
- DockerContainerParser.Parse(Fixture.Read("docker-containers.json")),
- "machine-a",
- host.Name,
- "192.168.1.20");
- await api.PublishAsync(DiscoveryDocument.ToYaml([host, .. services]));
- // runsOn was rewritten to the name the user chose, so the tree is not broken.
- Assert.Contains("- storage-01", api.StoredYaml);
- Assert.DoesNotContain("nas01", api.StoredYaml);
- }
- [Fact]
- public async Task A_remote_engines_services_follow_a_rename_they_cannot_see() {
- // A remote collector sends services only — no host System rides along, because
- // the facts it probes locally describe the wrong machine. What the inventory
- // looks like after the user documented the host and renamed it:
- using var api = new DiscoveryApiFixture($"""
- version: 4
- resources:
- - kind: System
- name: storage-01
- type: baremetal
- os: Debian
- cores: 12
- ram: 63
- discoveryId: {IdFor("machine-a")}
- - kind: Service
- name: jellyfin
- discoveryId: {DiscoveryId.Create(DiscoveryId.DockerScheme, "engine-a/jellyfin")}
- network:
- ip: 192.168.1.20
- port: 8096
- protocol: TCP
- runsOn:
- - storage-01
- """);
- // The engine still reports its hostname, which no longer names anything here.
- Service jellyfin = DockerServiceMapper.ToResources(
- DockerContainerParser.Parse(Fixture.Read("docker-containers.json")),
- "engine-a",
- "nas01",
- "192.168.1.20")
- .Single(s => s.Name == "jellyfin");
- await api.PublishAsync(DiscoveryDocument.ToYaml([jellyfin]));
- // The user's link survived the re-discovery; the stale hostname did not land.
- Assert.Contains("- storage-01", api.StoredYaml);
- Assert.Equal(1, Count(api.StoredYaml, "name: jellyfin"));
- Assert.DoesNotContain("- nas01", api.StoredYaml);
- }
- [Fact]
- public async Task A_dry_run_reports_the_change_without_making_it() {
- using var api = new DiscoveryApiFixture();
- ImportYamlResponse response = await api.PublishAsync(SystemYaml(), true);
- Assert.Equal(["nas01"], response.Added);
- Assert.DoesNotContain("nas01", api.StoredYaml);
- }
- [Fact]
- public async Task Machines_sharing_a_cloned_machine_id_are_rejected_rather_than_silently_merged() {
- using var api = new DiscoveryApiFixture();
- var yaml = DiscoveryDocument.ToYaml([
- SystemResourceMapper.ToResource(Facts("clone", "vm-a")),
- SystemResourceMapper.ToResource(Facts("clone", "vm-b"))
- ]);
- InvalidOperationException error =
- await Assert.ThrowsAsync<InvalidOperationException>(() => api.PublishAsync(yaml));
- Assert.Contains("machine-id", error.Message);
- }
- private static int Count(string haystack, string needle) {
- var count = 0;
- var index = haystack.IndexOf(needle, StringComparison.Ordinal);
- while (index >= 0) {
- count++;
- index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal);
- }
- return count;
- }
- [Fact]
- public async Task Discovering_a_machine_does_not_destroy_hardware_documented_under_the_same_name() {
- using var api = new DiscoveryApiFixture();
- // A very ordinary starting point: the box was documented as hardware by hand.
- await api.PublishAsync("""
- version: 3
- resources:
- - kind: Server
- name: nas01
- notes: 4U chassis, bought 2019
- ram:
- size: 64
- """);
- ImportYamlResponse response = await api.PublishAsync(SystemYaml());
- // The hardware is untouched...
- Assert.Contains("kind: Server", api.StoredYaml);
- Assert.Contains("4U chassis", api.StoredYaml);
- // ...and the operating system is recorded alongside it rather than instead of it.
- Assert.Single(response.Added);
- Assert.StartsWith("nas01-", response.Added[0]);
- Assert.Contains("kind: System", api.StoredYaml);
- }
- [Fact]
- public async Task Many_machines_pushing_at_once_do_not_lose_each_other() {
- // The realistic shape of this feature: a fleet on the same nightly timer, all
- // arriving within the same second. A read-modify-write that is not serialised
- // would silently drop most of them.
- using var api = new DiscoveryApiFixture();
- const int machines = 12;
- await Task.WhenAll(Enumerable.Range(0, machines)
- .Select(i => api.PublishAsync(SystemYaml($"machine-{i}", $"box-{i:00}"))));
- var stored = api.StoredYaml;
- for (var i = 0; i < machines; i++) {
- Assert.Contains($"name: box-{i:00}", stored);
- Assert.Contains($"discoveryId: {IdFor($"machine-{i}")}", stored);
- }
- Assert.Equal(machines, Count(stored, "kind: System"));
- }
- [Fact]
- public async Task Repeated_runs_converge_rather_than_accumulating() {
- using var api = new DiscoveryApiFixture();
- for (var i = 0; i < 10; i++)
- await api.PublishAsync(SystemYaml(ramGb: 60 + i));
- Assert.Equal(1, Count(api.StoredYaml, "kind: System"));
- Assert.Contains("ram: 69", api.StoredYaml);
- }
- private static string ProxmoxYaml(string guestName = "docker-01") {
- ProxmoxNode node = new() {
- Name = "pve01",
- Cores = 12,
- MemoryBytes = 67438305280,
- Version = "pve-manager/8.2.2/x"
- };
- ProxmoxGuest guest = new() {
- VmId = 104,
- Node = "pve01",
- Name = guestName,
- Type = "vm",
- Cores = 4,
- MemoryBytes = 8589934592,
- Os = "Linux"
- };
- return DiscoveryDocument.ToYaml(ProxmoxResourceMapper.ToResources("homelab", [node], [guest]));
- }
- [Fact]
- public async Task A_proxmox_estate_arrives_with_its_tree_intact() {
- using var api = new DiscoveryApiFixture();
- ImportYamlResponse response = await api.PublishAsync(ProxmoxYaml());
- // The machine, the hypervisor on it, and the guest on that.
- Assert.Equal(["pve01", "pve01-pve", "docker-01"], response.Added);
- // The relationships are the tedious part to type, so they have to survive.
- Assert.Contains("kind: Server", api.StoredYaml);
- Assert.Contains("- pve01\n", api.StoredYaml);
- Assert.Contains("- pve01-pve", api.StoredYaml);
- }
- [Fact]
- public async Task A_guest_renamed_in_proxmox_updates_rather_than_duplicating() {
- using var api = new DiscoveryApiFixture();
- await api.PublishAsync(ProxmoxYaml());
- ImportYamlResponse response = await api.PublishAsync(ProxmoxYaml("docker-renamed"));
- // The vmid is the identity, so renaming the guest in Proxmox does not make a
- // second resource — and the name the user sees in RackPeek is left alone.
- Assert.Empty(response.Added);
- Assert.Equal(1, Count(api.StoredYaml, "type: vm"));
- Assert.DoesNotContain("docker-renamed", api.StoredYaml);
- }
- [Fact]
- public async Task Known_limitation_a_guest_discovered_twice_over_is_two_resources() {
- // Proxmox identifies a guest by vmid; the guest identifies itself by machine-id.
- // Neither can derive the other, so running both collectors over the same machine
- // produces two resources. Documented rather than silently surprising: the second
- // one is reported as an addition with a suffixed name, not merged into the first.
- using var api = new DiscoveryApiFixture();
- await api.PublishAsync(ProxmoxYaml());
- ImportYamlResponse response = await api.PublishAsync(SystemYaml(hostname: "docker-01"));
- Assert.Single(response.Added);
- Assert.StartsWith("docker-01-", response.Added[0]);
- }
- }
|