Просмотр исходного кода

Add auto discovery: rpk discover system / docker / proxmox

`rpk discover` reads infrastructure and emits it as RackPeek YAML —
one System for the machine it runs on, a Service per published Docker
container, and a Server/System tree for a Proxmox cluster. Safe by
default: prints unless --push, --dry-run previews, and imports are
merge-only — discovery can add and update but never remove or rename
what the user wrote.

Identity: each discovered resource carries a hashed discoveryId
(machine-id / platform UUID / Docker daemon id), so re-runs update
renamed resources instead of duplicating them, hand-written entries
are adopted, and cloned machine-ids are rejected with a fix hint.
Remote Docker engines are identified via GET /info with a graceful
fallback behind restricted socket proxies, and the merge preserves
user-chosen runsOn links a remote collector cannot see.

Persistence: discoveryId ships as schema v4 with a forward migration,
per AGENTS.md §6; v3 files load and re-save as v4. The web server now
eagerly loads the config at startup so the inventory API cannot merge
against an empty collection, and every write path retries a failed
load instead of overwriting the user's file.

Tested by fixture-driven suites for the probes, parsers, mappers and
id resolution, HTTP-level merge tests against a real server, real
probe runs in CI on Linux and macOS, and E2E CLI coverage; verified
live against a real Docker engine (local socket, TCP bridge, and a
CONTAINERS=1 socket proxy) and a Proxmox fixture set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 3 часов назад
Родитель
Сommit
e5cce72c53
100 измененных файлов с 7998 добавлено и 52 удалено
  1. 30 0
      .github/workflows/test.yml
  2. 3 0
      README.md
  3. 5 0
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  4. 15 0
      RackPeek.Domain/Discovery/DiscoveryDocument.cs
  5. 38 0
      RackPeek.Domain/Discovery/DiscoveryId.cs
  6. 166 0
      RackPeek.Domain/Discovery/DiscoveryIdResolver.cs
  7. 90 0
      RackPeek.Domain/Discovery/DiscoveryNaming.cs
  8. 95 0
      RackPeek.Domain/Discovery/DiscoveryPublisher.cs
  9. 127 0
      RackPeek.Domain/Discovery/DockerApiClient.cs
  10. 99 0
      RackPeek.Domain/Discovery/DockerContainer.cs
  11. 48 0
      RackPeek.Domain/Discovery/DockerEngineInfo.cs
  12. 77 0
      RackPeek.Domain/Discovery/DockerServiceMapper.cs
  13. 21 0
      RackPeek.Domain/Discovery/IDockerClient.cs
  14. 44 0
      RackPeek.Domain/Discovery/IProxmoxClient.cs
  15. 30 0
      RackPeek.Domain/Discovery/ISystemProbe.cs
  16. 67 0
      RackPeek.Domain/Discovery/LinuxSystemProbe.cs
  17. 76 0
      RackPeek.Domain/Discovery/MacSystemProbe.cs
  18. 193 0
      RackPeek.Domain/Discovery/ProxmoxApiClient.cs
  19. 427 0
      RackPeek.Domain/Discovery/ProxmoxModels.cs
  20. 246 0
      RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs
  21. 69 0
      RackPeek.Domain/Discovery/SystemFacts.cs
  22. 165 0
      RackPeek.Domain/Discovery/SystemFactsParser.cs
  23. 118 0
      RackPeek.Domain/Discovery/SystemProbeCommon.cs
  24. 35 0
      RackPeek.Domain/Discovery/SystemResourceMapper.cs
  25. 5 2
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  26. 15 1
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  27. 55 16
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  28. 7 0
      RackPeek.Domain/Resources/Resource.cs
  29. 7 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  30. 5 0
      RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs
  31. 28 2
      RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json
  32. 701 0
      RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json
  33. 19 0
      RackPeek.Web/Program.cs
  34. 28 2
      RackPeek.Web/wwwroot/schemas/v3/schema.v3.json
  35. 701 0
      RackPeek.Web/wwwroot/schemas/v4/schema.v4.json
  36. 6 0
      RackPeek.sln
  37. 45 5
      Shared.Rcl/CliBootstrap.cs
  38. 123 0
      Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs
  39. 141 0
      Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs
  40. 46 0
      Shared.Rcl/Commands/Discovery/DiscoverSettings.cs
  41. 41 0
      Shared.Rcl/Commands/Discovery/DiscoverSystemCommand.cs
  42. 94 0
      Shared.Rcl/Commands/Discovery/DiscoveryOutput.cs
  43. 4 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  44. 121 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  45. 340 0
      Shared.Rcl/wwwroot/raw_docs/discovery-guide.md
  46. 2 1
      Shared.Rcl/wwwroot/raw_docs/docs-index.json
  47. 67 0
      Tests.Discovery/DiscoveryApiFixture.cs
  48. 148 0
      Tests.Discovery/DiscoveryIdentityTests.cs
  49. 387 0
      Tests.Discovery/DiscoveryMergeTests.cs
  50. 111 0
      Tests.Discovery/DockerDiscoveryTests.cs
  51. 102 0
      Tests.Discovery/FailureModeTests.cs
  52. 94 0
      Tests.Discovery/Fixture.cs
  53. 71 0
      Tests.Discovery/Fixtures/docker-containers.json
  54. 20 0
      Tests.Discovery/Fixtures/docker-info.json
  55. 1 0
      Tests.Discovery/Fixtures/linux-cgroup-container
  56. 1 0
      Tests.Discovery/Fixtures/linux-cgroup-host
  57. 7 0
      Tests.Discovery/Fixtures/linux-meminfo
  58. 9 0
      Tests.Discovery/Fixtures/linux-os-release
  59. 8 0
      Tests.Discovery/Fixtures/macos-ioreg.txt
  60. 3 0
      Tests.Discovery/Fixtures/pve-cluster-status-standalone.json
  61. 5 0
      Tests.Discovery/Fixtures/pve-cluster-status.json
  62. 6 0
      Tests.Discovery/Fixtures/pve-disks.json
  63. 64 0
      Tests.Discovery/Fixtures/pve-hardware-pci.json
  64. 5 0
      Tests.Discovery/Fixtures/pve-lxc-config-dhcp.json
  65. 11 0
      Tests.Discovery/Fixtures/pve-lxc-config.json
  66. 4 0
      Tests.Discovery/Fixtures/pve-lxc.json
  67. 7 0
      Tests.Discovery/Fixtures/pve-node-status.json
  68. 4 0
      Tests.Discovery/Fixtures/pve-nodes-full.json
  69. 3 0
      Tests.Discovery/Fixtures/pve-nodes.json
  70. 18 0
      Tests.Discovery/Fixtures/pve-qemu-config-passthrough.json
  71. 15 0
      Tests.Discovery/Fixtures/pve-qemu-config.json
  72. 5 0
      Tests.Discovery/Fixtures/pve-qemu.json
  73. 142 0
      Tests.Discovery/ProxmoxClientTests.cs
  74. 518 0
      Tests.Discovery/ProxmoxDiscoveryTests.cs
  75. 103 0
      Tests.Discovery/RealProbeTests.cs
  76. 182 0
      Tests.Discovery/RemoteDockerDiscoveryTests.cs
  77. 194 0
      Tests.Discovery/SystemDiscoveryTests.cs
  78. 43 0
      Tests.Discovery/Tests.Discovery.csproj
  79. 13 7
      Tests/Api/ApiTestBase.cs
  80. 87 0
      Tests/Api/InventoryEndpointStartupTests.cs
  81. 2 2
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  82. 2 2
      Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs
  83. 2 2
      Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs
  84. 2 2
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  85. 1 1
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  86. 1 1
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs
  87. 2 2
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs
  88. 2 2
      Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs
  89. 2 2
      Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs
  90. 34 0
      Tests/TestConfigs/v4/01-server.yaml
  91. 17 0
      Tests/TestConfigs/v4/02-firewall.yaml
  92. 17 0
      Tests/TestConfigs/v4/03-router.yaml
  93. 17 0
      Tests/TestConfigs/v4/04-switch.yaml
  94. 15 0
      Tests/TestConfigs/v4/05-accesspoint.yaml
  95. 11 0
      Tests/TestConfigs/v4/06-ups.yaml
  96. 25 0
      Tests/TestConfigs/v4/07-desktop.yaml
  97. 18 0
      Tests/TestConfigs/v4/08-laptop.yaml
  98. 13 0
      Tests/TestConfigs/v4/09-service.yaml
  99. 17 0
      Tests/TestConfigs/v4/10-system.yaml
  100. 522 0
      Tests/TestConfigs/v4/11-demo-config.yaml

+ 30 - 0
.github/workflows/test.yml

@@ -26,6 +26,36 @@ jobs:
         run: dotnet format --verify-no-changes
         run: dotnet format --verify-no-changes
 
 
 
 
+  discovery-tests:
+    name: Discovery Tests (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    needs: format
+
+    # Discovery runs on the machines being inventoried rather than on the RackPeek
+    # host, so its tests are the ones that have to pass on every platform. They are
+    # driven entirely from captured fixtures and never read the runner itself.
+    # windows-latest joins this list when the Windows probe lands.
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore Tests.Discovery
+
+      - name: Run Discovery Tests
+        run: dotnet test Tests.Discovery --configuration Release --verbosity normal
+
+
   cli-tests:
   cli-tests:
     name: CLI Tests
     name: CLI Tests
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest

+ 3 - 0
README.md

@@ -85,6 +85,9 @@ volumes:
 * 
 * 
   [**Ansible Inventory Generator Guide**](https://timmoth.github.io/RackPeek/docs/ansible-generator-guide)
   [**Ansible Inventory Generator Guide**](https://timmoth.github.io/RackPeek/docs/ansible-generator-guide)
 
 
+* 
+  [**Auto Discovery Guide**](https://timmoth.github.io/RackPeek/docs/discovery-guide)
+
 * 
 * 
   [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)
   [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)
 
 

+ 5 - 0
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -2,6 +2,7 @@ using System.Collections.Specialized;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using System.Text.Json;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using System.Text.Json.Serialization;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
@@ -62,6 +63,10 @@ public class UpsertInventoryUseCase(
         List<Resource>? incomingResources = incomingRoot.Resources;
         List<Resource>? incomingResources = incomingRoot.Resources;
         IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
         IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
 
 
+        // Line discovered resources up with what they already map to before anything
+        // else looks at names, so the diff below reports against the right resources.
+        DiscoveryIdResolver.ResolveNames(currentResources, incomingResources, incomingRoot.Connections);
+
         IGrouping<string, Resource>? duplicate = incomingResources
         IGrouping<string, Resource>? duplicate = incomingResources
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
             .FirstOrDefault(g => g.Count() > 1);
             .FirstOrDefault(g => g.Count() > 1);

+ 15 - 0
RackPeek.Domain/Discovery/DiscoveryDocument.cs

@@ -0,0 +1,15 @@
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Renders discovered resources as a RackPeek YAML document.</summary>
+public static class DiscoveryDocument {
+    public static string ToYaml(IEnumerable<Resource> resources) {
+        return YamlResourceCollection.SerializeRootAsync(new YamlRoot {
+            Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+            Resources = resources.ToList(),
+            Connections = []
+        });
+    }
+}

+ 38 - 0
RackPeek.Domain/Discovery/DiscoveryId.cs

@@ -0,0 +1,38 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Deterministic, globally unique identity for a discovered resource.
+///     Format: <c>rpk1:{scheme}:{16 hex chars}</c>
+///     The hash keeps low-value identifiers (machine-id, MAC) out of a config file
+///     that is frequently committed to a public repository. Note this is
+///     obfuscation rather than secrecy: the salt is public, so a low entropy seed
+///     such as a MAC address remains recoverable by brute force.
+/// </summary>
+public static class DiscoveryId {
+    public const string Prefix = "rpk1";
+    public const string SystemScheme = "sys";
+    public const string DockerScheme = "docker";
+
+    public static string Create(string scheme, string seed) {
+        if (string.IsNullOrWhiteSpace(scheme))
+            throw new ArgumentException("Scheme is required.", nameof(scheme));
+
+        if (string.IsNullOrWhiteSpace(seed))
+            throw new ArgumentException("Seed is required.", nameof(seed));
+
+        var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{Prefix}:{scheme}:{seed}"));
+
+        return $"{Prefix}:{scheme}:{Convert.ToHexString(hash, 0, 8).ToLowerInvariant()}";
+    }
+
+    /// <summary>Short, stable fragment used to disambiguate generated names.</summary>
+    public static string ShortSuffix(string discoveryId) {
+        var lastColon = discoveryId.LastIndexOf(':');
+        var hash = lastColon >= 0 ? discoveryId[(lastColon + 1)..] : discoveryId;
+
+        return hash.Length <= 8 ? hash : hash[..8];
+    }
+}

+ 166 - 0
RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

@@ -0,0 +1,166 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Reconciles incoming discovered resources against what is already stored, by
+///     <see cref="Resource.DiscoveryId" /> rather than by name.
+///     <para>
+///         Runs immediately before the merge, and only ever rewrites the *incoming*
+///         names. The invariant it exists to protect: discovery never renames a
+///         resource the user already has. Names are user-owned, ids are machine-owned.
+///     </para>
+/// </summary>
+public static class DiscoveryIdResolver {
+    /// <summary>
+    ///     Rewrites <paramref name="incoming" /> in place so that its names line up with
+    ///     the stored resources the ids point at. Also rewrites <c>runsOn</c> references
+    ///     between incoming resources — and the payload's <paramref name="connections" />,
+    ///     which name resources the same way — so a rename does not break the tree.
+    /// </summary>
+    public static void ResolveNames(
+        IReadOnlyList<Resource> existing,
+        IReadOnlyList<Resource> incoming,
+        IReadOnlyList<Connection>? connections = null) {
+        var incomingWithId = incoming
+            .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
+            .ToList();
+
+        if (incomingWithId.Count == 0)
+            return;
+
+        GuardAgainstDuplicates(incomingWithId, "payload");
+        GuardAgainstDuplicates(existing.Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId)), "inventory");
+
+        var existingById = existing
+            .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
+            .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
+
+        // Tolerant of a hand-edited file that managed to get two resources of the
+        // same name: the first wins, rather than crashing the import.
+        var existingByName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in existing)
+            existingByName.TryAdd(resource.Name, resource);
+
+        var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in incomingWithId) {
+            var resolved = ResolveName(resource, existingById, existingByName);
+
+            if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            renames[resource.Name] = resolved;
+            resource.Name = resolved;
+        }
+
+        if (renames.Count > 0) {
+            RewriteRunsOn(incoming, renames);
+            RewriteConnections(connections, renames);
+        }
+
+        PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
+    }
+
+    /// <summary>
+    ///     A collector that cannot see its host — docker discovery over TCP — sends
+    ///     <c>runsOn</c> as a bare hostname it cannot reconcile after the user renames
+    ///     that host. When an update's runsOn points at nothing at all while the stored
+    ///     resource already points at something real, the stored link is the user's truth
+    ///     and re-discovery must not tear it up. A runsOn that resolves — even to a
+    ///     resource arriving in the same payload — is left alone: that is a genuine move.
+    /// </summary>
+    private static void PreserveStoredRunsOn(
+        IReadOnlyList<Resource> incomingWithId,
+        IReadOnlyList<Resource> incoming,
+        Dictionary<string, Resource> existingById,
+        Dictionary<string, Resource> existingByName) {
+        var incomingNames = new HashSet<string>(incoming.Select(r => r.Name), StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in incomingWithId) {
+            if (resource.RunsOn.Count == 0
+                || !existingById.TryGetValue(resource.DiscoveryId!, out Resource? stored)
+                || stored.RunsOn.Count == 0)
+                continue;
+
+            var anchored = resource.RunsOn.Any(name =>
+                existingByName.ContainsKey(name) || incomingNames.Contains(name));
+
+            if (!anchored)
+                resource.RunsOn = [.. stored.RunsOn];
+        }
+    }
+
+    private static string ResolveName(
+        Resource resource,
+        Dictionary<string, Resource> existingById,
+        Dictionary<string, Resource> existingByName) {
+        // Known id: the stored resource wins on name, whatever the user has renamed it to.
+        if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
+            return matched.Name;
+
+        // Unknown id and the name is free: nothing to reconcile.
+        if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
+            return resource.Name;
+
+        // Taken by something carrying a different id — another machine's resource.
+        var belongsToAnotherMachine = !string.IsNullOrWhiteSpace(sameName.DiscoveryId)
+                                      && !sameName.DiscoveryId.Equals(
+                                          resource.DiscoveryId,
+                                          StringComparison.OrdinalIgnoreCase);
+
+        // Taken by a different kind of thing. Very common: the box is documented as a
+        // Server by hand and discovery reports the operating system on it as a System.
+        // The merge replaces on a type change, so adopting here would delete the
+        // hardware the user wrote.
+        var describesSomethingElse = sameName.GetType() != resource.GetType();
+
+        if (belongsToAnotherMachine || describesSomethingElse)
+            return DiscoveryNaming.WithSuffix(
+                resource.Name,
+                DiscoveryId.ShortSuffix(resource.DiscoveryId!));
+
+        // Same kind, no competing id: this is the adoption case, where the merge stamps
+        // the id onto the resource the user already wrote and keeps everything in it.
+        return resource.Name;
+    }
+
+    private static void RewriteRunsOn(IReadOnlyList<Resource> incoming, Dictionary<string, string> renames) {
+        foreach (Resource resource in incoming)
+            for (var i = 0; i < resource.RunsOn.Count; i++)
+                if (renames.TryGetValue(resource.RunsOn[i], out var renamed))
+                    resource.RunsOn[i] = renamed;
+    }
+
+    private static void RewriteConnections(IReadOnlyList<Connection>? connections, Dictionary<string, string> renames) {
+        if (connections == null)
+            return;
+
+        foreach (Connection connection in connections) {
+            if (connection.A?.Resource != null && renames.TryGetValue(connection.A.Resource, out var a))
+                connection.A.Resource = a;
+
+            if (connection.B?.Resource != null && renames.TryGetValue(connection.B.Resource, out var b))
+                connection.B.Resource = b;
+        }
+    }
+
+    private static void GuardAgainstDuplicates(IEnumerable<Resource> resources, string scope) {
+        IGrouping<string, Resource>? duplicate = resources
+            .GroupBy(r => r.DiscoveryId!, StringComparer.OrdinalIgnoreCase)
+            .FirstOrDefault(g => g.Count() > 1);
+
+        if (duplicate == null)
+            return;
+
+        var names = string.Join(", ", duplicate.Select(r => r.Name));
+
+        throw new ValidationException(
+            $"Duplicate discoveryId '{duplicate.Key}' in the {scope} ({names}). " +
+            "Machines cloned from a VM template often share /etc/machine-id; " +
+            "run 'systemd-machine-id-setup' on the clones to give them distinct identities.");
+    }
+}

+ 90 - 0
RackPeek.Domain/Discovery/DiscoveryNaming.cs

@@ -0,0 +1,90 @@
+using System.Text;
+using RackPeek.Domain.Helpers;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Turns machine-supplied strings into names a human would have typed.</summary>
+public static class DiscoveryNaming {
+    /// <summary>
+    ///     The limit RackPeek's own validation enforces. The import API does not check
+    ///     it, so a longer name would be accepted and then be un-editable from the CLI.
+    /// </summary>
+    public const int MaxNameLength = ThrowIfInvalid.MaxResourceNameLength;
+
+    /// <summary>
+    ///     Lowercase, alphanumeric and dashes only. Returns an empty string when the
+    ///     input contains nothing usable, so callers can fall back to an id-derived name.
+    /// </summary>
+    public static string Slug(string? value) {
+        if (string.IsNullOrWhiteSpace(value))
+            return string.Empty;
+
+        var builder = new StringBuilder(value.Length);
+
+        foreach (var c in value.Trim().ToLowerInvariant())
+            if (char.IsAsciiLetterOrDigit(c))
+                builder.Append(c);
+            else if ((c == '-' || c == '.' || c == '_' || char.IsWhiteSpace(c)) && builder.Length > 0 &&
+                     builder[^1] != '-')
+                builder.Append('-');
+
+        return builder.ToString().Trim('-');
+    }
+
+    /// <summary>
+    ///     The first label of a host name: <c>nas01.lan</c> becomes <c>nas01</c>, which is
+    ///     what people call the machine.
+    /// </summary>
+    public static string HostLabel(string? hostname) {
+        if (string.IsNullOrWhiteSpace(hostname))
+            return string.Empty;
+
+        var dot = hostname.IndexOf('.');
+
+        return dot > 0 ? hostname[..dot] : hostname;
+    }
+
+    /// <summary>
+    ///     The name a collector proposes: the machine's own name where it has a usable
+    ///     one, otherwise derived from the id so it is still deterministic and unique.
+    /// </summary>
+    public static string Suggest(string? preferred, string kind, string discoveryId) {
+        var slug = Slug(preferred);
+
+        return slug.Length > 0
+            ? Truncate(slug)
+            : $"{Slug(kind)}-{DiscoveryId.ShortSuffix(discoveryId)}";
+    }
+
+    /// <summary>Cuts a name down to the allowed length without leaving a trailing dash.</summary>
+    public static string Truncate(string name) =>
+        name.Length <= MaxNameLength ? name : name[..MaxNameLength].TrimEnd('-');
+
+    /// <summary>
+    ///     Appends a disambiguating suffix, shortening the name to make room. Compose
+    ///     puts the replica index at the end of a container name, so two long names
+    ///     often differ only in the part truncation removes — the suffix is what keeps
+    ///     them apart.
+    /// </summary>
+    public static string WithSuffix(string name, string suffix) {
+        var room = MaxNameLength - suffix.Length - 1;
+        var head = name.Length <= room ? name : name[..Math.Max(1, room)];
+
+        return $"{head.TrimEnd('-')}-{suffix}";
+    }
+
+    /// <summary>
+    ///     Returns a name not already in <paramref name="taken" />, adding it to the set.
+    ///     Guards the case where two resources in one payload truncate to the same name,
+    ///     which the import rejects outright as a duplicate.
+    /// </summary>
+    public static string Unique(string candidate, string discoveryId, ISet<string> taken) {
+        var name = taken.Add(candidate)
+            ? candidate
+            : WithSuffix(candidate, DiscoveryId.ShortSuffix(discoveryId));
+
+        taken.Add(name);
+
+        return name;
+    }
+}

+ 95 - 0
RackPeek.Domain/Discovery/DiscoveryPublisher.cs

@@ -0,0 +1,95 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Sends a discovery document to a running RackPeek server's inventory API.
+///     Always merges — discovery adds and updates what it finds, and must never be able
+///     to remove what it did not.
+/// </summary>
+public sealed class DiscoveryPublisher : IDisposable {
+    public const string ServerEnvironmentVariable = "RPK_SERVER";
+    public const string ApiKeyEnvironmentVariable = "RPK_API_KEY";
+
+    private static readonly JsonSerializerOptions _jsonOptions = new() {
+        PropertyNameCaseInsensitive = true,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+    };
+
+    private readonly HttpClient _httpClient;
+
+    public DiscoveryPublisher(string serverUrl, string apiKey, HttpClient? httpClient = null) {
+        _httpClient = httpClient ?? new HttpClient();
+        _httpClient.BaseAddress = new Uri(serverUrl.TrimEnd('/') + "/");
+        _httpClient.DefaultRequestHeaders.Remove("X-Api-Key");
+        _httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
+    }
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public static string? ResolveServer(string? explicitValue) =>
+        Resolve(explicitValue, ServerEnvironmentVariable);
+
+    public static string? ResolveApiKey(string? explicitValue) =>
+        Resolve(explicitValue, ApiKeyEnvironmentVariable);
+
+    /// <summary>An explicit value wins; a blank one falls back to the environment.</summary>
+    public static string? Resolve(string? explicitValue, string environmentVariable) =>
+        Coalesce(explicitValue, Environment.GetEnvironmentVariable(environmentVariable));
+
+    public async Task<ImportYamlResponse> PublishAsync(
+        string yaml,
+        bool dryRun,
+        CancellationToken cancellationToken = default) {
+        var payload = JsonSerializer.Serialize(
+            new ImportYamlRequest { Yaml = yaml, Mode = MergeMode.Merge, DryRun = dryRun },
+            _jsonOptions);
+
+        using var content = new StringContent(payload, Encoding.UTF8);
+        content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
+
+        using HttpResponseMessage response =
+            await _httpClient.PostAsync("api/inventory", content, cancellationToken);
+
+        var body = await response.Content.ReadAsStringAsync(cancellationToken);
+
+        if (!response.IsSuccessStatusCode)
+            throw new InvalidOperationException(Describe(response.StatusCode, body));
+
+        return JsonSerializer.Deserialize<ImportYamlResponse>(body, _jsonOptions)
+               ?? new ImportYamlResponse();
+    }
+
+    private static string Describe(System.Net.HttpStatusCode statusCode, string body) {
+        var detail = ExtractError(body);
+
+        return statusCode switch {
+            System.Net.HttpStatusCode.Unauthorized =>
+                "Rejected by the server (401). Check the API key matches RPK_API_KEY on the server.",
+            System.Net.HttpStatusCode.ServiceUnavailable =>
+                "The server has no API key configured (503). Set RPK_API_KEY on the RackPeek server.",
+            _ => $"Upload failed ({(int)statusCode}). {detail}".TrimEnd()
+        };
+    }
+
+    private static string ExtractError(string body) {
+        try {
+            using var document = JsonDocument.Parse(body);
+
+            return document.RootElement.TryGetProperty("error", out JsonElement error)
+                ? error.GetString() ?? string.Empty
+                : body;
+        }
+        catch {
+            return body;
+        }
+    }
+
+    private static string? Coalesce(params string?[] values) =>
+        values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
+}

+ 127 - 0
RackPeek.Domain/Discovery/DockerApiClient.cs

@@ -0,0 +1,127 @@
+using System.Net.Sockets;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Talks to the Docker Engine API over either a unix socket or TCP. Podman's socket
+///     speaks the same API, so <c>--docker-host unix:///run/user/1000/podman/podman.sock</c>
+///     works without anything extra.
+/// </summary>
+public sealed class DockerApiClient : IDockerClient, IDisposable {
+    public const string DefaultSocketPath = "/var/run/docker.sock";
+
+    private readonly HttpClient _httpClient;
+
+    public DockerApiClient(string? dockerHost = null) {
+        var host = string.IsNullOrWhiteSpace(dockerHost)
+            ? Environment.GetEnvironmentVariable("DOCKER_HOST")
+            : dockerHost;
+
+        (_httpClient, Endpoint) = Create(host);
+        _httpClient.Timeout = TimeSpan.FromSeconds(30);
+    }
+
+    public string Endpoint { get; }
+
+    /// <summary>
+    ///     Whether the endpoint is a socket on this machine. Over TCP the engine is some
+    ///     other machine, so facts probed locally must not be attributed to it.
+    /// </summary>
+    public bool IsLocal => Endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase);
+
+    /// <summary>
+    ///     The host part of a TCP endpoint — where the containers actually live — or null
+    ///     for a local socket. A name or an address, exactly as the user dialled it.
+    /// </summary>
+    public string? RemoteHost => IsLocal
+        ? null
+        : new Uri(Endpoint.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)).DnsSafeHost;
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public async Task<IReadOnlyList<DockerContainer>> ListContainersAsync(
+        CancellationToken cancellationToken = default) {
+        using HttpResponseMessage response =
+            await _httpClient.GetAsync("/containers/json", cancellationToken);
+        response.EnsureSuccessStatusCode();
+
+        var json = await response.Content.ReadAsStringAsync(cancellationToken);
+
+        return DockerContainerParser.Parse(json);
+    }
+
+    public async Task<DockerEngineInfo?> GetInfoAsync(CancellationToken cancellationToken = default) {
+        try {
+            using HttpResponseMessage response = await _httpClient.GetAsync("/info", cancellationToken);
+
+            if (!response.IsSuccessStatusCode)
+                return null;
+
+            return DockerEngineInfoParser.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
+        }
+        catch (Exception ex) when (
+            ex is HttpRequestException or IOException or TimeoutException
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            // /info being unreadable never fails discovery; the caller degrades instead.
+            return null;
+        }
+    }
+
+    /// <summary>
+    ///     The address the user dialled, as IPv4: taken verbatim when it is a literal,
+    ///     resolved once when it is a name. The inventory schema holds IPv4 only, so an
+    ///     IPv6-only endpoint yields null and the caller falls back.
+    /// </summary>
+    public static async Task<string?> ResolveIpv4Async(string host, CancellationToken cancellationToken = default) {
+        if (System.Net.IPAddress.TryParse(host, out System.Net.IPAddress? literal))
+            return literal.AddressFamily == AddressFamily.InterNetwork ? host : null;
+
+        try {
+            System.Net.IPAddress[] addresses = await System.Net.Dns.GetHostAddressesAsync(host, cancellationToken);
+
+            return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)?.ToString();
+        }
+        catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) {
+            // Unresolvable, malformed, or no DNS on this platform (the browser console):
+            // the address is a nicety, never worth failing discovery over.
+            return null;
+        }
+    }
+
+    private static (HttpClient Client, string Endpoint) Create(string? dockerHost) {
+        if (string.IsNullOrWhiteSpace(dockerHost))
+            return (UnixSocketClient(DefaultSocketPath), $"unix://{DefaultSocketPath}");
+
+        if (dockerHost.StartsWith("unix://", StringComparison.OrdinalIgnoreCase)) {
+            var path = dockerHost["unix://".Length..];
+
+            return (UnixSocketClient(path), dockerHost);
+        }
+
+        // tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire.
+        var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase));
+
+        return (new HttpClient { BaseAddress = uri }, dockerHost);
+    }
+
+    private static HttpClient UnixSocketClient(string socketPath) {
+        var handler = new SocketsHttpHandler {
+            ConnectCallback = async (_, cancellationToken) => {
+                var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+
+                try {
+                    await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken);
+
+                    return new NetworkStream(socket, true);
+                }
+                catch {
+                    socket.Dispose();
+                    throw;
+                }
+            }
+        };
+
+        // The host part is ignored for a unix socket but HttpClient still requires one.
+        return new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+    }
+}

+ 99 - 0
RackPeek.Domain/Discovery/DockerContainer.cs

