using RackPeek.Domain.Api;
using RackPeek.Domain.Discovery;
using RackPeek.Domain.Resources.Services;
using RackPeek.Domain.Resources.SystemResources;
namespace Tests.Discovery;
///
/// 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.
///
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 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(() => 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]);
}
}