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

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 2 часов назад
Родитель
С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
 
 
+  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:
     name: CLI Tests
     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)
 
+* 
+  [**Auto Discovery Guide**](https://timmoth.github.io/RackPeek/docs/discovery-guide)
+
 * 
   [**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.Text.Json;
 using System.Text.Json.Serialization;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Domain.Resources;
@@ -62,6 +63,10 @@ public class UpsertInventoryUseCase(
         List<Resource>? incomingResources = incomingRoot.Resources;
         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
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
             .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;
 
 public static class ThrowIfInvalid {
+    public const int MaxResourceNameLength = 50;
+    public const int MaxLabelValueLength = 200;
+
     public static void ResourceName(string name) {
         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) {
@@ -18,7 +21,7 @@ public static class ThrowIfInvalid {
 
     public static void LabelValue(string value) {
         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) {

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

@@ -24,7 +24,8 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         {
             EnsureSchemaVersionExists,
             ConvertScalarRunsOnToList,
-            ConvertNicsToPortsV3
+            ConvertNicsToPortsV3,
+            AllowDiscoveryIdsV4
         };
 
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
@@ -167,5 +168,18 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         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
 }

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

@@ -1,6 +1,7 @@
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.Diagnostics;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.Connections;
@@ -24,6 +25,14 @@ public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public List<Resource> Resources { 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(
@@ -125,9 +134,17 @@ public sealed class YamlResourceCollection(
 
         await resourceCollection.FileLock.WaitAsync();
         try {
+            await EnsureLoadedAsync();
+
             YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
 
             List<Resource> incomingResources = incomingRoot.Resources ?? new List<Resource>();
+
+            DiscoveryIdResolver.ResolveNames(
+                resourceCollection.Resources,
+                incomingResources,
+                incomingRoot.Connections);
+
             List<Resource> merged = ResourceCollectionMerger.Merge(
                 resourceCollection.Resources,
                 incomingResources,
@@ -200,27 +217,45 @@ public sealed class YamlResourceCollection(
         // "Index was outside the bounds of the array" out of List.Clear.
         await resourceCollection.FileLock.WaitAsync();
         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) {
@@ -353,6 +388,8 @@ public sealed class YamlResourceCollection(
     private async Task UpdateWithLockAsync(Action<List<Resource>> action) {
         await resourceCollection.FileLock.WaitAsync();
         try {
+            await EnsureLoadedAsync();
+
             action(resourceCollection.Resources);
 
             // 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) {
         await resourceCollection.FileLock.WaitAsync();
         try {
+            await EnsureLoadedAsync();
+
             action(resourceCollection.Connections);
 
             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; }
 
+    /// <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 Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }

+ 7 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -1,6 +1,7 @@
 using System.Reflection;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
@@ -72,6 +73,12 @@ public static class ServiceCollectionExtensions {
 
     public static IServiceCollection AddUseCases(
         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(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
         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);
         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);
     }
 }

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

@@ -102,6 +102,9 @@
         {
           "$ref": "#/$defs/ups"
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
           "$ref": "#/$defs/desktop"
         },
@@ -582,6 +585,28 @@
       ],
       "unevaluatedProperties": false
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
       "allOf": [
         {
@@ -639,7 +664,8 @@
               ]
             },
             "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": {
               "type": "string"
@@ -664,4 +690,4 @@
       "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();
 
+        // 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()) {
             app.UseExceptionHandler("/Error");
             app.UseHsts();

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

@@ -102,6 +102,9 @@
         {
           "$ref": "#/$defs/ups"
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
           "$ref": "#/$defs/desktop"
         },
@@ -582,6 +585,28 @@
       ],
       "unevaluatedProperties": false
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
       "allOf": [
         {
@@ -639,7 +664,8 @@
               ]
             },
             "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": {
               "type": "string"
@@ -664,4 +690,4 @@
       "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
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2e", "Tests.E2e\Tests.E2e.csproj", "{47288A74-AD2C-4E5A-BD88-45648EA9029E}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Discovery", "Tests.Discovery\Tests.Discovery.csproj", "{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		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}.Release|Any CPU.ActiveCfg = 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
 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.Nics;
 using Shared.Rcl.Commands.Desktops.Rename;
+using Shared.Rcl.Commands.Discovery;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls.Labels;
@@ -90,15 +91,28 @@ public static class CliBootstrap {
         services.AddSingleton(configuration);
         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)
             ? yamlDir
             : Path.Combine(appBasePath, yamlDir);
 
-        Directory.CreateDirectory(resolvedYamlDir);
-
         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.AddScoped<RackPeekConfigMigrationDeserializer>();
@@ -113,13 +127,20 @@ public static class CliBootstrap {
             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);
 
         // Infrastructure
         services.AddYamlRepos();
 
-        // Application
+        // Application (also registers the discovery probes, for every host)
         services.AddUseCases();
         services.AddCommands();
     }
@@ -728,6 +749,25 @@ public static class CliBootstrap {
             // ----------------------------
             // 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 => {
                 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
       - [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
+  - [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
     - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
   - [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:
     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:
     -h, --help       Prints help information   
     -v, --version    Prints version information
@@ -22,6 +29,7 @@ COMMANDS:
     desktops        Manage desktop computers and their components              
     laptops         Manage Laptop computers and their components               
     services        Manage services and their configurations                   
+    discover        Read infrastructure and emit it as RackPeek YAML           
     ansible         Generate and manage Ansible inventory                      
     ssh             Generate SSH configuration from infrastructure             
     hosts           Generate a hosts file from infrastructure                  
@@ -3725,6 +3733,119 @@ OPTIONS:
     -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`
 ```
 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",
   "git-integration.md",
   "ansible-generator-guide.md",
+  "discovery-guide.md",
   "docker-gen-guide.md",
   "cli-commands.md",
   "cli-commands-index.md",
@@ -11,4 +12,4 @@
   "hosts-file-export.md",
   "inventory-api.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;
 
 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 ITestOutputHelper Output;
 
     protected ApiTestBase(ITestOutputHelper output) {
         Output = output;
 
-        _tempDir = Path.Combine(
+        TempDir = Path.Combine(
             Path.GetTempPath(),
             "rackpeek-tests",
             Guid.NewGuid().ToString());
 
-        Directory.CreateDirectory(_tempDir);
+        Directory.CreateDirectory(TempDir);
 
         Factory = new WebApplicationFactory<Program>()
             .WithWebHostBuilder(builder => {
-                builder.UseSetting("RPK_YAML_DIR", _tempDir);
+                builder.UseSetting("RPK_YAML_DIR", TempDir);
 
                 builder.ConfigureAppConfiguration((context, configBuilder) => {
                     var baseConfig = new Dictionary<string, string?> {
+                        ["RPK_YAML_DIR"] = TempDir,
                         ["RPK_API_KEY"] = "test-key-123"
                     };
 
@@ -41,7 +47,7 @@ public abstract class ApiTestBase : IDisposable {
                     CliBootstrap.RegisterInternals(
                             new ServiceCollection(),
                             configuration,
-                            _tempDir,
+                            TempDir,
                             "test.yaml")
                         .GetAwaiter()
                         .GetResult();
@@ -63,8 +69,8 @@ public abstract class ApiTestBase : IDisposable {
         try {
             Factory.Dispose();
 
-            if (Directory.Exists(_tempDir))
-                Directory.Delete(_tempDir, true);
+            if (Directory.Exists(TempDir))
+                Directory.Delete(TempDir, true);
         }
         catch {
             // 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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: AccessPoint
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: AccessPoint
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Firewall
                        model: Fortinet FG-60F
@@ -65,7 +65,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Firewall
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Other
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Other
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
@@ -65,7 +65,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Router
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Server
                        ram:

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

@@ -46,7 +46,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
         outputHelper.WriteLine(yaml);
 
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: System
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -67,7 +67,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Switch
                        model: Netgear GS108

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

@@ -46,7 +46,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Server
                        name: proxmox-node01
@@ -158,7 +158,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
         // Assert resulting YAML
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Server
                        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("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500
@@ -59,7 +59,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 3
+                     version: 4
                      resources:
                      - kind: Ups
                        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

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