@@ -0,0 +1,99 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>A published port binding on the host side.</summary>
+public sealed record DockerPortBinding(int HostPort, string Protocol, string HostIp = "") {
+    /// <summary>
+    ///     Bound to a loopback address, so only the host itself can reach it. Docker
+    ///     writes the address as given (<c>127.0.0.1</c> mostly, but any 127.x works).
+    /// </summary>
+    public bool IsLoopback =>
+        HostIp.StartsWith("127.", StringComparison.Ordinal) || HostIp == "::1";
+
+    /// <summary>Bound to every interface rather than one address.</summary>
+    public bool IsWildcard => HostIp is "" or "0.0.0.0" or "::";
+}
+
+/// <summary>
+///     A container, reduced to the parts RackPeek models. Everything here comes from a
+///     single <c>GET /containers/json</c> call — the per-container inspect adds nothing
+///     a Service resource can hold.
+/// </summary>
+public sealed record DockerContainer {
+    public required string Name { get; init; }
+    public required string Image { get; init; }
+    public string? ComposeProject { get; init; }
+    public IReadOnlyList<DockerPortBinding> PublishedPorts { get; init; } = [];
+}
+
+/// <summary>Parses the Docker Engine list response. Pure, so it tests from a fixture.</summary>
+public static class DockerContainerParser {
+    private const string _composeProjectLabel = "com.docker.compose.project";
+
+    public static List<DockerContainer> Parse(string json) {
+        using var document = JsonDocument.Parse(json);
+
+        if (document.RootElement.ValueKind != JsonValueKind.Array)
+            return [];
+
+        return document.RootElement.EnumerateArray()
+            .Select(ParseContainer)
+            .OfType<DockerContainer>()
+            .OrderBy(c => c.Name, StringComparer.Ordinal)
+            .ToList();
+    }
+
+    private static DockerContainer? ParseContainer(JsonElement element) {
+        var name = ParseName(element);
+
+        if (string.IsNullOrWhiteSpace(name))
+            return null;
+
+        return new DockerContainer {
+            Name = name,
+            Image = GetString(element, "Image") ?? "unknown",
+            ComposeProject = GetLabel(element, _composeProjectLabel),
+            PublishedPorts = ParsePorts(element)
+        };
+    }
+
+    private static string? ParseName(JsonElement element) {
+        if (!element.TryGetProperty("Names", out JsonElement names) || names.ValueKind != JsonValueKind.Array)
+            return GetString(element, "Name")?.TrimStart('/');
+
+        return names.EnumerateArray()
+            .Select(n => n.GetString()?.TrimStart('/'))
+            .FirstOrDefault(n => !string.IsNullOrWhiteSpace(n));
+    }
+
+    private static List<DockerPortBinding> ParsePorts(JsonElement element) {
+        if (!element.TryGetProperty("Ports", out JsonElement ports) || ports.ValueKind != JsonValueKind.Array)
+            return [];
+
+        // Dual-stack publishes show up twice, once per family ("0.0.0.0" and "::");
+        // folding wildcards to one spelling keeps that one binding, not two.
+        return ports.EnumerateArray()
+            .Where(p => p.TryGetProperty("PublicPort", out JsonElement port) && port.ValueKind == JsonValueKind.Number)
+            .Select(p => new DockerPortBinding(
+                p.GetProperty("PublicPort").GetInt32(),
+                (GetString(p, "Type") ?? "tcp").ToUpperInvariant(),
+                GetString(p, "IP") ?? ""))
+            .Select(p => p.IsWildcard ? p with { HostIp = "" } : p)
+            .DistinctBy(p => (p.HostPort, p.Protocol, p.HostIp))
+            .OrderBy(p => p.HostPort)
+            .ToList();
+    }
+
+    private static string? GetLabel(JsonElement element, string label) {
+        if (!element.TryGetProperty("Labels", out JsonElement labels) || labels.ValueKind != JsonValueKind.Object)
+            return null;
+
+        return labels.TryGetProperty(label, out JsonElement value) ? value.GetString() : null;
+    }
+
+    private static string? GetString(JsonElement element, string property) =>
+        element.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String
+            ? value.GetString()
+            : null;
+}

+ 48 - 0
RackPeek.Domain/Discovery/DockerEngineInfo.cs

@@ -0,0 +1,48 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     The engine host's own account of itself, from <c>GET /info</c>. This is what makes
+///     a remote engine discoverable without user intervention: the daemon id gives the
+///     services an identity seed that does not depend on which machine ran the command,
+///     and the name is the hostname <c>rpk discover system</c> would report on that box.
+/// </summary>
+public sealed record DockerEngineInfo {
+    /// <summary>
+    ///     The daemon's persisted unique id (kept under <c>/var/lib/docker</c>). Survives
+    ///     reboots; changes only on an engine reinstall — the same failure mode
+    ///     <c>/etc/machine-id</c> has for local discovery.
+    /// </summary>
+    public string? Id { get; init; }
+
+    /// <summary>The engine host's hostname.</summary>
+    public string? Hostname { get; init; }
+}
+
+/// <summary>Parses the Docker Engine <c>GET /info</c> response. Pure, so it tests from a fixture.</summary>
+public static class DockerEngineInfoParser {
+    public static DockerEngineInfo? Parse(string json) {
+        try {
+            using var document = JsonDocument.Parse(json);
+
+            if (document.RootElement.ValueKind != JsonValueKind.Object)
+                return null;
+
+            return new DockerEngineInfo {
+                Id = GetString(document.RootElement, "ID"),
+                Hostname = GetString(document.RootElement, "Name")
+            };
+        }
+        catch (JsonException) {
+            return null;
+        }
+    }
+
+    private static string? GetString(JsonElement element, string property) =>
+        element.TryGetProperty(property, out JsonElement value)
+        && value.ValueKind == JsonValueKind.String
+        && !string.IsNullOrWhiteSpace(value.GetString())
+            ? value.GetString()
+            : null;
+}

+ 77 - 0
RackPeek.Domain/Discovery/DockerServiceMapper.cs

@@ -0,0 +1,77 @@
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Maps containers onto the Service resources RackPeek stores. Pure.</summary>
+public static class DockerServiceMapper {
+    /// <summary>
+    ///     Containers nothing outside the host can reach are skipped — no published port,
+    ///     or every binding on a loopback address. They are not services anyone would put
+    ///     on an inventory, and a Service is required to carry an address.
+    /// </summary>
+    /// <param name="hostSeed">
+    ///     Identity of the machine the containers run on — the host's machine-id where
+    ///     there is one. Part of the container's own id, so the same container name on
+    ///     two different hosts stays two different resources.
+    /// </param>
+    public static List<Service> ToResources(
+        IReadOnlyList<DockerContainer> containers,
+        string hostSeed,
+        string hostName,
+        string? hostIp) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        return containers
+            .Where(c => c.PublishedPorts.Any(p => !p.IsLoopback))
+            .Select(c => ToResource(c, hostSeed, hostName, hostIp, taken))
+            .ToList();
+    }
+
+    private static Service ToResource(
+        DockerContainer container,
+        string hostSeed,
+        string hostName,
+        string? hostIp,
+        ISet<string> taken) {
+        var discoveryId = DiscoveryId.Create(
+            DiscoveryId.DockerScheme,
+            $"{hostSeed}/{container.Name}");
+
+        // A wildcard binding is reachable on the host's own address; a binding pinned to
+        // one interface is only reachable there, so that address wins over the host's.
+        DockerPortBinding port = container.PublishedPorts
+            .Where(p => !p.IsLoopback)
+            .OrderBy(p => p.IsWildcard ? 0 : 1)
+            .ThenBy(p => p.HostPort)
+            .First();
+
+        return new Service {
+            Kind = Service.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest(container.Name, "service", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Network = new Network {
+                Ip = ServiceIp(port, hostIp),
+                Port = port.HostPort,
+                Protocol = port.Protocol
+            },
+            Notes = container.Image,
+            Tags = string.IsNullOrWhiteSpace(container.ComposeProject)
+                ? []
+                : [DiscoveryNaming.Slug(container.ComposeProject)],
+            RunsOn = [hostName]
+        };
+    }
+
+    /// <summary>
+    ///     The inventory schema holds IPv4 only, so a binding pinned to an IPv6 address
+    ///     falls back to the host's address — the right machine, if not the exact socket.
+    /// </summary>
+    private static string? ServiceIp(DockerPortBinding port, string? hostIp) =>
+        !port.IsWildcard && System.Net.IPAddress.TryParse(port.HostIp, out System.Net.IPAddress? ip)
+        && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
+            ? port.HostIp
+            : hostIp;
+}

+ 21 - 0
RackPeek.Domain/Discovery/IDockerClient.cs

@@ -0,0 +1,21 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Reads containers off a Docker Engine API. The IO half of docker discovery.</summary>
+public interface IDockerClient {
+    /// <summary>Describes where this client is pointed, for error messages.</summary>
+    string Endpoint { get; }
+
+    /// <summary>
+    ///     Running containers only. A stopped container has no host port bindings — the
+    ///     daemon does not create them until it runs — so it can never be described as a
+    ///     Service, which makes listing stopped containers pointless here.
+    /// </summary>
+    Task<IReadOnlyList<DockerContainer>> ListContainersAsync(CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The engine's own identity, or null when it cannot be read. Null is an expected
+    ///     answer, not an error: the read-only socket proxies the docs recommend usually
+    ///     allow <c>/containers</c> but block <c>/info</c>.
+    /// </summary>
+    Task<DockerEngineInfo?> GetInfoAsync(CancellationToken cancellationToken = default);
+}

+ 44 - 0
RackPeek.Domain/Discovery/IProxmoxClient.cs

@@ -0,0 +1,44 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Reads a Proxmox VE API. The IO half of hypervisor discovery.</summary>
+public interface IProxmoxClient {
+    /// <summary>Where this client is pointed, for error messages.</summary>
+    string Endpoint { get; }
+
+    /// <summary>
+    ///     The scope a vmid is unique within — the cluster name where there is one, and
+    ///     the node otherwise. Part of every guest's identity, so a guest that migrates
+    ///     between nodes stays the same resource.
+    /// </summary>
+    Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default);
+
+    Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     Adds what only the per-node status call knows — currently the Proxmox version.
+    ///     Returns the node unchanged if the token may not read it: a read-only token
+    ///     without Sys.Audit can still see the guests, and a partial answer beats none.
+    /// </summary>
+    Task<ProxmoxNode> EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default);
+
+    /// <summary>Guests of one kind on one node. <paramref name="endpoint" /> is qemu or lxc.</summary>
+    Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
+        string node,
+        string endpoint,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     Physical disks on a node. Needs the same permission as the status call, so it
+    ///     is gathered as part of enrichment and simply absent without it.
+    /// </summary>
+    Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(string node, CancellationToken cancellationToken = default);
+
+    /// <summary>Display adapters in a node, from its PCI device list.</summary>
+    Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(string node, CancellationToken cancellationToken = default);
+
+    Task<ProxmoxGuestConfig> GetGuestConfigAsync(
+        string node,
+        string endpoint,
+        int vmId,
+        CancellationToken cancellationToken = default);
+}

+ 30 - 0
RackPeek.Domain/Discovery/ISystemProbe.cs

@@ -0,0 +1,30 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Reads raw facts off the host. The only platform-specific code in discovery, and
+///     deliberately the only part that is not unit tested — it does IO and nothing else.
+/// </summary>
+public interface ISystemProbe {
+    /// <summary>True when this probe can run on the current host.</summary>
+    bool IsSupported { get; }
+
+    Task<RawSystemSnapshot> ReadAsync(CancellationToken cancellationToken = default);
+}
+
+/// <summary>
+///     Probe selection, shared by every collector so they all describe the host — and
+///     seed discovery ids — identically.
+/// </summary>
+public static class SystemProbes {
+    /// <summary>Facts from the first supported probe, or null on an unsupported platform.</summary>
+    public static async Task<SystemFacts?> TryReadHostAsync(
+        IEnumerable<ISystemProbe> probes,
+        CancellationToken cancellationToken) {
+        ISystemProbe? probe = probes.FirstOrDefault(p => p.IsSupported);
+
+        if (probe == null)
+            return null;
+
+        return SystemFactsParser.Parse(await probe.ReadAsync(cancellationToken));
+    }
+}

+ 67 - 0
RackPeek.Domain/Discovery/LinuxSystemProbe.cs

@@ -0,0 +1,67 @@
+namespace RackPeek.Domain.Discovery;
+
+public sealed class LinuxSystemProbe : ISystemProbe {
+    private const string _blockDeviceRoot = "/sys/block";
+    private const long _sectorBytes = 512;
+
+    public bool IsSupported => OperatingSystem.IsLinux();
+
+    public async Task<RawSystemSnapshot> ReadAsync(CancellationToken cancellationToken = default) {
+        return new RawSystemSnapshot {
+            Hostname = SystemProbeCommon.Hostname(),
+            Cores = SystemProbeCommon.Cores(),
+            Nics = SystemProbeCommon.Nics(),
+            FallbackMemoryBytes = SystemProbeCommon.FallbackMemoryBytes(),
+            BlockDevices = ReadBlockDevices(),
+            OsReleaseFile = await SystemProbeCommon.TryReadFileAsync("/etc/os-release", cancellationToken),
+            MemInfoFile = await SystemProbeCommon.TryReadFileAsync("/proc/meminfo", cancellationToken),
+            MachineIdFile = await ReadMachineIdAsync(cancellationToken),
+            CgroupFile = await SystemProbeCommon.TryReadFileAsync("/proc/1/cgroup", cancellationToken),
+            DockerEnvPresent = File.Exists("/.dockerenv"),
+            DmiVendor = await SystemProbeCommon.TryReadFileAsync("/sys/class/dmi/id/sys_vendor", cancellationToken),
+            DmiProduct = await SystemProbeCommon.TryReadFileAsync("/sys/class/dmi/id/product_name", cancellationToken)
+        };
+    }
+
+    private static async Task<string?> ReadMachineIdAsync(CancellationToken cancellationToken) =>
+        await SystemProbeCommon.TryReadFileAsync("/etc/machine-id", cancellationToken)
+        ?? await SystemProbeCommon.TryReadFileAsync("/var/lib/dbus/machine-id", cancellationToken);
+
+    /// <summary>
+    ///     Whole disks as the kernel sees them, which is closer to what goes on an
+    ///     inventory card than the mounted filesystems would be.
+    /// </summary>
+    private static List<BlockDeviceFact> ReadBlockDevices() {
+        try {
+            if (!Directory.Exists(_blockDeviceRoot))
+                return [];
+
+            return Directory.EnumerateDirectories(_blockDeviceRoot)
+                .Select(ReadBlockDevice)
+                .OfType<BlockDeviceFact>()
+                .OrderBy(d => d.Name, StringComparer.Ordinal)
+                .ToList();
+        }
+        catch {
+            return [];
+        }
+    }
+
+    private static BlockDeviceFact? ReadBlockDevice(string path) {
+        try {
+            var name = Path.GetFileName(path);
+
+            if (!long.TryParse(File.ReadAllText(Path.Combine(path, "size")).Trim(), out var sectors))
+                return null;
+
+            var rotationalPath = Path.Combine(path, "queue", "rotational");
+            var rotational = File.Exists(rotationalPath)
+                             && File.ReadAllText(rotationalPath).Trim() == "1";
+
+            return new BlockDeviceFact(name, sectors * _sectorBytes, rotational);
+        }
+        catch {
+            return null;
+        }
+    }
+}

+ 76 - 0
RackPeek.Domain/Discovery/MacSystemProbe.cs

@@ -0,0 +1,76 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     macOS host probe. Disks are deliberately left out: everything that reports them
+///     (diskutil, system_profiler) needs a plist parser for a field that is optional
+///     anyway, and omitting is better than guessing — null means "don't touch" on merge.
+/// </summary>
+public sealed class MacSystemProbe : ISystemProbe {
+    public bool IsSupported => OperatingSystem.IsMacOS();
+
+    public async Task<RawSystemSnapshot> ReadAsync(CancellationToken cancellationToken = default) {
+        // The four probes spawn independent processes, so they run side by side.
+        Task<string?> osName = ReadOsNameAsync(cancellationToken);
+        Task<long?> memoryBytes = ReadMemoryBytesAsync(cancellationToken);
+        Task<string?> platformUuid = ReadPlatformUuidAsync(cancellationToken);
+        Task<bool> hypervisorPresent = ReadHypervisorPresentAsync(cancellationToken);
+
+        return new RawSystemSnapshot {
+            Hostname = SystemProbeCommon.Hostname(),
+            Cores = SystemProbeCommon.Cores(),
+            Nics = SystemProbeCommon.Nics(),
+            FallbackMemoryBytes = SystemProbeCommon.FallbackMemoryBytes(),
+            OsName = await osName,
+            MemoryBytes = await memoryBytes,
+            PlatformUuid = await platformUuid,
+            HypervisorPresent = await hypervisorPresent
+        };
+    }
+
+    private static async Task<string?> ReadOsNameAsync(CancellationToken cancellationToken) {
+        var product = await SystemProbeCommon.TryRunAsync("sw_vers", "-productName", cancellationToken);
+        var version = await SystemProbeCommon.TryRunAsync("sw_vers", "-productVersion", cancellationToken);
+
+        if (string.IsNullOrWhiteSpace(product))
+            return null;
+
+        return string.IsNullOrWhiteSpace(version) ? product : $"{product} {version}";
+    }
+
+    private static async Task<long?> ReadMemoryBytesAsync(CancellationToken cancellationToken) {
+        var value = await SystemProbeCommon.TryRunAsync("sysctl", "-n hw.memsize", cancellationToken);
+
+        return long.TryParse(value, out var bytes) ? bytes : null;
+    }
+
+    private static async Task<bool> ReadHypervisorPresentAsync(CancellationToken cancellationToken) {
+        var value = await SystemProbeCommon.TryRunAsync("sysctl", "-n kern.hv_vmm_present", cancellationToken);
+
+        return value?.Trim() == "1";
+    }
+
+    private static async Task<string?> ReadPlatformUuidAsync(CancellationToken cancellationToken) {
+        var output = await SystemProbeCommon.TryRunAsync(
+            "ioreg", "-rd1 -c IOPlatformExpertDevice", cancellationToken);
+
+        return ParsePlatformUuid(output);
+    }
+
+    /// <summary>Pulls IOPlatformUUID out of an ioreg dump. Public so it can be tested off a macOS host.</summary>
+    public static string? ParsePlatformUuid(string? ioregOutput) {
+        if (string.IsNullOrWhiteSpace(ioregOutput))
+            return null;
+
+        foreach (var line in ioregOutput.Split('\n')) {
+            if (!line.Contains("IOPlatformUUID", StringComparison.Ordinal))
+                continue;
+
+            var parts = line.Split('=', 2);
+
+            if (parts.Length == 2)
+                return parts[1].Trim().Trim('"').Trim();
+        }
+
+        return null;
+    }
+}

+ 193 - 0
RackPeek.Domain/Discovery/ProxmoxApiClient.cs

@@ -0,0 +1,193 @@
+using System.Net.Security;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Talks to the Proxmox VE API with an API token. A token is used rather than a
+///     password because it can be given a read-only role and revoked on its own.
+/// </summary>
+public sealed class ProxmoxApiClient : IProxmoxClient, IDisposable {
+    public const string QemuEndpoint = "qemu";
+    public const string LxcEndpoint = "lxc";
+    public const string TokenIdEnvironmentVariable = "RPK_PVE_TOKEN_ID";
+    public const string TokenSecretEnvironmentVariable = "RPK_PVE_TOKEN_SECRET";
+
+    private readonly HttpClient _httpClient;
+
+    /// <param name="allowUntrustedCertificate">
+    ///     Proxmox ships with a self-signed certificate and most installations keep it,
+    ///     so this is needed more often than not. It is opt-in all the same.
+    /// </param>
+    public ProxmoxApiClient(
+        string host,
+        string tokenId,
+        string tokenSecret,
+        bool allowUntrustedCertificate = false,
+        HttpClient? httpClient = null) {
+        Endpoint = Normalise(host);
+
+        _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate));
+        _httpClient.BaseAddress = new Uri(Endpoint + "/api2/json/");
+        _httpClient.Timeout = TimeSpan.FromSeconds(30);
+
+        // Proxmox expects the whole token as one opaque Authorization value.
+        _httpClient.DefaultRequestHeaders.TryAddWithoutValidation(
+            "Authorization",
+            $"PVEAPIToken={tokenId}={tokenSecret}");
+    }
+
+    public string Endpoint { get; }
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public async Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default) {
+        // A standalone host has no cluster, and Proxmox answers 5xx rather than an empty
+        // list, so a failure here is expected and means "not clustered".
+        try {
+            var json = await GetAsync("cluster/status", cancellationToken);
+            var clusterName = ProxmoxResponseParser.ParseIdentityScope(json, string.Empty);
+
+            // Only fall back to the node list when there is no cluster name — the
+            // fallback costs a second call, and on a cluster it would be thrown away.
+            return clusterName.Length > 0 ? clusterName : await FirstNodeAsync(cancellationToken);
+        }
+        catch (HttpRequestException) {
+            // Either there is no cluster, or the token may not read it. Either way the
+            // node is a sound scope: a vmid is unique within it.
+            return await FirstNodeAsync(cancellationToken);
+        }
+    }
+
+    public async Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default) =>
+        ProxmoxResponseParser.ParseNodes(await GetAsync("nodes", cancellationToken));
+
+    public async Task<ProxmoxNode> EnrichAsync(
+        ProxmoxNode node,
+        CancellationToken cancellationToken = default) {
+        try {
+            // The three endpoints are independent, so the round trips overlap.
+            Task<string> status = GetAsync($"nodes/{Uri.EscapeDataString(node.Name)}/status", cancellationToken);
+            Task<IReadOnlyList<ProxmoxDisk>> disks = GetDisksAsync(node.Name, cancellationToken);
+            Task<IReadOnlyList<ProxmoxGpu>> gpus = GetGpusAsync(node.Name, cancellationToken);
+
+            ProxmoxNode detail = ProxmoxResponseParser.ParseNodeStatus(await status, node.Name);
+
+            return node with {
+                Cores = detail.Cores > 0 ? detail.Cores : node.Cores,
+                MemoryBytes = detail.MemoryBytes > 0 ? detail.MemoryBytes : node.MemoryBytes,
+                Version = detail.Version,
+                CpuModel = detail.CpuModel,
+                Sockets = detail.Sockets,
+                PhysicalCores = detail.PhysicalCores,
+                Disks = await disks,
+                Gpus = await gpus
+            };
+        }
+        catch (HttpRequestException) {
+            return node;
+        }
+    }
+
+    public async Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
+        string node,
+        string endpoint,
+        CancellationToken cancellationToken = default) {
+        var json = await GetAsync($"nodes/{Uri.EscapeDataString(node)}/{endpoint}", cancellationToken);
+
+        return ProxmoxResponseParser.ParseGuests(
+            json,
+            node,
+            endpoint == LxcEndpoint ? ProxmoxResponseParser.ContainerType : ProxmoxResponseParser.VmType);
+    }
+
+    public async Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(
+        string node,
+        CancellationToken cancellationToken = default) {
+        try {
+            return ProxmoxResponseParser.ParseDisks(
+                await GetAsync($"nodes/{Uri.EscapeDataString(node)}/disks/list", cancellationToken));
+        }
+        catch (HttpRequestException) {
+            return [];
+        }
+    }
+
+    public async Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(
+        string node,
+        CancellationToken cancellationToken = default) {
+        try {
+            return ProxmoxResponseParser.ParseGpus(
+                await GetAsync($"nodes/{Uri.EscapeDataString(node)}/hardware/pci", cancellationToken));
+        }
+        catch (HttpRequestException) {
+            return [];
+        }
+    }
+
+    public async Task<ProxmoxGuestConfig> GetGuestConfigAsync(
+        string node,
+        string endpoint,
+        int vmId,
+        CancellationToken cancellationToken = default) {
+        // A guest can disappear between listing and reading it; that is not worth failing
+        // the whole run over, so it simply contributes nothing.
+        try {
+            var json = await GetAsync(
+                $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/config",
+                cancellationToken);
+
+            return ProxmoxResponseParser.ParseGuestConfig(json);
+        }
+        catch (HttpRequestException) {
+            return new ProxmoxGuestConfig(null, null, [], []);
+        }
+    }
+
+    private async Task<string> FirstNodeAsync(CancellationToken cancellationToken) {
+        IReadOnlyList<ProxmoxNode> nodes = await GetNodesAsync(cancellationToken);
+
+        return nodes.FirstOrDefault()?.Name ?? "proxmox";
+    }
+
+    private async Task<string> GetAsync(string path, CancellationToken cancellationToken) {
+        using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken);
+
+        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
+            throw new HttpRequestException(
+                "Proxmox rejected the API token (401). Check the token id is of the form " +
+                "user@realm!tokenname and that the secret matches.");
+
+        if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
+            throw new HttpRequestException(
+                $"The API token is not permitted to read {path} (403). In the Proxmox UI: " +
+                "Datacenter -> Permissions -> Add -> API Token Permission, path '/', " +
+                "role PVEAuditor, with Propagate ticked.");
+
+        response.EnsureSuccessStatusCode();
+
+        return await response.Content.ReadAsStringAsync(cancellationToken);
+    }
+
+    private static HttpClientHandler Handler(bool allowUntrustedCertificate) {
+        var handler = new HttpClientHandler();
+
+        if (allowUntrustedCertificate)
+            handler.ServerCertificateCustomValidationCallback =
+                (_, _, _, _) => true;
+
+        return handler;
+    }
+
+    private static string Normalise(string host) {
+        var trimmed = host.Trim().TrimEnd('/');
+
+        if (trimmed.Contains("://", StringComparison.Ordinal))
+            return trimmed;
+
+        // A bare name gets the default scheme and port, but "pve.lan:8006" already
+        // carries a port — appending another would make the URL unparseable.
+        return trimmed.Contains(':', StringComparison.Ordinal)
+            ? $"https://{trimmed}"
+            : $"https://{trimmed}:8006";
+    }
+}

+ 427 - 0
RackPeek.Domain/Discovery/ProxmoxModels.cs

