using RackPeek.Domain.Discovery;
using RackPeek.Domain.Resources;
using RackPeek.Domain.Resources.SystemResources;
namespace Tests.Discovery;
///
/// Learning where a guest actually is. Proxmox only records an address in a guest's
/// config when someone set one statically, so on a DHCP estate every guest arrived
/// without one — and an address is what lets a guest line up with the host a network
/// sweep found. The guest itself knows, and will say so through its agent.
///
/// The hard part is that the agent reports every interface inside the machine,
/// including the bridges Docker and Home Assistant create. Picking the wrong one
/// would record 172.17.0.1 as the machine's address, which every container host
/// on the estate would also report.
///
///
public class ProxmoxGuestAddressTests {
private const string _nicMac = "bc:24:11:00:1a:01";
private static List AgentAddresses() =>
ProxmoxResponseParser.ParseAgentInterfaces(Fixture.Read("pve-agent-interfaces.json"));
[Fact]
public void An_agent_reports_every_routable_ipv4_it_can_see() {
List addresses = AgentAddresses();
// Loopback and the IPv6 entries are dropped; the NIC and both docker bridges stay,
// because deciding between them is the caller's job, not the parser's.
Assert.Equal(["192.0.2.105", "172.17.0.1", "172.18.0.1"], addresses.Select(a => a.Ip));
}
[Fact]
public void The_guests_own_nic_wins_over_the_bridges_it_created() =>
Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [_nicMac]));
[Fact]
// Proxmox configs upper-case them; agents vary. Both go through the same normaliser
// the ARP reader uses, so a scan and this collector always agree.
public void The_mac_is_matched_however_either_side_spells_it() =>
Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["BC-24-11-00-1A-01"]));
[Fact]
// Every address on offer belongs to something the guest invented. Recording one
// would be worse than recording nothing.
public void Nothing_is_claimed_when_no_interface_carries_a_configured_mac() =>
Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["bc:24:11:ff:ff:ff"]));
[Fact]
// A token that cannot read the guest config leaves no discriminator, so there is no
// way to tell a NIC from a bridge.
public void Nothing_is_claimed_when_the_configured_macs_are_unknown() =>
Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), []));
[Fact]
public void An_agent_that_answers_nothing_yields_nothing() {
Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":null}"""));
Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":{"result":[]}}"""));
}
[Fact]
public void A_container_reports_its_address_with_the_prefix_stripped() {
List addresses =
ProxmoxResponseParser.ParseContainerInterfaces(Fixture.Read("pve-lxc-interfaces.json"));
ProxmoxGuestAddress only = Assert.Single(addresses);
Assert.Equal("192.0.2.150", only.Ip);
Assert.Equal("eth0", only.Interface);
}
[Fact]
public void A_guest_that_failed_dhcp_is_not_recorded_at_its_self_assigned_address() {
// 169.254 means "I could not get an address", which is not an address worth
// writing into an inventory.
var json = """
{"data":{"result":[{"name":"ens18","hardware-address":"bc:24:11:00:1a:01",
"ip-addresses":[{"ip-address":"169.254.12.7","ip-address-type":"ipv4","prefix":16}]}]}}
""";
Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces(json));
}
[Fact]
public async Task A_statically_configured_address_still_wins_over_the_agent() {
// The config is what the administrator asked for; the agent is what the guest
// happens to report. Where both exist they agree, and where they do not the
// configured one is the intent.
var client = new ScriptedProxmoxClient {
Guests = [Guest(100, "static-guest")],
Configs = { [100] = new ProxmoxGuestConfig("Linux", "192.0.2.9", [], [], [_nicMac]) },
Addresses = { [100] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] }
};
List resources = await ProxmoxDiscovery.ReadAsync(client);
Assert.Equal("192.0.2.9", GuestCard(resources, "static-guest").Ip);
}
[Fact]
public async Task A_dhcp_guest_takes_the_address_its_agent_reports() {
var client = new ScriptedProxmoxClient {
Guests = [Guest(101, "dhcp-guest")],
Configs = { [101] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) },
Addresses = { [101] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] }
};
List resources = await ProxmoxDiscovery.ReadAsync(client);
Assert.Equal("192.0.2.105", GuestCard(resources, "dhcp-guest").Ip);
}
[Fact]
public async Task A_stopped_guest_is_never_asked_where_it_is() {
// It has no address to report, and asking costs a round trip per guest on an
// estate where most guests may be off.
var client = new ScriptedProxmoxClient {
Guests = [Guest(102, "stopped-guest", "stopped")],
Configs = { [102] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }
};
await ProxmoxDiscovery.ReadAsync(client);
Assert.Empty(client.AddressCalls);
}
[Fact]
public async Task The_guests_macs_reach_the_card() {
// The MCP tool used to run its own copy of this orchestration which dropped the
// MACs, silently costing every guest its chance of unifying with a scan.
var client = new ScriptedProxmoxClient {
Guests = [Guest(103, "mac-guest")],
Configs = { [103] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }
};
List resources = await ProxmoxDiscovery.ReadAsync(client);
Assert.Equal(_nicMac, GuestCard(resources, "mac-guest").Labels["macs"]);
}
private static SystemResource GuestCard(List resources, string name) =>
resources.OfType().Single(r => r.Name == name);
private static ProxmoxGuest Guest(int vmId, string name, string status = "running") =>
new() {
VmId = vmId,
Node = "pve01",
Name = name,
Type = "vm",
Status = status
};
private sealed class ScriptedProxmoxClient : IProxmoxClient {
public List Guests { get; init; } = [];
public Dictionary Configs { get; } = [];
public Dictionary> Addresses { get; } = [];
public List AddressCalls { get; } = [];
public string Endpoint => "https://pve.example.com:8006";
public Task GetIdentityScopeAsync(CancellationToken cancellationToken = default) =>
Task.FromResult("example-cluster");
public Task> GetNodesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult>([new ProxmoxNode { Name = "pve01" }]);
public Task EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default) =>
Task.FromResult(node);
public Task> GetGuestsAsync(
string node,
string endpoint,
CancellationToken cancellationToken = default) =>
Task.FromResult>(
endpoint == ProxmoxApiClient.QemuEndpoint ? Guests : []);
public Task> GetDisksAsync(
string node,
CancellationToken cancellationToken = default) =>
Task.FromResult>([]);
public Task> GetGpusAsync(
string node,
CancellationToken cancellationToken = default) =>
Task.FromResult>([]);
public Task GetGuestConfigAsync(
string node,
string endpoint,
int vmId,
CancellationToken cancellationToken = default) =>
Task.FromResult(Configs.TryGetValue(vmId, out ProxmoxGuestConfig? config)
? config
: new ProxmoxGuestConfig(null, null, [], []));
public Task> GetGuestAddressesAsync(
string node,
string endpoint,
int vmId,
CancellationToken cancellationToken = default) {
AddressCalls.Add(vmId);
return Task.FromResult>(
Addresses.TryGetValue(vmId, out List? found) ? found : []);
}
}
}