@@ -0,0 +1,427 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     A Proxmox node: the physical machine and the hypervisor installed on it. RackPeek
+///     models those as two resources, so both sets of facts are gathered here.
+/// </summary>
+public sealed record ProxmoxNode {
+    public required string Name { get; init; }
+
+    /// <summary>Logical processors, which is what the hypervisor OS sees.</summary>
+    public int Cores { get; init; }
+
+    public long MemoryBytes { get; init; }
+
+    /// <summary>e.g. <c>pve-manager/8.2.2/9355359cd7afbae4</c>, only from the status call.</summary>
+    public string? Version { get; init; }
+
+    // Hardware, all from the status call and all optional — a token without Sys.Audit
+    // still gets a usable node, just without these.
+    public string? CpuModel { get; init; }
+    public int Sockets { get; init; }
+    public int PhysicalCores { get; init; }
+    public IReadOnlyList<ProxmoxDisk> Disks { get; init; } = [];
+
+    /// <summary>
+    ///     Display adapters in the machine. A GPU passed through to a guest is still
+    ///     physically in the host, so this is where it belongs — the PCI address is kept
+    ///     so a guest holding it can say which card it has.
+    /// </summary>
+    public IReadOnlyList<ProxmoxGpu> Gpus { get; init; } = [];
+}
+
+/// <summary>A physical disk as Proxmox reports it, already classified by type.</summary>
+public sealed record ProxmoxDisk(string Type, long SizeBytes, string? Model);
+
+/// <summary>A display adapter and where it sits on the bus.</summary>
+public sealed record ProxmoxGpu(string Address, string Model);
+
+/// <summary>A guest on a node. QEMU and LXC differ only in the kind of system they are.</summary>
+public sealed record ProxmoxGuest {
+    public required int VmId { get; init; }
+    public required string Node { get; init; }
+
+    /// <summary>Empty for a guest that has never been named; the mapper falls back to the vmid.</summary>
+    public string Name { get; init; } = string.Empty;
+
+    /// <summary><c>vm</c> or <c>container</c>, matching SystemResource.ValidSystemTypes.</summary>
+    public required string Type { get; init; }
+
+    public int Cores { get; init; }
+    public long MemoryBytes { get; init; }
+    /// <summary>The boot disk, from the guest list. A fallback for when the config is unreadable.</summary>
+    public long DiskBytes { get; init; }
+
+    /// <summary>Every attached disk, from the guest's config.</summary>
+    public IReadOnlyList<long> Disks { get; init; } = [];
+
+    /// <summary>PCI addresses handed exclusively to this guest, from its config.</summary>
+    public IReadOnlyList<string> PassthroughAddresses { get; init; } = [];
+
+    public IReadOnlyList<string> Tags { get; init; } = [];
+
+    /// <summary>Filled in from the guest's config, which is the only place it is known.</summary>
+    public string? Os { get; init; }
+
+    public string? Ip { get; init; }
+}
+
+/// <summary>
+///     Parses the Proxmox API. Every response wraps its payload in a <c>data</c> member.
+///     Pure, so it tests from captured responses without a Proxmox to talk to.
+/// </summary>
+public static class ProxmoxResponseParser {
+    public const string VmType = "vm";
+    public const string ContainerType = "container";
+
+    /// <summary>
+    ///     Nodes with whatever detail the token is allowed to see. Proxmox strips
+    ///     <c>maxcpu</c> and <c>maxmem</c> from this response for a token without the
+    ///     rights to read them, rather than refusing the call, so both shapes are normal.
+    /// </summary>
+    public static List<ProxmoxNode> ParseNodes(string json) {
+        return Data(json)
+            .Where(n => !string.IsNullOrWhiteSpace(GetString(n, "node")))
+            .Select(n => new ProxmoxNode {
+                Name = GetString(n, "node")!,
+                Cores = GetInt(n, "maxcpu") ?? 0,
+                MemoryBytes = GetLong(n, "maxmem") ?? 0
+            })
+            .OrderBy(n => n.Name, StringComparer.Ordinal)
+            .ToList();
+    }
+
+    public static ProxmoxNode ParseNodeStatus(string json, string nodeName) {
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data))
+            return new ProxmoxNode { Name = nodeName };
+
+        var cores = data.TryGetProperty("cpuinfo", out JsonElement cpu)
+            ? GetInt(cpu, "cpus") ?? GetInt(cpu, "cores") ?? 0
+            : 0;
+
+        var memory = data.TryGetProperty("memory", out JsonElement mem)
+            ? GetLong(mem, "total") ?? 0
+            : 0;
+
+        return new ProxmoxNode {
+            Name = nodeName,
+            Cores = cores,
+            MemoryBytes = memory,
+            Version = GetString(data, "pveversion"),
+            CpuModel = cpu.ValueKind == JsonValueKind.Object ? GetString(cpu, "model") : null,
+            Sockets = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "sockets") ?? 0 : 0,
+            PhysicalCores = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "cores") ?? 0 : 0
+        };
+    }
+
+    /// <summary>
+    ///     Physical disks. Proxmox has already worked out nvme/ssd/hdd, which is better
+    ///     than the guess <c>discover system</c> has to make from a rotational flag.
+    /// </summary>
+    public static List<ProxmoxDisk> ParseDisks(string json) {
+        return Data(json)
+            .Select(d => new ProxmoxDisk(
+                NormaliseDiskType(GetString(d, "type")),
+                GetLong(d, "size") ?? 0,
+                GetString(d, "model")))
+            .Where(d => d.SizeBytes > 0)
+            .ToList();
+    }
+
+    /// <summary>Proxmox says "unknown" for a disk it cannot classify; RackPeek omits the type.</summary>
+    private static string NormaliseDiskType(string? type) {
+        return type?.ToLowerInvariant() switch {
+            "nvme" => "nvme",
+            "ssd" => "ssd",
+            "hdd" => "hdd",
+            _ => string.Empty
+        };
+    }
+
+    /// <summary>
+    ///     Display adapters from the node's PCI device list. PCI class 0x03 is the
+    ///     display-controller class, which is how a GPU is told apart from the other
+    ///     couple of dozen devices on a modern board.
+    /// </summary>
+    public static List<ProxmoxGpu> ParseGpus(string json) {
+        return Data(json)
+            .Where(d => (GetString(d, "class") ?? string.Empty).StartsWith("0x03", StringComparison.Ordinal))
+            .Select(d => new { Address = GetString(d, "id"), Model = MarketingName(GetString(d, "device_name")) })
+            .Where(g => !string.IsNullOrWhiteSpace(g.Address) && !string.IsNullOrWhiteSpace(g.Model))
+            .Select(g => new ProxmoxGpu(g.Address!, g.Model!))
+            .ToList();
+    }
+
+    /// <summary>
+    ///     PCI addresses a guest has been given exclusive use of. The config writes them
+    ///     as <c>hostpci0: 0000:01:00</c>, optionally with trailing options and sometimes
+    ///     without the function suffix the device list carries.
+    /// </summary>
+    public static List<string> ParsePassthrough(JsonElement config) {
+        return config.EnumerateObject()
+            .Where(p => p.Name.StartsWith("hostpci", StringComparison.OrdinalIgnoreCase))
+            .Select(p => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null)
+            .Where(v => !string.IsNullOrWhiteSpace(v))
+            .Select(v => v!.Split(',')[0].Trim())
+            .Where(v => v.Length > 0)
+            .ToList();
+    }
+
+    /// <summary>
+    ///     PCI names the part and then the product: <c>GA102 [GeForce RTX 3090]</c>. The
+    ///     bracketed half is the one people would have typed, so it wins where it exists.
+    /// </summary>
+    public static string? MarketingName(string? deviceName) {
+        if (string.IsNullOrWhiteSpace(deviceName))
+            return null;
+
+        var open = deviceName.IndexOf('[');
+        var close = deviceName.IndexOf(']');
+
+        return close > open && open >= 0
+            ? deviceName[(open + 1)..close].Trim()
+            : deviceName.Trim();
+    }
+
+    public static List<ProxmoxGuest> ParseGuests(string json, string node, string type) {
+        return Data(json)
+            .Select(g => ParseGuest(g, node, type))
+            .OfType<ProxmoxGuest>()
+            .OrderBy(g => g.VmId)
+            .ToList();
+    }
+
+    /// <summary>
+    ///     The scope a vmid is unique within. A clustered guest can migrate between nodes,
+    ///     so the cluster name is what keeps its identity stable; a standalone host has no
+    ///     cluster entry and falls back to the node.
+    /// </summary>
+    public static string ParseIdentityScope(string clusterStatusJson, string fallbackNode) {
+        JsonElement cluster = Data(clusterStatusJson)
+            .FirstOrDefault(e => GetString(e, "type") == "cluster");
+
+        var name = cluster.ValueKind == JsonValueKind.Object ? GetString(cluster, "name") : null;
+
+        return string.IsNullOrWhiteSpace(name) ? fallbackNode : name;
+    }
+
+    /// <summary>
+    ///     Reads the guest's own config. This is the only place the OS is knowable, and
+    ///     for a container it carries the address too — which is why the extra call per
+    ///     guest earns its place.
+    /// </summary>
+    public static ProxmoxGuestConfig ParseGuestConfig(string json) {
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data))
+            return new ProxmoxGuestConfig(null, null, [], []);
+
+        return new ProxmoxGuestConfig(
+            DescribeOs(GetString(data, "ostype")),
+            ParseStaticIp(GetString(data, "net0")),
+            ParseDiskSizes(data),
+            ParsePassthrough(data));
+    }
+
+    /// <summary>
+    ///     Every disk attached to a guest. The guest list only carries <c>maxdisk</c>,
+    ///     which is the boot disk alone — a VM with a small root and a large data volume
+    ///     would otherwise be recorded at a fraction of its real size.
+    /// </summary>
+    public static List<long> ParseDiskSizes(JsonElement config) {
+        var sizes = new List<long>();
+
+        foreach (JsonProperty property in config.EnumerateObject()) {
+            if (!IsDiskSlot(property.Name))
+                continue;
+
+            var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+
+            if (value == null || value.Contains("media=cdrom", StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            var size = ParseSize(value);
+
+            if (size > 0)
+                sizes.Add(size);
+        }
+
+        return sizes;
+    }
+
+    /// <summary>
+    ///     Disk-bearing config keys. <c>unusedN</c> is excluded because it is a detached
+    ///     volume with no size, and the EFI and TPM state volumes because they are
+    ///     firmware scratch space of a few megabytes rather than storage anyone inventories.
+    /// </summary>
+    private static bool IsDiskSlot(string key) {
+        string[] prefixes = ["scsi", "virtio", "sata", "ide", "mp"];
+
+        if (key.Equals("rootfs", StringComparison.OrdinalIgnoreCase))
+            return true;
+
+        return prefixes.Any(p =>
+            key.StartsWith(p, StringComparison.OrdinalIgnoreCase)
+            && key.Length > p.Length
+            && key[p.Length..].All(char.IsAsciiDigit));
+    }
+
+    /// <summary>
+    ///     Reads <c>size=64G</c> out of a volume definition, in bytes. Proxmox permits a
+    ///     fractional number (<c>size=4.5G</c>, after an odd resize) and a bare number,
+    ///     which is bytes.
+    /// </summary>
+    public static long ParseSize(string volume) {
+        foreach (var part in volume.Split(',', StringSplitOptions.TrimEntries)) {
+            if (!part.StartsWith("size=", StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            var raw = part[5..].Trim();
+
+            if (raw.Length == 0)
+                return 0;
+
+            var multiplier = char.ToUpperInvariant(raw[^1]) switch {
+                'K' => 1024L,
+                'M' => 1024L * 1024,
+                'G' => 1024L * 1024 * 1024,
+                'T' => 1024L * 1024 * 1024 * 1024,
+                _ => 0L
+            };
+
+            if (multiplier == 0)
+                return long.TryParse(raw, out var bytes) && bytes > 0 ? bytes : 0;
+
+            return double.TryParse(
+                       raw[..^1],
+                       System.Globalization.NumberStyles.Float,
+                       System.Globalization.CultureInfo.InvariantCulture,
+                       out var value)
+                   && value > 0
+                ? (long)Math.Round(value * multiplier)
+                : 0;
+        }
+
+        return 0;
+    }
+
+    /// <summary>
+    ///     Proxmox stores an ostype code. The container ones name a real distribution and
+    ///     are worth having; the QEMU ones are coarse by nature — <c>l26</c> means any
+    ///     Linux since 2.6 — so they stay vague rather than pretending to precision.
+    /// </summary>
+    internal static string? DescribeOs(string? ostype) {
+        if (string.IsNullOrWhiteSpace(ostype))
+            return null;
+
+        return ostype.ToLowerInvariant() switch {
+            "l24" => "Linux",
+            "l26" => "Linux",
+            "solaris" => "Solaris",
+            "wxp" => "Windows XP",
+            "w2k" => "Windows 2000",
+            "w2k3" => "Windows Server 2003",
+            "w2k8" => "Windows Server 2008",
+            "wvista" => "Windows Vista",
+            "win7" => "Windows 7",
+            "win8" => "Windows 8",
+            "win10" => "Windows 10",
+            "win11" => "Windows 11",
+            "other" => null,
+            "unmanaged" => null,
+            // Container templates are named after the distribution itself.
+            var distribution => char.ToUpperInvariant(distribution[0]) + distribution[1..]
+        };
+    }
+
+    /// <summary>
+    ///     Pulls the address out of a net interface line such as
+    ///     <c>name=eth0,bridge=vmbr0,ip=192.168.1.53/24</c>. Returns null for
+    ///     <c>ip=dhcp</c> and <c>ip=manual</c>, where the config knows no more than we do.
+    /// </summary>
+    internal static string? ParseStaticIp(string? net) {
+        if (string.IsNullOrWhiteSpace(net))
+            return null;
+
+        foreach (var part in net.Split(',', StringSplitOptions.TrimEntries)) {
+            if (!part.StartsWith("ip=", StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            var value = part[3..].Split('/')[0].Trim();
+
+            return value.Equals("dhcp", StringComparison.OrdinalIgnoreCase)
+                   || value.Equals("manual", StringComparison.OrdinalIgnoreCase)
+                   || value.Length == 0
+                ? null
+                : value;
+        }
+
+        return null;
+    }
+
+    private static ProxmoxGuest? ParseGuest(JsonElement element, string node, string type) {
+        var vmid = GetInt(element, "vmid");
+
+        if (vmid == null)
+            return null;
+
+        return new ProxmoxGuest {
+            VmId = vmid.Value,
+            Node = node,
+            Name = GetString(element, "name") ?? string.Empty,
+            Type = type,
+            Cores = GetInt(element, "cpus") ?? 0,
+            MemoryBytes = GetLong(element, "maxmem") ?? 0,
+            DiskBytes = GetLong(element, "maxdisk") ?? 0,
+            Tags = ParseTags(GetString(element, "tags"))
+        };
+    }
+
+    /// <summary>Proxmox joins guest tags with semicolons.</summary>
+    private static List<string> ParseTags(string? tags) {
+        if (string.IsNullOrWhiteSpace(tags))
+            return [];
+
+        return tags.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+            .Select(DiscoveryNaming.Slug)
+            .Where(t => t.Length > 0)
+            .Distinct(StringComparer.OrdinalIgnoreCase)
+            .ToList();
+    }
+
+    private static IEnumerable<JsonElement> Data(string json) {
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data)
+            || data.ValueKind != JsonValueKind.Array)
+            return [];
+
+        return data.EnumerateArray().Select(e => e.Clone()).ToList();
+    }
+
+    private static string? GetString(JsonElement element, string name) =>
+        element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
+            ? value.GetString()
+            : null;
+
+    private static int? GetInt(JsonElement element, string name) =>
+        element.TryGetProperty(name, out JsonElement value) && value.TryGetInt32(out var result)
+            ? result
+            : null;
+
+    private static long? GetLong(JsonElement element, string name) =>
+        element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result)
+            ? result
+            : null;
+}
+
+/// <summary>The parts of a guest's config worth recording. Everything is optional.</summary>
+public sealed record ProxmoxGuestConfig(
+    string? Os,
+    string? Ip,
+    IReadOnlyList<long> DiskBytes,
+    IReadOnlyList<string> PassthroughAddresses);

+ 246 - 0
RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs

@@ -0,0 +1,246 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Maps a Proxmox estate onto RackPeek's three levels. Pure.
+///     <para>
+///         A node becomes two resources, because it is two things: the machine
+///         (<c>kepler</c>, a Server carrying the CPU, memory and disks) and the
+///         hypervisor installed on it (<c>kepler-pve</c>, a System). Guests then run on
+///         the hypervisor, giving Hardware -> System -> System all the way down.
+///     </para>
+/// </summary>
+public static class ProxmoxResourceMapper {
+    public const string Scheme = "pve";
+
+    /// <summary>Label recording which cards a guest has exclusive use of.</summary>
+    public const string GpuLabel = "gpu";
+
+    /// <summary>The cap RackPeek's own validation puts on a label value.</summary>
+    private const int _maxLabelLength = Helpers.ThrowIfInvalid.MaxLabelValueLength;
+
+    public static List<Resource> ToResources(
+        string scope,
+        IReadOnlyList<ProxmoxNode> nodes,
+        IReadOnlyList<ProxmoxGuest> guests) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var resources = new List<Resource>();
+
+        // Nodes first, so a guest named after its node does not take the node's name.
+        var hypervisorNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        // PCI addresses repeat on every machine, so a guest can only be matched against
+        // the cards in the node it actually runs on.
+        var gpusByNode = nodes.ToDictionary(
+            n => n.Name,
+            n => n.Gpus,
+            StringComparer.OrdinalIgnoreCase);
+
+        foreach (ProxmoxNode node in nodes) {
+            Server server = ToServer(node, scope, taken);
+            SystemResource hypervisor = ToHypervisor(node, scope, server.Name, taken);
+
+            hypervisorNames[node.Name] = hypervisor.Name;
+            resources.Add(server);
+            resources.Add(hypervisor);
+        }
+
+        // A vmid is unique within the scope, so two entries carrying the same one are
+        // the same guest — which is what a migration in flight looks like, reported by
+        // both the node it is leaving and the node it is joining. Without this they
+        // would come out as two resources sharing an identity.
+        resources.AddRange(guests
+            .DistinctBy(g => g.VmId)
+            .Select(g => ToResource(g, scope, hypervisorNames, gpusByNode, taken)));
+
+        return resources;
+    }
+
+    /// <summary>The machine itself. Takes the node's own name, being the thing people point at.</summary>
+    private static Server ToServer(ProxmoxNode node, string scope, ISet<string> taken) {
+        var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}");
+
+        return new Server {
+            Kind = Server.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest(node.Name, "server", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Ram = ToGb(node.MemoryBytes) is { } ram ? new Ram { Size = ram } : null,
+            Cpus = ToCpus(node),
+            Drives = ToDrives(node.Disks),
+
+            // A GPU passed through to a guest is still bolted into this machine, so it
+            // is recorded here rather than on whatever borrows it. VRAM is not something
+            // the PCI list knows, so it is left off.
+            Gpus = node.Gpus.Count == 0 ? null : node.Gpus.Select(g => new Gpu { Model = g.Model }).ToList()
+        };
+    }
+
+    /// <summary>The Proxmox install running on the machine, which is what guests run on.</summary>
+    private static SystemResource ToHypervisor(
+        ProxmoxNode node,
+        string scope,
+        string serverName,
+        ISet<string> taken) {
+        var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}/pve");
+
+        return new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest($"{node.Name}-pve", "hypervisor", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Type = "hypervisor",
+            Os = DescribeVersion(node.Version),
+            Cores = node.Cores > 0 ? node.Cores : null,
+            Ram = ToGb(node.MemoryBytes),
+            RunsOn = [serverName]
+        };
+    }
+
+    /// <summary>
+    ///     One entry per socket. Proxmox reports totals across the machine, so they are
+    ///     divided down — two sockets of an 8-core part read as 2 x 8, not 1 x 16.
+    /// </summary>
+    private static List<Cpu>? ToCpus(ProxmoxNode node) {
+        if (string.IsNullOrWhiteSpace(node.CpuModel))
+            return null;
+
+        var sockets = Math.Max(1, node.Sockets);
+
+        var cpu = new Cpu {
+            Model = node.CpuModel,
+            Cores = node.PhysicalCores > 0 ? node.PhysicalCores / sockets : null,
+            Threads = node.Cores > 0 ? node.Cores / sockets : null
+        };
+
+        return Enumerable.Range(0, sockets).Select(_ => cpu).ToList();
+    }
+
+    private static List<Drive>? ToDrives(IReadOnlyList<ProxmoxDisk> disks) {
+        if (disks.Count == 0)
+            return null;
+
+        return disks
+            .Select(d => new Drive {
+                Type = string.IsNullOrEmpty(d.Type) ? null : d.Type,
+                Size = (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes)
+            })
+            .ToList();
+    }
+
+    private static SystemResource ToResource(
+        ProxmoxGuest guest,
+        string scope,
+        IReadOnlyDictionary<string, string> hypervisorNames,
+        IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode,
+        ISet<string> taken) {
+        // The vmid is unique within the cluster and survives a rename or a migration
+        // between nodes, which is exactly what an identity needs to do.
+        var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}");
+
+        return new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest(FallbackName(guest), "system", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Type = guest.Type,
+            Os = guest.Os,
+            Cores = guest.Cores > 0 ? guest.Cores : null,
+            Ram = ToGb(guest.MemoryBytes),
+            Ip = guest.Ip,
+            Drives = ToGuestDrives(guest),
+            Tags = guest.Tags.ToArray(),
+            Labels = PassthroughLabels(guest, gpusByNode),
+            RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : []
+        };
+    }
+
+    /// <summary>
+    ///     Every disk the config lists, falling back to the boot disk from the guest list
+    ///     when the config could not be read. The storage backend says nothing about the
+    ///     underlying medium, so the type is left off rather than guessed.
+    /// </summary>
+    private static List<Drive>? ToGuestDrives(ProxmoxGuest guest) {
+        IReadOnlyList<long> sizes = guest.Disks.Count > 0
+            ? guest.Disks
+            : guest.DiskBytes > 0
+                ? [guest.DiskBytes]
+                : [];
+
+        if (sizes.Count == 0)
+            return null;
+
+        return sizes
+            .Select(bytes => new Drive { Size = (int)DiscoveryUnits.BytesToWholeGb(bytes) })
+            .ToList();
+    }
+
+    /// <summary>
+    ///     Records the cards a guest holds. The GPU itself stays on the Server, because
+    ///     that is where it is physically installed; this is the assignment, which
+    ///     RackPeek has no first-class way to express.
+    /// </summary>
+    private static Dictionary<string, string> PassthroughLabels(
+        ProxmoxGuest guest,
+        IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode) {
+        var labels = new Dictionary<string, string>();
+
+        if (guest.PassthroughAddresses.Count == 0
+            || !gpusByNode.TryGetValue(guest.Node, out IReadOnlyList<ProxmoxGpu>? gpus))
+            return labels;
+
+        var held = guest.PassthroughAddresses
+            .Select(address => gpus.FirstOrDefault(g => AddressesMatch(g.Address, address)))
+            .OfType<ProxmoxGpu>()
+            .Select(g => g.Model)
+            .ToList();
+
+        if (held.Count == 0)
+            return labels;
+
+        var value = string.Join(", ", held);
+
+        labels[GpuLabel] = value.Length <= _maxLabelLength
+            ? value
+            : value[.._maxLabelLength].TrimEnd(',', ' ');
+
+        return labels;
+    }
+
+    /// <summary>
+    ///     The device list gives a function suffix (<c>0000:01:00.0</c>) that a guest
+    ///     config usually leaves off (<c>0000:01:00</c>), so either may be the longer.
+    /// </summary>
+    private static bool AddressesMatch(string deviceAddress, string configured) =>
+        deviceAddress.StartsWith(configured, StringComparison.OrdinalIgnoreCase)
+        || configured.StartsWith(deviceAddress, StringComparison.OrdinalIgnoreCase);
+
+    /// <summary>A guest that was never named still needs one; the vmid is what people call it.</summary>
+    private static string FallbackName(ProxmoxGuest guest) =>
+        string.IsNullOrWhiteSpace(guest.Name)
+            ? $"{(guest.Type == ProxmoxResponseParser.ContainerType ? "ct" : "vm")}-{guest.VmId}"
+            : guest.Name;
+
+    /// <summary><c>pve-manager/8.2.2/9355359c</c> becomes <c>Proxmox VE 8.2.2</c>.</summary>
+    internal static string? DescribeVersion(string? pveVersion) {
+        if (string.IsNullOrWhiteSpace(pveVersion))
+            return null;
+
+        var parts = pveVersion.Split('/');
+
+        return parts.Length >= 2 ? $"Proxmox VE {parts[1]}" : pveVersion;
+    }
+
+    private static double? ToGb(long bytes) =>
+        bytes > 0 ? DiscoveryUnits.BytesToWholeGb(bytes) : null;
+}

+ 69 - 0
RackPeek.Domain/Discovery/SystemFacts.cs

@@ -0,0 +1,69 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     The one place discovery turns bytes into the whole gigabytes RackPeek stores,
+///     so every collector reports the same size for the same hardware.
+/// </summary>
+public static class DiscoveryUnits {
+    private const double _bytesPerGb = 1024d * 1024 * 1024;
+
+    /// <summary>Rounded, floored at 1 — a real device is never zero gigabytes.</summary>
+    public static double BytesToWholeGb(long bytes) => Math.Max(1, Math.Round(bytes / _bytesPerGb));
+}
+
+/// <summary>A physical or virtual disk as reported by the host.</summary>
+public sealed record BlockDeviceFact(string Name, long SizeBytes, bool Rotational);
+
+/// <summary>A network interface, reduced to the parts that pick a primary address.</summary>
+public sealed record NicFact(string Name, bool IsUp, bool IsLoopback, bool HasGateway, string? Ipv4);
+
+/// <summary>
+///     Everything a probe managed to read off the host, still in its raw form.
+///     Kept deliberately dumb: probes do IO and nothing else, so that every decision
+///     made about this data lives in <see cref="SystemFactsParser" /> and is testable
+///     on any platform from a captured fixture.
+/// </summary>
+public sealed record RawSystemSnapshot {
+    public string Hostname { get; init; } = string.Empty;
+    public int Cores { get; init; }
+    public IReadOnlyList<NicFact> Nics { get; init; } = [];
+    public IReadOnlyList<BlockDeviceFact> BlockDevices { get; init; } = [];
+
+    /// <summary>Fallback when the platform-specific read fails; always populated.</summary>
+    public long FallbackMemoryBytes { get; init; }
+
+    // Linux
+    public string? OsReleaseFile { get; init; }
+    public string? MemInfoFile { get; init; }
+    public string? MachineIdFile { get; init; }
+    public string? CgroupFile { get; init; }
+    public bool DockerEnvPresent { get; init; }
+    public string? DmiVendor { get; init; }
+    public string? DmiProduct { get; init; }
+
+    // macOS
+    public string? OsName { get; init; }
+    public long? MemoryBytes { get; init; }
+    public string? PlatformUuid { get; init; }
+    public bool HypervisorPresent { get; init; }
+}
+
+/// <summary>The host, once the raw snapshot has been interpreted.</summary>
+public sealed record SystemFacts {
+    public required string Hostname { get; init; }
+
+    /// <summary>Seed for the discovery id. Null when the host offers nothing stable.</summary>
+    public string? MachineId { get; init; }
+
+    public required string Os { get; init; }
+    public required int Cores { get; init; }
+    public required double RamGb { get; init; }
+
+    /// <summary>One of <see cref="Resources.SystemResources.SystemResource.ValidSystemTypes" />.</summary>
+    public required string Type { get; init; }
+
+    public string? Ip { get; init; }
+    public IReadOnlyList<DriveFact> Drives { get; init; } = [];
+}
+
+public sealed record DriveFact(string Type, int SizeGb);

+ 165 - 0
RackPeek.Domain/Discovery/SystemFactsParser.cs

@@ -0,0 +1,165 @@
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Turns a <see cref="RawSystemSnapshot" /> into <see cref="SystemFacts" />.
+///     Pure: no IO, no platform checks, so it runs and is tested identically everywhere.
+/// </summary>
+public static class SystemFactsParser {
+    /// <summary>Strings that appear in DMI when the host is a guest rather than real hardware.</summary>
+    private static readonly string[] _virtualMachineMarkers =
+    [
+        "qemu", "kvm", "vmware", "virtualbox", "innotek", "xen", "bochs",
+        "bhyve", "parallels", "hyper-v", "virtual machine", "openstack"
+    ];
+
+    /// <summary>Kernel-managed devices that are not disks anyone wants in an inventory.</summary>
+    private static readonly string[] _ignoredBlockDevicePrefixes =
+        ["loop", "ram", "zram", "sr", "dm-", "fd", "md"];
+
+    public static SystemFacts Parse(RawSystemSnapshot raw) {
+        var type = ParseType(raw);
+
+        return new SystemFacts {
+            Hostname = raw.Hostname,
+            MachineId = ParseMachineId(raw),
+            Os = ParseOs(raw),
+            Cores = raw.Cores > 0 ? raw.Cores : 1,
+            RamGb = ParseRamGb(raw),
+            Type = type,
+            Ip = SelectPrimaryIp(raw.Nics),
+
+            // A container sees the host's block devices through /sys/block. They belong
+            // to the machine underneath it, so reporting them here would attribute
+            // someone else's disks to this resource.
+            Drives = type == "container" ? [] : ParseDrives(raw.BlockDevices)
+        };
+    }
+
+    internal static string? ParseMachineId(RawSystemSnapshot raw) {
+        var id = Clean(raw.PlatformUuid) ?? Clean(raw.MachineIdFile);
+
+        return string.IsNullOrEmpty(id) ? null : id;
+    }
+
+    internal static string ParseOs(RawSystemSnapshot raw) {
+        var name = Clean(raw.OsName);
+        if (!string.IsNullOrEmpty(name))
+            return name;
+
+        var pretty = ReadKeyValue(raw.OsReleaseFile, "PRETTY_NAME");
+        if (!string.IsNullOrEmpty(pretty))
+            return pretty;
+
+        var id = ReadKeyValue(raw.OsReleaseFile, "NAME");
+        var version = ReadKeyValue(raw.OsReleaseFile, "VERSION");
+
+        if (!string.IsNullOrEmpty(id))
+            return string.IsNullOrEmpty(version) ? id : $"{id} {version}";
+
+        return "Unknown";
+    }
+
+    internal static double ParseRamGb(RawSystemSnapshot raw) {
+        if (raw.MemoryBytes is > 0)
+            return DiscoveryUnits.BytesToWholeGb(raw.MemoryBytes.Value);
+
+        // MemTotal is in kB, and is a little under the physical total because the
+        // kernel reserves some. Reported as-is rather than rounded up to a DIMM size.
+        var memTotal = ReadKeyValue(raw.MemInfoFile, "MemTotal", ':');
+
+        if (!string.IsNullOrEmpty(memTotal)) {
+            var digits = new string(memTotal.TakeWhile(char.IsAsciiDigit).ToArray());
+
+            if (long.TryParse(digits, out var kb) && kb > 0)
+                return DiscoveryUnits.BytesToWholeGb(kb * 1024L);
+        }
+
+        return DiscoveryUnits.BytesToWholeGb(raw.FallbackMemoryBytes);
+    }
+
+    internal static string ParseType(RawSystemSnapshot raw) {
+        if (raw.DockerEnvPresent || ContainsContainerMarker(raw.CgroupFile))
+            return "container";
+
+        if (raw.HypervisorPresent)
+            return "vm";
+
+        var dmi = $"{raw.DmiVendor} {raw.DmiProduct}".ToLowerInvariant();
+
+        if (_virtualMachineMarkers.Any(marker => dmi.Contains(marker, StringComparison.Ordinal)))
+            return "vm";
+
+        return "baremetal";
+    }
+
+    internal static string? SelectPrimaryIp(IReadOnlyList<NicFact> nics) {
+        var usable = nics
+            .Where(n => n is { IsUp: true, IsLoopback: false } && !string.IsNullOrWhiteSpace(n.Ipv4))
+            .ToList();
+
+        // An interface holding the default route is the address other machines reach
+        // this host on. Otherwise prefer anything that is not an obvious virtual bridge.
+        return usable.FirstOrDefault(n => n.HasGateway)?.Ipv4
+               ?? usable.FirstOrDefault(n => !IsVirtual(n.Name))?.Ipv4
+               ?? usable.FirstOrDefault()?.Ipv4;
+    }
+
+    internal static bool IsVirtual(string name) {
+        string[] prefixes = ["docker", "br-", "veth", "virbr", "tailscale", "utun", "tun", "tap", "cni", "flannel"];
+
+        return prefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase));
+    }
+
+    internal static List<DriveFact> ParseDrives(IReadOnlyList<BlockDeviceFact> devices) {
+        return devices
+            .Where(d => !_ignoredBlockDevicePrefixes.Any(p =>
+                d.Name.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+            .Where(d => d.SizeBytes > 0)
+            .Select(d => new DriveFact(DriveType(d), (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes)))
+            .ToList();
+    }
+
+    private static string DriveType(BlockDeviceFact device) {
+        if (device.Name.StartsWith("nvme", StringComparison.OrdinalIgnoreCase))
+            return "nvme";
+
+        if (device.Name.StartsWith("mmcblk", StringComparison.OrdinalIgnoreCase))
+            return "sdcard";
+
+        return device.Rotational ? "hdd" : "ssd";
+    }
+
+    private static bool ContainsContainerMarker(string? cgroup) {
+        if (string.IsNullOrWhiteSpace(cgroup))
+            return false;
+
+        string[] markers = ["docker", "lxc", "kubepods", "containerd", "podman"];
+
+        return markers.Any(m => cgroup.Contains(m, StringComparison.OrdinalIgnoreCase));
+    }
+
+    /// <summary>Reads one entry out of a key=value file such as /etc/os-release.</summary>
+    private static string? ReadKeyValue(string? contents, string key, char separator = '=') {
+        if (string.IsNullOrWhiteSpace(contents))
+            return null;
+
+        foreach (var rawLine in contents.Split('\n')) {
+            var line = rawLine.Trim();
+            var index = line.IndexOf(separator);
+
+            if (index <= 0)
+                continue;
+
+            if (!line[..index].Trim().Equals(key, StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            return line[(index + 1)..].Trim().Trim('"').Trim();
+        }
+
+        return null;
+    }
+
+    private static string? Clean(string? value) =>
+        string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}

+ 118 - 0
RackPeek.Domain/Discovery/SystemProbeCommon.cs

@@ -0,0 +1,118 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Host reads that the BCL already does the same way on every platform.</summary>
+internal static class SystemProbeCommon {
+    public static string Hostname() {
+        try {
+            return Dns.GetHostName();
+        }
+        catch {
+            return Environment.MachineName;
+        }
+    }
+
+    public static int Cores() => Environment.ProcessorCount;
+
+    public static long FallbackMemoryBytes() => GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
+
+    public static IReadOnlyList<NicFact> Nics() {
+        try {
+            return NetworkInterface.GetAllNetworkInterfaces()
+                .Select(ToFact)
+                .Where(n => n.Ipv4 != null)
+                .ToList();
+        }
+        catch {
+            return [];
+        }
+    }
+
+    private static NicFact ToFact(NetworkInterface nic) {
+        IPInterfaceProperties properties = nic.GetIPProperties();
+
+        var ipv4 = properties.UnicastAddresses
+            .FirstOrDefault(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
+            ?.Address.ToString();
+
+        var hasGateway = properties.GatewayAddresses
+            .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork
+                      && !g.Address.Equals(IPAddress.Any));
+
+        return new NicFact(
+            nic.Name,
+            nic.OperationalStatus == OperationalStatus.Up,
+            nic.NetworkInterfaceType == NetworkInterfaceType.Loopback,
+            hasGateway,
+            ipv4);
+    }
+
+    /// <summary>Reads a file, returning null for anything unreadable rather than throwing.</summary>
+    public static async Task<string?> TryReadFileAsync(string path, CancellationToken cancellationToken) {
+        try {
+            return File.Exists(path)
+                ? await File.ReadAllTextAsync(path, cancellationToken)
+                : null;
+        }
+        catch {
+            return null;
+        }
+    }
+
+    /// <summary>
+    ///     Runs a command and returns stdout, or null if it cannot be run, fails, or
+    ///     takes longer than a few seconds — a hung probe must not hang a timer-driven
+    ///     <c>rpk discover</c> forever. Stderr is drained concurrently so a chatty child
+    ///     cannot deadlock on a full pipe.
+    /// </summary>
+    public static async Task<string?> TryRunAsync(
+        string fileName,
+        string arguments,
+        CancellationToken cancellationToken) {
+        try {
+            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            timeout.CancelAfter(TimeSpan.FromSeconds(10));
+
+            using var process = new Process {
+                StartInfo = new ProcessStartInfo {
+                    FileName = fileName,
+                    Arguments = arguments,
+                    RedirectStandardOutput = true,
+                    RedirectStandardError = true,
+                    UseShellExecute = false,
+                    CreateNoWindow = true
+                }
+            };
+
+            if (!process.Start())
+                return null;
+
+            try {
+                Task<string> stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
+                Task<string> stderr = process.StandardError.ReadToEndAsync(timeout.Token);
+
+                await process.WaitForExitAsync(timeout.Token);
+                await stderr;
+
+                return process.ExitCode == 0 ? (await stdout).Trim() : null;
+            }
+            catch (OperationCanceledException) {
+                try {
+                    process.Kill(true);
+                }
+                catch {
+                    // It may have exited in the meantime; nothing left to do.
+                }
+
+                return null;
+            }
+        }
+        catch {
+            return null;
+        }
+    }
+}

+ 35 - 0
RackPeek.Domain/Discovery/SystemResourceMapper.cs

@@ -0,0 +1,35 @@
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Maps host facts onto the System resource RackPeek stores. Pure.</summary>
+public static class SystemResourceMapper {
+    /// <summary>
+    ///     <paramref name="nameOverride" /> is what makes repeated runs on a box stable
+    ///     regardless of hostname changes, and is the recommended way to run this from a
+    ///     timer. Without it the hostname is used.
+    /// </summary>
+    public static SystemResource ToResource(SystemFacts facts, string? nameOverride = null) {
+        var discoveryId = DiscoveryId.Create(
+            DiscoveryId.SystemScheme,
+            facts.MachineId ?? facts.Hostname);
+
+        return new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = DiscoveryNaming.Suggest(
+                nameOverride ?? DiscoveryNaming.HostLabel(facts.Hostname),
+                "system",
+                discoveryId),
+            DiscoveryId = discoveryId,
+            Type = facts.Type,
+            Os = facts.Os,
+            Cores = facts.Cores,
+            Ram = facts.RamGb,
+            Ip = facts.Ip,
+            Drives = facts.Drives.Count == 0
+                ? null
+                : facts.Drives.Select(d => new Drive { Type = d.Type, Size = d.SizeGb }).ToList()
+        };
+    }
+}

+ 5 - 2
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -5,10 +5,13 @@ using RackPeek.Domain.Resources.SystemResources;
 namespace RackPeek.Domain.Helpers;
 namespace RackPeek.Domain.Helpers;
 
 
 public static class ThrowIfInvalid {
 public static class ThrowIfInvalid {
+    public const int MaxResourceNameLength = 50;
+    public const int MaxLabelValueLength = 200;
+
     public static void ResourceName(string name) {
     public static void ResourceName(string name) {
         if (string.IsNullOrWhiteSpace(name)) throw new ValidationException("Name is required.");
         if (string.IsNullOrWhiteSpace(name)) throw new ValidationException("Name is required.");
 
 
-        if (name.Length > 50) throw new ValidationException("Name is too long.");
+        if (name.Length > MaxResourceNameLength) throw new ValidationException("Name is too long.");
     }
     }
 
 
     public static void LabelKey(string key) {
     public static void LabelKey(string key) {
@@ -18,7 +21,7 @@ public static class ThrowIfInvalid {
 
 
     public static void LabelValue(string value) {
     public static void LabelValue(string value) {
         if (string.IsNullOrWhiteSpace(value)) throw new ValidationException("Label value is required.");
         if (string.IsNullOrWhiteSpace(value)) throw new ValidationException("Label value is required.");
-        if (value.Length > 200) throw new ValidationException("Label value is too long.");
+        if (value.Length > MaxLabelValueLength) throw new ValidationException("Label value is too long.");
     }
     }
 
 
     public static void AccessPointModelName(string name) {
     public static void AccessPointModelName(string name) {

+ 15 - 1
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -24,7 +24,8 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         {
         {
             EnsureSchemaVersionExists,
             EnsureSchemaVersionExists,
             ConvertScalarRunsOnToList,
             ConvertScalarRunsOnToList,
-            ConvertNicsToPortsV3
+            ConvertNicsToPortsV3,
+            AllowDiscoveryIdsV4
         };
         };
 
 
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
@@ -167,5 +168,18 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         return ValueTask.CompletedTask;
         return ValueTask.CompletedTask;
     }
     }
 
 
+    /// <summary>
+    ///     v4 adds the optional <c>discoveryId</c> field for <c>rpk discover</c>. Purely
+    ///     additive, so a v3 document only needs its version stamped — but it still gets
+    ///     a version of its own so that a v3-era binary refuses a discovery-written file
+    ///     cleanly ("version 4 is newer than this application supports") instead of
+    ///     failing schema validation on a field it has never heard of.
+    /// </summary>
+    public static ValueTask AllowDiscoveryIdsV4(IServiceProvider serviceProvider, Dictionary<object, object> obj) {
+        obj["version"] = 4;
+
+        return ValueTask.CompletedTask;
+    }
+
     #endregion
     #endregion
 }
 }

+ 55 - 16
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -1,6 +1,7 @@
 using System.Collections.ObjectModel;
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.Collections.Specialized;
 using System.Diagnostics;
 using System.Diagnostics;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Connections;
@@ -24,6 +25,14 @@ public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public List<Resource> Resources { get; } = new();
     public List<Resource> Resources { get; } = new();
     public List<Connection> Connections { get; } = new();
     public List<Connection> Connections { get; } = new();
+
+    /// <summary>
+    ///     Whether the store has ever been read successfully. Guarded by
+    ///     <see cref="FileLock" />. Write paths check it so a boot that survived an
+    ///     unreadable config cannot later persist the empty in-memory collection over
+    ///     the user's file.
+    /// </summary>
+    public bool Loaded { get; set; }
 }
 }
 
 
 public sealed class YamlResourceCollection(
 public sealed class YamlResourceCollection(
@@ -125,9 +134,17 @@ public sealed class YamlResourceCollection(
 
 
         await resourceCollection.FileLock.WaitAsync();
         await resourceCollection.FileLock.WaitAsync();
         try {
         try {
+            await EnsureLoadedAsync();
+
             YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
             YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
 
 
             List<Resource> incomingResources = incomingRoot.Resources ?? new List<Resource>();
             List<Resource> incomingResources = incomingRoot.Resources ?? new List<Resource>();
+
+            DiscoveryIdResolver.ResolveNames(
+                resourceCollection.Resources,
+                incomingResources,
+                incomingRoot.Connections);
+
             List<Resource> merged = ResourceCollectionMerger.Merge(
             List<Resource> merged = ResourceCollectionMerger.Merge(
                 resourceCollection.Resources,
                 resourceCollection.Resources,
                 incomingResources,
                 incomingResources,
@@ -200,27 +217,45 @@ public sealed class YamlResourceCollection(
         // "Index was outside the bounds of the array" out of List.Clear.
         // "Index was outside the bounds of the array" out of List.Clear.
         await resourceCollection.FileLock.WaitAsync();
         await resourceCollection.FileLock.WaitAsync();
         try {
         try {
-            var yaml = await fileStore.ReadAllTextAsync(filePath);
+            await LoadUnderLockAsync();
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
+    }
 
 
-            YamlRoot root = await migrationService.DeserializeAsync(
-                yaml,
-                async originalYaml => await BackupOriginalAsync(originalYaml),
-                async migratedRoot => await SaveRootAsync(migratedRoot)
-            );
+    private async Task LoadUnderLockAsync() {
+        var yaml = await fileStore.ReadAllTextAsync(filePath);
 
 
-            resourceCollection.Resources.Clear();
+        YamlRoot root = await migrationService.DeserializeAsync(
+            yaml,
+            async originalYaml => await BackupOriginalAsync(originalYaml),
+            async migratedRoot => await SaveRootAsync(migratedRoot)
+        );
 
 
-            if (root.Resources != null)
-                resourceCollection.Resources.AddRange(root.Resources);
+        resourceCollection.Resources.Clear();
 
 
-            resourceCollection.Connections.Clear();
+        if (root.Resources != null)
+            resourceCollection.Resources.AddRange(root.Resources);
 
 
-            if (root.Connections != null)
-                resourceCollection.Connections.AddRange(root.Connections);
-        }
-        finally {
-            resourceCollection.FileLock.Release();
-        }
+        resourceCollection.Connections.Clear();
+
+        if (root.Connections != null)
+            resourceCollection.Connections.AddRange(root.Connections);
+
+        resourceCollection.Loaded = true;
+    }
+
+    /// <summary>
+    ///     Called at the top of every write path, under the lock. Normally a no-op:
+    ///     both the CLI and the web host load at startup. When that startup load failed
+    ///     (unreadable or malformed file, tolerated so the process can boot), this
+    ///     retries — and if the store still cannot be read, the write fails HERE, before
+    ///     the empty in-memory collection can be persisted over the user's config.
+    /// </summary>
+    private async Task EnsureLoadedAsync() {
+        if (!resourceCollection.Loaded)
+            await LoadUnderLockAsync();
     }
     }
 
 
     public Task AddAsync(Resource resource) {
     public Task AddAsync(Resource resource) {
@@ -353,6 +388,8 @@ public sealed class YamlResourceCollection(
     private async Task UpdateWithLockAsync(Action<List<Resource>> action) {
     private async Task UpdateWithLockAsync(Action<List<Resource>> action) {
         await resourceCollection.FileLock.WaitAsync();
         await resourceCollection.FileLock.WaitAsync();
         try {
         try {
+            await EnsureLoadedAsync();
+
             action(resourceCollection.Resources);
             action(resourceCollection.Resources);
 
 
             // Always write current schema version when app writes the file.
             // Always write current schema version when app writes the file.
@@ -461,6 +498,8 @@ public sealed class YamlResourceCollection(
     private async Task UpdateConnectionsWithLockAsync(Action<List<Connection>> action) {
     private async Task UpdateConnectionsWithLockAsync(Action<List<Connection>> action) {
         await resourceCollection.FileLock.WaitAsync();
         await resourceCollection.FileLock.WaitAsync();
         try {
         try {
+            await EnsureLoadedAsync();
+
             action(resourceCollection.Connections);
             action(resourceCollection.Connections);
 
 
             var root = new YamlRoot {
             var root = new YamlRoot {

+ 7 - 0
RackPeek.Domain/Resources/Resource.cs

@@ -52,6 +52,13 @@ public abstract class Resource {
 
 
     public required string Name { get; set; }
     public required string Name { get; set; }
 
 
+    /// <summary>
+    ///     Stable machine-generated identity, set by <c>rpk discover</c>. Optional, and
+    ///     absent on everything entered by hand. Lets a re-run find this resource again
+    ///     after the user has renamed it. See <c>RackPeek.Domain.Discovery.DiscoveryId</c>.
+    /// </summary>
+    public string? DiscoveryId { get; set; }
+
     public string[] Tags { get; set; } = [];
     public string[] Tags { get; set; } = [];
     public Dictionary<string, string> Labels { get; set; } = new();
     public Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }
     public string? Notes { get; set; }

+ 7 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -1,6 +1,7 @@
 using System.Reflection;
 using System.Reflection;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Git;
 using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
@@ -72,6 +73,12 @@ public static class ServiceCollectionExtensions {
 
 
     public static IServiceCollection AddUseCases(
     public static IServiceCollection AddUseCases(
         this IServiceCollection services) {
         this IServiceCollection services) {
+        // Discovery probes. Both are registered on every platform and every host (CLI,
+        // web console, viewer); the command picks whichever reports itself supported,
+        // so an unsupported host fails with a message rather than a missing registration.
+        services.AddSingleton<ISystemProbe, LinuxSystemProbe>();
+        services.AddSingleton<ISystemProbe, MacSystemProbe>();
+
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
         services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
         services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));
         services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));

+ 5 - 0
RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs

@@ -27,6 +27,11 @@ public class CloneResourceUseCase<T>(IResourceCollection repo) : ICloneResourceU
         T clone = Clone.DeepClone(original);
         T clone = Clone.DeepClone(original);
         clone.Name = cloneName;
         clone.Name = cloneName;
 
 
+        // A discoveryId names one machine; a copy of its card is not that machine.
+        // Keeping it would also put two resources with the same id in the store,
+        // which DiscoveryIdResolver rejects on every subsequent discovery import.
+        clone.DiscoveryId = null;
+
         await repo.AddAsync(clone);
         await repo.AddAsync(clone);
     }
     }
 }
 }

+ 28 - 2
RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json

@@ -102,6 +102,9 @@
         {
         {
           "$ref": "#/$defs/ups"
           "$ref": "#/$defs/ups"
         },
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
         {
           "$ref": "#/$defs/desktop"
           "$ref": "#/$defs/desktop"
         },
         },
@@ -582,6 +585,28 @@
       ],
       ],
       "unevaluatedProperties": false
       "unevaluatedProperties": false
     },
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
     "service": {
       "allOf": [
       "allOf": [
         {
         {
@@ -639,7 +664,8 @@
               ]
               ]
             },
             },
             "ip": {
             "ip": {
-              "type": "string"
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
             },
             },
             "os": {
             "os": {
               "type": "string"
               "type": "string"
@@ -664,4 +690,4 @@
       "unevaluatedProperties": false
       "unevaluatedProperties": false
     }
     }
   }
   }
-}
+}

+ 701 - 0
RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json

@@ -0,0 +1,701 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v4/schema.v4.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 4
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveryId": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
+          "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 19 - 0
RackPeek.Web/Program.cs

@@ -94,6 +94,25 @@ public class Program {
 
 
         WebApplication app = builder.Build();
         WebApplication app = builder.Build();
 
 
+        // Read the config into memory before anything can be served. Blazor reloads it
+        // on every circuit init, but the inventory API has no circuit — without this it
+        // would merge against an empty collection and persist that over the user's file,
+        // destroying the inventory on the first request after a restart.
+        await using (AsyncServiceScope scope = app.Services.CreateAsyncScope()) {
+            try {
+                await scope.ServiceProvider.GetRequiredService<IResourceCollection>().LoadAsync();
+            }
+            catch (Exception ex) {
+                // An unreadable config must not stop the server booting: the web UI is
+                // how someone fixes it, and a container that will not start is worse
+                // than one showing the error. Blazor surfaces it on the first page load,
+                // and every write path re-checks the load before persisting anything,
+                // so booting in this state cannot overwrite the file.
+                scope.ServiceProvider.GetRequiredService<ILogger<Program>>()
+                    .LogError(ex, "Could not read the config at {Path}. Fix it in the web UI.", yamlFilePath);
+            }
+        }
+
         if (!app.Environment.IsDevelopment()) {
         if (!app.Environment.IsDevelopment()) {
             app.UseExceptionHandler("/Error");
             app.UseExceptionHandler("/Error");
             app.UseHsts();
             app.UseHsts();

+ 28 - 2
RackPeek.Web/wwwroot/schemas/v3/schema.v3.json

@@ -102,6 +102,9 @@
         {
         {
           "$ref": "#/$defs/ups"
           "$ref": "#/$defs/ups"
         },
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
         {
           "$ref": "#/$defs/desktop"
           "$ref": "#/$defs/desktop"
         },
         },
@@ -582,6 +585,28 @@
       ],
       ],
       "unevaluatedProperties": false
       "unevaluatedProperties": false
     },
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
     "service": {
       "allOf": [
       "allOf": [
         {
         {
@@ -639,7 +664,8 @@
               ]
               ]
             },
             },
             "ip": {
             "ip": {
-              "type": "string"
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
             },
             },
             "os": {
             "os": {
               "type": "string"
               "type": "string"
@@ -664,4 +690,4 @@
       "unevaluatedProperties": false
       "unevaluatedProperties": false
     }
     }
   }
   }
-}
+}

+ 701 - 0
RackPeek.Web/wwwroot/schemas/v4/schema.v4.json

@@ -0,0 +1,701 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v4/schema.v4.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 4
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveryId": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
+          "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 6 - 0
RackPeek.sln

@@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RackPeek.Web.Viewer", "Rack
 EndProject
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2e", "Tests.E2e\Tests.E2e.csproj", "{47288A74-AD2C-4E5A-BD88-45648EA9029E}"
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2e", "Tests.E2e\Tests.E2e.csproj", "{47288A74-AD2C-4E5A-BD88-45648EA9029E}"
 EndProject
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Discovery", "Tests.Discovery\Tests.Discovery.csproj", "{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}"
+EndProject
 Global
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
 		Debug|Any CPU = Debug|Any CPU
@@ -48,5 +50,9 @@ Global
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.Build.0 = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	EndGlobalSection
 EndGlobal
 EndGlobal

+ 45 - 5
Shared.Rcl/CliBootstrap.cs

@@ -26,6 +26,7 @@ using Shared.Rcl.Commands.Desktops.Gpus;
 using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Nics;
 using Shared.Rcl.Commands.Desktops.Nics;
 using Shared.Rcl.Commands.Desktops.Rename;
 using Shared.Rcl.Commands.Desktops.Rename;
+using Shared.Rcl.Commands.Discovery;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls.Labels;
 using Shared.Rcl.Commands.Firewalls.Labels;
@@ -90,15 +91,28 @@ public static class CliBootstrap {
         services.AddSingleton(configuration);
         services.AddSingleton(configuration);
         var appBasePath = AppContext.BaseDirectory;
         var appBasePath = AppContext.BaseDirectory;
 
 
+        // The store lives next to the binary, which is fine when rpk is run from its
+        // own directory but not when it is dropped somewhere read-only — a container,
+        // or /usr/local/bin as a non-root user. `rpk discover` is designed to run on
+        // machines that hold no inventory at all, so an unavailable store must not stop
+        // the process starting; commands that actually need one fail when they use it.
         var resolvedYamlDir = Path.IsPathRooted(yamlDir)
         var resolvedYamlDir = Path.IsPathRooted(yamlDir)
             ? yamlDir
             ? yamlDir
             : Path.Combine(appBasePath, yamlDir);
             : Path.Combine(appBasePath, yamlDir);
 
 
-        Directory.CreateDirectory(resolvedYamlDir);
-
         var fullYamlPath = Path.Combine(resolvedYamlDir, yamlFile);
         var fullYamlPath = Path.Combine(resolvedYamlDir, yamlFile);
 
 
-        if (!File.Exists(fullYamlPath)) await File.WriteAllTextAsync(fullYamlPath, "");
+        try {
+            Directory.CreateDirectory(resolvedYamlDir);
+
+            if (!File.Exists(fullYamlPath)) await File.WriteAllTextAsync(fullYamlPath, "");
+        }
+        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
+            await System.Console.Error.WriteLineAsync(
+                $"Warning: cannot use the config at {fullYamlPath} ({ex.Message}). " +
+                "Continuing with an empty inventory — reads will show nothing, and " +
+                "writes are refused until the config can be read.");
+        }
 
 
         services.AddLogging();
         services.AddLogging();
         services.AddScoped<RackPeekConfigMigrationDeserializer>();
         services.AddScoped<RackPeekConfigMigrationDeserializer>();
@@ -113,13 +127,20 @@ public static class CliBootstrap {
             b.GetRequiredService<IResourceYamlMigrationService>());
             b.GetRequiredService<IResourceYamlMigrationService>());
 
 
 
 
-        await collection.LoadAsync();
+        try {
+            await collection.LoadAsync();
+        }
+        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
+            // Unreadable store, warned about above. A malformed config is a different
+            // matter and is still allowed to fail loudly — the user has one to fix.
+            await System.Console.Error.WriteLineAsync($"Warning: could not read {fullYamlPath} ({ex.Message}).");
+        }
         services.AddSingleton<IResourceCollection>(collection);
         services.AddSingleton<IResourceCollection>(collection);
 
 
         // Infrastructure
         // Infrastructure
         services.AddYamlRepos();
         services.AddYamlRepos();
 
 
-        // Application
+        // Application (also registers the discovery probes, for every host)
         services.AddUseCases();
         services.AddUseCases();
         services.AddCommands();
         services.AddCommands();
     }
     }
@@ -728,6 +749,25 @@ public static class CliBootstrap {
             // ----------------------------
             // ----------------------------
             // Ansible
             // Ansible
             // ----------------------------
             // ----------------------------
+            config.AddBranch("discover", discover => {
+                discover.SetDescription("Read infrastructure and emit it as RackPeek YAML.");
+
+                discover.AddCommand<DiscoverSystemCommand>("system")
+                    .WithDescription("Inspect this machine and emit it as a System resource.")
+                    .WithExample("discover", "system")
+                    .WithExample("discover", "system", "--name", "nas01", "--push");
+
+                discover.AddCommand<DiscoverDockerCommand>("docker")
+                    .WithDescription("Read the Docker API and emit each published container as a Service on this host's System.")
+                    .WithExample("discover", "docker")
+                    .WithExample("discover", "docker", "--push");
+
+                discover.AddCommand<DiscoverProxmoxCommand>("proxmox")
+                    .WithDescription("Read a Proxmox cluster and emit its nodes and guests as Systems.")
+                    .WithExample("discover", "proxmox", "--host", "https://pve.lan:8006", "--insecure")
+                    .WithExample("discover", "proxmox", "--host", "pve.lan", "--push");
+            });
+
             config.AddBranch("ansible", ansible => {
             config.AddBranch("ansible", ansible => {
                 ansible.SetDescription("Generate and manage Ansible inventory.");
                 ansible.SetDescription("Generate and manage Ansible inventory.");
 
 

+ 123 - 0
Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs

@@ -0,0 +1,123 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverDockerSettings : DiscoverSettings {
+    [CommandOption("--docker-host <URI>")]
+    [Description("Docker endpoint, e.g. unix:///var/run/docker.sock or tcp://host:2375. " +
+                 "Defaults to DOCKER_HOST, then the local socket.")]
+    public string? DockerHost { get; init; }
+
+    [CommandOption("--host <NAME>")]
+    [Description("Name of the machine these containers run on. Defaults to its hostname.")]
+    public string? HostName { get; init; }
+}
+
+/// <summary>Reads the Docker Engine API and emits each published container as a Service.</summary>
+public sealed class DiscoverDockerCommand(IEnumerable<ISystemProbe> probes)
+    : AsyncCommand<DiscoverDockerSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverDockerSettings settings,
+        CancellationToken cancellationToken) {
+        // The host's own facts give the services a stable id seed, the address they are
+        // reachable on, and something to hang runsOn off.
+        SystemFacts host = await ReadHostAsync(cancellationToken);
+
+        DockerApiClient client;
+
+        try {
+            client = new DockerApiClient(settings.DockerHost);
+        }
+        catch (UriFormatException ex) {
+            AnsiConsole.MarkupLine(
+                $"[red]'{Markup.Escape(settings.DockerHost ?? string.Empty)}' is not a usable Docker endpoint.[/] " +
+                $"{Markup.Escape(ex.Message)}");
+
+            return 1;
+        }
+
+        using DockerApiClient _ = client;
+
+        IReadOnlyList<DockerContainer> containers;
+
+        try {
+            containers = await client.ListContainersAsync(cancellationToken);
+        }
+        catch (Exception ex) when (
+            ex is HttpRequestException or IOException or TimeoutException
+            // HttpClient reports its own timeout as a cancellation.
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            AnsiConsole.MarkupLine(
+                $"[red]Could not reach Docker at {Markup.Escape(client.Endpoint)}.[/] " +
+                $"{Markup.Escape(ex.Message)}");
+
+            return 1;
+        }
+
+        // Over TCP the machine running this command is not the machine running the
+        // containers, so the engine is asked about itself instead of trusting the local
+        // probe: its daemon id seeds the services' identity (the same ids from any
+        // workstation), and its hostname is what runsOn should point at.
+        DockerEngineInfo? engine = client.IsLocal ? null : await client.GetInfoAsync(cancellationToken);
+
+        if (!client.IsLocal && engine == null)
+            AnsiConsole.MarkupLine(
+                "[grey]The engine does not expose /info (a restricted socket proxy blocks it by " +
+                "default), so the endpoint itself is the identity seed — keep addressing this " +
+                "engine the same way, and pass --host to name the machine it runs on.[/]");
+
+        // Named through the same mapper the system collector uses, so runsOn always
+        // points at exactly the resource 'rpk discover system' produces on that machine.
+        SystemResource hostResource = SystemResourceMapper.ToResource(host, settings.HostName);
+
+        var hostName = client.IsLocal
+            ? hostResource.Name
+            : settings.HostName ?? engine?.Hostname ?? hostResource.Name;
+
+        var seed = client.IsLocal
+            ? host.MachineId ?? host.Hostname
+            : engine?.Id ?? client.Endpoint;
+
+        // Published ports live on the engine host, so a remote service's address is the
+        // endpoint the user dialled — the local probe's address is only the last resort.
+        var serviceIp = client.IsLocal
+            ? host.Ip
+            : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip;
+
+        List<Service> services = DockerServiceMapper.ToResources(containers, seed, hostName, serviceIp);
+
+        var skipped = containers.Count - services.Count;
+
+        if (skipped > 0)
+            AnsiConsole.MarkupLine(
+                $"[grey]Skipped {skipped} container(s) not reachable from outside the host.[/]");
+
+        // The host System rides along so the server can line runsOn up by the host's id
+        // even after the user has renamed it — a name alone could not be reconciled. Over
+        // TCP the facts probed here describe this machine, not the engine's, so they stay
+        // out; a rename there is preserved instead by the merge keeping the stored link
+        // whenever an update's runsOn points at nothing.
+        List<Resource> resources = client.IsLocal && services.Count > 0
+            ? [hostResource, .. services]
+            : [.. services];
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+
+    private async Task<SystemFacts> ReadHostAsync(CancellationToken cancellationToken) {
+        // Unlike `discover system`, an unsupported platform is not fatal here — the
+        // containers can still be read; only the host's own facts fall back to basics.
+        return await SystemProbes.TryReadHostAsync(probes, cancellationToken)
+               ?? SystemFactsParser.Parse(new RawSystemSnapshot {
+                   Hostname = Environment.MachineName,
+                   Cores = Environment.ProcessorCount
+               });
+    }
+}

+ 141 - 0
Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs

@@ -0,0 +1,141 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverProxmoxSettings : DiscoverSettings {
+    [CommandOption("--host <URL>")]
+    [Description("Proxmox host, e.g. https://pve.lan:8006. A bare host name gets https and :8006.")]
+    public string? Host { get; init; }
+
+    [CommandOption("--token-id <ID>")]
+    [Description("API token id, e.g. root@pam!rackpeek. Defaults to RPK_PVE_TOKEN_ID.")]
+    public string? TokenId { get; init; }
+
+    [CommandOption("--token-secret <SECRET>")]
+    [Description("API token secret. Defaults to RPK_PVE_TOKEN_SECRET.")]
+    public string? TokenSecret { get; init; }
+
+    [CommandOption("--insecure")]
+    [Description("Accept a self-signed certificate, which Proxmox ships with by default.")]
+    public bool Insecure { get; init; }
+
+    public string? ResolvedTokenId =>
+        DiscoveryPublisher.Resolve(TokenId, ProxmoxApiClient.TokenIdEnvironmentVariable);
+
+    public string? ResolvedTokenSecret =>
+        DiscoveryPublisher.Resolve(TokenSecret, ProxmoxApiClient.TokenSecretEnvironmentVariable);
+
+    public override ValidationResult Validate() {
+        if (string.IsNullOrWhiteSpace(Host))
+            return ValidationResult.Error("Pass --host, e.g. --host https://pve.lan:8006");
+
+        if (string.IsNullOrWhiteSpace(ResolvedTokenId))
+            return ValidationResult.Error(
+                $"No API token id. Pass --token-id or set {ProxmoxApiClient.TokenIdEnvironmentVariable}.");
+
+        if (string.IsNullOrWhiteSpace(ResolvedTokenSecret))
+            return ValidationResult.Error(
+                $"No API token secret. Pass --token-secret or set {ProxmoxApiClient.TokenSecretEnvironmentVariable}.");
+
+        return base.Validate();
+    }
+}
+
+/// <summary>
+///     Reads a Proxmox estate and emits its nodes and guests as Systems, already wired
+///     together — which is the part that is tedious to type by hand.
+/// </summary>
+public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverProxmoxSettings settings,
+        CancellationToken cancellationToken) {
+        ProxmoxApiClient client;
+
+        try {
+            client = new ProxmoxApiClient(
+                settings.Host!,
+                settings.ResolvedTokenId!,
+                settings.ResolvedTokenSecret!,
+                settings.Insecure);
+        }
+        catch (UriFormatException ex) {
+            AnsiConsole.MarkupLine(
+                $"[red]'{Markup.Escape(settings.Host!)}' is not a usable host.[/] {Markup.Escape(ex.Message)}");
+
+            return 1;
+        }
+
+        List<Resource> resources;
+
+        try {
+            resources = await ReadAsync(client, cancellationToken);
+        }
+        catch (HttpRequestException ex) {
+            AnsiConsole.MarkupLine(
+                $"[red]Could not read {Markup.Escape(client.Endpoint)}.[/] {Markup.Escape(ex.Message)}");
+
+            if (!settings.Insecure && ex.InnerException is System.Security.Authentication.AuthenticationException)
+                AnsiConsole.MarkupLine(
+                    "[yellow]Proxmox uses a self-signed certificate by default — try --insecure.[/]");
+
+            return 1;
+        }
+        catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) {
+            // HttpClient reports its timeout as a cancellation.
+            AnsiConsole.MarkupLine(
+                $"[red]{Markup.Escape(client.Endpoint)} did not answer within the timeout.[/]");
+
+            return 1;
+        }
+        finally {
+            client.Dispose();
+        }
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+
+    private static async Task<List<Resource>> ReadAsync(
+        IProxmoxClient client,
+        CancellationToken cancellationToken) {
+        var scope = await client.GetIdentityScopeAsync(cancellationToken);
+        IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
+
+        var nodes = new List<ProxmoxNode>();
+        var guests = new List<ProxmoxGuest>();
+
+        foreach (ProxmoxNode listedNode in listed) {
+            // Node detail needs a broader permission than listing guests does, so it is
+            // enrichment rather than a requirement — a read-only token still gets a tree.
+            ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
+            nodes.Add(node);
+
+            var nodeName = node.Name;
+
+            foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
+                IReadOnlyList<ProxmoxGuest> listedGuests =
+                    await client.GetGuestsAsync(nodeName, endpoint, cancellationToken);
+
+                // The list call knows nothing about the OS, and for a container it does
+                // not know the address either. Both live in the guest's own config — one
+                // call per guest, so they run concurrently rather than one at a time.
+                ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
+                    client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken)));
+
+                for (var i = 0; i < listedGuests.Count; i++)
+                    guests.Add(listedGuests[i] with {
+                        Os = configs[i].Os,
+                        Ip = configs[i].Ip,
+                        Disks = configs[i].DiskBytes,
+                        PassthroughAddresses = configs[i].PassthroughAddresses
+                    });
+            }
+        }
+
+        return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
+    }
+}

+ 46 - 0
Shared.Rcl/Commands/Discovery/DiscoverSettings.cs

@@ -0,0 +1,46 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+/// <summary>Output and upload options shared by every <c>rpk discover</c> command.</summary>
+public abstract class DiscoverSettings : CommandSettings {
+    [CommandOption("--push")]
+    [Description("Upload the result to a RackPeek server instead of printing it.")]
+    public bool Push { get; init; }
+
+    [CommandOption("--server <URL>")]
+    [Description("RackPeek server to upload to. Defaults to the RPK_SERVER environment variable.")]
+    public string? Server { get; init; }
+
+    [CommandOption("--api-key <KEY>")]
+    [Description("API key for the server. Defaults to the RPK_API_KEY environment variable.")]
+    public string? ApiKey { get; init; }
+
+    [CommandOption("--dry-run")]
+    [Description("Ask the server what would change, without changing anything. Implies --push.")]
+    public bool DryRun { get; init; }
+
+    public bool ShouldUpload => Push || DryRun;
+
+    public string? ResolvedServer => DiscoveryPublisher.ResolveServer(Server);
+
+    public string? ResolvedApiKey => DiscoveryPublisher.ResolveApiKey(ApiKey);
+
+    public override ValidationResult Validate() {
+        if (!ShouldUpload)
+            return ValidationResult.Success();
+
+        if (string.IsNullOrWhiteSpace(ResolvedServer))
+            return ValidationResult.Error(
+                "No server to upload to. Pass --server or set RPK_SERVER.");
+
+        if (string.IsNullOrWhiteSpace(ResolvedApiKey))
+            return ValidationResult.Error(
+                "No API key. Pass --api-key or set RPK_API_KEY.");
+
+        return ValidationResult.Success();
+    }
+}

+ 41 - 0
Shared.Rcl/Commands/Discovery/DiscoverSystemCommand.cs

@@ -0,0 +1,41 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverSystemSettings : DiscoverSettings {
+    [CommandOption("-n|--name <NAME>")]
+    [Description("Name for this machine. Defaults to its hostname. Recommended when running from a timer.")]
+    public string? Name { get; init; }
+}
+
+/// <summary>Inspects the machine it is running on and emits it as a System resource.</summary>
+public sealed class DiscoverSystemCommand(IEnumerable<ISystemProbe> probes)
+    : AsyncCommand<DiscoverSystemSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverSystemSettings settings,
+        CancellationToken cancellationToken) {
+        SystemFacts? facts = await SystemProbes.TryReadHostAsync(probes, cancellationToken);
+
+        if (facts == null) {
+            AnsiConsole.MarkupLine(
+                "[red]No probe for this platform.[/] System discovery currently supports Linux and macOS.");
+
+            return 1;
+        }
+
+        if (facts.MachineId == null)
+            AnsiConsole.MarkupLine(
+                "[yellow]Warning:[/] no machine id available, so the hostname is being used as this " +
+                "machine's identity. Renaming the host will look like a new machine.");
+
+        SystemResource resource = SystemResourceMapper.ToResource(facts, settings.Name);
+
+        return await DiscoveryOutput.EmitAsync([resource], settings, cancellationToken);
+    }
+}

+ 94 - 0
Shared.Rcl/Commands/Discovery/DiscoveryOutput.cs

@@ -0,0 +1,94 @@
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using Spectre.Console;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+/// <summary>
+///     The one place a discovery result leaves the process: printed as YAML, or sent to
+///     a server. Shared so every collector behaves identically.
+/// </summary>
+public static class DiscoveryOutput {
+    public static async Task<int> EmitAsync(
+        IReadOnlyList<Resource> resources,
+        DiscoverSettings settings,
+        CancellationToken cancellationToken) {
+        if (resources.Count == 0) {
+            AnsiConsole.MarkupLine("[yellow]Nothing discovered.[/]");
+
+            return 0;
+        }
+
+        var yaml = DiscoveryDocument.ToYaml(resources);
+
+        if (!settings.ShouldUpload) {
+            // Raw write, not through Spectre's renderer: this is meant to be redirected
+            // to a file, and the renderer word-wraps lines longer than the console
+            // width — which splits a long single-token scalar and corrupts the YAML.
+            // The active console's own writer keeps the web console emulator working.
+            await AnsiConsole.Console.Profile.Out.Writer.WriteLineAsync(yaml);
+
+            return 0;
+        }
+
+        return await UploadAsync(yaml, settings, cancellationToken);
+    }
+
+    private static async Task<int> UploadAsync(
+        string yaml,
+        DiscoverSettings settings,
+        CancellationToken cancellationToken) {
+        var server = settings.ResolvedServer;
+        var apiKey = settings.ResolvedApiKey;
+
+        if (string.IsNullOrWhiteSpace(server) || string.IsNullOrWhiteSpace(apiKey)) {
+            // Validate() enforces both; this is the guard for a caller that skipped it.
+            AnsiConsole.MarkupLine(
+                "[red]No server or API key. Pass --server and --api-key, or set RPK_SERVER and RPK_API_KEY.[/]");
+
+            return 1;
+        }
+
+        try {
+            using var publisher = new DiscoveryPublisher(server, apiKey);
+
+            ImportYamlResponse response = await publisher.PublishAsync(yaml, settings.DryRun, cancellationToken);
+
+            Report(response, settings.DryRun, server);
+
+            return 0;
+        }
+        catch (Exception ex) when (
+            ex is InvalidOperationException or HttpRequestException or UriFormatException
+            // HttpClient reports its own timeout as a cancellation.
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]");
+
+            return 1;
+        }
+    }
+
+    private static void Report(ImportYamlResponse response, bool dryRun, string server) {
+        List(response.Added, "added", "green");
+        List(response.Updated, "updated", "yellow");
+        List(response.Replaced, "replaced", "yellow");
+
+        var total = response.Added.Count + response.Updated.Count + response.Replaced.Count;
+
+        if (total == 0) {
+            AnsiConsole.MarkupLine($"[grey]No changes — {Markup.Escape(server)} is already up to date.[/]");
+
+            return;
+        }
+
+        AnsiConsole.MarkupLine(dryRun
+            ? $"[grey]Dry run — nothing was written to {Markup.Escape(server)}.[/]"
+            : $"[grey]{total} resource(s) written to {Markup.Escape(server)}.[/]");
+    }
+
+    private static void List(IReadOnlyList<string> names, string label, string colour) {
+        foreach (var name in names)
+            AnsiConsole.MarkupLine($"[{colour}]{label}[/] {Markup.Escape(name)}");
+    }
+}

+ 4 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md

@@ -226,6 +226,10 @@
     - [tag](docs/Commands.md#rpk-services-tag) - Manage tags on a service
     - [tag](docs/Commands.md#rpk-services-tag) - Manage tags on a service
       - [add](docs/Commands.md#rpk-services-tag-add) - Add a tag to a service
       - [add](docs/Commands.md#rpk-services-tag-add) - Add a tag to a service
       - [remove](docs/Commands.md#rpk-services-tag-remove) - Remove a tag from a service
       - [remove](docs/Commands.md#rpk-services-tag-remove) - Remove a tag from a service
+  - [discover](docs/Commands.md#rpk-discover) - Read infrastructure and emit it as RackPeek YAML
+    - [system](docs/Commands.md#rpk-discover-system) - Inspect this machine and emit it as a System resource
+    - [docker](docs/Commands.md#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this
+    - [proxmox](docs/Commands.md#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems
   - [ansible](docs/Commands.md#rpk-ansible) - Generate and manage Ansible inventory
   - [ansible](docs/Commands.md#rpk-ansible) - Generate and manage Ansible inventory
     - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
     - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
   - [ssh](docs/Commands.md#rpk-ssh) - Generate SSH configuration from infrastructure
   - [ssh](docs/Commands.md#rpk-ssh) - Generate SSH configuration from infrastructure

+ 121 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -5,6 +5,13 @@
 USAGE:
 USAGE:
     rpk [OPTIONS] <COMMAND>
     rpk [OPTIONS] <COMMAND>
 
 
+EXAMPLES:
+    rpk discover system
+    rpk discover system --name nas01 --push
+    rpk discover docker
+    rpk discover docker --push
+    rpk discover proxmox --host https://pve.lan:8006 --insecure
+
 OPTIONS:
 OPTIONS:
     -h, --help       Prints help information   
     -h, --help       Prints help information   
     -v, --version    Prints version information
     -v, --version    Prints version information
@@ -22,6 +29,7 @@ COMMANDS:
     desktops        Manage desktop computers and their components              
     desktops        Manage desktop computers and their components              
     laptops         Manage Laptop computers and their components               
     laptops         Manage Laptop computers and their components               
     services        Manage services and their configurations                   
     services        Manage services and their configurations                   
+    discover        Read infrastructure and emit it as RackPeek YAML           
     ansible         Generate and manage Ansible inventory                      
     ansible         Generate and manage Ansible inventory                      
     ssh             Generate SSH configuration from infrastructure             
     ssh             Generate SSH configuration from infrastructure             
     hosts           Generate a hosts file from infrastructure                  
     hosts           Generate a hosts file from infrastructure                  
@@ -3725,6 +3733,119 @@ OPTIONS:
     -h, --help    Prints help information
     -h, --help    Prints help information
 ```
 ```
 
 
+## `rpk discover`
+```
+DESCRIPTION:
+Read infrastructure and emit it as RackPeek YAML
+
+USAGE:
+    rpk discover [OPTIONS] <COMMAND>
+
+EXAMPLES:
+    rpk discover system
+    rpk discover system --name nas01 --push
+    rpk discover docker
+    rpk discover docker --push
+    rpk discover proxmox --host https://pve.lan:8006 --insecure
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    system     Inspect this machine and emit it as a System resource            
+    docker     Read the Docker API and emit each published container as a       
+               Service on this host's System                                    
+    proxmox    Read a Proxmox cluster and emit its nodes and guests as Systems  
+```
+
+## `rpk discover system`
+```
+DESCRIPTION:
+Inspect this machine and emit it as a System resource
+
+USAGE:
+    rpk discover system [OPTIONS]
+
+EXAMPLES:
+    rpk discover system
+    rpk discover system --name nas01 --push
+
+OPTIONS:
+    -h, --help             Prints help information                              
+        --push             Upload the result to a RackPeek server instead of    
+                           printing it                                          
+        --server <URL>     RackPeek server to upload to. Defaults to the        
+                           RPK_SERVER environment variable                      
+        --api-key <KEY>    API key for the server. Defaults to the RPK_API_KEY  
+                           environment variable                                 
+        --dry-run          Ask the server what would change, without changing   
+                           anything. Implies --push                             
+    -n, --name <NAME>      Name for this machine. Defaults to its hostname.     
+                           Recommended when running from a timer                
+```
+
+## `rpk discover docker`
+```
+DESCRIPTION:
+Read the Docker API and emit each published container as a Service on this 
+host's System
+
+USAGE:
+    rpk discover docker [OPTIONS]
+
+EXAMPLES:
+    rpk discover docker
+    rpk discover docker --push
+
+OPTIONS:
+    -h, --help                 Prints help information                          
+        --push                 Upload the result to a RackPeek server instead of
+                               printing it                                      
+        --server <URL>         RackPeek server to upload to. Defaults to the    
+                               RPK_SERVER environment variable                  
+        --api-key <KEY>        API key for the server. Defaults to the          
+                               RPK_API_KEY environment variable                 
+        --dry-run              Ask the server what would change, without        
+                               changing anything. Implies --push                
+        --docker-host <URI>    Docker endpoint, e.g. unix:///var/run/docker.sock
+                               or tcp://host:2375. Defaults to DOCKER_HOST, then
+                               the local socket                                 
+        --host <NAME>          Name of the machine these containers run on.     
+                               Defaults to its hostname                         
+```
+
+## `rpk discover proxmox`
+```
+DESCRIPTION:
+Read a Proxmox cluster and emit its nodes and guests as Systems
+
+USAGE:
+    rpk discover proxmox [OPTIONS]
+
+EXAMPLES:
+    rpk discover proxmox --host https://pve.lan:8006 --insecure
+    rpk discover proxmox --host pve.lan --push
+
+OPTIONS:
+    -h, --help                     Prints help information                      
+        --push                     Upload the result to a RackPeek server       
+                                   instead of printing it                       
+        --server <URL>             RackPeek server to upload to. Defaults to the
+                                   RPK_SERVER environment variable              
+        --api-key <KEY>            API key for the server. Defaults to the      
+                                   RPK_API_KEY environment variable             
+        --dry-run                  Ask the server what would change, without    
+                                   changing anything. Implies --push            
+        --host <URL>               Proxmox host, e.g. https://pve.lan:8006. A   
+                                   bare host name gets https and :8006          
+        --token-id <ID>            API token id, e.g. root@pam!rackpeek.        
+                                   Defaults to RPK_PVE_TOKEN_ID                 
+        --token-secret <SECRET>    API token secret. Defaults to                
+                                   RPK_PVE_TOKEN_SECRET                         
+        --insecure                 Accept a self-signed certificate, which      
+                                   Proxmox ships with by default                
+```
+
 ## `rpk ansible`
 ## `rpk ansible`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:

+ 340 - 0
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -0,0 +1,340 @@
+# Auto Discovery Guide
+
+`rpk discover` reads your infrastructure and writes it out as RackPeek YAML, so you
+don't have to type in what the machine already knows about itself.
+
+| Command | Reads | Produces |
+|---|---|---|
+| `rpk discover system` | the machine it runs on | one **System** resource |
+| `rpk discover docker` | the Docker Engine API | one **Service** per published container, plus the **System** they run on |
+| `rpk discover proxmox` | a Proxmox VE cluster | a **Server** and **System** per node, a **System** per guest, already wired together |
+
+Both print YAML to standard output by default and change nothing, so it is always safe
+to run one and look at the result first.
+
+---
+
+## Quick start
+
+```bash
+# Look at what this machine reports
+rpk discover system
+
+# Save it
+rpk discover system > nas01.yaml
+
+# Send it straight to your RackPeek server
+export RPK_SERVER=http://rack.lan:8080
+export RPK_API_KEY=your-shared-secret
+rpk discover system --push
+```
+
+`--push` uses the same [Inventory API](/docs/inventory-api) as any other import, so the
+server needs `RPK_API_KEY` set. Discovery always **merges** — it can add and update, and
+never removes anything it did not find.
+
+---
+
+## Keeping it honest: names and identity
+
+The problem with re-running discovery is names. A RackPeek resource is identified by its
+name, and names are yours to choose — but a machine only knows its hostname. Run
+discovery twice, rename something in between, and a naive tool gives you two resources.
+
+Each discovered resource therefore carries a `discoveryId`:
+
+```yaml
+- kind: System
+  name: nas01
+  discoveryId: rpk1:sys:a3f9c2e1b8d47e60
+  type: baremetal
+  os: Debian GNU/Linux 12 (bookworm)
+```
+
+The id is derived from something stable about the machine — `/etc/machine-id` on Linux,
+the platform UUID on macOS — hashed, so no raw machine identifier ends up in a config
+file you might commit. The same machine produces the same id every time, with nothing
+stored locally, from any machine you run the command on.
+
+What that buys you:
+
+* **Renaming is safe.** Call it `storage-01` in the web UI and the next discovery run
+  updates `storage-01`. It will never rename a resource you named.
+* **Existing resources are adopted.** If you already documented `nas01` by hand, the
+  first discovery run attaches to it — keeping your notes and gaining an id — rather
+  than creating a duplicate.
+* **Two machines cannot collide.** A second machine that happens to share a hostname is
+  given a suffixed name instead of overwriting the first.
+
+> **Cloned VM templates share `/etc/machine-id`.** If you clone a Proxmox or VMware
+> template without resetting it, every clone reports the same identity. RackPeek rejects
+> a payload containing duplicate ids rather than silently merging the machines. Run
+> `systemd-machine-id-setup` on the clones, or reset it in the template before cloning.
+
+---
+
+## `rpk discover system`
+
+Supported on **Linux and macOS**. Reports hostname, OS, cores, RAM, primary address, and
+whether the machine is bare metal, a VM or a container. Disks are included on Linux.
+
+```bash
+rpk discover system --name nas01
+```
+
+| Option | Meaning |
+|---|---|
+| `-n`, `--name <NAME>` | Name for this machine. Defaults to its hostname. |
+| `--push` | Upload instead of printing. |
+| `--server <URL>` | Server to upload to. Defaults to `RPK_SERVER`. |
+| `--api-key <KEY>` | API key. Defaults to `RPK_API_KEY`. |
+| `--dry-run` | Ask the server what would change, without changing it. |
+
+Anything the host cannot answer is left out rather than guessed at, and the merge treats
+a missing field as "leave whatever is already there alone".
+
+### Keeping it up to date
+
+Because re-runs update rather than duplicate, this is safe to put on a timer. On a
+systemd host:
+
+```ini
+# /etc/systemd/system/rackpeek-discover.service
+[Service]
+Type=oneshot
+Environment=RPK_SERVER=http://rack.lan:8080
+Environment=RPK_API_KEY=your-shared-secret
+ExecStart=/usr/local/bin/rpk discover system --name nas01 --push
+```
+
+```ini
+# /etc/systemd/system/rackpeek-discover.timer
+[Timer]
+OnCalendar=daily
+Persistent=true
+
+[Install]
+WantedBy=timers.target
+```
+
+Passing `--name` is worth it here: it pins the resource name so a hostname change does
+not look like a new machine.
+
+---
+
+## `rpk discover docker`
+
+Reads the Docker Engine API and emits every container with a **published port** as a
+Service, pointed at the host it runs on. For a local engine the host's own **System**
+resource rides along in front of the services — that is what lets the server keep
+`runsOn` pointing at the right resource even after you rename the host (the id travels
+with the System; the services only know a name).
+
+```bash
+rpk discover docker
+```
+
+| Option | Meaning |
+|---|---|
+| `--docker-host <URI>` | Docker endpoint. Defaults to `DOCKER_HOST`, then `/var/run/docker.sock`. |
+| `--host <NAME>` | Name of the machine the containers run on. Defaults to its hostname. |
+
+Plus the same `--push` / `--server` / `--api-key` / `--dry-run` options as above.
+
+Containers nothing outside the host can reach are skipped and counted in a note — no
+published port, or every binding on a loopback address (`-p 127.0.0.1:5050:80`), which
+only the host itself can reach. A binding pinned to one interface
+(`-p 192.168.1.21:8443:8443`) is recorded at that address rather than the host's. Stopped
+containers are never listed for the same reason — the daemon does not create host port
+bindings until a container runs, so there is no address to record.
+
+A container's compose project becomes a tag, so a stack stays grouped. `runsOn` points at
+the same name `rpk discover system` produces on that machine, so running both gives you a
+connected tree.
+
+### Remote and rootless daemons
+
+```bash
+# A remote daemon behind a read-only socket proxy
+rpk discover docker --docker-host tcp://192.168.1.20:2375 --host nas01
+
+# Podman speaks the same API
+rpk discover docker --docker-host unix:///run/user/1000/podman/podman.sock
+```
+
+For remote hosts, exposing the socket through a read-only proxy such as
+[tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) with
+only `CONTAINERS=1` is the safer arrangement — the same one the
+[docker-gen guide](/docs/docker-gen-guide) describes.
+
+Over TCP the machine running the command is not the machine running the containers, so
+nothing probed locally is attributed to the engine. Instead the engine is asked about
+itself (`GET /info`): its daemon id seeds the services' identities — the same ids no
+matter which machine runs the command — and its hostname is what `runsOn` points at,
+which is the same name `rpk discover system` reports on that box. Services are recorded
+at the endpoint's address (resolved once if you dialled a name). No System resource is
+emitted for the host itself; document it with `rpk discover system` on that machine, or
+by hand, and the services attach to it by name.
+
+If you rename that host in RackPeek, re-discovery keeps your link: an update whose
+`runsOn` points at nothing that exists leaves the stored link alone. A `runsOn` that
+does name a real resource is recorded — that is a genuine move.
+
+A proxy restricted to `CONTAINERS=1` blocks `/info`, and discovery says so and degrades:
+the endpoint itself becomes the identity seed (so keep addressing the engine the same
+way — switching between an IP and a hostname would re-mint every id), and `--host` is
+how to name the machine the containers run on. Allowing `INFO=1` on the proxy removes
+both caveats.
+
+---
+
+## `rpk discover proxmox`
+
+Reads a Proxmox cluster and emits its nodes and guests as Systems, with `runsOn`
+already pointing each guest at the node it runs on. That tree is the tedious part to
+type by hand, and it is the reason this collector is worth more than its fields suggest:
+one call inventories the whole estate without installing anything on the guests.
+
+```bash
+rpk discover proxmox --host https://pve.lan:8006 --insecure
+```
+
+| Option | Meaning |
+|---|---|
+| `--host <URL>` | Proxmox host. A bare name gets `https://` and `:8006`. |
+| `--token-id <ID>` | API token id, e.g. `root@pam!rackpeek`. Defaults to `RPK_PVE_TOKEN_ID`. |
+| `--token-secret <SECRET>` | Token secret. Defaults to `RPK_PVE_TOKEN_SECRET`. |
+| `--insecure` | Accept a self-signed certificate. |
+
+Plus the same `--push` / `--server` / `--api-key` / `--dry-run` options as above.
+
+### Making a token
+
+In the Proxmox UI: **Datacenter → Permissions → API Tokens → Add**. Give it a read-only
+role (`PVEAuditor` is enough) and clear "Privilege Separation" only if you need to.
+
+```bash
+export RPK_PVE_TOKEN_ID='root@pam!rackpeek'
+export RPK_PVE_TOKEN_SECRET='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
+rpk discover proxmox --host pve.lan --insecure --push
+```
+
+`--insecure` is needed more often than not: Proxmox ships with a self-signed certificate
+and most installations keep it.
+
+### What it reports
+
+**A node becomes two resources**, because it is two things:
+
+* a **Server** named after the node (`kepler`) — the machine, carrying its processor
+  (model, cores and threads per socket), memory, physical disks with Proxmox's own
+  nvme/ssd/hdd classification, and its GPUs;
+* a **System** of type `hypervisor` (`kepler-pve`) — the Proxmox install running on that
+  machine, carrying the PVE version.
+
+Guests then run on the hypervisor, giving the full Hardware → System → System tree that
+the graph views are built around.
+
+```yaml
+- kind: Server
+  name: kepler
+  cpus:
+  - model: AMD Ryzen 5 5600G
+    cores: 6
+    threads: 12
+  ram:
+    size: 63
+  drives:
+  - type: nvme
+    size: 932
+  gpus:
+  - model: GeForce RTX 3090
+  - model: GeForce RTX 3090
+- kind: System
+  name: kepler-pve
+  type: hypervisor
+  os: Proxmox VE 8.2.2
+  runsOn: [kepler]
+- kind: System
+  name: docker-01
+  type: vm
+  runsOn: [kepler-pve]
+```
+
+Each QEMU guest becomes a `vm` and each LXC guest a `container`, with its allocated
+cores, memory and its Proxmox tags. A container with a static address keeps it; one on
+DHCP reports none rather than a wrong one.
+
+**Every disk is recorded, not just the boot one.** The guest list only reports the boot
+disk, so a VM with a 64 GB root and a 2 TB data volume would otherwise appear as a 64 GB
+machine; the guest's config is read for the full set. Container mount points count too.
+Install media, detached volumes and the few megabytes of EFI or TPM scratch space are
+left out. The storage backend says nothing about the underlying medium, so guest disks
+carry a size but no nvme/ssd/hdd type — unlike the node's own disks, which Proxmox has
+already classified.
+
+Stopped guests are included — unlike a stopped container, a stopped VM is still a real
+system with real resources.
+
+A GPU passed through to a guest is recorded on the **Server**, not the guest — the card
+is bolted into the host, and a System has nowhere to put one. Integrated graphics are
+included too, since they are equally present. VRAM is not something the PCI device list
+knows, so it is left off.
+
+The guest that holds a card gets a `gpu` **label** naming it, so the assignment is
+visible from either end:
+
+```yaml
+- kind: System
+  name: ai
+  type: vm
+  labels:
+    gpu: GeForce RTX 3090, GeForce RTX 3090
+  runsOn: [kepler-pve]
+```
+
+A label rather than a field, because RackPeek has no first-class way to say "this device
+is assigned to that system". PCI addresses repeat on every machine, so a guest is only
+ever matched against cards in the node it actually runs on.
+
+The hardware detail needs the same permission as the node status call. Without it you
+still get the Server, the hypervisor and the whole guest tree — just without the
+processor and disks.
+
+Running `rpk discover system --with-hardware` on the node itself gives better hardware
+data still, since it reads real DMI rather than Proxmox's second-hand view.
+
+### Identity
+
+A guest is identified by its vmid within the cluster, so renaming it in Proxmox, or
+migrating it between nodes, still updates the same RackPeek resource. A standalone host
+with no cluster uses its node name as the scope instead.
+
+> **Known limitation.** Proxmox identifies a guest by vmid; the guest identifies itself
+> by its machine-id. Neither can derive the other, so running both `rpk discover proxmox`
+> and `rpk discover system` *inside* the same guest produces two resources rather than
+> one. The second is reported as an addition with a suffixed name, so it is visible
+> rather than silent — but pick one collector per guest for now.
+
+---
+
+## Reviewing before you commit to it
+
+`--dry-run` asks the server what would change and writes nothing:
+
+```bash
+rpk discover system --dry-run
+```
+
+```text
+updated nas01
+Dry run — nothing was written to http://rack.lan:8080.
+```
+
+Without a server, redirect the output and read it:
+
+```bash
+rpk discover docker > services.yaml
+```
+
+Then import it through the web UI's **Import YAML** tool, which shows you the same diff.

+ 2 - 1
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -4,6 +4,7 @@
   "install-guide.md",
   "install-guide.md",
   "git-integration.md",
   "git-integration.md",
   "ansible-generator-guide.md",
   "ansible-generator-guide.md",
+  "discovery-guide.md",
   "docker-gen-guide.md",
   "docker-gen-guide.md",
   "cli-commands.md",
   "cli-commands.md",
   "cli-commands-index.md",
   "cli-commands-index.md",
@@ -11,4 +12,4 @@
   "hosts-file-export.md",
   "hosts-file-export.md",
   "inventory-api.md",
   "inventory-api.md",
   "versioning.md"
   "versioning.md"
-]
+]

+ 67 - 0
Tests.Discovery/DiscoveryApiFixture.cs

@@ -0,0 +1,67 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Discovery;
+using RackPeek.Web;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     A real RackPeek server backed by a temporary config file, driven through the
+///     same <see cref="DiscoveryPublisher" /> the CLI uses. These are the end-to-end
+///     tests: discovery YAML goes over HTTP into the inventory API and the assertions
+///     are made against what actually lands on disk.
+/// </summary>
+public sealed class DiscoveryApiFixture : IDisposable {
+    private const string _apiKey = "discovery-test-key";
+
+    private readonly WebApplicationFactory<Program> _factory;
+    private readonly string _tempDir;
+
+    /// <param name="initialConfig">
+    ///     Contents to seed config.yaml with before the server first reads it — the way
+    ///     to test how the server behaves against a file it did not write itself.
+    /// </param>
+    public DiscoveryApiFixture(string? initialConfig = null) {
+        _tempDir = Path.Combine(Path.GetTempPath(), "rackpeek-discovery-tests", Guid.NewGuid().ToString());
+        Directory.CreateDirectory(_tempDir);
+
+        if (initialConfig != null)
+            File.WriteAllText(Path.Combine(_tempDir, "config.yaml"), initialConfig);
+
+        _factory = new WebApplicationFactory<Program>()
+            .WithWebHostBuilder(builder => {
+                builder.UseSetting("RPK_YAML_DIR", _tempDir);
+                builder.ConfigureAppConfiguration((_, config) =>
+                    config.AddInMemoryCollection(new Dictionary<string, string?> {
+                        ["RPK_YAML_DIR"] = _tempDir,
+                        ["RPK_API_KEY"] = _apiKey
+                    }));
+            });
+    }
+
+    public string StoredYaml => File.ReadAllText(Path.Combine(_tempDir, "config.yaml"));
+
+    public void Dispose() {
+        try {
+            _factory.Dispose();
+
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // Cleanup only; a leftover temp directory must never fail a test run.
+        }
+    }
+
+    public async Task<ImportYamlResponse> PublishAsync(string yaml, bool dryRun = false) {
+        HttpClient client = _factory.CreateClient();
+
+        using var publisher = new DiscoveryPublisher(
+            client.BaseAddress!.ToString(),
+            _apiKey,
+            client);
+
+        return await publisher.PublishAsync(yaml, dryRun);
+    }
+}

+ 148 - 0
Tests.Discovery/DiscoveryIdentityTests.cs

@@ -0,0 +1,148 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Identity has to be deterministic (the same machine gets the same id forever,
+///     with nothing stored locally) and opaque (a config file gets committed to public
+///     repositories, so raw machine ids and MAC addresses must not appear in it).
+/// </summary>
+public class DiscoveryIdentityTests {
+    [Fact]
+    public void The_same_seed_always_produces_the_same_id() =>
+        Assert.Equal(
+            DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"),
+            DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"));
+
+    [Fact]
+    public void Different_seeds_produce_different_ids() =>
+        Assert.NotEqual(
+            DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"),
+            DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-b"));
+
+    [Fact]
+    public void The_same_seed_in_different_schemes_stays_distinct() =>
+        Assert.NotEqual(
+            DiscoveryId.Create(DiscoveryId.SystemScheme, "seed"),
+            DiscoveryId.Create(DiscoveryId.DockerScheme, "seed"));
+
+    [Fact]
+    public void The_seed_is_not_recoverable_by_reading_the_id() {
+        var machineId = "7f3c9a1e5b2d4f6081a3c5e7b9d1f3a5";
+
+        Assert.DoesNotContain(machineId, DiscoveryId.Create(DiscoveryId.SystemScheme, machineId));
+    }
+
+    [Fact]
+    public void Ids_match_the_shape_the_published_schema_requires() =>
+        Assert.Matches("^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$", DiscoveryId.Create("sys", "seed"));
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("   ")]
+    public void An_empty_seed_is_refused_rather_than_producing_a_shared_id(string seed) =>
+        Assert.Throws<ArgumentException>(() => DiscoveryId.Create("sys", seed));
+
+    [Theory]
+    [InlineData("NAS01", "nas01")]
+    [InlineData("Tims-MacBook-Pro", "tims-macbook-pro")]
+    [InlineData("paperless ngx", "paperless-ngx")]
+    [InlineData("Paperless_Stack", "paperless-stack")]
+    [InlineData("  spaced  out  ", "spaced-out")]
+    [InlineData("--dashes--", "dashes")]
+    [InlineData("!!!", "")]
+    public void Names_are_slugged_the_way_a_person_would_type_them(string input, string expected) =>
+        Assert.Equal(expected, DiscoveryNaming.Slug(input));
+
+    [Theory]
+    [InlineData("nas01.lan", "nas01")]
+    [InlineData("tims-macbook-pro.local", "tims-macbook-pro")]
+    [InlineData("nas01", "nas01")]
+    [InlineData("", "")]
+    public void A_host_name_is_reduced_to_its_first_label(string input, string expected) =>
+        Assert.Equal(expected, DiscoveryNaming.HostLabel(input));
+
+    [Fact]
+    public void A_machine_with_no_usable_name_still_gets_a_deterministic_one() {
+        var id = DiscoveryId.Create("sys", "seed");
+
+        var first = DiscoveryNaming.Suggest("???", "system", id);
+        var second = DiscoveryNaming.Suggest(null, "system", id);
+
+        Assert.Equal(first, second);
+        Assert.StartsWith("system-", first);
+    }
+
+    // RackPeek's own validation caps a resource name at 50 characters, and the import
+    // API does not enforce it — so a longer name is accepted and then cannot be renamed
+    // or edited from the CLI. Discovery has to stay inside the limit on its own.
+
+    [Fact]
+    public void A_long_container_name_is_cut_to_a_length_rackpeek_accepts() {
+        var id = DiscoveryId.Create(DiscoveryId.DockerScheme, "seed");
+
+        var name = DiscoveryNaming.Suggest(
+            "homeassistant-production-stack-mosquitto-broker-primary-1", "service", id);
+
+        Assert.True(name.Length <= DiscoveryNaming.MaxNameLength, name);
+        Assert.DoesNotContain("--", name);
+        Assert.False(name.EndsWith('-'));
+    }
+
+    [Fact]
+    public void A_disambiguating_suffix_shortens_the_name_to_make_room() {
+        var suffixed = DiscoveryNaming.WithSuffix(new string('a', 48), "a3f9c2e1");
+
+        Assert.True(suffixed.Length <= DiscoveryNaming.MaxNameLength, suffixed);
+        Assert.EndsWith("-a3f9c2e1", suffixed);
+    }
+
+    [Fact]
+    public void Two_names_that_truncate_alike_stay_distinct() {
+        // Compose puts the replica index last, which is exactly what truncation removes.
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var prefix = "homeassistant-production-stack-mosquitto-broker-primary";
+
+        var first = DiscoveryNaming.Unique(
+            DiscoveryNaming.Suggest($"{prefix}-1", "service", DiscoveryId.Create("docker", "a")),
+            DiscoveryId.Create("docker", "a"),
+            taken);
+
+        var second = DiscoveryNaming.Unique(
+            DiscoveryNaming.Suggest($"{prefix}-2", "service", DiscoveryId.Create("docker", "b")),
+            DiscoveryId.Create("docker", "b"),
+            taken);
+
+        Assert.NotEqual(first, second);
+        Assert.True(second.Length <= DiscoveryNaming.MaxNameLength, second);
+    }
+
+    [Theory]
+    [InlineData("nas01")]
+    [InlineData("a-really-long-hostname-that-somebody-actually-configured-somewhere")]
+    [InlineData("!!!")]
+    [InlineData("")]
+    public void Every_suggested_name_is_valid_to_rackpeek(string input) {
+        var id = DiscoveryId.Create(DiscoveryId.SystemScheme, "seed");
+
+        var name = DiscoveryNaming.Suggest(input, "system", id);
+
+        // The same checks ThrowIfInvalid.ResourceName makes.
+        Assert.False(string.IsNullOrWhiteSpace(name));
+        Assert.True(name.Length <= DiscoveryNaming.MaxNameLength, name);
+    }
+
+    [Theory]
+    [InlineData("日本語サーバー")]
+    [InlineData("сервер")]
+    [InlineData("🎉🎉🎉")]
+    public void A_name_with_nothing_ascii_in_it_falls_back_to_the_id(string input) {
+        var id = DiscoveryId.Create(DiscoveryId.SystemScheme, "seed");
+
+        var name = DiscoveryNaming.Suggest(input, "system", id);
+
+        // Slugging keeps to ASCII, so these produce nothing usable and the id-derived
+        // name takes over — still deterministic, still valid, still unique.
+        Assert.Equal($"system-{DiscoveryId.ShortSuffix(id)}", name);
+    }
+}

+ 387 - 0
Tests.Discovery/DiscoveryMergeTests.cs

@@ -0,0 +1,387 @@
+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]);
+    }
+}

+ 111 - 0
Tests.Discovery/DockerDiscoveryTests.cs

@@ -0,0 +1,111 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources.Services;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     A captured <c>GET /containers/json</c> response in, Service resources out.
+///     No Docker daemon is needed, so this runs anywhere.
+/// </summary>
+public class DockerDiscoveryTests {
+    private const string _hostSeed = "7f3c9a1e5b2d4f6081a3c5e7b9d1f3a5";
+
+    private static List<Service> Discover() =>
+        DockerServiceMapper.ToResources(
+            DockerContainerParser.Parse(Fixture.Read("docker-containers.json")),
+            _hostSeed,
+            "nas01",
+            "192.168.1.20");
+
+    [Fact]
+    public void Only_containers_reachable_from_outside_the_host_become_services() {
+        List<Service> services = Discover();
+
+        // redis publishes nothing and pgadmin only binds loopback, so nothing outside
+        // the host can reach either and neither is a service.
+        Assert.Equal(["jellyfin", "paperless-ngx", "unifi", "wireguard"], services.Select(s => s.Name));
+    }
+
+    [Fact]
+    public void A_binding_pinned_to_one_interface_is_reported_at_that_address() {
+        Service unifi = Discover().Single(s => s.Name == "unifi");
+
+        // The loopback 8843 binding is ignored; the 8443 binding is only reachable on
+        // the address it is pinned to, so that address wins over the host's.
+        Assert.Equal("192.168.1.21", unifi.Network!.Ip);
+        Assert.Equal(8443, unifi.Network.Port);
+    }
+
+    [Fact]
+    public void A_dual_stack_publish_is_one_binding_not_two() {
+        List<DockerContainer> containers = DockerContainerParser.Parse(Fixture.Read("docker-containers.json"));
+
+        // Docker reports 0.0.0.0 and :: separately for the same publish.
+        DockerContainer jellyfin = containers.Single(c => c.Name == "jellyfin");
+
+        Assert.Single(jellyfin.PublishedPorts);
+        Assert.True(jellyfin.PublishedPorts[0].IsWildcard);
+    }
+
+    [Fact]
+    public void A_service_carries_the_address_it_is_reachable_on() {
+        Service jellyfin = Discover().Single(s => s.Name == "jellyfin");
+
+        Assert.Equal("192.168.1.20", jellyfin.Network!.Ip);
+        Assert.Equal(8096, jellyfin.Network.Port);
+        Assert.Equal("TCP", jellyfin.Network.Protocol);
+        Assert.Equal("jellyfin/jellyfin:10.9.6", jellyfin.Notes);
+        Assert.Equal(["nas01"], jellyfin.RunsOn);
+    }
+
+    [Fact]
+    public void Udp_bindings_keep_their_protocol() =>
+        Assert.Equal("UDP", Discover().Single(s => s.Name == "wireguard").Network!.Protocol);
+
+    [Fact]
+    public void A_compose_project_becomes_a_tag_so_a_stack_stays_grouped() {
+        Assert.Equal(["media"], Discover().Single(s => s.Name == "jellyfin").Tags);
+        Assert.Equal(["paperless-stack"], Discover().Single(s => s.Name == "paperless-ngx").Tags);
+    }
+
+    [Fact]
+    public void A_container_outside_compose_gets_no_tag() =>
+        Assert.Empty(Discover().Single(s => s.Name == "wireguard").Tags);
+
+    [Fact]
+    public void The_same_container_name_on_two_hosts_is_two_different_resources() {
+        List<DockerContainer> containers = DockerContainerParser.Parse(Fixture.Read("docker-containers.json"));
+
+        List<Service> onNas = DockerServiceMapper.ToResources(containers, "host-a", "nas01", "192.168.1.20");
+        List<Service> onPi = DockerServiceMapper.ToResources(containers, "host-b", "pi01", "192.168.1.34");
+
+        Assert.NotEqual(onNas[0].DiscoveryId, onPi[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void Rediscovering_the_same_host_produces_the_same_ids() =>
+        Assert.Equal(
+            Discover().Select(s => s.DiscoveryId),
+            Discover().Select(s => s.DiscoveryId));
+
+    [Fact]
+    public void Output_conforms_to_the_published_schema() =>
+        Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(Discover()));
+
+    [Fact]
+    public void An_empty_daemon_yields_nothing_rather_than_failing() =>
+        Assert.Empty(DockerContainerParser.Parse("[]"));
+
+    [Fact]
+    public void A_socket_endpoint_counts_as_local_but_tcp_does_not() {
+        // Local is what decides whether the host System rides along in the payload:
+        // over TCP the locally probed facts describe this machine, not the engine's.
+        // The no-argument default is not asserted here because it honours DOCKER_HOST,
+        // which a developer machine may legitimately point anywhere.
+        using var bySocket = new DockerApiClient("unix:///run/user/1000/podman/podman.sock");
+        using var byTcp = new DockerApiClient("tcp://nas01:2375");
+
+        Assert.True(bySocket.IsLocal);
+        Assert.False(byTcp.IsLocal);
+    }
+}

+ 102 - 0
Tests.Discovery/FailureModeTests.cs

@@ -0,0 +1,102 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Discovery runs unattended on machines nobody is watching, so its failures have to
+///     be legible from a log line rather than a stack trace.
+/// </summary>
+public class FailureModeTests {
+    [Fact]
+    public async Task An_unreachable_server_fails_with_a_network_error_not_a_crash() {
+        // Port 1 is reserved and nothing listens on it.
+        using var publisher = new DiscoveryPublisher("http://127.0.0.1:1", "key");
+
+        await Assert.ThrowsAnyAsync<HttpRequestException>(
+            () => publisher.PublishAsync("version: 3\nresources: []\n", false));
+    }
+
+    [Fact]
+    public async Task An_unreachable_docker_socket_reports_where_it_looked() {
+        using var client = new DockerApiClient("unix:///tmp/definitely-not-a-docker.sock");
+
+        Assert.Equal("unix:///tmp/definitely-not-a-docker.sock", client.Endpoint);
+
+        await Assert.ThrowsAnyAsync<Exception>(() => client.ListContainersAsync());
+    }
+
+    [Fact]
+    public void The_default_docker_endpoint_is_the_conventional_socket() {
+        using var client = new DockerApiClient();
+
+        // DOCKER_HOST wins when set, which is how the remote and Podman cases work.
+        Assert.Contains("docker.sock", client.Endpoint, StringComparison.Ordinal);
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    public void A_missing_server_or_key_resolves_to_nothing_so_validation_can_catch_it(string? value) {
+        Assert.Null(DiscoveryPublisher.ResolveServer(value)
+                    ?? Environment.GetEnvironmentVariable(DiscoveryPublisher.ServerEnvironmentVariable));
+    }
+
+    [Fact]
+    public void Garbage_from_the_docker_api_does_not_take_the_process_down() =>
+        Assert.ThrowsAny<Exception>(() => DockerContainerParser.Parse("not json at all"));
+
+    [Fact]
+    public void A_container_with_no_name_is_skipped_rather_than_named_badly() {
+        List<DockerContainer> containers = DockerContainerParser.Parse("""
+                                                     [
+                                                       { "Id": "abc", "Image": "x", "Ports": [] },
+                                                       { "Id": "def", "Names": ["/real"], "Image": "y",
+                                                         "Ports": [{ "PrivatePort": 80, "PublicPort": 80, "Type": "tcp" }] }
+                                                     ]
+                                                     """);
+
+        Assert.Equal(["real"], containers.Select(c => c.Name));
+    }
+
+    [Fact]
+    public async Task A_push_against_an_unreadable_config_fails_rather_than_overwriting_it() {
+        // The server tolerates a malformed config at boot so the web UI can be used to
+        // fix it. A push arriving in that state must fail — merging against the empty
+        // in-memory collection and saving would replace the user's whole inventory
+        // with just the pushed resources.
+        const string malformed = "version: 3\nresources:\n  - kind: [not yaml";
+
+        using var api = new DiscoveryApiFixture(malformed);
+
+        SystemFacts facts = SystemFactsParser.Parse(new RawSystemSnapshot {
+            Hostname = "pusher",
+            Cores = 2,
+            OsName = "Debian",
+            MemoryBytes = 4L * 1024 * 1024 * 1024,
+            PlatformUuid = "uuid"
+        });
+
+        await Assert.ThrowsAsync<InvalidOperationException>(() =>
+            api.PublishAsync(DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(facts)])));
+
+        Assert.Equal(malformed, api.StoredYaml);
+    }
+
+    [Fact]
+    public void A_machine_with_no_network_still_produces_an_importable_resource() {
+        SystemFacts facts = SystemFactsParser.Parse(new RawSystemSnapshot {
+            Hostname = "offline-box",
+            Cores = 2,
+            OsName = "Debian",
+            MemoryBytes = 4L * 1024 * 1024 * 1024,
+            PlatformUuid = "uuid"
+        });
+
+        Assert.Null(facts.Ip);
+
+        // ip is optional in the schema; type, os, cores and ram are not.
+        Fixture.AssertConformsToSchema(
+            DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(facts)]));
+    }
+}

+ 94 - 0
Tests.Discovery/Fixture.cs

@@ -0,0 +1,94 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Text.Json;
+using Json.Schema;
+using YamlDotNet.RepresentationModel;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Captured output from real machines. Reading these rather than the host is what
+///     lets one set of tests run unchanged on Linux, macOS and Windows.
+/// </summary>
+public static class Fixture {
+    // JsonSchema.Net keeps a process-wide registry keyed on $id, so loading the same
+    // schema from two test classes at once races. Load each one exactly once.
+    private static readonly ConcurrentDictionary<int, Lazy<JsonSchema>> _schemas = new();
+
+    public static string Read(string name) =>
+        File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name));
+
+    /// <summary>
+    ///     Asserts a discovery document satisfies the published RackPeek schema, so the
+    ///     collectors cannot drift away from the contract the rest of the world imports.
+    /// </summary>
+    public static void AssertConformsToSchema(string yaml, int version = 4) {
+        JsonSchema schema = _schemas.GetOrAdd(version, v => new Lazy<JsonSchema>(() =>
+            JsonSchema.FromText(
+                File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "schemas", $"schema.v{v}.json"))),
+            LazyThreadSafetyMode.ExecutionAndPublication)).Value;
+
+        EvaluationResults results = schema.Evaluate(
+            ToJson(yaml),
+            new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical });
+
+        if (results.IsValid)
+            return;
+
+        var errors = new List<string>();
+        Collect(results, errors);
+
+        Assert.Fail($"Discovery output does not match schema v{version}:{Environment.NewLine}"
+                    + string.Join(Environment.NewLine, errors.Distinct())
+                    + Environment.NewLine + Environment.NewLine + yaml);
+    }
+
+    private static void Collect(EvaluationResults node, List<string> errors) {
+        if (node.Errors != null)
+            foreach (KeyValuePair<string, string> error in node.Errors)
+                errors.Add($"{node.InstanceLocation}: {error.Value}");
+
+        if (node.Details != null)
+            foreach (EvaluationResults child in node.Details)
+                Collect(child, errors);
+    }
+
+    private static JsonElement ToJson(string yaml) {
+        var stream = new YamlStream();
+        stream.Load(new StringReader(yaml));
+
+        using var document = JsonDocument.Parse(Convert(stream.Documents[0].RootNode));
+
+        return document.RootElement.Clone();
+    }
+
+    private static string Convert(YamlNode node) {
+        switch (node) {
+            case YamlScalarNode scalar:
+                if (scalar.Style is YamlDotNet.Core.ScalarStyle.SingleQuoted
+                    or YamlDotNet.Core.ScalarStyle.DoubleQuoted)
+                    return JsonSerializer.Serialize(scalar.Value);
+
+                if (int.TryParse(scalar.Value, out var i))
+                    return i.ToString();
+
+                if (double.TryParse(scalar.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
+                    return d.ToString(CultureInfo.InvariantCulture);
+
+                if (bool.TryParse(scalar.Value, out var b))
+                    return b.ToString().ToLowerInvariant();
+
+                return JsonSerializer.Serialize(scalar.Value);
+
+            case YamlSequenceNode sequence:
+                return "[" + string.Join(",", sequence.Children.Select(Convert)) + "]";
+
+            case YamlMappingNode mapping:
+                return "{" + string.Join(",", mapping.Children.Select(kvp =>
+                    JsonSerializer.Serialize(((YamlScalarNode)kvp.Key).Value) + ":" + Convert(kvp.Value))) + "}";
+
+            default:
+                return "null";
+        }
+    }
+}

+ 71 - 0
Tests.Discovery/Fixtures/docker-containers.json

@@ -0,0 +1,71 @@
+[
+  {
+    "Id": "8f2c1e9a7b3d",
+    "Names": ["/jellyfin"],
+    "Image": "jellyfin/jellyfin:10.9.6",
+    "State": "running",
+    "Status": "Up 2 days",
+    "Labels": {
+      "com.docker.compose.project": "media",
+      "com.docker.compose.service": "jellyfin"
+    },
+    "Ports": [
+      { "IP": "0.0.0.0", "PrivatePort": 8096, "PublicPort": 8096, "Type": "tcp" },
+      { "IP": "::", "PrivatePort": 8096, "PublicPort": 8096, "Type": "tcp" },
+      { "PrivatePort": 8920, "Type": "tcp" }
+    ]
+  },
+  {
+    "Id": "1a2b3c4d5e6f",
+    "Names": ["/paperless-ngx"],
+    "Image": "ghcr.io/paperless-ngx/paperless-ngx:2.11",
+    "State": "running",
+    "Labels": {
+      "com.docker.compose.project": "Paperless Stack"
+    },
+    "Ports": [
+      { "IP": "0.0.0.0", "PrivatePort": 8000, "PublicPort": 8000, "Type": "tcp" }
+    ]
+  },
+  {
+    "Id": "9z8y7x6w5v4u",
+    "Names": ["/redis"],
+    "Image": "redis:7-alpine",
+    "State": "running",
+    "Labels": {},
+    "Ports": [
+      { "PrivatePort": 6379, "Type": "tcp" }
+    ]
+  },
+  {
+    "Id": "aabbccddeeff",
+    "Names": ["/wireguard"],
+    "Image": "linuxserver/wireguard:latest",
+    "State": "running",
+    "Labels": {},
+    "Ports": [
+      { "IP": "0.0.0.0", "PrivatePort": 51820, "PublicPort": 51820, "Type": "udp" }
+    ]
+  },
+  {
+    "Id": "5t4r3e2w1q0p",
+    "Names": ["/pgadmin"],
+    "Image": "dpage/pgadmin4:8.11",
+    "State": "running",
+    "Labels": {},
+    "Ports": [
+      { "IP": "127.0.0.1", "PrivatePort": 80, "PublicPort": 5050, "Type": "tcp" }
+    ]
+  },
+  {
+    "Id": "0p1o2i3u4y5t",
+    "Names": ["/unifi"],
+    "Image": "linuxserver/unifi-network-application:8.4",
+    "State": "running",
+    "Labels": {},
+    "Ports": [
+      { "IP": "127.0.0.1", "PrivatePort": 8843, "PublicPort": 8843, "Type": "tcp" },
+      { "IP": "192.168.1.21", "PrivatePort": 8443, "PublicPort": 8443, "Type": "tcp" }
+    ]
+  }
+]

+ 20 - 0
Tests.Discovery/Fixtures/docker-info.json

@@ -0,0 +1,20 @@
+{
+  "ID": "e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d",
+  "Containers": 4,
+  "ContainersRunning": 4,
+  "ContainersPaused": 0,
+  "ContainersStopped": 0,
+  "Images": 12,
+  "Driver": "overlay2",
+  "MemoryLimit": true,
+  "SwapLimit": true,
+  "NCPU": 12,
+  "MemTotal": 67383418880,
+  "OperatingSystem": "Debian GNU/Linux 12 (bookworm)",
+  "OSType": "linux",
+  "Architecture": "x86_64",
+  "Name": "nas01",
+  "ServerVersion": "27.1.1",
+  "Labels": [],
+  "KernelVersion": "6.1.0-23-amd64"
+}

+ 1 - 0
Tests.Discovery/Fixtures/linux-cgroup-container

@@ -0,0 +1 @@
+0::/docker/3f1a9c0e5b7d4a2f8c6e1d9b3a5f7c2e4d6b8a0c2e4f6a8b0d2f4a6c8e0b2d4f

+ 1 - 0
Tests.Discovery/Fixtures/linux-cgroup-host

@@ -0,0 +1 @@
+0::/init.scope

+ 7 - 0
Tests.Discovery/Fixtures/linux-meminfo

@@ -0,0 +1,7 @@
+MemTotal:       65790000 kB
+MemFree:        41234567 kB
+MemAvailable:   60123456 kB
+Buffers:          123456 kB
+Cached:          8765432 kB
+SwapTotal:       8388604 kB
+SwapFree:        8388604 kB

+ 9 - 0
Tests.Discovery/Fixtures/linux-os-release

@@ -0,0 +1,9 @@
+PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
+NAME="Debian GNU/Linux"
+VERSION_ID="12"
+VERSION="12 (bookworm)"
+VERSION_CODENAME=bookworm
+ID=debian
+HOME_URL="https://www.debian.org/"
+SUPPORT_URL="https://www.debian.org/support"
+BUG_REPORT_URL="https://bugs.debian.org/"

+ 8 - 0
Tests.Discovery/Fixtures/macos-ioreg.txt

@@ -0,0 +1,8 @@
++-o J316sAP  <class IOPlatformExpertDevice, id 0x100000253, registered, matched, active, busy 0 (2 ms), retain 42>
+    {
+      "IOPlatformSystemSleepPolicy" = <mumble>
+      "IOPolledInterface" = "AppleARMWatchdogTimerHibernateHandler is not serializable"
+      "IOPlatformUUID" = "5C8E1F2A-3B4D-5E6F-7A8B-9C0D1E2F3A4B"
+      "model" = <"Mac14,6">
+      "serial-number" = <mumble>
+    }

+ 3 - 0
Tests.Discovery/Fixtures/pve-cluster-status-standalone.json

@@ -0,0 +1,3 @@
+{"data":[
+  {"type":"node","id":"node/pve01","name":"pve01","online":1,"local":1,"ip":"10.0.50.10"}
+]}

+ 5 - 0
Tests.Discovery/Fixtures/pve-cluster-status.json

@@ -0,0 +1,5 @@
+{"data":[
+  {"type":"cluster","id":"cluster","name":"homelab","nodes":2,"quorate":1,"version":4},
+  {"type":"node","id":"node/pve01","name":"pve01","online":1,"local":1,"ip":"10.0.50.10"},
+  {"type":"node","id":"node/pve02","name":"pve02","online":1,"local":0,"ip":"10.0.50.11"}
+]}

+ 6 - 0
Tests.Discovery/Fixtures/pve-disks.json

@@ -0,0 +1,6 @@
+{"data":[
+  {"devpath":"/dev/nvme0n1","size":1000204886016,"type":"nvme","model":"Samsung SSD 980 1TB","serial":"S64ANL0T123456","rpm":0,"used":"LVM","health":"PASSED","wearout":97},
+  {"devpath":"/dev/sda","size":8001563222016,"type":"hdd","model":"WDC WD80EFAX-68LHPN0","serial":"7SGXYZ1B","rpm":5400,"used":"ZFS","health":"PASSED"},
+  {"devpath":"/dev/sdb","size":512110190592,"type":"ssd","model":"Crucial CT500MX500SSD1","serial":"2019E2345678","rpm":0,"health":"PASSED"},
+  {"devpath":"/dev/sdc","size":0,"type":"unknown","model":"","serial":""}
+]}

+ 64 - 0
Tests.Discovery/Fixtures/pve-hardware-pci.json

@@ -0,0 +1,64 @@
+{
+  "data": [
+    {
+      "iommugroup": -1,
+      "device": "0x14d9",
+      "vendor_name": "Advanced Micro Devices, Inc. [AMD]",
+      "subsystem_vendor": "0x1022",
+      "id": "0000:00:00.2",
+      "subsystem_vendor_name": "Advanced Micro Devices, Inc. [AMD]",
+      "subsystem_device": "0x14d9",
+      "vendor": "0x1022",
+      "device_name": "Raphael/Granite Ridge IOMMU",
+      "class": "0x080600"
+    },
+    {
+      "class": "0x0c0500",
+      "device_name": "FCH SMBus Controller",
+      "subsystem_vendor_name": "ASRock Incorporation",
+      "subsystem_device": "0x790b",
+      "vendor": "0x1022",
+      "subsystem_vendor": "0x1849",
+      "id": "0000:00:14.0",
+      "vendor_name": "Advanced Micro Devices, Inc. [AMD]",
+      "iommugroup": 11,
+      "device": "0x790b"
+    },
+    {
+      "iommugroup": 13,
+      "device": "0x2204",
+      "vendor_name": "NVIDIA Corporation",
+      "subsystem_vendor": "0x10de",
+      "id": "0000:01:00.0",
+      "vendor": "0x10de",
+      "subsystem_device": "0x147d",
+      "subsystem_vendor_name": "NVIDIA Corporation",
+      "class": "0x030000",
+      "device_name": "GA102 [GeForce RTX 3090]"
+    },
+    {
+      "device_name": "GA102 [GeForce RTX 3090]",
+      "class": "0x030000",
+      "subsystem_vendor_name": "NVIDIA Corporation",
+      "vendor": "0x10de",
+      "subsystem_device": "0x147d",
+      "subsystem_vendor": "0x10de",
+      "id": "0000:02:00.0",
+      "vendor_name": "NVIDIA Corporation",
+      "device": "0x2204",
+      "iommugroup": 14
+    },
+    {
+      "class": "0x030000",
+      "device_name": "Raphael",
+      "subsystem_vendor_name": "ASRock Incorporation",
+      "subsystem_device": "0x364e",
+      "vendor": "0x1002",
+      "subsystem_vendor": "0x1849",
+      "vendor_name": "Advanced Micro Devices, Inc. [AMD/ATI]",
+      "id": "0000:10:00.0",
+      "device": "0x164e",
+      "iommugroup": 27
+    }
+  ]
+}

+ 5 - 0
Tests.Discovery/Fixtures/pve-lxc-config-dhcp.json

@@ -0,0 +1,5 @@
+{"data":{
+  "ostype":"alpine",
+  "hostname":"tiny",
+  "net0":"name=eth0,bridge=vmbr0,hwaddr=BC:24:11:DD:EE:FF,ip=dhcp,type=veth"
+}}

+ 11 - 0
Tests.Discovery/Fixtures/pve-lxc-config.json

@@ -0,0 +1,11 @@
+{"data":{
+  "ostype":"debian",
+  "hostname":"pihole",
+  "arch":"amd64",
+  "cores":1,
+  "memory":1024,
+  "rootfs":"local-lvm:vm-201-disk-0,size=8G",
+  "mp0":"tank:subvol-201-disk-0,mp=/data,size=100G",
+  "net0":"name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,hwaddr=BC:24:11:AA:BB:CC,ip=192.168.1.53/24,type=veth",
+  "swap":512
+}}

+ 4 - 0
Tests.Discovery/Fixtures/pve-lxc.json

@@ -0,0 +1,4 @@
+{"data":[
+  {"vmid":201,"name":"pihole","status":"running","cpus":1,"maxmem":1073741824,"maxdisk":8589934592,"tags":"dns","type":"lxc"},
+  {"vmid":202,"name":"Paperless Stack","status":"running","cpus":2,"maxmem":4294967296,"maxdisk":21474836480,"type":"lxc"}
+]}

+ 7 - 0
Tests.Discovery/Fixtures/pve-node-status.json

@@ -0,0 +1,7 @@
+{"data":{
+  "cpuinfo":{"model":"AMD Ryzen 5 5600G with Radeon Graphics","cores":6,"cpus":12,"sockets":1,"mhz":"3900.000"},
+  "memory":{"total":67438305280,"used":34359738368,"free":33078566912},
+  "rootfs":{"total":100861014016,"used":20971520000},
+  "pveversion":"pve-manager/8.2.2/9355359cd7afbae4",
+  "kversion":"Linux 6.8.4-2-pve #1 SMP PREEMPT_DYNAMIC PMX 6.8.4-2"
+}}

+ 4 - 0
Tests.Discovery/Fixtures/pve-nodes-full.json

@@ -0,0 +1,4 @@
+{"data":[
+  {"node":"pve01","status":"online","type":"node","maxcpu":12,"maxmem":67438305280,"uptime":1209600},
+  {"node":"pve02","status":"online","type":"node","maxcpu":8,"maxmem":33719152640,"uptime":864000}
+]}

+ 3 - 0
Tests.Discovery/Fixtures/pve-nodes.json

@@ -0,0 +1,3 @@
+{"data":[
+  {"level":"","type":"node","node":"kepler","id":"node/kepler","status":"online","ssl_fingerprint":"88:1B:EE:7B:6E:D3:06:82:46:1F:33:59:65:97:70:BA"}
+]}

+ 18 - 0
Tests.Discovery/Fixtures/pve-qemu-config-passthrough.json

@@ -0,0 +1,18 @@
+{"data":{
+  "scsi0": "local-lvm:vm-100-disk-0,iothread=1,size=128G",
+  "hostpci0": "0000:01:00",
+  "hostpci1": "0000:02:00",
+  "ostype": "l26",
+  "cores": 8,
+  "net0": "virtio=BC:24:11:0E:8D:A2,bridge=vmbr0,firewall=1,tag=50",
+  "memory": "32768",
+  "name": "ai",
+  "smbios1": "uuid=282dd57c-be42-441d-bd0e-6976f734f7b5",
+  "sockets": 1,
+  "cpu": "x86-64-v2-AES",
+  "numa": 0,
+  "ide2": "none,media=cdrom",
+  "scsihw": "virtio-scsi-single",
+  "agent": "1",
+  "boot": "order=scsi0;ide2;net0"
+}}

+ 15 - 0
Tests.Discovery/Fixtures/pve-qemu-config.json

@@ -0,0 +1,15 @@
+{"data":{
+  "ostype":"l26",
+  "name":"docker-01",
+  "cores":4,
+  "sockets":1,
+  "memory":8192,
+  "bios":"ovmf",
+  "efidisk0":"local-lvm:vm-104-disk-0,efitype=4m,size=4M",
+  "scsi0":"local-lvm:vm-104-disk-1,iothread=1,size=64G",
+  "scsi1":"tank:vm-104-disk-0,backup=0,size=2T",
+  "ide2":"local:iso/debian-12.5.0-amd64-netinst.iso,media=cdrom,size=629M",
+  "unused0":"local-lvm:vm-104-disk-9",
+  "net0":"virtio=BC:24:11:12:34:56,bridge=vmbr0",
+  "smbios1":"uuid=5c8e1f2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b"
+}}

+ 5 - 0
Tests.Discovery/Fixtures/pve-qemu.json

@@ -0,0 +1,5 @@
+{"data":[
+  {"vmid":104,"name":"docker-01","status":"running","cpus":4,"maxmem":8589934592,"maxdisk":68719476736,"tags":"production;web","uptime":604800},
+  {"vmid":105,"name":"windows-vm","status":"stopped","cpus":8,"maxmem":17179869184,"maxdisk":274877906944,"tags":"","uptime":0},
+  {"vmid":110,"name":"","status":"running","cpus":2,"maxmem":4294967296,"maxdisk":34359738368}
+]}

+ 142 - 0
Tests.Discovery/ProxmoxClientTests.cs

@@ -0,0 +1,142 @@
+using System.Net;
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Exercises the IO half against a stub that records what was actually asked for.
+///     Path construction and the authorization header cannot be checked any other way
+///     without a Proxmox to point at.
+/// </summary>
+public class ProxmoxClientTests {
+    private static (ProxmoxApiClient Client, StubHandler Stub) Create(
+        string host = "https://pve.lan:8006",
+        bool clusterAvailable = true) {
+        var stub = new StubHandler(clusterAvailable);
+
+        return (new ProxmoxApiClient(host, "root@pam!rackpeek", "secret-uuid", false, new HttpClient(stub)), stub);
+    }
+
+    [Fact]
+    public async Task Requests_go_to_the_documented_api_paths() {
+        (ProxmoxApiClient client, StubHandler stub) = Create();
+
+        using (client) {
+            await client.GetNodesAsync();
+            await client.EnrichAsync(new ProxmoxNode { Name = "pve01" });
+            await client.GetGuestsAsync("pve01", ProxmoxApiClient.QemuEndpoint);
+            await client.GetGuestConfigAsync("pve01", ProxmoxApiClient.LxcEndpoint, 201);
+        }
+
+        Assert.Equal([
+            "/api2/json/nodes",
+            "/api2/json/nodes/pve01/status",
+            "/api2/json/nodes/pve01/disks/list",
+            "/api2/json/nodes/pve01/hardware/pci",
+            "/api2/json/nodes/pve01/qemu",
+            "/api2/json/nodes/pve01/lxc/201/config"
+        ], stub.Paths);
+    }
+
+    [Fact]
+    public async Task The_token_is_sent_the_way_proxmox_expects_it() {
+        (ProxmoxApiClient client, StubHandler stub) = Create();
+
+        using (client)
+            await client.GetNodesAsync();
+
+        Assert.Equal("PVEAPIToken=root@pam!rackpeek=secret-uuid", stub.Authorization);
+    }
+
+    [Theory]
+    [InlineData("pve.lan", "https://pve.lan:8006")]
+    [InlineData("https://pve.lan:8006", "https://pve.lan:8006")]
+    [InlineData("https://pve.lan:8006/", "https://pve.lan:8006")]
+    [InlineData("http://10.0.50.10:8006", "http://10.0.50.10:8006")]
+    public void A_bare_host_name_gets_the_scheme_and_port_proxmox_uses(string input, string expected) {
+        (ProxmoxApiClient client, _) = Create(input);
+
+        using (client)
+            Assert.Equal(expected, client.Endpoint);
+    }
+
+    [Fact]
+    public async Task A_clustered_host_identifies_guests_by_the_cluster() {
+        (ProxmoxApiClient client, _) = Create();
+
+        using (client)
+            Assert.Equal("homelab", await client.GetIdentityScopeAsync());
+    }
+
+    [Fact]
+    public async Task A_standalone_host_has_no_cluster_endpoint_and_falls_back_to_its_node() {
+        // Proxmox answers an error rather than an empty list when there is no cluster.
+        (ProxmoxApiClient client, _) = Create(clusterAvailable: false);
+
+        using (client)
+            Assert.Equal("pve01", await client.GetIdentityScopeAsync());
+    }
+
+    [Fact]
+    public async Task A_guest_that_vanishes_mid_run_contributes_nothing_instead_of_failing() {
+        (ProxmoxApiClient client, _) = Create();
+
+        using (client) {
+            ProxmoxGuestConfig config =
+                await client.GetGuestConfigAsync("pve01", ProxmoxApiClient.QemuEndpoint, 999);
+
+            Assert.Null(config.Os);
+            Assert.Null(config.Ip);
+        }
+    }
+
+    [Fact]
+    public async Task A_rejected_token_says_so_in_terms_that_point_at_the_fix() {
+        var stub = new StubHandler(true) { Unauthorized = true };
+
+        using var client = new ProxmoxApiClient(
+            "https://pve.lan:8006", "bad", "worse", false, new HttpClient(stub));
+
+        HttpRequestException error =
+            await Assert.ThrowsAsync<HttpRequestException>(() => client.GetNodesAsync());
+
+        Assert.Contains("user@realm!tokenname", error.Message);
+    }
+
+    private sealed class StubHandler(bool clusterAvailable) : HttpMessageHandler {
+        public List<string> Paths { get; } = [];
+        public string? Authorization { get; private set; }
+        public bool Unauthorized { get; init; }
+
+        protected override Task<HttpResponseMessage> SendAsync(
+            HttpRequestMessage request,
+            CancellationToken cancellationToken) {
+            var path = request.RequestUri!.AbsolutePath;
+            Paths.Add(path);
+            Authorization = request.Headers.TryGetValues("Authorization", out IEnumerable<string>? values)
+                ? string.Join("", values)
+                : null;
+
+            if (Unauthorized)
+                return Respond(HttpStatusCode.Unauthorized, "{}");
+
+            return path switch {
+                "/api2/json/nodes" => Respond(HttpStatusCode.OK, Fixture.Read("pve-nodes-full.json")),
+                "/api2/json/cluster/status" when clusterAvailable =>
+                    Respond(HttpStatusCode.OK, Fixture.Read("pve-cluster-status.json")),
+                "/api2/json/cluster/status" => Respond(HttpStatusCode.InternalServerError, "{}"),
+                "/api2/json/nodes/pve01/status" => Respond(HttpStatusCode.OK, Fixture.Read("pve-node-status.json")),
+                "/api2/json/nodes/pve01/qemu" => Respond(HttpStatusCode.OK, Fixture.Read("pve-qemu.json")),
+                "/api2/json/nodes/pve01/lxc" => Respond(HttpStatusCode.OK, Fixture.Read("pve-lxc.json")),
+                "/api2/json/nodes/pve01/disks/list" => Respond(HttpStatusCode.OK, Fixture.Read("pve-disks.json")),
+                "/api2/json/nodes/pve01/hardware/pci" => Respond(HttpStatusCode.OK, Fixture.Read("pve-hardware-pci.json")),
+                "/api2/json/nodes/pve01/lxc/201/config" =>
+                    Respond(HttpStatusCode.OK, Fixture.Read("pve-lxc-config.json")),
+                _ => Respond(HttpStatusCode.NotFound, "{}")
+            };
+        }
+
+        private static Task<HttpResponseMessage> Respond(HttpStatusCode status, string body) =>
+            Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) });
+    }
+}

+ 518 - 0
Tests.Discovery/ProxmoxDiscoveryTests.cs

@@ -0,0 +1,518 @@
+using System.Text.Json;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Captured Proxmox API responses in, RackPeek resources out. A node becomes two
+///     things — the machine and the hypervisor on it — so most of these are about the
+///     tree rather than the fields.
+/// </summary>
+public class ProxmoxDiscoveryTests {
+    private const string _scope = "homelab";
+
+    private static List<Resource> Discover() {
+        ProxmoxNode node = ProxmoxResponseParser.ParseNodeStatus(Fixture.Read("pve-node-status.json"), "pve01")
+            with {
+            Disks = ProxmoxResponseParser.ParseDisks(Fixture.Read("pve-disks.json")),
+            Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
+        };
+
+        List<ProxmoxGuest> qemu = ProxmoxResponseParser.ParseGuests(
+            Fixture.Read("pve-qemu.json"), "pve01", ProxmoxResponseParser.VmType);
+
+        List<ProxmoxGuest> lxc = ProxmoxResponseParser.ParseGuests(
+            Fixture.Read("pve-lxc.json"), "pve01", ProxmoxResponseParser.ContainerType);
+
+        // The command reads each guest's config; here the same two are applied by hand.
+        ProxmoxGuestConfig vmConfig = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-qemu-config.json"));
+        ProxmoxGuestConfig ctConfig = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-lxc-config.json"));
+
+        List<ProxmoxGuest> guests = [
+            ..qemu.Select(g => g with { Os = vmConfig.Os, Disks = vmConfig.DiskBytes }),
+            ..lxc.Select(g => g with { Os = ctConfig.Os, Ip = ctConfig.Ip, Disks = ctConfig.DiskBytes })
+        ];
+
+        return ProxmoxResourceMapper.ToResources(_scope, [node], guests);
+    }
+
+    private static SystemResource System(string name) =>
+        Discover().OfType<SystemResource>().Single(r => r.Name == name);
+
+    // ---------------------------------------------------------------- the tree
+
+    [Fact]
+    public void A_node_becomes_both_the_machine_and_the_hypervisor_on_it() {
+        List<Resource> resources = Discover();
+
+        Server server = Assert.Single(resources.OfType<Server>());
+        SystemResource hypervisor = resources.OfType<SystemResource>().Single(r => r.Type == "hypervisor");
+
+        Assert.Equal("pve01", server.Name);
+        Assert.Equal("pve01-pve", hypervisor.Name);
+
+        // Hardware -> System, which is the relationship RackPeek is built around.
+        Assert.Equal(["pve01"], hypervisor.RunsOn);
+    }
+
+    [Fact]
+    public void Guests_run_on_the_hypervisor_rather_than_straight_on_the_metal() {
+        var guests = Discover()
+            .OfType<SystemResource>()
+            .Where(r => r.Type != "hypervisor")
+            .ToList();
+
+        Assert.NotEmpty(guests);
+        Assert.All(guests, g => Assert.Equal(["pve01-pve"], g.RunsOn));
+    }
+
+    // ------------------------------------------------------------- the machine
+
+    [Fact]
+    public void The_machine_carries_its_processor() {
+        Server server = Discover().OfType<Server>().Single();
+
+        Cpu cpu = Assert.Single(server.Cpus!);
+        Assert.Equal("AMD Ryzen 5 5600G with Radeon Graphics", cpu.Model);
+        Assert.Equal(6, cpu.Cores);
+        Assert.Equal(12, cpu.Threads);
+    }
+
+    [Fact]
+    public void A_two_socket_machine_reports_each_socket_separately() {
+        // Proxmox reports totals across the machine, so 2 x 8-core reads as cores=16.
+        ProxmoxNode node = new() {
+            Name = "dual",
+            CpuModel = "Intel Xeon Silver 4208",
+            Sockets = 2,
+            PhysicalCores = 16,
+            Cores = 32
+        };
+
+        Server server = ProxmoxResourceMapper.ToResources(_scope, [node], []).OfType<Server>().Single();
+
+        Assert.Equal(2, server.Cpus!.Count);
+        Assert.All(server.Cpus, c => {
+            Assert.Equal(8, c.Cores);
+            Assert.Equal(16, c.Threads);
+        });
+    }
+
+    [Fact]
+    public void The_machine_carries_its_physical_disks_already_classified() {
+        Server server = Discover().OfType<Server>().Single();
+
+        List<Drive> drives = server.Drives!;
+
+        Assert.Equal(["nvme", "hdd", "ssd"], drives.Select(d => d.Type));
+        Assert.Equal(932, drives[0].Size);
+    }
+
+    [Fact]
+    public void A_disk_proxmox_cannot_classify_is_left_untyped_rather_than_guessed() {
+        List<ProxmoxDisk> disks = ProxmoxResponseParser.ParseDisks(Fixture.Read("pve-disks.json"));
+
+        // The zero-size unknown entry is dropped entirely; a real one would keep its size.
+        Assert.Equal(3, disks.Count);
+        Assert.DoesNotContain(disks, d => d.Type == "unknown");
+    }
+
+    [Fact]
+    public void The_machine_carries_the_gpus_bolted_into_it() {
+        // Including ones passed through to a guest: the card is still in this machine.
+        Server server = Discover().OfType<Server>().Single();
+
+        Assert.Equal(
+            ["GeForce RTX 3090", "GeForce RTX 3090", "Raphael"],
+            server.Gpus!.Select(g => g.Model));
+    }
+
+    [Fact]
+    public void Only_display_adapters_count_as_gpus() =>
+        // The same PCI list carries an IOMMU and an SMBus controller.
+        Assert.Equal(3, ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json")).Count);
+
+    [Theory]
+    [InlineData("GA102 [GeForce RTX 3090]", "GeForce RTX 3090")]
+    [InlineData("AD102 [GeForce RTX 4090]", "GeForce RTX 4090")]
+    [InlineData("Raphael", "Raphael")]
+    [InlineData("", null)]
+    public void A_pci_name_is_reduced_to_the_product_people_know(string input, string? expected) =>
+        Assert.Equal(expected, ProxmoxResponseParser.MarketingName(input));
+
+    [Fact]
+    public void A_machine_with_no_gpu_says_nothing_rather_than_an_empty_list() {
+        ProxmoxNode node = new() { Name = "headless" };
+
+        Assert.Null(ProxmoxResourceMapper.ToResources(_scope, [node], []).OfType<Server>().Single().Gpus);
+    }
+
+    [Fact]
+    public void The_machine_carries_its_memory() =>
+        Assert.Equal(63, Discover().OfType<Server>().Single().Ram?.Size);
+
+    // ---------------------------------------------------------- the hypervisor
+
+    [Fact]
+    public void The_hypervisor_reports_what_it_is_running() {
+        SystemResource hypervisor = System("pve01-pve");
+
+        Assert.Equal("Proxmox VE 8.2.2", hypervisor.Os);
+        Assert.Equal(12, hypervisor.Cores);
+        Assert.Equal(63, hypervisor.Ram);
+    }
+
+    // --------------------------------------------------------------- the guests
+
+    [Fact]
+    public void Qemu_guests_are_vms_and_lxc_guests_are_containers() {
+        Assert.Equal("vm", System("docker-01").Type);
+        Assert.Equal("container", System("pihole").Type);
+    }
+
+    [Fact]
+    public void A_stopped_guest_is_still_part_of_the_inventory() =>
+        // Unlike a stopped container, a stopped VM is a real system with real resources.
+        Assert.Contains(Discover(), r => r.Name == "windows-vm");
+
+    [Fact]
+    public void A_guest_carries_its_allocation() {
+        SystemResource vm = System("docker-01");
+
+        Assert.Equal(4, vm.Cores);
+        Assert.Equal(8, vm.Ram);
+        Assert.Equal("Linux", vm.Os);
+    }
+
+    [Fact]
+    public void Every_disk_on_a_guest_is_recorded_not_just_the_boot_one() {
+        // maxdisk in the guest list is the boot disk alone, so a VM with a small root
+        // and a large data volume would otherwise be recorded at a fraction of its size.
+        List<Drive> drives = System("docker-01").Drives!;
+
+        Assert.Equal([64, 2048], drives.Select(d => d.Size));
+    }
+
+    [Fact]
+    public void Container_mount_points_count_as_disks_too() {
+        List<Drive> drives = System("pihole").Drives!;
+
+        Assert.Equal([8, 100], drives.Select(d => d.Size));
+    }
+
+    [Theory]
+    [InlineData("local-lvm:vm-104-disk-1,iothread=1,size=64G", 68719476736L)]
+    [InlineData("tank:vm-104-disk-0,backup=0,size=2T", 2199023255552L)]
+    [InlineData("local-lvm:vm-104-disk-0,efitype=4m,size=4M", 4194304L)]
+    [InlineData("local-lvm:vm-104-disk-9", 0L)]
+    public void A_volume_definition_yields_its_size(string volume, long expected) =>
+        Assert.Equal(expected, ProxmoxResponseParser.ParseSize(volume));
+
+    [Fact]
+    public void Firmware_scratch_and_install_media_are_not_storage() {
+        // efidisk0 is a few megabytes of EFI variables and ide2 is a mounted ISO;
+        // neither belongs on an inventory, and the detached unused0 has no size at all.
+        List<Drive> drives = System("docker-01").Drives!;
+
+        Assert.DoesNotContain(drives, d => d.Size < 8);
+        Assert.Equal(2, drives.Count);
+    }
+
+    [Fact]
+    public void A_container_with_a_static_address_keeps_it() {
+        SystemResource container = System("pihole");
+
+        Assert.Equal("192.168.1.53", container.Ip);
+        Assert.Equal("Debian", container.Os);
+    }
+
+    [Fact]
+    public void A_dhcp_container_reports_no_address_rather_than_a_wrong_one() {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-lxc-config-dhcp.json"));
+
+        Assert.Null(config.Ip);
+        Assert.Equal("Alpine", config.Os);
+    }
+
+    [Fact]
+    public void Proxmox_tags_carry_across() {
+        Assert.Equal(["production", "web"], System("docker-01").Tags);
+        Assert.Equal(["dns"], System("pihole").Tags);
+    }
+
+    [Fact]
+    public void A_guest_name_with_spaces_becomes_a_usable_one() =>
+        Assert.Contains(Discover(), r => r.Name == "paperless-stack");
+
+    [Fact]
+    public void An_unnamed_guest_is_identified_by_its_vmid() =>
+        Assert.Contains(Discover(), r => r.Name == "vm-110");
+
+    // ------------------------------------------------------------------ identity
+
+    [Fact]
+    public void Identity_survives_a_rename_and_a_migration_between_nodes() {
+        ProxmoxGuest onFirstNode = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
+        ProxmoxGuest afterMoving = new() { VmId = 104, Node = "pve02", Name = "renamed", Type = "vm" };
+
+        var first = ProxmoxResourceMapper.ToResources(_scope, [], [onFirstNode]).Single().DiscoveryId;
+        var second = ProxmoxResourceMapper.ToResources(_scope, [], [afterMoving]).Single().DiscoveryId;
+
+        // The vmid is unique cluster-wide, so neither the node nor the name is part of it.
+        Assert.Equal(first, second);
+    }
+
+    [Fact]
+    public void The_same_vmid_in_a_different_cluster_is_a_different_machine() {
+        ProxmoxGuest guest = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
+
+        Assert.NotEqual(
+            ProxmoxResourceMapper.ToResources("homelab", [], [guest]).Single().DiscoveryId,
+            ProxmoxResourceMapper.ToResources("office", [], [guest]).Single().DiscoveryId);
+    }
+
+    [Fact]
+    public void The_machine_and_the_hypervisor_on_it_are_separate_identities() {
+        List<Resource> resources = Discover();
+
+        Assert.NotEqual(
+            resources.OfType<Server>().Single().DiscoveryId,
+            resources.OfType<SystemResource>().Single(r => r.Type == "hypervisor").DiscoveryId);
+    }
+
+    [Fact]
+    public void A_guest_reported_by_two_nodes_at_once_is_still_one_resource() {
+        ProxmoxGuest leaving = new() { VmId = 104, Node = "pve01", Name = "docker-01", Type = "vm" };
+        ProxmoxGuest arriving = new() { VmId = 104, Node = "pve02", Name = "docker-01", Type = "vm" };
+
+        Assert.Single(ProxmoxResourceMapper.ToResources(_scope, [], [leaving, arriving]));
+    }
+
+    [Fact]
+    public void No_two_resources_ever_share_an_identity() {
+        List<Resource> resources = Discover();
+
+        Assert.Equal(resources.Select(r => r.DiscoveryId).Distinct().Count(), resources.Count);
+    }
+
+    [Fact]
+    public void A_guest_named_after_its_node_does_not_take_the_node_name() {
+        ProxmoxNode node = new() { Name = "pve01", Cores = 4, MemoryBytes = 8589934592, Version = "pve-manager/8.2.2/x" };
+        ProxmoxGuest guest = new() { VmId = 104, Node = "pve01", Name = "pve01", Type = "vm" };
+
+        List<Resource> resources = ProxmoxResourceMapper.ToResources(_scope, [node], [guest]);
+
+        Assert.Equal("pve01", resources[0].Name);
+        Assert.Equal(3, resources.Count);
+        Assert.Equal(3, resources.Select(r => r.Name).Distinct().Count());
+    }
+
+    // -------------------------------------------------------- restricted tokens
+
+    [Fact]
+    public void A_restricted_token_still_yields_the_node_list() {
+        // What the real API returns for a token without the rights to read node detail.
+        List<ProxmoxNode> nodes = ProxmoxResponseParser.ParseNodes(Fixture.Read("pve-nodes.json"));
+
+        ProxmoxNode node = Assert.Single(nodes);
+        Assert.Equal("kepler", node.Name);
+        Assert.Equal(0, node.Cores);
+    }
+
+    [Fact]
+    public void A_permitted_token_gets_the_node_sizing_from_the_same_call() {
+        List<ProxmoxNode> nodes = ProxmoxResponseParser.ParseNodes(Fixture.Read("pve-nodes-full.json"));
+
+        Assert.Equal(["pve01", "pve02"], nodes.Select(n => n.Name));
+        Assert.Equal(12, nodes[0].Cores);
+        Assert.Equal(67438305280, nodes[0].MemoryBytes);
+    }
+
+    [Fact]
+    public void A_node_with_no_readable_detail_still_gives_a_usable_tree() {
+        ProxmoxNode bare = new() { Name = "kepler" };
+        ProxmoxGuest guest = new() { VmId = 104, Node = "kepler", Name = "docker-01", Type = "vm", Os = "Linux" };
+
+        List<Resource> resources = ProxmoxResourceMapper.ToResources(_scope, [bare], [guest]);
+
+        Server server = resources.OfType<Server>().Single();
+        Assert.Equal("kepler", server.Name);
+        Assert.Null(server.Cpus);
+        Assert.Null(server.Drives);
+
+        // The guest still hangs off the hypervisor, which is what makes the run worth it.
+        Assert.Equal(["kepler-pve"], resources.OfType<SystemResource>().Single(r => r.Type == "vm").RunsOn);
+    }
+
+    // --------------------------------------------------------------- conformance
+
+    [Fact]
+    public void Types_are_ones_the_schema_accepts() =>
+        Assert.All(
+            Discover().OfType<SystemResource>(),
+            r => Assert.Contains(r.Type, SystemResource.ValidSystemTypes));
+
+    [Fact]
+    public void A_cluster_names_the_scope_and_a_standalone_host_falls_back_to_its_node() {
+        Assert.Equal("homelab",
+            ProxmoxResponseParser.ParseIdentityScope(Fixture.Read("pve-cluster-status.json"), "pve01"));
+
+        Assert.Equal("pve01",
+            ProxmoxResponseParser.ParseIdentityScope(
+                Fixture.Read("pve-cluster-status-standalone.json"), "pve01"));
+    }
+
+    [Fact]
+    public void Output_conforms_to_the_published_schema() =>
+        Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(Discover()));
+
+    // ------------------------------------------------- passthrough assignment
+
+    private static List<Resource> DiscoverWithPassthrough() {
+        ProxmoxNode node = new() {
+            Name = "kepler",
+            Cores = 32,
+            MemoryBytes = 100_000_000_000,
+            Version = "pve-manager/9.2.2/x",
+            Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
+        };
+
+        ProxmoxGuestConfig config =
+            ProxmoxResponseParser.ParseGuestConfig(Fixture.Read("pve-qemu-config-passthrough.json"));
+
+        ProxmoxGuest guest = new() {
+            VmId = 100,
+            Node = "kepler",
+            Name = "ai",
+            Type = "vm",
+            Cores = 8,
+            MemoryBytes = 34_359_738_368,
+            Os = config.Os,
+            Disks = config.DiskBytes,
+            PassthroughAddresses = config.PassthroughAddresses
+        };
+
+        return ProxmoxResourceMapper.ToResources(_scope, [node], [guest]);
+    }
+
+    [Fact]
+    public void A_guest_records_the_cards_it_has_been_given() {
+        SystemResource guest = DiscoverWithPassthrough().OfType<SystemResource>().Single(r => r.Type == "vm");
+
+        Assert.Equal(
+            "GeForce RTX 3090, GeForce RTX 3090",
+            guest.Labels[ProxmoxResourceMapper.GpuLabel]);
+    }
+
+    [Fact]
+    public void The_card_itself_still_belongs_to_the_machine_it_is_installed_in() {
+        List<Resource> resources = DiscoverWithPassthrough();
+
+        // The guest records the assignment; the Server records the hardware.
+        Server server = resources.OfType<Server>().Single();
+
+        Assert.Equal(3, server.Gpus!.Count);
+        Assert.DoesNotContain(server.Labels, l => l.Key == ProxmoxResourceMapper.GpuLabel);
+    }
+
+    [Fact]
+    public void A_config_address_without_a_function_suffix_still_matches_the_device() {
+        // The config writes 0000:01:00; the PCI list reports 0000:01:00.0.
+        IReadOnlyList<string> addresses = ProxmoxResponseParser.ParseGuestConfig(
+            Fixture.Read("pve-qemu-config-passthrough.json")).PassthroughAddresses;
+
+        Assert.Equal(["0000:01:00", "0000:02:00"], addresses);
+    }
+
+    [Fact]
+    public void Options_after_the_address_are_not_part_of_it() {
+        using var document = JsonDocument.Parse(
+            """{"hostpci0":"0000:01:00,pcie=1,x-vga=1"}""");
+
+        Assert.Equal(["0000:01:00"], ProxmoxResponseParser.ParsePassthrough(document.RootElement));
+    }
+
+    [Fact]
+    public void A_guest_holding_nothing_carries_no_label() =>
+        Assert.All(Discover().OfType<SystemResource>(), g => Assert.Empty(g.Labels));
+
+    [Fact]
+    public void Passthrough_of_something_that_is_not_a_display_adapter_is_ignored() {
+        ProxmoxNode node = new() {
+            Name = "kepler",
+            Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
+        };
+
+        // 0000:00:14.0 is the SMBus controller in the same fixture.
+        ProxmoxGuest guest = new() {
+            VmId = 100,
+            Node = "kepler",
+            Name = "hba",
+            Type = "vm",
+            PassthroughAddresses = ["0000:00:14.0"]
+        };
+
+        SystemResource mapped = ProxmoxResourceMapper.ToResources(_scope, [node], [guest])
+            .OfType<SystemResource>().Single(r => r.Type == "vm");
+
+        Assert.Empty(mapped.Labels);
+    }
+
+    [Fact]
+    public void The_same_address_on_a_different_node_is_a_different_card() {
+        ProxmoxNode withCards = new() {
+            Name = "kepler",
+            Gpus = ProxmoxResponseParser.ParseGpus(Fixture.Read("pve-hardware-pci.json"))
+        };
+
+        ProxmoxNode headless = new() { Name = "nebula" };
+
+        // Every machine has a 0000:01:00; a guest on the headless node holds nothing.
+        ProxmoxGuest guest = new() {
+            VmId = 100,
+            Node = "nebula",
+            Name = "elsewhere",
+            Type = "vm",
+            PassthroughAddresses = ["0000:01:00"]
+        };
+
+        SystemResource mapped = ProxmoxResourceMapper
+            .ToResources(_scope, [withCards, headless], [guest])
+            .OfType<SystemResource>().Single(r => r.Type == "vm");
+
+        Assert.Empty(mapped.Labels);
+    }
+
+    [Fact]
+    public void A_guest_holding_more_cards_than_a_label_can_hold_is_truncated() {
+        var many = Enumerable.Range(0, 20)
+            .Select(i => new ProxmoxGpu($"0000:{i:00}:00.0", "NVIDIA RTX A6000 Ada Generation"))
+            .ToList();
+
+        ProxmoxNode node = new() { Name = "dense", Gpus = many };
+
+        ProxmoxGuest guest = new() {
+            VmId = 100,
+            Node = "dense",
+            Name = "trainer",
+            Type = "vm",
+            PassthroughAddresses = many.Select(g => g.Address).ToList()
+        };
+
+        var label = ProxmoxResourceMapper.ToResources(_scope, [node], [guest])
+            .OfType<SystemResource>().Single(r => r.Type == "vm")
+            .Labels[ProxmoxResourceMapper.GpuLabel];
+
+        // RackPeek caps a label value at 200 characters.
+        Assert.True(label.Length <= 200, $"len={label.Length}");
+        Assert.False(label.EndsWith(','));
+    }
+
+    [Fact]
+    public void Output_with_passthrough_conforms_to_the_published_schema() =>
+        Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(DiscoverWithPassthrough()));
+}

+ 103 - 0
Tests.Discovery/RealProbeTests.cs

@@ -0,0 +1,103 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Everywhere else in this project the probes are bypassed and the parser is driven
+///     from fixtures. These tests do the opposite: they run the real probe against the
+///     real machine, which is the only way to catch a wrong path or a changed command.
+///     Each one is skipped off its own platform, so the CI matrix covers Linux on the
+///     ubuntu runner and macOS on the macos runner.
+/// </summary>
+public class RealProbeTests {
+    [Fact]
+    public async Task The_linux_probe_reads_this_machine() {
+        // No-op off Linux. xUnit v2 has no skip-at-runtime, and a custom attribute is
+        // more machinery than this needs — the CI matrix is what makes it run.
+        if (!OperatingSystem.IsLinux())
+            return;
+
+        RawSystemSnapshot snapshot = await new LinuxSystemProbe().ReadAsync(CancellationToken.None);
+
+        Assert.False(string.IsNullOrWhiteSpace(snapshot.Hostname));
+        Assert.True(snapshot.Cores > 0);
+
+        // Each of these guards a hard-coded path. If one is wrong the probe silently
+        // returns null and the resource quietly loses a field, which no fixture can catch.
+        AssertReadIfPresent("/etc/os-release", snapshot.OsReleaseFile);
+        AssertReadIfPresent("/proc/meminfo", snapshot.MemInfoFile);
+        AssertReadIfPresent("/proc/1/cgroup", snapshot.CgroupFile);
+        AssertReadIfPresent("/etc/machine-id", snapshot.MachineIdFile);
+        AssertReadIfPresent("/sys/class/dmi/id/sys_vendor", snapshot.DmiVendor);
+        AssertReadIfPresent("/sys/class/dmi/id/product_name", snapshot.DmiProduct);
+
+        if (Directory.Exists("/sys/block") && Directory.EnumerateDirectories("/sys/block").Any())
+            Assert.NotEmpty(snapshot.BlockDevices);
+
+        AssertUsable(SystemFactsParser.Parse(snapshot));
+    }
+
+    [Fact]
+    public async Task The_macos_probe_reads_this_machine() {
+        if (!OperatingSystem.IsMacOS())
+            return;
+
+        RawSystemSnapshot snapshot = await new MacSystemProbe().ReadAsync(CancellationToken.None);
+
+        Assert.False(string.IsNullOrWhiteSpace(snapshot.Hostname));
+        Assert.True(snapshot.Cores > 0);
+
+        // These come from sw_vers, sysctl and ioreg — all shell-outs, none of which a
+        // fixture can prove are still spelled correctly.
+        Assert.StartsWith("macOS", snapshot.OsName);
+        Assert.True(snapshot.MemoryBytes > 0);
+        Assert.False(string.IsNullOrWhiteSpace(snapshot.PlatformUuid));
+
+        AssertUsable(SystemFactsParser.Parse(snapshot));
+    }
+
+    [Fact]
+    public void Exactly_one_probe_claims_this_platform() {
+        ISystemProbe[] probes = [new LinuxSystemProbe(), new MacSystemProbe()];
+
+        var supported = probes.Count(p => p.IsSupported);
+
+        Assert.Equal(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() ? 1 : 0, supported);
+    }
+
+    /// <summary>
+    ///     Reads the file itself and, if this machine actually has content there, insists
+    ///     the probe found it too. Note the test has to read rather than stat: everything
+    ///     under /proc reports a length of zero, so a size check silently passes and
+    ///     covers none of the paths that matter most.
+    /// </summary>
+    private static void AssertReadIfPresent(string path, string? value) {
+        string? actual;
+
+        try {
+            actual = File.Exists(path) ? File.ReadAllText(path) : null;
+        }
+        catch {
+            return; // Present but unreadable for this user; nothing to hold the probe to.
+        }
+
+        if (string.IsNullOrWhiteSpace(actual))
+            return;
+
+        Assert.False(
+            string.IsNullOrWhiteSpace(value),
+            $"{path} has content on this machine but the probe read nothing from it.");
+    }
+
+    private static void AssertUsable(SystemFacts facts) {
+        Assert.Contains(facts.Type, SystemResource.ValidSystemTypes);
+        Assert.NotEqual("Unknown", facts.Os);
+        Assert.True(facts.RamGb > 0);
+
+        // The schema requires type, os, cores and ram, so a real machine has to produce
+        // something importable rather than a half-filled resource.
+        Fixture.AssertConformsToSchema(
+            DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(facts)]));
+    }
+}

+ 182 - 0
Tests.Discovery/RemoteDockerDiscoveryTests.cs

@@ -0,0 +1,182 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Over TCP the machine running the command is not the machine running the
+///     containers, so nothing probed locally may leak into what gets recorded: identity
+///     comes from the engine (<c>GET /info</c>), the address from the endpoint the user
+///     dialled, and a rename of the host is preserved by the merge rather than by the
+///     collector, which cannot see it.
+/// </summary>
+public class RemoteDockerDiscoveryTests {
+    // -- GET /info --------------------------------------------------------------------
+
+    [Fact]
+    public void The_engines_identity_is_read_from_info() {
+        DockerEngineInfo? info = DockerEngineInfoParser.Parse(Fixture.Read("docker-info.json"));
+
+        Assert.NotNull(info);
+        Assert.Equal("e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d", info.Id);
+        Assert.Equal("nas01", info.Hostname);
+    }
+
+    [Theory]
+    [InlineData("{}")]
+    [InlineData("""{"ID": "", "Name": "   "}""")]
+    [InlineData("""{"ID": 42, "Name": ["nas01"]}""")]
+    public void Missing_or_unusable_fields_come_back_null_rather_than_empty(string json) {
+        DockerEngineInfo? info = DockerEngineInfoParser.Parse(json);
+
+        Assert.NotNull(info);
+        Assert.Null(info.Id);
+        Assert.Null(info.Hostname);
+    }
+
+    [Theory]
+    [InlineData("not json at all")]
+    [InlineData("[]")]
+    [InlineData("\"a string\"")]
+    public void A_broken_info_response_is_null_not_an_exception(string json) =>
+        Assert.Null(DockerEngineInfoParser.Parse(json));
+
+    // -- The endpoint -----------------------------------------------------------------
+
+    [Theory]
+    [InlineData("tcp://nas01:2375", "nas01")]
+    [InlineData("tcp://192.168.1.20:2375", "192.168.1.20")]
+    [InlineData("http://nas01.lan:2376", "nas01.lan")]
+    public void The_remote_host_is_the_host_part_of_the_endpoint(string endpoint, string expected) {
+        using var client = new DockerApiClient(endpoint);
+
+        Assert.Equal(expected, client.RemoteHost);
+    }
+
+    [Fact]
+    public void A_local_socket_has_no_remote_host() {
+        using var client = new DockerApiClient("unix:///var/run/docker.sock");
+
+        Assert.Null(client.RemoteHost);
+    }
+
+    [Fact]
+    public async Task An_ipv4_endpoint_address_is_used_verbatim() =>
+        Assert.Equal("192.168.1.20", await DockerApiClient.ResolveIpv4Async("192.168.1.20"));
+
+    [Fact]
+    public async Task An_ipv6_endpoint_yields_null_because_the_schema_holds_ipv4() =>
+        Assert.Null(await DockerApiClient.ResolveIpv4Async("::1"));
+
+    [Fact]
+    public async Task A_resolvable_name_becomes_its_ipv4_address() =>
+        // localhost is the one name every test machine resolves without real DNS.
+        Assert.Equal("127.0.0.1", await DockerApiClient.ResolveIpv4Async("localhost"));
+
+    [Fact]
+    public async Task An_unresolvable_name_is_null_rather_than_an_exception() =>
+        Assert.Null(await DockerApiClient.ResolveIpv4Async("host name with spaces!"));
+
+    // -- Identity independent of the workstation ---------------------------------------
+
+    [Fact]
+    public void Two_workstations_discovering_the_same_engine_agree_on_every_id() {
+        List<DockerContainer> containers = DockerContainerParser.Parse(Fixture.Read("docker-containers.json"));
+        const string engineId = "e7c3a2d0-5a8f-4b2e-9c1d-2f6e8a9b0c3d";
+
+        // Same engine seed; everything the workstation contributes differs.
+        List<Service> fromLaptop = DockerServiceMapper.ToResources(containers, engineId, "nas01", "192.168.1.20");
+        List<Service> fromCi = DockerServiceMapper.ToResources(containers, engineId, "nas01", "10.0.0.9");
+
+        Assert.Equal(
+            fromLaptop.Select(s => s.DiscoveryId),
+            fromCi.Select(s => s.DiscoveryId));
+    }
+
+    // -- Preserving the user's links when the collector cannot see the host ------------
+
+    private static SystemResource StoredHost(string name) => new() {
+        Kind = SystemResource.KindLabel,
+        Name = name,
+        DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"),
+        Type = "baremetal",
+        Os = "Debian",
+        Cores = 12,
+        Ram = 63
+    };
+
+    private static Service ServiceRunningOn(string host, string container = "jellyfin") => new() {
+        Kind = Service.KindLabel,
+        Name = container,
+        DiscoveryId = DiscoveryId.Create(DiscoveryId.DockerScheme, $"engine-a/{container}"),
+        RunsOn = [host]
+    };
+
+    [Fact]
+    public void A_dangling_runs_on_keeps_the_stored_link_the_user_chose() {
+        // The host was renamed on the server; a remote collector still sends the
+        // hostname it sees, which no longer names anything.
+        List<Resource> existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")];
+        List<Resource> incoming = [ServiceRunningOn("nas01")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["storage-01"], incoming[0].RunsOn);
+    }
+
+    [Fact]
+    public void A_genuine_move_to_a_documented_host_is_recorded() {
+        List<Resource> existing = [StoredHost("storage-01"), StoredHost2("pi01"), ServiceRunningOn("storage-01")];
+        List<Resource> incoming = [ServiceRunningOn("pi01")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["pi01"], incoming[0].RunsOn);
+    }
+
+    [Fact]
+    public void A_move_to_a_host_arriving_in_the_same_payload_is_recorded() {
+        // Local discovery sends the host along; its (possibly renamed) name anchors
+        // the services, so nothing here should fall back to the stored link.
+        List<Resource> existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")];
+        List<Resource> incoming = [StoredHost2("pi01"), ServiceRunningOn("pi01")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["pi01"], incoming[1].RunsOn);
+    }
+
+    [Fact]
+    public void A_service_seen_for_the_first_time_keeps_whatever_it_reports() {
+        // Nothing stored to preserve: the dangling name is still the best available.
+        List<Resource> existing = [StoredHost("storage-01")];
+        List<Resource> incoming = [ServiceRunningOn("nas01")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["nas01"], incoming[0].RunsOn);
+    }
+
+    [Fact]
+    public void A_stored_service_with_no_link_gains_the_reported_one() {
+        Service unparented = ServiceRunningOn("storage-01");
+        unparented.RunsOn = [];
+
+        List<Resource> existing = [unparented];
+        List<Resource> incoming = [ServiceRunningOn("nas01")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["nas01"], incoming[0].RunsOn);
+    }
+
+    /// <summary>A second stored host with its own identity.</summary>
+    private static SystemResource StoredHost2(string name) {
+        SystemResource host = StoredHost(name);
+        host.DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-b");
+
+        return host;
+    }
+}

+ 194 - 0
Tests.Discovery/SystemDiscoveryTests.cs

@@ -0,0 +1,194 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Host snapshot in, RackPeek YAML out. Nothing here touches the machine the tests
+///     run on, so a Linux host is inspected identically from macOS or Windows.
+/// </summary>
+public class SystemDiscoveryTests {
+    private static RawSystemSnapshot LinuxSnapshot(
+        string? machineId = "7f3c9a1e5b2d4f6081a3c5e7b9d1f3a5",
+        string? cgroup = null,
+        string? dmiVendor = "Dell Inc.",
+        string? dmiProduct = "PowerEdge R730",
+        string hostname = "nas01.lan") {
+        return new RawSystemSnapshot {
+            Hostname = hostname,
+            Cores = 12,
+            FallbackMemoryBytes = 1024L * 1024 * 1024,
+            OsReleaseFile = Fixture.Read("linux-os-release"),
+            MemInfoFile = Fixture.Read("linux-meminfo"),
+            MachineIdFile = machineId,
+            CgroupFile = cgroup ?? Fixture.Read("linux-cgroup-host"),
+            DmiVendor = dmiVendor,
+            DmiProduct = dmiProduct,
+            Nics = [
+                new NicFact("lo", true, true, false, "127.0.0.1"),
+                new NicFact("docker0", true, false, false, "172.17.0.1"),
+                new NicFact("eno1", true, false, true, "192.168.1.20")
+            ],
+            BlockDevices = [
+                new BlockDeviceFact("nvme0n1", 1_000_204_886_016, false),
+                new BlockDeviceFact("sda", 8_001_563_222_016, true),
+                new BlockDeviceFact("loop0", 67_108_864, false),
+                new BlockDeviceFact("sr0", 0, true)
+            ]
+        };
+    }
+
+    [Fact]
+    public void Linux_host_maps_onto_a_complete_system_resource() {
+        SystemFacts facts = SystemFactsParser.Parse(LinuxSnapshot());
+        SystemResource resource = SystemResourceMapper.ToResource(facts);
+
+        Assert.Equal("nas01", resource.Name);
+        Assert.Equal("Debian GNU/Linux 12 (bookworm)", resource.Os);
+        Assert.Equal("baremetal", resource.Type);
+        Assert.Equal(12, resource.Cores);
+        Assert.Equal("192.168.1.20", resource.Ip);
+        Assert.StartsWith("rpk1:sys:", resource.DiscoveryId);
+    }
+
+    [Fact]
+    public void Ram_comes_from_meminfo_which_reports_a_little_under_the_physical_total() {
+        // 65790000 kB is what a 64 GB machine reports once the kernel has taken its share.
+        // Reported as measured rather than rounded up to the DIMM size it was sold as.
+        SystemFacts facts = SystemFactsParser.Parse(LinuxSnapshot());
+
+        Assert.Equal(63, facts.RamGb);
+    }
+
+    [Fact]
+    public void Physical_disks_are_kept_and_kernel_devices_are_not() {
+        SystemFacts facts = SystemFactsParser.Parse(LinuxSnapshot());
+
+        Assert.Collection(facts.Drives,
+            nvme => {
+                Assert.Equal("nvme", nvme.Type);
+                Assert.Equal(932, nvme.SizeGb);
+            },
+            hdd => {
+                Assert.Equal("hdd", hdd.Type);
+                Assert.Equal(7452, hdd.SizeGb);
+            });
+    }
+
+    [Fact]
+    public void Routable_interface_wins_over_loopback_and_docker_bridge() =>
+        Assert.Equal("192.168.1.20", SystemFactsParser.Parse(LinuxSnapshot()).Ip);
+
+    [Fact]
+    public void Falls_back_to_a_real_interface_when_none_advertises_a_gateway() {
+        RawSystemSnapshot snapshot = LinuxSnapshot() with {
+            Nics = [
+                new NicFact("lo", true, true, false, "127.0.0.1"),
+                new NicFact("docker0", true, false, false, "172.17.0.1"),
+                new NicFact("eno1", true, false, false, "192.168.1.20")
+            ]
+        };
+
+        Assert.Equal("192.168.1.20", SystemFactsParser.Parse(snapshot).Ip);
+    }
+
+    [Theory]
+    [InlineData("QEMU", "Standard PC (i440FX + PIIX, 1996)", "vm")]
+    [InlineData("VMware, Inc.", "VMware Virtual Platform", "vm")]
+    [InlineData("Microsoft Corporation", "Virtual Machine", "vm")]
+    [InlineData("innotek GmbH", "VirtualBox", "vm")]
+    [InlineData("Dell Inc.", "PowerEdge R730", "baremetal")]
+    [InlineData("Supermicro", "X11SSH-F", "baremetal")]
+    public void Virtualisation_is_read_from_dmi(string vendor, string product, string expected) {
+        RawSystemSnapshot snapshot = LinuxSnapshot(dmiVendor: vendor, dmiProduct: product);
+
+        Assert.Equal(expected, SystemFactsParser.Parse(snapshot).Type);
+    }
+
+    [Fact]
+    public void A_container_is_detected_from_its_cgroup() {
+        RawSystemSnapshot snapshot = LinuxSnapshot(cgroup: Fixture.Read("linux-cgroup-container"));
+
+        Assert.Equal("container", SystemFactsParser.Parse(snapshot).Type);
+    }
+
+    [Fact]
+    public void A_container_does_not_claim_the_host_disks_it_can_see() {
+        // /sys/block inside a container shows the machine underneath it.
+        RawSystemSnapshot snapshot = LinuxSnapshot(cgroup: Fixture.Read("linux-cgroup-container"));
+
+        SystemFacts facts = SystemFactsParser.Parse(snapshot);
+
+        Assert.Equal("container", facts.Type);
+        Assert.Empty(facts.Drives);
+    }
+
+    [Fact]
+    public void A_container_is_detected_from_the_dockerenv_marker() {
+        RawSystemSnapshot snapshot = LinuxSnapshot() with { DockerEnvPresent = true };
+
+        Assert.Equal("container", SystemFactsParser.Parse(snapshot).Type);
+    }
+
+    [Fact]
+    public void Type_is_one_of_the_values_the_schema_accepts() {
+        SystemFacts facts = SystemFactsParser.Parse(LinuxSnapshot());
+
+        Assert.Contains(facts.Type, SystemResource.ValidSystemTypes);
+    }
+
+    [Fact]
+    public void Macos_reads_its_identity_out_of_ioreg() {
+        var uuid = MacSystemProbe.ParsePlatformUuid(Fixture.Read("macos-ioreg.txt"));
+
+        Assert.Equal("5C8E1F2A-3B4D-5E6F-7A8B-9C0D1E2F3A4B", uuid);
+    }
+
+    [Fact]
+    public void Macos_host_maps_without_disks_rather_than_guessing_at_them() {
+        var snapshot = new RawSystemSnapshot {
+            Hostname = "tims-macbook-pro.local",
+            Cores = 14,
+            OsName = "macOS 15.7.3",
+            MemoryBytes = 51_539_607_552,
+            PlatformUuid = "5C8E1F2A-3B4D-5E6F-7A8B-9C0D1E2F3A4B",
+            Nics = [new NicFact("en0", true, false, true, "10.0.20.157")]
+        };
+
+        SystemResource resource = SystemResourceMapper.ToResource(SystemFactsParser.Parse(snapshot));
+
+        Assert.Equal("tims-macbook-pro", resource.Name);
+        Assert.Equal("macOS 15.7.3", resource.Os);
+        Assert.Equal(48, resource.Ram);
+        Assert.Equal("baremetal", resource.Type);
+        Assert.Null(resource.Drives);
+    }
+
+    [Fact]
+    public void A_hypervisor_flag_on_macos_means_the_host_is_a_guest() {
+        var snapshot = new RawSystemSnapshot {
+            Hostname = "vm",
+            Cores = 4,
+            OsName = "macOS 15.7.3",
+            MemoryBytes = 8_589_934_592,
+            HypervisorPresent = true
+        };
+
+        Assert.Equal("vm", SystemFactsParser.Parse(snapshot).Type);
+    }
+
+    [Fact]
+    public void An_explicit_name_beats_the_hostname() {
+        SystemFacts facts = SystemFactsParser.Parse(LinuxSnapshot());
+
+        Assert.Equal("storage-01", SystemResourceMapper.ToResource(facts, "storage-01").Name);
+    }
+
+    [Fact]
+    public void Output_conforms_to_the_published_schema() {
+        SystemFacts facts = SystemFactsParser.Parse(LinuxSnapshot());
+
+        Fixture.AssertConformsToSchema(
+            DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(facts)]));
+    }
+}

+ 43 - 0
Tests.Discovery/Tests.Discovery.csproj

@@ -0,0 +1,43 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net10.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+        <IsPackable>false</IsPackable>
+    </PropertyGroup>
+
+    <!-- Discovery is the one part of RackPeek that runs on the machines being
+         inventoried rather than on the RackPeek host, so these tests are kept in
+         their own project and run on every OS in the CI matrix. Nothing here may
+         touch the host it runs on: probes are the untested seam, everything else
+         is driven from the captured fixtures below. -->
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1"/>
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12"/>
+        <PackageReference Include="JsonSchema.Net" Version="9.4.0"/>
+        <PackageReference Include="YamlDotNet" Version="18.1.0"/>
+        <PackageReference Include="xunit" Version="2.9.3"/>
+        <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0"/>
+        <PackageReference Include="coverlet.collector" Version="10.0.1">
+            <PrivateAssets>all</PrivateAssets>
+            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+        </PackageReference>
+    </ItemGroup>
+
+    <ItemGroup>
+        <Using Include="Xunit"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\RackPeek.Domain\RackPeek.Domain.csproj"/>
+        <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest"/>
+        <None Include="..\schemas\**\*.json" Link="schemas\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
+    </ItemGroup>
+
+</Project>

+ 13 - 7
Tests/Api/ApiTestBase.cs

@@ -9,26 +9,32 @@ using Xunit.Abstractions;
 namespace Tests.Api;
 namespace Tests.Api;
 
 
 public abstract class ApiTestBase : IDisposable {
 public abstract class ApiTestBase : IDisposable {
-    private readonly string _tempDir;
+    /// <summary>
+    ///     The config directory the server is pointed at. Writing to it before the first
+    ///     call to <see cref="CreateClient" /> seeds an inventory, because the host is
+    ///     not built until then.
+    /// </summary>
+    protected readonly string TempDir;
     protected readonly WebApplicationFactory<Program> Factory;
     protected readonly WebApplicationFactory<Program> Factory;
     protected readonly ITestOutputHelper Output;
     protected readonly ITestOutputHelper Output;
 
 
     protected ApiTestBase(ITestOutputHelper output) {
     protected ApiTestBase(ITestOutputHelper output) {
         Output = output;
         Output = output;
 
 
-        _tempDir = Path.Combine(
+        TempDir = Path.Combine(
             Path.GetTempPath(),
             Path.GetTempPath(),
             "rackpeek-tests",
             "rackpeek-tests",
             Guid.NewGuid().ToString());
             Guid.NewGuid().ToString());
 
 
-        Directory.CreateDirectory(_tempDir);
+        Directory.CreateDirectory(TempDir);
 
 
         Factory = new WebApplicationFactory<Program>()
         Factory = new WebApplicationFactory<Program>()
             .WithWebHostBuilder(builder => {
             .WithWebHostBuilder(builder => {
-                builder.UseSetting("RPK_YAML_DIR", _tempDir);
+                builder.UseSetting("RPK_YAML_DIR", TempDir);
 
 
                 builder.ConfigureAppConfiguration((context, configBuilder) => {
                 builder.ConfigureAppConfiguration((context, configBuilder) => {
                     var baseConfig = new Dictionary<string, string?> {
                     var baseConfig = new Dictionary<string, string?> {
+                        ["RPK_YAML_DIR"] = TempDir,
                         ["RPK_API_KEY"] = "test-key-123"
                         ["RPK_API_KEY"] = "test-key-123"
                     };
                     };
 
 
@@ -41,7 +47,7 @@ public abstract class ApiTestBase : IDisposable {
                     CliBootstrap.RegisterInternals(
                     CliBootstrap.RegisterInternals(
                             new ServiceCollection(),
                             new ServiceCollection(),
                             configuration,
                             configuration,
-                            _tempDir,
+                            TempDir,
                             "test.yaml")
                             "test.yaml")
                         .GetAwaiter()
                         .GetAwaiter()
                         .GetResult();
                         .GetResult();
@@ -63,8 +69,8 @@ public abstract class ApiTestBase : IDisposable {
         try {
         try {
             Factory.Dispose();
             Factory.Dispose();
 
 
-            if (Directory.Exists(_tempDir))
-                Directory.Delete(_tempDir, true);
+            if (Directory.Exists(TempDir))
+                Directory.Delete(TempDir, true);
         }
         }
         catch {
         catch {
             // ignore cleanup issues
             // ignore cleanup issues

+ 87 - 0
Tests/Api/InventoryEndpointStartupTests.cs

@@ -0,0 +1,87 @@
+using System.Net.Http.Json;
+using RackPeek.Domain.Api;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+/// <summary>
+///     The inventory API is reached without a browser — by scripts, by CI, and by
+///     <c>rpk discover --push</c> on a timer. It therefore has to work on a server that
+///     nobody has opened a page on yet.
+/// </summary>
+public class InventoryEndpointStartupTests(ITestOutputHelper output) : ApiTestBase(output) {
+    private const string _existingConfig = """
+                                           version: 3
+                                           resources:
+                                             - kind: Server
+                                               name: hand-written-server
+                                               notes: documented by hand years ago
+                                           connections: []
+                                           """;
+
+    [Fact]
+    public async Task The_first_request_after_a_restart_does_not_destroy_the_existing_inventory() {
+        var configPath = Path.Combine(TempDir, "config.yaml");
+        await File.WriteAllTextAsync(configPath, _existingConfig);
+
+        // The very first thing to touch this server is an API call, with no Blazor
+        // circuit having ever initialised and therefore no implicit load.
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new {
+            yaml = """
+                   version: 3
+                   resources:
+                     - kind: Server
+                       name: newly-discovered-box
+                   """,
+            mode = "Merge"
+        });
+
+        response.EnsureSuccessStatusCode();
+
+        var stored = await File.ReadAllTextAsync(configPath);
+
+        Assert.Contains("hand-written-server", stored);
+        Assert.Contains("documented by hand years ago", stored);
+        Assert.Contains("newly-discovered-box", stored);
+    }
+
+    [Fact]
+    public async Task An_existing_resource_is_updated_rather_than_re_added_on_a_cold_server() {
+        await File.WriteAllTextAsync(Path.Combine(TempDir, "config.yaml"), _existingConfig);
+
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new {
+            yaml = """
+                   version: 3
+                   resources:
+                     - kind: Server
+                       name: hand-written-server
+                       notes: updated
+                   """,
+            mode = "Merge"
+        });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Empty(result!.Added);
+        Assert.Equal(["hand-written-server"], result.Updated);
+    }
+
+    [Fact]
+    public async Task An_unreadable_config_does_not_stop_the_server_starting() {
+        // The web UI is how someone fixes a broken config, so it has to come up.
+        await File.WriteAllTextAsync(
+            Path.Combine(TempDir, "config.yaml"),
+            "version: 3\nresources:\n  - kind: Server\n    name: [unterminated\n");
+
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.GetAsync("/health");
+
+        response.EnsureSuccessStatusCode();
+        Assert.Equal("rackpeek", await response.Content.ReadAsStringAsync());
+    }
+}

+ 2 - 2
Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs

@@ -35,7 +35,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap01' updated.\n", output);
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: AccessPoint
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
                        model: Unifi-U6-Lite
@@ -56,7 +56,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap02' updated.\n", output);
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: AccessPoint
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
                        model: Unifi-U6-Lite

+ 2 - 2
Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs

@@ -41,7 +41,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw01' updated.\n", output);
         Assert.Equal("Firewall 'fw01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Firewall
                      - kind: Firewall
                        model: Fortinet FG-60F
                        model: Fortinet FG-60F
@@ -65,7 +65,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw02' updated.\n", output);
         Assert.Equal("Firewall 'fw02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Firewall
                      - kind: Firewall
                        model: Fortinet FG-60F
                        model: Fortinet FG-60F

+ 2 - 2
Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs

@@ -37,7 +37,7 @@ public class OtherWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Equal("Other hardware 'radio01' updated.\n", output);
         Assert.Equal("Other hardware 'radio01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Other
                      - kind: Other
                        model: Building-Bridge-XG
                        model: Building-Bridge-XG
@@ -59,7 +59,7 @@ public class OtherWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Equal("Other hardware 'bridge01' updated.\n", output);
         Assert.Equal("Other hardware 'bridge01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Other
                      - kind: Other
                        model: Building-Bridge-XG
                        model: Building-Bridge-XG

+ 2 - 2
Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs

@@ -41,7 +41,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt01' updated.\n", output);
         Assert.Equal("Router 'rt01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Router
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
                        model: Ubiquiti EdgeRouter 4
@@ -65,7 +65,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt02' updated.\n", output);
         Assert.Equal("Router 'rt02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Router
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
                        model: Ubiquiti EdgeRouter 4

+ 1 - 1
Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs

@@ -40,7 +40,7 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Server 'srv01' updated.\n", output);
         Assert.Equal("Server 'srv01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        ram:
                        ram:

+ 1 - 1
Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs

@@ -46,7 +46,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
         outputHelper.WriteLine(yaml);
         outputHelper.WriteLine(yaml);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: System
                      - kind: System
                        name: sys01
                        name: sys01

+ 2 - 2
Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

@@ -42,7 +42,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw01' updated.\n", output);
         Assert.Equal("Switch 'sw01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Switch
                      - kind: Switch
                        model: Netgear GS108
                        model: Netgear GS108
@@ -67,7 +67,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Switch
                      - kind: Switch
                        model: Netgear GS108
                        model: Netgear GS108

+ 2 - 2
Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs

@@ -46,7 +46,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         outputHelper.WriteLine(yaml);
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        name: proxmox-node01
                        name: proxmox-node01
@@ -158,7 +158,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         // Assert resulting YAML
         // Assert resulting YAML
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        name: proxmox-node01
                        name: proxmox-node01

+ 2 - 2
Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs

@@ -37,7 +37,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups01' updated.\n", output);
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Ups
                      - kind: Ups
                        model: APC-SmartUPS-1500
                        model: APC-SmartUPS-1500
@@ -59,7 +59,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups02' updated.\n", output);
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      resources:
                      - kind: Ups
                      - kind: Ups
                        model: APC-SmartUPS-1500
                        model: APC-SmartUPS-1500

+ 34 - 0
Tests/TestConfigs/v4/01-server.yaml

@@ -0,0 +1,34 @@
+version: 4
+resources:
+  - kind: Server
+    name: example-server
+    tags:
+      - production
+      - compute
+    notes: Primary hypervisor host
+    runsOn:
+      - rack-a1
+      - rack-a2
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
+    cpus:
+      - model: AMD EPYC 7302P
+        cores: 16
+        threads: 32
+    drives:
+      - type: nvme
+        size: 1024
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 4000
+        vram: 16
+    ports:
+      - type: rj45
+        speed: 1
+        count: 2
+      - type: sfp+
+        speed: 10
+        count: 2

+ 17 - 0
Tests/TestConfigs/v4/02-firewall.yaml

@@ -0,0 +1,17 @@
+version: 4
+resources:
+  - kind: Firewall
+    name: example-firewall
+    model: Netgate-6100
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp+
+        speed: 10
+        count: 2
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 17 - 0
Tests/TestConfigs/v4/03-router.yaml

@@ -0,0 +1,17 @@
+version: 4
+resources:
+  - kind: Router
+    name: example-router
+    model: Ubiquiti-ER-4
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp
+        speed: 10
+        count: 1
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 17 - 0
Tests/TestConfigs/v4/04-switch.yaml

@@ -0,0 +1,17 @@
+version: 4
+resources:
+  - kind: Switch
+    name: example-switch
+    model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 12
+      - type: sfp+
+        speed: 10
+        count: 4
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 15 - 0
Tests/TestConfigs/v4/05-accesspoint.yaml

@@ -0,0 +1,15 @@
+version: 4
+resources:
+  - kind: AccessPoint
+    name: example-accesspoint
+    tags:
+      - wireless
+    model: UniFi-U6-Pro
+    speed: 2.5
+    runsOn:
+      - rack-a1
+      - rack-a2
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1

+ 11 - 0
Tests/TestConfigs/v4/06-ups.yaml

@@ -0,0 +1,11 @@
+version: 4
+resources:
+  - kind: Ups
+    name: example-ups
+    tags:
+      - power
+    model: APC-SmartUPS-2200
+    va: 2200
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 25 - 0
Tests/TestConfigs/v4/07-desktop.yaml

@@ -0,0 +1,25 @@
+version: 4
+resources:
+  - kind: Desktop
+    name: example-desktop
+    notes: Engineering workstation
+    ram:
+      size: 64
+      mts: 3600
+    cpus:
+      - model: Intel Core i9-13900K
+        cores: 24
+        threads: 32
+    drives:
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 4090
+        vram: 24
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 18 - 0
Tests/TestConfigs/v4/08-laptop.yaml

@@ -0,0 +1,18 @@
+version: 4
+resources:
+  - kind: Laptop
+    name: example-laptop
+    notes: Developer machine
+    ram:
+      size: 32
+      mts: 5200
+    cpus:
+      - model: Intel Core i7-1260P
+        cores: 12
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1024
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 13 - 0
Tests/TestConfigs/v4/09-service.yaml

@@ -0,0 +1,13 @@
+version: 4
+resources:
+  - kind: Service
+    name: example-service
+    discoveryId: rpk1:docker:1359175a57049554
+    runsOn:
+      - rack-a1
+      - rack-a2
+    network:
+      ip: 192.168.1.10
+      port: 8080
+      protocol: TCP
+      url: http://example.local:8080

+ 17 - 0
Tests/TestConfigs/v4/10-system.yaml

@@ -0,0 +1,17 @@
+version: 4
+resources:
+  - kind: System
+    name: example-system
+    discoveryId: rpk1:sys:a3f9c2e1b8d47e60
+    notes: Virtual machine instance
+    runsOn:
+      - rack-a1
+      - rack-a2
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    ip: 10.0.20.10
+    drives:
+      - size: 128
+      - size: 256

+ 522 - 0
Tests/TestConfigs/v4/11-demo-config.yaml

@@ -0,0 +1,522 @@
+version: 4
+resources:
+  - kind: Server
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
+    cpus:
+      - model: AMD EPYC 7302P
+        cores: 16
+        threads: 32
+    drives:
+      - type: ssd
+        size: 1024
+      - type: ssd
+        size: 1024
+    ports:
+      - type: rj45
+        speed: 1
+        count: 2
+      - type: sfp+
+        speed: 10
+        count: 2
+    name: proxmox-node01
+  - kind: Server
+    ram:
+      size: 96
+      mts: 2666
+    ipmi: true
+    cpus:
+      - model: Intel Xeon Silver 4210
+        cores: 10
+        threads: 20
+    drives:
+      - type: ssd
+        size: 1024
+      - type: hdd
+        size: 4096
+    ports:
+      - type: rj45
+        speed: 1
+        count: 2
+      - type: sfp+
+        speed: 10
+        count: 1
+    name: proxmox-node02
+  - kind: Server
+    ram:
+      size: 64
+      mts: 2666
+    ipmi: true
+    cpus:
+      - model: Intel Xeon E-2236
+        cores: 6
+        threads: 12
+    drives:
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+      - type: sfp+
+        speed: 10
+        count: 1
+    name: truenas-storage
+  - kind: Firewall
+    model: Netgate-6100
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp+
+        speed: 10
+        count: 2
+    name: pfsense-fw
+  - kind: Router
+    model: Ubiquiti-ER-4
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp
+        speed: 10
+        count: 1
+    name: core-router
+  - kind: Switch
+    model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 12
+      - type: rj45
+        speed: 2.5
+        count: 8
+      - type: sfp+
+        speed: 10
+        count: 4
+    name: core-switch
+  - kind: Switch
+    model: UniFi-USW-16-PoE
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 16
+      - type: sfp
+        speed: 1
+        count: 2
+    name: access-switch
+  - kind: AccessPoint
+    model: UniFi-U6-Pro
+    speed: 2.5
+    name: lounge-ap
+  - kind: Ups
+    model: APC-SmartUPS-2200
+    va: 2200
+    name: rack-ups
+  - kind: Desktop
+    ram:
+      size: 64
+      mts: 3600
+    cpus:
+      - model: AMD Ryzen 9 5900X
+        cores: 12
+        threads: 24
+    drives:
+      - type: ssd
+        size: 1024
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 3080
+        vram: 10
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    name: workstation-linux
+  - kind: Desktop
+    ram:
+      size: 32
+      mts: 3200
+    cpus:
+      - model: Intel Core i7-12700K
+        cores: 12
+        threads: 20
+    drives:
+      - type: ssd
+        size: 1024
+    gpus:
+      - model: NVIDIA RTX 3070
+        vram: 8
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    name: gaming-pc
+  - kind: Laptop
+    ram:
+      size: 32
+      mts: 5200
+    cpus:
+      - model: Intel Core i7-1260P
+        cores: 12
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1024
+    name: dev-laptop
+  - kind: Service
+    network:
+      ip: 192.168.0.10
+      port: 8123
+      protocol: TCP
+      url: http://homeassistant.lan:8123
+    name: home-assistant
+    runsOn:
+      - vm-home-assistant
+  - kind: Service
+    network:
+      ip: 192.168.0.20
+      port: 32400
+      protocol: TCP
+      url: http://plex.lan:32400
+    name: plex
+    runsOn:
+      - vm-media-server
+      - vm-home-assistant
+  - kind: Service
+    network:
+      ip: 192.168.0.21
+      port: 8096
+      protocol: TCP
+      url: http://jellyfin.lan:8096
+    name: jellyfin
+    runsOn:
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.22
+      port: 8080
+      protocol: TCP
+      url: http://immich.lan:8080
+    name: immich
+    runsOn:
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.30
+      port: 443
+      protocol: TCP
+      url: https://truenas.lan
+    name: truenas-webui
+    runsOn:
+      - truenas-core-os
+  - kind: Service
+    network:
+      ip: 192.168.0.31
+      port: 9000
+      protocol: TCP
+      url: http://minio.lan:9000
+    name: minio
+    runsOn:
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.40
+      port: 9090
+      protocol: TCP
+      url: http://prometheus.lan:9090
+    name: prometheus
+    runsOn:
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.41
+      port: 3000
+      protocol: TCP
+      url: http://grafana.lan:3000
+    name: grafana
+  - kind: Service
+    network:
+      ip: 192.168.0.42
+      port: 9093
+      protocol: TCP
+      url: http://alertmanager.lan:9093
+    name: alertmanager
+  - kind: Service
+    network:
+      ip: 192.168.0.50
+      port: 3001
+      protocol: TCP
+      url: http://git.lan:3001
+    name: gitea
+    runsOn:
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.51
+      port: 5000
+      protocol: TCP
+      url: http://registry.lan:5000
+    name: docker-registry
+    runsOn:
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.52
+      port: 9000
+      protocol: TCP
+      url: http://portainer.lan:9000
+    name: portainer
+    runsOn:
+      - vm-monitoring
+      - vm-logging
+  - kind: Service
+    network:
+      ip: 192.168.0.53
+      port: 80
+      protocol: TCP
+      url: http://pihole.lan
+    name: pihole
+  - kind: Service
+    network:
+      ip: 192.168.0.1
+      port: 443
+      protocol: TCP
+      url: https://firewall.lan
+    name: firewall-webui
+    runsOn:
+      - firewall-os
+  - kind: Service
+    network:
+      ip: 192.168.0.254
+      port: 443
+      protocol: TCP
+      url: https://router.lan
+    name: router-webui
+    runsOn:
+      - router-os
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 16
+    ram: 128
+    ip: 10.0.20.10
+    drives:
+      - size: 1024
+      - size: 1024
+    name: proxmox-cluster-node01
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 10
+    ram: 96
+    drives:
+      - size: 1024
+      - size: 4096
+    name: proxmox-cluster-node02
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: Baremetal
+    os: truenas
+    cores: 6
+    ram: 64
+    drives:
+      - size: 8192
+      - size: 8192
+      - size: 8192
+      - size: 8192
+    name: truenas-core-os
+    runsOn:
+      - truenas-storage
+  - kind: System
+    type: Baremetal
+    os: idrac
+    cores: 1
+    ram: 1
+    name: ipmi-proxmox-node01
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: Baremetal
+    os: ipmi
+    cores: 1
+    ram: 1
+    name: ipmi-proxmox-node02
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: Baremetal
+    os: ipmi
+    cores: 1
+    ram: 1
+    name: ipmi-truenas-storage
+    runsOn:
+      - truenas-storage
+  - kind: System
+    type: Baremetal
+    os: pfsense
+    cores: 4
+    ram: 8
+    drives:
+      - size: 32
+    name: firewall-os
+    runsOn:
+      - pfsense-fw
+  - kind: System
+    type: Baremetal
+    os: edgeos
+    cores: 4
+    ram: 4
+    drives:
+      - size: 4
+    name: router-os
+    runsOn:
+      - core-router
+  - kind: System
+    type: Baremetal
+    os: unifi-os
+    cores: 2
+    ram: 2
+    drives:
+      - size: 8
+    name: unifi-core-switch-os
+    runsOn:
+      - core-switch
+  - kind: System
+    type: Baremetal
+    os: unifi-os
+    cores: 2
+    ram: 2
+    drives:
+      - size: 8
+    name: unifi-access-switch-os
+    runsOn:
+      - access-switch
+  - kind: System
+    type: Baremetal
+    os: unifi-firmware
+    cores: 2
+    ram: 1
+    drives:
+      - size: 4
+    name: unifi-lounge-ap-os
+    runsOn:
+      - lounge-ap
+  - kind: System
+    type: VM
+    os: hassos
+    cores: 2
+    ram: 4
+    drives:
+      - size: 64
+    name: vm-home-assistant
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    drives:
+      - size: 500
+    name: vm-media-server
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: VM
+    os: debian-12
+    cores: 2
+    ram: 4
+    drives:
+      - size: 64
+    name: vm-monitoring
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: VM
+    os: test
+    cores: 1
+    ram: 1
+    name: test-system
+    runsOn:
+      - proxmox-node01
+  - kind: Service
+    name: test-service
+    network:
+      ip: 192.168.0.250
+      port: 8080
+      protocol: TCP
+    runsOn:
+      - test-system
+  - kind: Service
+    name: test-service-no-host
+    network:
+      ip: 192.168.0.251
+      port: 8080
+      protocol: TCP
+  - kind: Service
+    name: test-ha-service
+    network:
+      ip: 192.168.0.252
+      port: 8080
+      protocol: TCP
+    runsOn:
+      - test-system
+      - proxmox-cluster-node01
+  - kind: AccessPoint
+    name: lounge-ap
+    model: UniFi-U6-Pro
+    speed: 2.5
+    ports:
+      - type: rj45
+        speed: 2.5
+        count: 1
+connections:
+  - a:
+      resource: core-router
+      portGroup: 0
+      portIndex: 0
+    b:
+      resource: pfsense-fw
+      portGroup: 0
+      portIndex: 0
+
+  - a:
+      resource: pfsense-fw
+      portGroup: 1
+      portIndex: 0
+    b:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 0
+
+  - a:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 1
+    b:
+      resource: access-switch
+      portGroup: 1
+      portIndex: 0
+    label: router-firewall
+    notes: internal uplink

Некоторые файлы не были показаны из-за большого количества измененных файлов