From e5cce72c53742a830e4c546b25b97e2a5235083f Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Fri, 25 Sep 2026 20:06:50 +0100 Subject: [PATCH 01/29] Add auto discovery: rpk discover system / docker / proxmox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .github/workflows/test.yml | 30 + README.md | 3 + RackPeek.Domain/Api/UpsertInventoryUseCase.cs | 5 + .../Discovery/DiscoveryDocument.cs | 15 + RackPeek.Domain/Discovery/DiscoveryId.cs | 38 + .../Discovery/DiscoveryIdResolver.cs | 166 +++++ RackPeek.Domain/Discovery/DiscoveryNaming.cs | 90 +++ .../Discovery/DiscoveryPublisher.cs | 95 +++ RackPeek.Domain/Discovery/DockerApiClient.cs | 127 ++++ RackPeek.Domain/Discovery/DockerContainer.cs | 99 +++ RackPeek.Domain/Discovery/DockerEngineInfo.cs | 48 ++ .../Discovery/DockerServiceMapper.cs | 77 ++ RackPeek.Domain/Discovery/IDockerClient.cs | 21 + RackPeek.Domain/Discovery/IProxmoxClient.cs | 44 ++ RackPeek.Domain/Discovery/ISystemProbe.cs | 30 + RackPeek.Domain/Discovery/LinuxSystemProbe.cs | 67 ++ RackPeek.Domain/Discovery/MacSystemProbe.cs | 76 ++ RackPeek.Domain/Discovery/ProxmoxApiClient.cs | 193 +++++ RackPeek.Domain/Discovery/ProxmoxModels.cs | 427 +++++++++++ .../Discovery/ProxmoxResourceMapper.cs | 246 ++++++ RackPeek.Domain/Discovery/SystemFacts.cs | 69 ++ .../Discovery/SystemFactsParser.cs | 165 +++++ .../Discovery/SystemProbeCommon.cs | 118 +++ .../Discovery/SystemResourceMapper.cs | 35 + RackPeek.Domain/Helpers/ThrowIfInvalid.cs | 7 +- .../RackPeekConfigMigrationDeserializer.cs | 16 +- .../Yaml/YamlResourceCollection.cs | 71 +- RackPeek.Domain/Resources/Resource.cs | 7 + .../ServiceCollectionExtensions.cs | 7 + .../UseCases/CloneAccessPointUseCase.cs | 5 + .../wwwroot/schemas/v3/schema.v3.json | 30 +- .../wwwroot/schemas/v4/schema.v4.json | 701 ++++++++++++++++++ RackPeek.Web/Program.cs | 19 + .../wwwroot/schemas/v3/schema.v3.json | 30 +- .../wwwroot/schemas/v4/schema.v4.json | 701 ++++++++++++++++++ RackPeek.sln | 6 + Shared.Rcl/CliBootstrap.cs | 50 +- .../Discovery/DiscoverDockerCommand.cs | 123 +++ .../Discovery/DiscoverProxmoxCommand.cs | 141 ++++ .../Commands/Discovery/DiscoverSettings.cs | 46 ++ .../Discovery/DiscoverSystemCommand.cs | 41 + .../Commands/Discovery/DiscoveryOutput.cs | 94 +++ .../wwwroot/raw_docs/cli-commands-index.md | 4 + Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 121 +++ .../wwwroot/raw_docs/discovery-guide.md | 340 +++++++++ Shared.Rcl/wwwroot/raw_docs/docs-index.json | 3 +- Tests.Discovery/DiscoveryApiFixture.cs | 67 ++ Tests.Discovery/DiscoveryIdentityTests.cs | 148 ++++ Tests.Discovery/DiscoveryMergeTests.cs | 387 ++++++++++ Tests.Discovery/DockerDiscoveryTests.cs | 111 +++ Tests.Discovery/FailureModeTests.cs | 102 +++ Tests.Discovery/Fixture.cs | 94 +++ .../Fixtures/docker-containers.json | 71 ++ Tests.Discovery/Fixtures/docker-info.json | 20 + .../Fixtures/linux-cgroup-container | 1 + Tests.Discovery/Fixtures/linux-cgroup-host | 1 + Tests.Discovery/Fixtures/linux-meminfo | 7 + Tests.Discovery/Fixtures/linux-os-release | 9 + Tests.Discovery/Fixtures/macos-ioreg.txt | 8 + .../pve-cluster-status-standalone.json | 3 + .../Fixtures/pve-cluster-status.json | 5 + Tests.Discovery/Fixtures/pve-disks.json | 6 + .../Fixtures/pve-hardware-pci.json | 64 ++ .../Fixtures/pve-lxc-config-dhcp.json | 5 + Tests.Discovery/Fixtures/pve-lxc-config.json | 11 + Tests.Discovery/Fixtures/pve-lxc.json | 4 + Tests.Discovery/Fixtures/pve-node-status.json | 7 + Tests.Discovery/Fixtures/pve-nodes-full.json | 4 + Tests.Discovery/Fixtures/pve-nodes.json | 3 + .../Fixtures/pve-qemu-config-passthrough.json | 18 + Tests.Discovery/Fixtures/pve-qemu-config.json | 15 + Tests.Discovery/Fixtures/pve-qemu.json | 5 + Tests.Discovery/ProxmoxClientTests.cs | 142 ++++ Tests.Discovery/ProxmoxDiscoveryTests.cs | 518 +++++++++++++ Tests.Discovery/RealProbeTests.cs | 103 +++ Tests.Discovery/RemoteDockerDiscoveryTests.cs | 182 +++++ Tests.Discovery/SystemDiscoveryTests.cs | 194 +++++ Tests.Discovery/Tests.Discovery.csproj | 43 ++ Tests/Api/ApiTestBase.cs | 20 +- Tests/Api/InventoryEndpointStartupTests.cs | 87 +++ .../AccessPointWorkflowTests.cs | 4 +- .../FirewallTests/FirewallWorkflowTests.cs | 4 +- .../EndToEnd/OtherTests/OtherWorkflowTests.cs | 4 +- .../RouterTests/RouterWorkflowTests.cs | 4 +- .../ServerTests/ServerWorkflowTests.cs | 2 +- .../ServiceTests/ServiceWorkflowTests.cs | 2 +- .../SwitchTests/SwitchWorkflowTests.cs | 4 +- .../SystemTests/SystemWorkflowTests.cs | 4 +- Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs | 4 +- Tests/TestConfigs/v4/01-server.yaml | 34 + Tests/TestConfigs/v4/02-firewall.yaml | 17 + Tests/TestConfigs/v4/03-router.yaml | 17 + Tests/TestConfigs/v4/04-switch.yaml | 17 + Tests/TestConfigs/v4/05-accesspoint.yaml | 15 + Tests/TestConfigs/v4/06-ups.yaml | 11 + Tests/TestConfigs/v4/07-desktop.yaml | 25 + Tests/TestConfigs/v4/08-laptop.yaml | 18 + Tests/TestConfigs/v4/09-service.yaml | 13 + Tests/TestConfigs/v4/10-system.yaml | 17 + Tests/TestConfigs/v4/11-demo-config.yaml | 522 +++++++++++++ Tests/TestConfigs/v4/12-other.yaml | 8 + Tests/Yaml/SchemaTests.cs | 1 + justfile | 9 +- schemas/v4/schema.v4.json | 701 ++++++++++++++++++ 104 files changed, 8715 insertions(+), 54 deletions(-) create mode 100644 RackPeek.Domain/Discovery/DiscoveryDocument.cs create mode 100644 RackPeek.Domain/Discovery/DiscoveryId.cs create mode 100644 RackPeek.Domain/Discovery/DiscoveryIdResolver.cs create mode 100644 RackPeek.Domain/Discovery/DiscoveryNaming.cs create mode 100644 RackPeek.Domain/Discovery/DiscoveryPublisher.cs create mode 100644 RackPeek.Domain/Discovery/DockerApiClient.cs create mode 100644 RackPeek.Domain/Discovery/DockerContainer.cs create mode 100644 RackPeek.Domain/Discovery/DockerEngineInfo.cs create mode 100644 RackPeek.Domain/Discovery/DockerServiceMapper.cs create mode 100644 RackPeek.Domain/Discovery/IDockerClient.cs create mode 100644 RackPeek.Domain/Discovery/IProxmoxClient.cs create mode 100644 RackPeek.Domain/Discovery/ISystemProbe.cs create mode 100644 RackPeek.Domain/Discovery/LinuxSystemProbe.cs create mode 100644 RackPeek.Domain/Discovery/MacSystemProbe.cs create mode 100644 RackPeek.Domain/Discovery/ProxmoxApiClient.cs create mode 100644 RackPeek.Domain/Discovery/ProxmoxModels.cs create mode 100644 RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs create mode 100644 RackPeek.Domain/Discovery/SystemFacts.cs create mode 100644 RackPeek.Domain/Discovery/SystemFactsParser.cs create mode 100644 RackPeek.Domain/Discovery/SystemProbeCommon.cs create mode 100644 RackPeek.Domain/Discovery/SystemResourceMapper.cs create mode 100644 RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json create mode 100644 RackPeek.Web/wwwroot/schemas/v4/schema.v4.json create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverSettings.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverSystemCommand.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoveryOutput.cs create mode 100644 Shared.Rcl/wwwroot/raw_docs/discovery-guide.md create mode 100644 Tests.Discovery/DiscoveryApiFixture.cs create mode 100644 Tests.Discovery/DiscoveryIdentityTests.cs create mode 100644 Tests.Discovery/DiscoveryMergeTests.cs create mode 100644 Tests.Discovery/DockerDiscoveryTests.cs create mode 100644 Tests.Discovery/FailureModeTests.cs create mode 100644 Tests.Discovery/Fixture.cs create mode 100644 Tests.Discovery/Fixtures/docker-containers.json create mode 100644 Tests.Discovery/Fixtures/docker-info.json create mode 100644 Tests.Discovery/Fixtures/linux-cgroup-container create mode 100644 Tests.Discovery/Fixtures/linux-cgroup-host create mode 100644 Tests.Discovery/Fixtures/linux-meminfo create mode 100644 Tests.Discovery/Fixtures/linux-os-release create mode 100644 Tests.Discovery/Fixtures/macos-ioreg.txt create mode 100644 Tests.Discovery/Fixtures/pve-cluster-status-standalone.json create mode 100644 Tests.Discovery/Fixtures/pve-cluster-status.json create mode 100644 Tests.Discovery/Fixtures/pve-disks.json create mode 100644 Tests.Discovery/Fixtures/pve-hardware-pci.json create mode 100644 Tests.Discovery/Fixtures/pve-lxc-config-dhcp.json create mode 100644 Tests.Discovery/Fixtures/pve-lxc-config.json create mode 100644 Tests.Discovery/Fixtures/pve-lxc.json create mode 100644 Tests.Discovery/Fixtures/pve-node-status.json create mode 100644 Tests.Discovery/Fixtures/pve-nodes-full.json create mode 100644 Tests.Discovery/Fixtures/pve-nodes.json create mode 100644 Tests.Discovery/Fixtures/pve-qemu-config-passthrough.json create mode 100644 Tests.Discovery/Fixtures/pve-qemu-config.json create mode 100644 Tests.Discovery/Fixtures/pve-qemu.json create mode 100644 Tests.Discovery/ProxmoxClientTests.cs create mode 100644 Tests.Discovery/ProxmoxDiscoveryTests.cs create mode 100644 Tests.Discovery/RealProbeTests.cs create mode 100644 Tests.Discovery/RemoteDockerDiscoveryTests.cs create mode 100644 Tests.Discovery/SystemDiscoveryTests.cs create mode 100644 Tests.Discovery/Tests.Discovery.csproj create mode 100644 Tests/Api/InventoryEndpointStartupTests.cs create mode 100644 Tests/TestConfigs/v4/01-server.yaml create mode 100644 Tests/TestConfigs/v4/02-firewall.yaml create mode 100644 Tests/TestConfigs/v4/03-router.yaml create mode 100644 Tests/TestConfigs/v4/04-switch.yaml create mode 100644 Tests/TestConfigs/v4/05-accesspoint.yaml create mode 100644 Tests/TestConfigs/v4/06-ups.yaml create mode 100644 Tests/TestConfigs/v4/07-desktop.yaml create mode 100644 Tests/TestConfigs/v4/08-laptop.yaml create mode 100644 Tests/TestConfigs/v4/09-service.yaml create mode 100644 Tests/TestConfigs/v4/10-system.yaml create mode 100644 Tests/TestConfigs/v4/11-demo-config.yaml create mode 100644 Tests/TestConfigs/v4/12-other.yaml create mode 100644 schemas/v4/schema.v4.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 472126c4..6507b793 100644 --- a/.github/workflows/test.yml +++ b/.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 diff --git a/README.md b/README.md index 2cccc51b..8ab291ca 100644 --- a/README.md +++ b/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) diff --git a/RackPeek.Domain/Api/UpsertInventoryUseCase.cs b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs index dc9629f8..ca0efb60 100644 --- a/RackPeek.Domain/Api/UpsertInventoryUseCase.cs +++ b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs @@ -2,6 +2,7 @@ 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 async Task ExecuteAsync(ImportYamlRequest request) { List? incomingResources = incomingRoot.Resources; IReadOnlyList currentResources = await repo.GetAllOfTypeAsync(); + // 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? duplicate = incomingResources .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(g => g.Count() > 1); diff --git a/RackPeek.Domain/Discovery/DiscoveryDocument.cs b/RackPeek.Domain/Discovery/DiscoveryDocument.cs new file mode 100644 index 00000000..2feefeed --- /dev/null +++ b/RackPeek.Domain/Discovery/DiscoveryDocument.cs @@ -0,0 +1,15 @@ +using RackPeek.Domain.Persistence.Yaml; +using RackPeek.Domain.Resources; + +namespace RackPeek.Domain.Discovery; + +/// Renders discovered resources as a RackPeek YAML document. +public static class DiscoveryDocument { + public static string ToYaml(IEnumerable resources) { + return YamlResourceCollection.SerializeRootAsync(new YamlRoot { + Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count, + Resources = resources.ToList(), + Connections = [] + }); + } +} diff --git a/RackPeek.Domain/Discovery/DiscoveryId.cs b/RackPeek.Domain/Discovery/DiscoveryId.cs new file mode 100644 index 00000000..64259a69 --- /dev/null +++ b/RackPeek.Domain/Discovery/DiscoveryId.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace RackPeek.Domain.Discovery; + +/// +/// Deterministic, globally unique identity for a discovered resource. +/// Format: rpk1:{scheme}:{16 hex chars} +/// 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. +/// +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()}"; + } + + /// Short, stable fragment used to disambiguate generated names. + 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]; + } +} diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs new file mode 100644 index 00000000..be0498dd --- /dev/null +++ b/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; + +/// +/// Reconciles incoming discovered resources against what is already stored, by +/// rather than by name. +/// +/// 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. +/// +/// +public static class DiscoveryIdResolver { + /// + /// Rewrites in place so that its names line up with + /// the stored resources the ids point at. Also rewrites runsOn references + /// between incoming resources — and the payload's , + /// which name resources the same way — so a rename does not break the tree. + /// + public static void ResolveNames( + IReadOnlyList existing, + IReadOnlyList incoming, + IReadOnlyList? 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(StringComparer.OrdinalIgnoreCase); + + foreach (Resource resource in existing) + existingByName.TryAdd(resource.Name, resource); + + var renames = new Dictionary(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); + } + + /// + /// A collector that cannot see its host — docker discovery over TCP — sends + /// runsOn 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. + /// + private static void PreserveStoredRunsOn( + IReadOnlyList incomingWithId, + IReadOnlyList incoming, + Dictionary existingById, + Dictionary existingByName) { + var incomingNames = new HashSet(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 existingById, + Dictionary 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 incoming, Dictionary 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? connections, Dictionary 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 resources, string scope) { + IGrouping? 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."); + } +} diff --git a/RackPeek.Domain/Discovery/DiscoveryNaming.cs b/RackPeek.Domain/Discovery/DiscoveryNaming.cs new file mode 100644 index 00000000..cbcae698 --- /dev/null +++ b/RackPeek.Domain/Discovery/DiscoveryNaming.cs @@ -0,0 +1,90 @@ +using System.Text; +using RackPeek.Domain.Helpers; + +namespace RackPeek.Domain.Discovery; + +/// Turns machine-supplied strings into names a human would have typed. +public static class DiscoveryNaming { + /// + /// 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. + /// + public const int MaxNameLength = ThrowIfInvalid.MaxResourceNameLength; + + /// + /// 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. + /// + 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('-'); + } + + /// + /// The first label of a host name: nas01.lan becomes nas01, which is + /// what people call the machine. + /// + public static string HostLabel(string? hostname) { + if (string.IsNullOrWhiteSpace(hostname)) + return string.Empty; + + var dot = hostname.IndexOf('.'); + + return dot > 0 ? hostname[..dot] : hostname; + } + + /// + /// 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. + /// + 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)}"; + } + + /// Cuts a name down to the allowed length without leaving a trailing dash. + public static string Truncate(string name) => + name.Length <= MaxNameLength ? name : name[..MaxNameLength].TrimEnd('-'); + + /// + /// 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. + /// + 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}"; + } + + /// + /// Returns a name not already in , 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. + /// + public static string Unique(string candidate, string discoveryId, ISet taken) { + var name = taken.Add(candidate) + ? candidate + : WithSuffix(candidate, DiscoveryId.ShortSuffix(discoveryId)); + + taken.Add(name); + + return name; + } +} diff --git a/RackPeek.Domain/Discovery/DiscoveryPublisher.cs b/RackPeek.Domain/Discovery/DiscoveryPublisher.cs new file mode 100644 index 00000000..01a45f87 --- /dev/null +++ b/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; + +/// +/// 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. +/// +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); + + /// An explicit value wins; a blank one falls back to the environment. + public static string? Resolve(string? explicitValue, string environmentVariable) => + Coalesce(explicitValue, Environment.GetEnvironmentVariable(environmentVariable)); + + public async Task 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(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)); +} diff --git a/RackPeek.Domain/Discovery/DockerApiClient.cs b/RackPeek.Domain/Discovery/DockerApiClient.cs new file mode 100644 index 00000000..a204770e --- /dev/null +++ b/RackPeek.Domain/Discovery/DockerApiClient.cs @@ -0,0 +1,127 @@ +using System.Net.Sockets; + +namespace RackPeek.Domain.Discovery; + +/// +/// Talks to the Docker Engine API over either a unix socket or TCP. Podman's socket +/// speaks the same API, so --docker-host unix:///run/user/1000/podman/podman.sock +/// works without anything extra. +/// +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; } + + /// + /// 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. + /// + public bool IsLocal => Endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase); + + /// + /// 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. + /// + public string? RemoteHost => IsLocal + ? null + : new Uri(Endpoint.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)).DnsSafeHost; + + public void Dispose() => _httpClient.Dispose(); + + public async Task> 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 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; + } + } + + /// + /// 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. + /// + public static async Task 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") }; + } +} diff --git a/RackPeek.Domain/Discovery/DockerContainer.cs b/RackPeek.Domain/Discovery/DockerContainer.cs new file mode 100644 index 00000000..7f6d8131 --- /dev/null +++ b/RackPeek.Domain/Discovery/DockerContainer.cs @@ -0,0 +1,99 @@ +using System.Text.Json; + +namespace RackPeek.Domain.Discovery; + +/// A published port binding on the host side. +public sealed record DockerPortBinding(int HostPort, string Protocol, string HostIp = "") { + /// + /// Bound to a loopback address, so only the host itself can reach it. Docker + /// writes the address as given (127.0.0.1 mostly, but any 127.x works). + /// + public bool IsLoopback => + HostIp.StartsWith("127.", StringComparison.Ordinal) || HostIp == "::1"; + + /// Bound to every interface rather than one address. + public bool IsWildcard => HostIp is "" or "0.0.0.0" or "::"; +} + +/// +/// A container, reduced to the parts RackPeek models. Everything here comes from a +/// single GET /containers/json call — the per-container inspect adds nothing +/// a Service resource can hold. +/// +public sealed record DockerContainer { + public required string Name { get; init; } + public required string Image { get; init; } + public string? ComposeProject { get; init; } + public IReadOnlyList PublishedPorts { get; init; } = []; +} + +/// Parses the Docker Engine list response. Pure, so it tests from a fixture. +public static class DockerContainerParser { + private const string _composeProjectLabel = "com.docker.compose.project"; + + public static List Parse(string json) { + using var document = JsonDocument.Parse(json); + + if (document.RootElement.ValueKind != JsonValueKind.Array) + return []; + + return document.RootElement.EnumerateArray() + .Select(ParseContainer) + .OfType() + .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 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; +} diff --git a/RackPeek.Domain/Discovery/DockerEngineInfo.cs b/RackPeek.Domain/Discovery/DockerEngineInfo.cs new file mode 100644 index 00000000..7a373d77 --- /dev/null +++ b/RackPeek.Domain/Discovery/DockerEngineInfo.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace RackPeek.Domain.Discovery; + +/// +/// The engine host's own account of itself, from GET /info. 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 rpk discover system would report on that box. +/// +public sealed record DockerEngineInfo { + /// + /// The daemon's persisted unique id (kept under /var/lib/docker). Survives + /// reboots; changes only on an engine reinstall — the same failure mode + /// /etc/machine-id has for local discovery. + /// + public string? Id { get; init; } + + /// The engine host's hostname. + public string? Hostname { get; init; } +} + +/// Parses the Docker Engine GET /info response. Pure, so it tests from a fixture. +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; +} diff --git a/RackPeek.Domain/Discovery/DockerServiceMapper.cs b/RackPeek.Domain/Discovery/DockerServiceMapper.cs new file mode 100644 index 00000000..acc471c2 --- /dev/null +++ b/RackPeek.Domain/Discovery/DockerServiceMapper.cs @@ -0,0 +1,77 @@ +using RackPeek.Domain.Resources.Services; + +namespace RackPeek.Domain.Discovery; + +/// Maps containers onto the Service resources RackPeek stores. Pure. +public static class DockerServiceMapper { + /// + /// 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. + /// + /// + /// 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. + /// + public static List ToResources( + IReadOnlyList containers, + string hostSeed, + string hostName, + string? hostIp) { + var taken = new HashSet(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 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] + }; + } + + /// + /// 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. + /// + 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; +} diff --git a/RackPeek.Domain/Discovery/IDockerClient.cs b/RackPeek.Domain/Discovery/IDockerClient.cs new file mode 100644 index 00000000..2f98cf93 --- /dev/null +++ b/RackPeek.Domain/Discovery/IDockerClient.cs @@ -0,0 +1,21 @@ +namespace RackPeek.Domain.Discovery; + +/// Reads containers off a Docker Engine API. The IO half of docker discovery. +public interface IDockerClient { + /// Describes where this client is pointed, for error messages. + string Endpoint { get; } + + /// + /// 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. + /// + Task> ListContainersAsync(CancellationToken cancellationToken = default); + + /// + /// 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 /containers but block /info. + /// + Task GetInfoAsync(CancellationToken cancellationToken = default); +} diff --git a/RackPeek.Domain/Discovery/IProxmoxClient.cs b/RackPeek.Domain/Discovery/IProxmoxClient.cs new file mode 100644 index 00000000..20531c49 --- /dev/null +++ b/RackPeek.Domain/Discovery/IProxmoxClient.cs @@ -0,0 +1,44 @@ +namespace RackPeek.Domain.Discovery; + +/// Reads a Proxmox VE API. The IO half of hypervisor discovery. +public interface IProxmoxClient { + /// Where this client is pointed, for error messages. + string Endpoint { get; } + + /// + /// 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. + /// + Task GetIdentityScopeAsync(CancellationToken cancellationToken = default); + + Task> GetNodesAsync(CancellationToken cancellationToken = default); + + /// + /// 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. + /// + Task EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default); + + /// Guests of one kind on one node. is qemu or lxc. + Task> GetGuestsAsync( + string node, + string endpoint, + CancellationToken cancellationToken = default); + + /// + /// 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. + /// + Task> GetDisksAsync(string node, CancellationToken cancellationToken = default); + + /// Display adapters in a node, from its PCI device list. + Task> GetGpusAsync(string node, CancellationToken cancellationToken = default); + + Task GetGuestConfigAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default); +} diff --git a/RackPeek.Domain/Discovery/ISystemProbe.cs b/RackPeek.Domain/Discovery/ISystemProbe.cs new file mode 100644 index 00000000..3b4fca21 --- /dev/null +++ b/RackPeek.Domain/Discovery/ISystemProbe.cs @@ -0,0 +1,30 @@ +namespace RackPeek.Domain.Discovery; + +/// +/// 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. +/// +public interface ISystemProbe { + /// True when this probe can run on the current host. + bool IsSupported { get; } + + Task ReadAsync(CancellationToken cancellationToken = default); +} + +/// +/// Probe selection, shared by every collector so they all describe the host — and +/// seed discovery ids — identically. +/// +public static class SystemProbes { + /// Facts from the first supported probe, or null on an unsupported platform. + public static async Task TryReadHostAsync( + IEnumerable probes, + CancellationToken cancellationToken) { + ISystemProbe? probe = probes.FirstOrDefault(p => p.IsSupported); + + if (probe == null) + return null; + + return SystemFactsParser.Parse(await probe.ReadAsync(cancellationToken)); + } +} diff --git a/RackPeek.Domain/Discovery/LinuxSystemProbe.cs b/RackPeek.Domain/Discovery/LinuxSystemProbe.cs new file mode 100644 index 00000000..2b1b2f6a --- /dev/null +++ b/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 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 ReadMachineIdAsync(CancellationToken cancellationToken) => + await SystemProbeCommon.TryReadFileAsync("/etc/machine-id", cancellationToken) + ?? await SystemProbeCommon.TryReadFileAsync("/var/lib/dbus/machine-id", cancellationToken); + + /// + /// Whole disks as the kernel sees them, which is closer to what goes on an + /// inventory card than the mounted filesystems would be. + /// + private static List ReadBlockDevices() { + try { + if (!Directory.Exists(_blockDeviceRoot)) + return []; + + return Directory.EnumerateDirectories(_blockDeviceRoot) + .Select(ReadBlockDevice) + .OfType() + .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; + } + } +} diff --git a/RackPeek.Domain/Discovery/MacSystemProbe.cs b/RackPeek.Domain/Discovery/MacSystemProbe.cs new file mode 100644 index 00000000..88617ed7 --- /dev/null +++ b/RackPeek.Domain/Discovery/MacSystemProbe.cs @@ -0,0 +1,76 @@ +namespace RackPeek.Domain.Discovery; + +/// +/// 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. +/// +public sealed class MacSystemProbe : ISystemProbe { + public bool IsSupported => OperatingSystem.IsMacOS(); + + public async Task ReadAsync(CancellationToken cancellationToken = default) { + // The four probes spawn independent processes, so they run side by side. + Task osName = ReadOsNameAsync(cancellationToken); + Task memoryBytes = ReadMemoryBytesAsync(cancellationToken); + Task platformUuid = ReadPlatformUuidAsync(cancellationToken); + Task 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 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 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 ReadHypervisorPresentAsync(CancellationToken cancellationToken) { + var value = await SystemProbeCommon.TryRunAsync("sysctl", "-n kern.hv_vmm_present", cancellationToken); + + return value?.Trim() == "1"; + } + + private static async Task ReadPlatformUuidAsync(CancellationToken cancellationToken) { + var output = await SystemProbeCommon.TryRunAsync( + "ioreg", "-rd1 -c IOPlatformExpertDevice", cancellationToken); + + return ParsePlatformUuid(output); + } + + /// Pulls IOPlatformUUID out of an ioreg dump. Public so it can be tested off a macOS host. + 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; + } +} diff --git a/RackPeek.Domain/Discovery/ProxmoxApiClient.cs b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs new file mode 100644 index 00000000..24ceda0f --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs @@ -0,0 +1,193 @@ +using System.Net.Security; + +namespace RackPeek.Domain.Discovery; + +/// +/// 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. +/// +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; + + /// + /// 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. + /// + 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 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> GetNodesAsync(CancellationToken cancellationToken = default) => + ProxmoxResponseParser.ParseNodes(await GetAsync("nodes", cancellationToken)); + + public async Task EnrichAsync( + ProxmoxNode node, + CancellationToken cancellationToken = default) { + try { + // The three endpoints are independent, so the round trips overlap. + Task status = GetAsync($"nodes/{Uri.EscapeDataString(node.Name)}/status", cancellationToken); + Task> disks = GetDisksAsync(node.Name, cancellationToken); + Task> 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> 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> 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> 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 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 FirstNodeAsync(CancellationToken cancellationToken) { + IReadOnlyList nodes = await GetNodesAsync(cancellationToken); + + return nodes.FirstOrDefault()?.Name ?? "proxmox"; + } + + private async Task 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"; + } +} diff --git a/RackPeek.Domain/Discovery/ProxmoxModels.cs b/RackPeek.Domain/Discovery/ProxmoxModels.cs new file mode 100644 index 00000000..c426988c --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -0,0 +1,427 @@ +using System.Text.Json; + +namespace RackPeek.Domain.Discovery; + +/// +/// 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. +/// +public sealed record ProxmoxNode { + public required string Name { get; init; } + + /// Logical processors, which is what the hypervisor OS sees. + public int Cores { get; init; } + + public long MemoryBytes { get; init; } + + /// e.g. pve-manager/8.2.2/9355359cd7afbae4, only from the status call. + 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 Disks { get; init; } = []; + + /// + /// 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. + /// + public IReadOnlyList Gpus { get; init; } = []; +} + +/// A physical disk as Proxmox reports it, already classified by type. +public sealed record ProxmoxDisk(string Type, long SizeBytes, string? Model); + +/// A display adapter and where it sits on the bus. +public sealed record ProxmoxGpu(string Address, string Model); + +/// A guest on a node. QEMU and LXC differ only in the kind of system they are. +public sealed record ProxmoxGuest { + public required int VmId { get; init; } + public required string Node { get; init; } + + /// Empty for a guest that has never been named; the mapper falls back to the vmid. + public string Name { get; init; } = string.Empty; + + /// vm or container, matching SystemResource.ValidSystemTypes. + public required string Type { get; init; } + + public int Cores { get; init; } + public long MemoryBytes { get; init; } + /// The boot disk, from the guest list. A fallback for when the config is unreadable. + public long DiskBytes { get; init; } + + /// Every attached disk, from the guest's config. + public IReadOnlyList Disks { get; init; } = []; + + /// PCI addresses handed exclusively to this guest, from its config. + public IReadOnlyList PassthroughAddresses { get; init; } = []; + + public IReadOnlyList Tags { get; init; } = []; + + /// Filled in from the guest's config, which is the only place it is known. + public string? Os { get; init; } + + public string? Ip { get; init; } +} + +/// +/// Parses the Proxmox API. Every response wraps its payload in a data member. +/// Pure, so it tests from captured responses without a Proxmox to talk to. +/// +public static class ProxmoxResponseParser { + public const string VmType = "vm"; + public const string ContainerType = "container"; + + /// + /// Nodes with whatever detail the token is allowed to see. Proxmox strips + /// maxcpu and maxmem from this response for a token without the + /// rights to read them, rather than refusing the call, so both shapes are normal. + /// + public static List 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 + }; + } + + /// + /// Physical disks. Proxmox has already worked out nvme/ssd/hdd, which is better + /// than the guess discover system has to make from a rotational flag. + /// + public static List 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(); + } + + /// Proxmox says "unknown" for a disk it cannot classify; RackPeek omits the type. + private static string NormaliseDiskType(string? type) { + return type?.ToLowerInvariant() switch { + "nvme" => "nvme", + "ssd" => "ssd", + "hdd" => "hdd", + _ => string.Empty + }; + } + + /// + /// 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. + /// + public static List 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(); + } + + /// + /// PCI addresses a guest has been given exclusive use of. The config writes them + /// as hostpci0: 0000:01:00, optionally with trailing options and sometimes + /// without the function suffix the device list carries. + /// + public static List 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(); + } + + /// + /// PCI names the part and then the product: GA102 [GeForce RTX 3090]. The + /// bracketed half is the one people would have typed, so it wins where it exists. + /// + 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 ParseGuests(string json, string node, string type) { + return Data(json) + .Select(g => ParseGuest(g, node, type)) + .OfType() + .OrderBy(g => g.VmId) + .ToList(); + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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)); + } + + /// + /// Every disk attached to a guest. The guest list only carries maxdisk, + /// 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. + /// + public static List ParseDiskSizes(JsonElement config) { + var sizes = new List(); + + 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; + } + + /// + /// Disk-bearing config keys. unusedN 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. + /// + 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)); + } + + /// + /// Reads size=64G out of a volume definition, in bytes. Proxmox permits a + /// fractional number (size=4.5G, after an odd resize) and a bare number, + /// which is bytes. + /// + 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; + } + + /// + /// Proxmox stores an ostype code. The container ones name a real distribution and + /// are worth having; the QEMU ones are coarse by nature — l26 means any + /// Linux since 2.6 — so they stay vague rather than pretending to precision. + /// + 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..] + }; + } + + /// + /// Pulls the address out of a net interface line such as + /// name=eth0,bridge=vmbr0,ip=192.168.1.53/24. Returns null for + /// ip=dhcp and ip=manual, where the config knows no more than we do. + /// + 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")) + }; + } + + /// Proxmox joins guest tags with semicolons. + private static List 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 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; +} + +/// The parts of a guest's config worth recording. Everything is optional. +public sealed record ProxmoxGuestConfig( + string? Os, + string? Ip, + IReadOnlyList DiskBytes, + IReadOnlyList PassthroughAddresses); diff --git a/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs b/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs new file mode 100644 index 00000000..018f7f21 --- /dev/null +++ b/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; + +/// +/// Maps a Proxmox estate onto RackPeek's three levels. Pure. +/// +/// A node becomes two resources, because it is two things: the machine +/// (kepler, a Server carrying the CPU, memory and disks) and the +/// hypervisor installed on it (kepler-pve, a System). Guests then run on +/// the hypervisor, giving Hardware -> System -> System all the way down. +/// +/// +public static class ProxmoxResourceMapper { + public const string Scheme = "pve"; + + /// Label recording which cards a guest has exclusive use of. + public const string GpuLabel = "gpu"; + + /// The cap RackPeek's own validation puts on a label value. + private const int _maxLabelLength = Helpers.ThrowIfInvalid.MaxLabelValueLength; + + public static List ToResources( + string scope, + IReadOnlyList nodes, + IReadOnlyList guests) { + var taken = new HashSet(StringComparer.OrdinalIgnoreCase); + var resources = new List(); + + // Nodes first, so a guest named after its node does not take the node's name. + var hypervisorNames = new Dictionary(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; + } + + /// The machine itself. Takes the node's own name, being the thing people point at. + private static Server ToServer(ProxmoxNode node, string scope, ISet 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() + }; + } + + /// The Proxmox install running on the machine, which is what guests run on. + private static SystemResource ToHypervisor( + ProxmoxNode node, + string scope, + string serverName, + ISet 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] + }; + } + + /// + /// 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. + /// + private static List? 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? ToDrives(IReadOnlyList 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 hypervisorNames, + IReadOnlyDictionary> gpusByNode, + ISet 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] : [] + }; + } + + /// + /// 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. + /// + private static List? ToGuestDrives(ProxmoxGuest guest) { + IReadOnlyList 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(); + } + + /// + /// 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. + /// + private static Dictionary PassthroughLabels( + ProxmoxGuest guest, + IReadOnlyDictionary> gpusByNode) { + var labels = new Dictionary(); + + if (guest.PassthroughAddresses.Count == 0 + || !gpusByNode.TryGetValue(guest.Node, out IReadOnlyList? gpus)) + return labels; + + var held = guest.PassthroughAddresses + .Select(address => gpus.FirstOrDefault(g => AddressesMatch(g.Address, address))) + .OfType() + .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; + } + + /// + /// The device list gives a function suffix (0000:01:00.0) that a guest + /// config usually leaves off (0000:01:00), so either may be the longer. + /// + private static bool AddressesMatch(string deviceAddress, string configured) => + deviceAddress.StartsWith(configured, StringComparison.OrdinalIgnoreCase) + || configured.StartsWith(deviceAddress, StringComparison.OrdinalIgnoreCase); + + /// A guest that was never named still needs one; the vmid is what people call it. + private static string FallbackName(ProxmoxGuest guest) => + string.IsNullOrWhiteSpace(guest.Name) + ? $"{(guest.Type == ProxmoxResponseParser.ContainerType ? "ct" : "vm")}-{guest.VmId}" + : guest.Name; + + /// pve-manager/8.2.2/9355359c becomes Proxmox VE 8.2.2. + 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; +} diff --git a/RackPeek.Domain/Discovery/SystemFacts.cs b/RackPeek.Domain/Discovery/SystemFacts.cs new file mode 100644 index 00000000..eedbd64c --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemFacts.cs @@ -0,0 +1,69 @@ +namespace RackPeek.Domain.Discovery; + +/// +/// The one place discovery turns bytes into the whole gigabytes RackPeek stores, +/// so every collector reports the same size for the same hardware. +/// +public static class DiscoveryUnits { + private const double _bytesPerGb = 1024d * 1024 * 1024; + + /// Rounded, floored at 1 — a real device is never zero gigabytes. + public static double BytesToWholeGb(long bytes) => Math.Max(1, Math.Round(bytes / _bytesPerGb)); +} + +/// A physical or virtual disk as reported by the host. +public sealed record BlockDeviceFact(string Name, long SizeBytes, bool Rotational); + +/// A network interface, reduced to the parts that pick a primary address. +public sealed record NicFact(string Name, bool IsUp, bool IsLoopback, bool HasGateway, string? Ipv4); + +/// +/// 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 and is testable +/// on any platform from a captured fixture. +/// +public sealed record RawSystemSnapshot { + public string Hostname { get; init; } = string.Empty; + public int Cores { get; init; } + public IReadOnlyList Nics { get; init; } = []; + public IReadOnlyList BlockDevices { get; init; } = []; + + /// Fallback when the platform-specific read fails; always populated. + 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; } +} + +/// The host, once the raw snapshot has been interpreted. +public sealed record SystemFacts { + public required string Hostname { get; init; } + + /// Seed for the discovery id. Null when the host offers nothing stable. + public string? MachineId { get; init; } + + public required string Os { get; init; } + public required int Cores { get; init; } + public required double RamGb { get; init; } + + /// One of . + public required string Type { get; init; } + + public string? Ip { get; init; } + public IReadOnlyList Drives { get; init; } = []; +} + +public sealed record DriveFact(string Type, int SizeGb); diff --git a/RackPeek.Domain/Discovery/SystemFactsParser.cs b/RackPeek.Domain/Discovery/SystemFactsParser.cs new file mode 100644 index 00000000..da1cd3aa --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemFactsParser.cs @@ -0,0 +1,165 @@ + +namespace RackPeek.Domain.Discovery; + +/// +/// Turns a into . +/// Pure: no IO, no platform checks, so it runs and is tested identically everywhere. +/// +public static class SystemFactsParser { + /// Strings that appear in DMI when the host is a guest rather than real hardware. + private static readonly string[] _virtualMachineMarkers = + [ + "qemu", "kvm", "vmware", "virtualbox", "innotek", "xen", "bochs", + "bhyve", "parallels", "hyper-v", "virtual machine", "openstack" + ]; + + /// Kernel-managed devices that are not disks anyone wants in an inventory. + 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 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 ParseDrives(IReadOnlyList 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)); + } + + /// Reads one entry out of a key=value file such as /etc/os-release. + 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(); +} diff --git a/RackPeek.Domain/Discovery/SystemProbeCommon.cs b/RackPeek.Domain/Discovery/SystemProbeCommon.cs new file mode 100644 index 00000000..ba056b58 --- /dev/null +++ b/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; + +/// Host reads that the BCL already does the same way on every platform. +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 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); + } + + /// Reads a file, returning null for anything unreadable rather than throwing. + public static async Task TryReadFileAsync(string path, CancellationToken cancellationToken) { + try { + return File.Exists(path) + ? await File.ReadAllTextAsync(path, cancellationToken) + : null; + } + catch { + return null; + } + } + + /// + /// 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 + /// rpk discover forever. Stderr is drained concurrently so a chatty child + /// cannot deadlock on a full pipe. + /// + public static async Task 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 stdout = process.StandardOutput.ReadToEndAsync(timeout.Token); + Task 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; + } + } +} diff --git a/RackPeek.Domain/Discovery/SystemResourceMapper.cs b/RackPeek.Domain/Discovery/SystemResourceMapper.cs new file mode 100644 index 00000000..a31212b9 --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemResourceMapper.cs @@ -0,0 +1,35 @@ +using RackPeek.Domain.Resources.SubResources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// Maps host facts onto the System resource RackPeek stores. Pure. +public static class SystemResourceMapper { + /// + /// 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. + /// + 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() + }; + } +} diff --git a/RackPeek.Domain/Helpers/ThrowIfInvalid.cs b/RackPeek.Domain/Helpers/ThrowIfInvalid.cs index 09d14e7f..34e79424 100644 --- a/RackPeek.Domain/Helpers/ThrowIfInvalid.cs +++ b/RackPeek.Domain/Helpers/ThrowIfInvalid.cs @@ -5,10 +5,13 @@ 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 void LabelKey(string key) { 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) { diff --git a/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs b/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs index 58da0763..94c3641e 100644 --- a/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs +++ b/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs @@ -24,7 +24,8 @@ public static readonly IReadOnlyList + /// v4 adds the optional discoveryId field for rpk discover. 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. + /// + public static ValueTask AllowDiscoveryIdsV4(IServiceProvider serviceProvider, Dictionary obj) { + obj["version"] = 4; + + return ValueTask.CompletedTask; + } + #endregion } diff --git a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs index 511728d5..8a6b62a1 100644 --- a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs +++ b/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 Resources { get; } = new(); public List Connections { get; } = new(); + + /// + /// Whether the store has ever been read successfully. Guarded by + /// . 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. + /// + public bool Loaded { get; set; } } public sealed class YamlResourceCollection( @@ -125,9 +134,17 @@ public async Task Merge(string incomingYaml, MergeMode mode) { await resourceCollection.FileLock.WaitAsync(); try { + await EnsureLoadedAsync(); + YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml); List incomingResources = incomingRoot.Resources ?? new List(); + + DiscoveryIdResolver.ResolveNames( + resourceCollection.Resources, + incomingResources, + incomingRoot.Connections); + List merged = ResourceCollectionMerger.Merge( resourceCollection.Resources, incomingResources, @@ -200,27 +217,45 @@ public async Task LoadAsync() { // "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; + } + + /// + /// 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. + /// + private async Task EnsureLoadedAsync() { + if (!resourceCollection.Loaded) + await LoadUnderLockAsync(); } public Task AddAsync(Resource resource) { @@ -353,6 +388,8 @@ public Task> GetConnectionsForResourceAsync(string res private async Task UpdateWithLockAsync(Action> 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 @@ private static bool PortsMatch(PortReference a, PortReference b) { private async Task UpdateConnectionsWithLockAsync(Action> action) { await resourceCollection.FileLock.WaitAsync(); try { + await EnsureLoadedAsync(); + action(resourceCollection.Connections); var root = new YamlRoot { diff --git a/RackPeek.Domain/Resources/Resource.cs b/RackPeek.Domain/Resources/Resource.cs index 89c948e1..74b417c3 100644 --- a/RackPeek.Domain/Resources/Resource.cs +++ b/RackPeek.Domain/Resources/Resource.cs @@ -52,6 +52,13 @@ public abstract class Resource { public required string Name { get; set; } + /// + /// Stable machine-generated identity, set by rpk discover. Optional, and + /// absent on everything entered by hand. Lets a re-run find this resource again + /// after the user has renamed it. See RackPeek.Domain.Discovery.DiscoveryId. + /// + public string? DiscoveryId { get; set; } + public string[] Tags { get; set; } = []; public Dictionary Labels { get; set; } = new(); public string? Notes { get; set; } diff --git a/RackPeek.Domain/ServiceCollectionExtensions.cs b/RackPeek.Domain/ServiceCollectionExtensions.cs index a8fb2690..bf935afe 100644 --- a/RackPeek.Domain/ServiceCollectionExtensions.cs +++ b/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 IServiceCollection AddResourceUseCases( 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(); + services.AddSingleton(); + services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>)); services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>)); services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>)); diff --git a/RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs b/RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs index c37db221..8e87da3d 100644 --- a/RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs +++ b/RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs @@ -27,6 +27,11 @@ public async Task ExecuteAsync(string originalName, string cloneName) { 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); } } diff --git a/RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json b/RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json index 63704103..bdcd8f81 100644 --- a/RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json +++ b/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 } } -} \ No newline at end of file +} diff --git a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json new file mode 100644 index 00000000..dbaf1934 --- /dev/null +++ b/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 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 + } + } +} diff --git a/RackPeek.Web/Program.cs b/RackPeek.Web/Program.cs index fc49cf5e..5d29633a 100644 --- a/RackPeek.Web/Program.cs +++ b/RackPeek.Web/Program.cs @@ -94,6 +94,25 @@ public static async Task BuildApp(WebApplicationBuilder builder) 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().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>() + .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(); diff --git a/RackPeek.Web/wwwroot/schemas/v3/schema.v3.json b/RackPeek.Web/wwwroot/schemas/v3/schema.v3.json index 63704103..bdcd8f81 100644 --- a/RackPeek.Web/wwwroot/schemas/v3/schema.v3.json +++ b/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 } } -} \ No newline at end of file +} diff --git a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json new file mode 100644 index 00000000..dbaf1934 --- /dev/null +++ b/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 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 + } + } +} diff --git a/RackPeek.sln b/RackPeek.sln index a483d167..9b5fd793 100644 --- a/RackPeek.sln +++ b/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 diff --git a/Shared.Rcl/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index 24bff39a..c374ef5a 100644 --- a/Shared.Rcl/CliBootstrap.cs +++ b/Shared.Rcl/CliBootstrap.cs @@ -26,6 +26,7 @@ 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 async Task RegisterInternals( 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(); @@ -113,13 +127,20 @@ public static async Task RegisterInternals( b.GetRequiredService()); - 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(collection); // Infrastructure services.AddYamlRepos(); - // Application + // Application (also registers the discovery probes, for every host) services.AddUseCases(); services.AddCommands(); } @@ -728,6 +749,25 @@ public static void BuildApp(CommandApp app) { // ---------------------------- // Ansible // ---------------------------- + config.AddBranch("discover", discover => { + discover.SetDescription("Read infrastructure and emit it as RackPeek YAML."); + + discover.AddCommand("system") + .WithDescription("Inspect this machine and emit it as a System resource.") + .WithExample("discover", "system") + .WithExample("discover", "system", "--name", "nas01", "--push"); + + discover.AddCommand("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("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."); diff --git a/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs new file mode 100644 index 00000000..0005537d --- /dev/null +++ b/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 ")] + [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 ")] + [Description("Name of the machine these containers run on. Defaults to its hostname.")] + public string? HostName { get; init; } +} + +/// Reads the Docker Engine API and emits each published container as a Service. +public sealed class DiscoverDockerCommand(IEnumerable probes) + : AsyncCommand { + protected override async Task 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 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 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 resources = client.IsLocal && services.Count > 0 + ? [hostResource, .. services] + : [.. services]; + + return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken); + } + + private async Task 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 + }); + } +} diff --git a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs new file mode 100644 index 00000000..2971c862 --- /dev/null +++ b/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 ")] + [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 ")] + [Description("API token id, e.g. root@pam!rackpeek. Defaults to RPK_PVE_TOKEN_ID.")] + public string? TokenId { get; init; } + + [CommandOption("--token-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(); + } +} + +/// +/// 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. +/// +public sealed class DiscoverProxmoxCommand : AsyncCommand { + protected override async Task 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 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> ReadAsync( + IProxmoxClient client, + CancellationToken cancellationToken) { + var scope = await client.GetIdentityScopeAsync(cancellationToken); + IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); + + var nodes = new List(); + var guests = new List(); + + 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 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); + } +} diff --git a/Shared.Rcl/Commands/Discovery/DiscoverSettings.cs b/Shared.Rcl/Commands/Discovery/DiscoverSettings.cs new file mode 100644 index 00000000..ab8b1df0 --- /dev/null +++ b/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; + +/// Output and upload options shared by every rpk discover command. +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 ")] + [Description("RackPeek server to upload to. Defaults to the RPK_SERVER environment variable.")] + public string? Server { get; init; } + + [CommandOption("--api-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(); + } +} diff --git a/Shared.Rcl/Commands/Discovery/DiscoverSystemCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverSystemCommand.cs new file mode 100644 index 00000000..ac0d6f3b --- /dev/null +++ b/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 ")] + [Description("Name for this machine. Defaults to its hostname. Recommended when running from a timer.")] + public string? Name { get; init; } +} + +/// Inspects the machine it is running on and emits it as a System resource. +public sealed class DiscoverSystemCommand(IEnumerable probes) + : AsyncCommand { + protected override async Task 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); + } +} diff --git a/Shared.Rcl/Commands/Discovery/DiscoveryOutput.cs b/Shared.Rcl/Commands/Discovery/DiscoveryOutput.cs new file mode 100644 index 00000000..c31f9320 --- /dev/null +++ b/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; + +/// +/// The one place a discovery result leaves the process: printed as YAML, or sent to +/// a server. Shared so every collector behaves identically. +/// +public static class DiscoveryOutput { + public static async Task EmitAsync( + IReadOnlyList 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 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 names, string label, string colour) { + foreach (var name in names) + AnsiConsole.MarkupLine($"[{colour}]{label}[/] {Markup.Escape(name)}"); + } +} diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index aa325387..c57dae0f 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/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 diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 319bdb35..9fbb7cbf 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -5,6 +5,13 @@ USAGE: rpk [OPTIONS] +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] + +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 RackPeek server to upload to. Defaults to the + RPK_SERVER environment variable + --api-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 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 RackPeek server to upload to. Defaults to the + RPK_SERVER environment variable + --api-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 Docker endpoint, e.g. unix:///var/run/docker.sock + or tcp://host:2375. Defaults to DOCKER_HOST, then + the local socket + --host 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 RackPeek server to upload to. Defaults to the + RPK_SERVER environment variable + --api-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 Proxmox host, e.g. https://pve.lan:8006. A + bare host name gets https and :8006 + --token-id API token id, e.g. root@pam!rackpeek. + Defaults to RPK_PVE_TOKEN_ID + --token-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: diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md new file mode 100644 index 00000000..faeedd3d --- /dev/null +++ b/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 for this machine. Defaults to its hostname. | +| `--push` | Upload instead of printing. | +| `--server ` | Server to upload to. Defaults to `RPK_SERVER`. | +| `--api-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 ` | Docker endpoint. Defaults to `DOCKER_HOST`, then `/var/run/docker.sock`. | +| `--host ` | 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 ` | Proxmox host. A bare name gets `https://` and `:8006`. | +| `--token-id ` | API token id, e.g. `root@pam!rackpeek`. Defaults to `RPK_PVE_TOKEN_ID`. | +| `--token-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. diff --git a/Shared.Rcl/wwwroot/raw_docs/docs-index.json b/Shared.Rcl/wwwroot/raw_docs/docs-index.json index 03dd8c3d..483a76d7 100644 --- a/Shared.Rcl/wwwroot/raw_docs/docs-index.json +++ b/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" -] \ No newline at end of file +] diff --git a/Tests.Discovery/DiscoveryApiFixture.cs b/Tests.Discovery/DiscoveryApiFixture.cs new file mode 100644 index 00000000..da4838a5 --- /dev/null +++ b/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; + +/// +/// A real RackPeek server backed by a temporary config file, driven through the +/// same 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. +/// +public sealed class DiscoveryApiFixture : IDisposable { + private const string _apiKey = "discovery-test-key"; + + private readonly WebApplicationFactory _factory; + private readonly string _tempDir; + + /// + /// 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. + /// + 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() + .WithWebHostBuilder(builder => { + builder.UseSetting("RPK_YAML_DIR", _tempDir); + builder.ConfigureAppConfiguration((_, config) => + config.AddInMemoryCollection(new Dictionary { + ["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 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); + } +} diff --git a/Tests.Discovery/DiscoveryIdentityTests.cs b/Tests.Discovery/DiscoveryIdentityTests.cs new file mode 100644 index 00000000..f2306d16 --- /dev/null +++ b/Tests.Discovery/DiscoveryIdentityTests.cs @@ -0,0 +1,148 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// 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). +/// +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(() => 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(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); + } +} diff --git a/Tests.Discovery/DiscoveryMergeTests.cs b/Tests.Discovery/DiscoveryMergeTests.cs new file mode 100644 index 00000000..8da7712d --- /dev/null +++ b/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; + +/// +/// The promise discovery has to keep: run it as often as you like, from a timer, +/// and it updates what is already there instead of piling up duplicates — even +/// after the resource has been renamed by hand. +/// Every test here goes over HTTP into a real server and asserts on the stored YAML. +/// +public class DiscoveryMergeTests { + private static SystemFacts Facts( + string machineId = "machine-a", + string hostname = "nas01", + double ramGb = 63) { + return new SystemFacts { + Hostname = hostname, + MachineId = machineId, + Os = "Debian GNU/Linux 12 (bookworm)", + Cores = 12, + RamGb = ramGb, + Type = "baremetal", + Ip = "192.168.1.20" + }; + } + + private static string SystemYaml( + string machineId = "machine-a", + string hostname = "nas01", + double ramGb = 63) => + DiscoveryDocument.ToYaml([SystemResourceMapper.ToResource(Facts(machineId, hostname, ramGb))]); + + private static string IdFor(string machineId) => + DiscoveryId.Create(DiscoveryId.SystemScheme, machineId); + + [Fact] + public async Task First_run_adds_the_machine() { + using var api = new DiscoveryApiFixture(); + + ImportYamlResponse response = await api.PublishAsync(SystemYaml()); + + Assert.Equal(["nas01"], response.Added); + Assert.Contains($"discoveryId: {IdFor("machine-a")}", api.StoredYaml); + } + + [Fact] + public async Task A_v3_config_still_imports_and_is_saved_as_v4() { + using var api = new DiscoveryApiFixture(); + + // discoveryId arrived with schema v4; a pre-discovery v3 file must keep working. + ImportYamlResponse response = await api.PublishAsync(""" + version: 3 + resources: + - kind: System + name: nas01 + type: baremetal + os: Debian + cores: 12 + ram: 63 + """); + + Assert.Equal(["nas01"], response.Added); + Assert.StartsWith("version: 4", api.StoredYaml); + } + + [Fact] + public async Task Running_again_updates_rather_than_duplicating() { + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(SystemYaml()); + ImportYamlResponse second = await api.PublishAsync(SystemYaml(ramGb: 127)); + + Assert.Empty(second.Added); + Assert.Equal(["nas01"], second.Updated); + Assert.Equal(1, Count(api.StoredYaml, "name: nas01")); + Assert.Contains("ram: 127", api.StoredYaml); + } + + [Fact] + public async Task A_machine_renamed_by_hand_is_still_found_by_its_id() { + using var api = new DiscoveryApiFixture(); + + // What the inventory looks like after the user renamed it in the web UI. + await api.PublishAsync($""" + version: 3 + resources: + - kind: System + name: storage-01 + type: baremetal + os: Debian GNU/Linux 12 (bookworm) + cores: 12 + ram: 63 + discoveryId: {IdFor("machine-a")} + """); + + // The machine itself still reports its hostname. + ImportYamlResponse response = await api.PublishAsync(SystemYaml(ramGb: 127)); + + Assert.Empty(response.Added); + Assert.Equal(["storage-01"], response.Updated); + Assert.DoesNotContain("nas01", api.StoredYaml); + Assert.Contains("ram: 127", api.StoredYaml); + } + + [Fact] + public async Task A_hand_written_resource_of_the_same_name_is_adopted_not_duplicated() { + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(""" + version: 3 + resources: + - kind: System + name: nas01 + type: baremetal + os: Debian + cores: 12 + ram: 63 + notes: bought in 2019 + """); + + ImportYamlResponse response = await api.PublishAsync(SystemYaml()); + + Assert.Empty(response.Added); + Assert.Equal(["nas01"], response.Updated); + Assert.Equal(1, Count(api.StoredYaml, "name: nas01")); + + // Adoption keeps what the user wrote, and the resource gains an identity. + Assert.Contains("2019", api.StoredYaml); + Assert.Contains($"discoveryId: {IdFor("machine-a")}", api.StoredYaml); + } + + [Fact] + public async Task A_second_machine_with_the_same_hostname_does_not_hijack_the_first() { + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(SystemYaml("machine-a")); + ImportYamlResponse response = await api.PublishAsync(SystemYaml("machine-b")); + + // The newcomer stands aside rather than overwriting, and both are kept. + Assert.Single(response.Added); + Assert.StartsWith("nas01-", response.Added[0]); + Assert.Equal(1, Count(api.StoredYaml, "name: nas01\n")); + Assert.Contains($"name: {response.Added[0]}", api.StoredYaml); + } + + [Fact] + public async Task Services_follow_the_host_when_it_has_been_renamed() { + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync($""" + version: 3 + resources: + - kind: System + name: storage-01 + type: baremetal + os: Debian + cores: 12 + ram: 63 + discoveryId: {IdFor("machine-a")} + """); + + // Docker discovery on that machine still knows it only by its hostname. The + // payload below — host System first, then its services — is exactly what + // DiscoverDockerCommand sends for a local engine; the host rides along because + // this rename could not be reconciled from the services' bare runsOn names. + SystemResource host = SystemResourceMapper.ToResource(Facts()); + + List services = DockerServiceMapper.ToResources( + DockerContainerParser.Parse(Fixture.Read("docker-containers.json")), + "machine-a", + host.Name, + "192.168.1.20"); + + await api.PublishAsync(DiscoveryDocument.ToYaml([host, .. services])); + + // runsOn was rewritten to the name the user chose, so the tree is not broken. + Assert.Contains("- storage-01", api.StoredYaml); + Assert.DoesNotContain("nas01", api.StoredYaml); + } + + [Fact] + public async Task A_remote_engines_services_follow_a_rename_they_cannot_see() { + // A remote collector sends services only — no host System rides along, because + // the facts it probes locally describe the wrong machine. What the inventory + // looks like after the user documented the host and renamed it: + using var api = new DiscoveryApiFixture($""" + version: 4 + resources: + - kind: System + name: storage-01 + type: baremetal + os: Debian + cores: 12 + ram: 63 + discoveryId: {IdFor("machine-a")} + - kind: Service + name: jellyfin + discoveryId: {DiscoveryId.Create(DiscoveryId.DockerScheme, "engine-a/jellyfin")} + network: + ip: 192.168.1.20 + port: 8096 + protocol: TCP + runsOn: + - storage-01 + """); + + // The engine still reports its hostname, which no longer names anything here. + Service jellyfin = DockerServiceMapper.ToResources( + DockerContainerParser.Parse(Fixture.Read("docker-containers.json")), + "engine-a", + "nas01", + "192.168.1.20") + .Single(s => s.Name == "jellyfin"); + + await api.PublishAsync(DiscoveryDocument.ToYaml([jellyfin])); + + // The user's link survived the re-discovery; the stale hostname did not land. + Assert.Contains("- storage-01", api.StoredYaml); + Assert.Equal(1, Count(api.StoredYaml, "name: jellyfin")); + Assert.DoesNotContain("- nas01", api.StoredYaml); + } + + [Fact] + public async Task A_dry_run_reports_the_change_without_making_it() { + using var api = new DiscoveryApiFixture(); + + ImportYamlResponse response = await api.PublishAsync(SystemYaml(), true); + + Assert.Equal(["nas01"], response.Added); + Assert.DoesNotContain("nas01", api.StoredYaml); + } + + [Fact] + public async Task Machines_sharing_a_cloned_machine_id_are_rejected_rather_than_silently_merged() { + using var api = new DiscoveryApiFixture(); + + var yaml = DiscoveryDocument.ToYaml([ + SystemResourceMapper.ToResource(Facts("clone", "vm-a")), + SystemResourceMapper.ToResource(Facts("clone", "vm-b")) + ]); + + InvalidOperationException error = + await Assert.ThrowsAsync(() => api.PublishAsync(yaml)); + + Assert.Contains("machine-id", error.Message); + } + + private static int Count(string haystack, string needle) { + var count = 0; + var index = haystack.IndexOf(needle, StringComparison.Ordinal); + + while (index >= 0) { + count++; + index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal); + } + + return count; + } + + [Fact] + public async Task Discovering_a_machine_does_not_destroy_hardware_documented_under_the_same_name() { + using var api = new DiscoveryApiFixture(); + + // A very ordinary starting point: the box was documented as hardware by hand. + await api.PublishAsync(""" + version: 3 + resources: + - kind: Server + name: nas01 + notes: 4U chassis, bought 2019 + ram: + size: 64 + """); + + ImportYamlResponse response = await api.PublishAsync(SystemYaml()); + + // The hardware is untouched... + Assert.Contains("kind: Server", api.StoredYaml); + Assert.Contains("4U chassis", api.StoredYaml); + + // ...and the operating system is recorded alongside it rather than instead of it. + Assert.Single(response.Added); + Assert.StartsWith("nas01-", response.Added[0]); + Assert.Contains("kind: System", api.StoredYaml); + } + + [Fact] + public async Task Many_machines_pushing_at_once_do_not_lose_each_other() { + // The realistic shape of this feature: a fleet on the same nightly timer, all + // arriving within the same second. A read-modify-write that is not serialised + // would silently drop most of them. + using var api = new DiscoveryApiFixture(); + + const int machines = 12; + + await Task.WhenAll(Enumerable.Range(0, machines) + .Select(i => api.PublishAsync(SystemYaml($"machine-{i}", $"box-{i:00}")))); + + var stored = api.StoredYaml; + + for (var i = 0; i < machines; i++) { + Assert.Contains($"name: box-{i:00}", stored); + Assert.Contains($"discoveryId: {IdFor($"machine-{i}")}", stored); + } + + Assert.Equal(machines, Count(stored, "kind: System")); + } + + [Fact] + public async Task Repeated_runs_converge_rather_than_accumulating() { + using var api = new DiscoveryApiFixture(); + + for (var i = 0; i < 10; i++) + await api.PublishAsync(SystemYaml(ramGb: 60 + i)); + + Assert.Equal(1, Count(api.StoredYaml, "kind: System")); + Assert.Contains("ram: 69", api.StoredYaml); + } + + private static string ProxmoxYaml(string guestName = "docker-01") { + ProxmoxNode node = new() { + Name = "pve01", + Cores = 12, + MemoryBytes = 67438305280, + Version = "pve-manager/8.2.2/x" + }; + + ProxmoxGuest guest = new() { + VmId = 104, + Node = "pve01", + Name = guestName, + Type = "vm", + Cores = 4, + MemoryBytes = 8589934592, + Os = "Linux" + }; + + return DiscoveryDocument.ToYaml(ProxmoxResourceMapper.ToResources("homelab", [node], [guest])); + } + + [Fact] + public async Task A_proxmox_estate_arrives_with_its_tree_intact() { + using var api = new DiscoveryApiFixture(); + + ImportYamlResponse response = await api.PublishAsync(ProxmoxYaml()); + + // The machine, the hypervisor on it, and the guest on that. + Assert.Equal(["pve01", "pve01-pve", "docker-01"], response.Added); + + // The relationships are the tedious part to type, so they have to survive. + Assert.Contains("kind: Server", api.StoredYaml); + Assert.Contains("- pve01\n", api.StoredYaml); + Assert.Contains("- pve01-pve", api.StoredYaml); + } + + [Fact] + public async Task A_guest_renamed_in_proxmox_updates_rather_than_duplicating() { + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(ProxmoxYaml()); + ImportYamlResponse response = await api.PublishAsync(ProxmoxYaml("docker-renamed")); + + // The vmid is the identity, so renaming the guest in Proxmox does not make a + // second resource — and the name the user sees in RackPeek is left alone. + Assert.Empty(response.Added); + Assert.Equal(1, Count(api.StoredYaml, "type: vm")); + Assert.DoesNotContain("docker-renamed", api.StoredYaml); + } + + [Fact] + public async Task Known_limitation_a_guest_discovered_twice_over_is_two_resources() { + // Proxmox identifies a guest by vmid; the guest identifies itself by machine-id. + // Neither can derive the other, so running both collectors over the same machine + // produces two resources. Documented rather than silently surprising: the second + // one is reported as an addition with a suffixed name, not merged into the first. + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(ProxmoxYaml()); + ImportYamlResponse response = await api.PublishAsync(SystemYaml(hostname: "docker-01")); + + Assert.Single(response.Added); + Assert.StartsWith("docker-01-", response.Added[0]); + } +} diff --git a/Tests.Discovery/DockerDiscoveryTests.cs b/Tests.Discovery/DockerDiscoveryTests.cs new file mode 100644 index 00000000..d0701c1b --- /dev/null +++ b/Tests.Discovery/DockerDiscoveryTests.cs @@ -0,0 +1,111 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources.Services; + +namespace Tests.Discovery; + +/// +/// A captured GET /containers/json response in, Service resources out. +/// No Docker daemon is needed, so this runs anywhere. +/// +public class DockerDiscoveryTests { + private const string _hostSeed = "7f3c9a1e5b2d4f6081a3c5e7b9d1f3a5"; + + private static List 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 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 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 containers = DockerContainerParser.Parse(Fixture.Read("docker-containers.json")); + + List onNas = DockerServiceMapper.ToResources(containers, "host-a", "nas01", "192.168.1.20"); + List 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); + } +} diff --git a/Tests.Discovery/FailureModeTests.cs b/Tests.Discovery/FailureModeTests.cs new file mode 100644 index 00000000..51315ff2 --- /dev/null +++ b/Tests.Discovery/FailureModeTests.cs @@ -0,0 +1,102 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// Discovery runs unattended on machines nobody is watching, so its failures have to +/// be legible from a log line rather than a stack trace. +/// +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( + () => 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(() => 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(() => DockerContainerParser.Parse("not json at all")); + + [Fact] + public void A_container_with_no_name_is_skipped_rather_than_named_badly() { + List 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(() => + 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)])); + } +} diff --git a/Tests.Discovery/Fixture.cs b/Tests.Discovery/Fixture.cs new file mode 100644 index 00000000..9488e8f3 --- /dev/null +++ b/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; + +/// +/// 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. +/// +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> _schemas = new(); + + public static string Read(string name) => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name)); + + /// + /// Asserts a discovery document satisfies the published RackPeek schema, so the + /// collectors cannot drift away from the contract the rest of the world imports. + /// + public static void AssertConformsToSchema(string yaml, int version = 4) { + JsonSchema schema = _schemas.GetOrAdd(version, v => new Lazy(() => + 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(); + 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 errors) { + if (node.Errors != null) + foreach (KeyValuePair 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"; + } + } +} diff --git a/Tests.Discovery/Fixtures/docker-containers.json b/Tests.Discovery/Fixtures/docker-containers.json new file mode 100644 index 00000000..6abf2f4c --- /dev/null +++ b/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" } + ] + } +] diff --git a/Tests.Discovery/Fixtures/docker-info.json b/Tests.Discovery/Fixtures/docker-info.json new file mode 100644 index 00000000..a7384ad0 --- /dev/null +++ b/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" +} diff --git a/Tests.Discovery/Fixtures/linux-cgroup-container b/Tests.Discovery/Fixtures/linux-cgroup-container new file mode 100644 index 00000000..b26c6892 --- /dev/null +++ b/Tests.Discovery/Fixtures/linux-cgroup-container @@ -0,0 +1 @@ +0::/docker/3f1a9c0e5b7d4a2f8c6e1d9b3a5f7c2e4d6b8a0c2e4f6a8b0d2f4a6c8e0b2d4f diff --git a/Tests.Discovery/Fixtures/linux-cgroup-host b/Tests.Discovery/Fixtures/linux-cgroup-host new file mode 100644 index 00000000..8dc7ddcf --- /dev/null +++ b/Tests.Discovery/Fixtures/linux-cgroup-host @@ -0,0 +1 @@ +0::/init.scope diff --git a/Tests.Discovery/Fixtures/linux-meminfo b/Tests.Discovery/Fixtures/linux-meminfo new file mode 100644 index 00000000..474329f0 --- /dev/null +++ b/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 diff --git a/Tests.Discovery/Fixtures/linux-os-release b/Tests.Discovery/Fixtures/linux-os-release new file mode 100644 index 00000000..33208621 --- /dev/null +++ b/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/" diff --git a/Tests.Discovery/Fixtures/macos-ioreg.txt b/Tests.Discovery/Fixtures/macos-ioreg.txt new file mode 100644 index 00000000..a49dc274 --- /dev/null +++ b/Tests.Discovery/Fixtures/macos-ioreg.txt @@ -0,0 +1,8 @@ ++-o J316sAP + { + "IOPlatformSystemSleepPolicy" = + "IOPolledInterface" = "AppleARMWatchdogTimerHibernateHandler is not serializable" + "IOPlatformUUID" = "5C8E1F2A-3B4D-5E6F-7A8B-9C0D1E2F3A4B" + "model" = <"Mac14,6"> + "serial-number" = + } diff --git a/Tests.Discovery/Fixtures/pve-cluster-status-standalone.json b/Tests.Discovery/Fixtures/pve-cluster-status-standalone.json new file mode 100644 index 00000000..c7e787f4 --- /dev/null +++ b/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"} +]} diff --git a/Tests.Discovery/Fixtures/pve-cluster-status.json b/Tests.Discovery/Fixtures/pve-cluster-status.json new file mode 100644 index 00000000..4b5a6b37 --- /dev/null +++ b/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"} +]} diff --git a/Tests.Discovery/Fixtures/pve-disks.json b/Tests.Discovery/Fixtures/pve-disks.json new file mode 100644 index 00000000..fad13f71 --- /dev/null +++ b/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":""} +]} diff --git a/Tests.Discovery/Fixtures/pve-hardware-pci.json b/Tests.Discovery/Fixtures/pve-hardware-pci.json new file mode 100644 index 00000000..0430237e --- /dev/null +++ b/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 + } + ] +} diff --git a/Tests.Discovery/Fixtures/pve-lxc-config-dhcp.json b/Tests.Discovery/Fixtures/pve-lxc-config-dhcp.json new file mode 100644 index 00000000..206b485c --- /dev/null +++ b/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" +}} diff --git a/Tests.Discovery/Fixtures/pve-lxc-config.json b/Tests.Discovery/Fixtures/pve-lxc-config.json new file mode 100644 index 00000000..683e2fa8 --- /dev/null +++ b/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 +}} diff --git a/Tests.Discovery/Fixtures/pve-lxc.json b/Tests.Discovery/Fixtures/pve-lxc.json new file mode 100644 index 00000000..3efaff51 --- /dev/null +++ b/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"} +]} diff --git a/Tests.Discovery/Fixtures/pve-node-status.json b/Tests.Discovery/Fixtures/pve-node-status.json new file mode 100644 index 00000000..fa9d2869 --- /dev/null +++ b/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" +}} diff --git a/Tests.Discovery/Fixtures/pve-nodes-full.json b/Tests.Discovery/Fixtures/pve-nodes-full.json new file mode 100644 index 00000000..4223e357 --- /dev/null +++ b/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} +]} diff --git a/Tests.Discovery/Fixtures/pve-nodes.json b/Tests.Discovery/Fixtures/pve-nodes.json new file mode 100644 index 00000000..656316f3 --- /dev/null +++ b/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"} +]} diff --git a/Tests.Discovery/Fixtures/pve-qemu-config-passthrough.json b/Tests.Discovery/Fixtures/pve-qemu-config-passthrough.json new file mode 100644 index 00000000..7b1b3f74 --- /dev/null +++ b/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" +}} diff --git a/Tests.Discovery/Fixtures/pve-qemu-config.json b/Tests.Discovery/Fixtures/pve-qemu-config.json new file mode 100644 index 00000000..0fe30ac0 --- /dev/null +++ b/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" +}} diff --git a/Tests.Discovery/Fixtures/pve-qemu.json b/Tests.Discovery/Fixtures/pve-qemu.json new file mode 100644 index 00000000..9e9a63fd --- /dev/null +++ b/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} +]} diff --git a/Tests.Discovery/ProxmoxClientTests.cs b/Tests.Discovery/ProxmoxClientTests.cs new file mode 100644 index 00000000..94c9f267 --- /dev/null +++ b/Tests.Discovery/ProxmoxClientTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// 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. +/// +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(() => client.GetNodesAsync()); + + Assert.Contains("user@realm!tokenname", error.Message); + } + + private sealed class StubHandler(bool clusterAvailable) : HttpMessageHandler { + public List Paths { get; } = []; + public string? Authorization { get; private set; } + public bool Unauthorized { get; init; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) { + var path = request.RequestUri!.AbsolutePath; + Paths.Add(path); + Authorization = request.Headers.TryGetValues("Authorization", out IEnumerable? 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 Respond(HttpStatusCode status, string body) => + Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } +} diff --git a/Tests.Discovery/ProxmoxDiscoveryTests.cs b/Tests.Discovery/ProxmoxDiscoveryTests.cs new file mode 100644 index 00000000..ff248eb4 --- /dev/null +++ b/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; + +/// +/// 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. +/// +public class ProxmoxDiscoveryTests { + private const string _scope = "homelab"; + + private static List 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 qemu = ProxmoxResponseParser.ParseGuests( + Fixture.Read("pve-qemu.json"), "pve01", ProxmoxResponseParser.VmType); + + List 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 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().Single(r => r.Name == name); + + // ---------------------------------------------------------------- the tree + + [Fact] + public void A_node_becomes_both_the_machine_and_the_hypervisor_on_it() { + List resources = Discover(); + + Server server = Assert.Single(resources.OfType()); + SystemResource hypervisor = resources.OfType().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() + .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().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().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().Single(); + + List 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 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().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().Single().Gpus); + } + + [Fact] + public void The_machine_carries_its_memory() => + Assert.Equal(63, Discover().OfType().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 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 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 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 resources = Discover(); + + Assert.NotEqual( + resources.OfType().Single().DiscoveryId, + resources.OfType().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 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 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 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 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 resources = ProxmoxResourceMapper.ToResources(_scope, [bare], [guest]); + + Server server = resources.OfType().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().Single(r => r.Type == "vm").RunsOn); + } + + // --------------------------------------------------------------- conformance + + [Fact] + public void Types_are_ones_the_schema_accepts() => + Assert.All( + Discover().OfType(), + 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 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().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 resources = DiscoverWithPassthrough(); + + // The guest records the assignment; the Server records the hardware. + Server server = resources.OfType().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 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(), 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().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().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().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())); +} diff --git a/Tests.Discovery/RealProbeTests.cs b/Tests.Discovery/RealProbeTests.cs new file mode 100644 index 00000000..4d78eccd --- /dev/null +++ b/Tests.Discovery/RealProbeTests.cs @@ -0,0 +1,103 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// 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. +/// +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); + } + + /// + /// 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. + /// + 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)])); + } +} diff --git a/Tests.Discovery/RemoteDockerDiscoveryTests.cs b/Tests.Discovery/RemoteDockerDiscoveryTests.cs new file mode 100644 index 00000000..bd4857b4 --- /dev/null +++ b/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; + +/// +/// 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 (GET /info), 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. +/// +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 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 fromLaptop = DockerServiceMapper.ToResources(containers, engineId, "nas01", "192.168.1.20"); + List 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 existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")]; + List 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 existing = [StoredHost("storage-01"), StoredHost2("pi01"), ServiceRunningOn("storage-01")]; + List 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 existing = [StoredHost("storage-01"), ServiceRunningOn("storage-01")]; + List 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 existing = [StoredHost("storage-01")]; + List 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 existing = [unparented]; + List incoming = [ServiceRunningOn("nas01")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["nas01"], incoming[0].RunsOn); + } + + /// A second stored host with its own identity. + private static SystemResource StoredHost2(string name) { + SystemResource host = StoredHost(name); + host.DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-b"); + + return host; + } +} diff --git a/Tests.Discovery/SystemDiscoveryTests.cs b/Tests.Discovery/SystemDiscoveryTests.cs new file mode 100644 index 00000000..d5baa603 --- /dev/null +++ b/Tests.Discovery/SystemDiscoveryTests.cs @@ -0,0 +1,194 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// 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. +/// +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)])); + } +} diff --git a/Tests.Discovery/Tests.Discovery.csproj b/Tests.Discovery/Tests.Discovery.csproj new file mode 100644 index 00000000..65f88ba4 --- /dev/null +++ b/Tests.Discovery/Tests.Discovery.csproj @@ -0,0 +1,43 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/Tests/Api/ApiTestBase.cs b/Tests/Api/ApiTestBase.cs index 55494872..957e0144 100644 --- a/Tests/Api/ApiTestBase.cs +++ b/Tests/Api/ApiTestBase.cs @@ -9,26 +9,32 @@ namespace Tests.Api; public abstract class ApiTestBase : IDisposable { - private readonly string _tempDir; + /// + /// The config directory the server is pointed at. Writing to it before the first + /// call to seeds an inventory, because the host is + /// not built until then. + /// + protected readonly string TempDir; protected readonly WebApplicationFactory 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() .WithWebHostBuilder(builder => { - builder.UseSetting("RPK_YAML_DIR", _tempDir); + builder.UseSetting("RPK_YAML_DIR", TempDir); builder.ConfigureAppConfiguration((context, configBuilder) => { var baseConfig = new Dictionary { + ["RPK_YAML_DIR"] = TempDir, ["RPK_API_KEY"] = "test-key-123" }; @@ -41,7 +47,7 @@ protected ApiTestBase(ITestOutputHelper output) { CliBootstrap.RegisterInternals( new ServiceCollection(), configuration, - _tempDir, + TempDir, "test.yaml") .GetAwaiter() .GetResult(); @@ -63,8 +69,8 @@ public void Dispose() { try { Factory.Dispose(); - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); + if (Directory.Exists(TempDir)) + Directory.Delete(TempDir, true); } catch { // ignore cleanup issues diff --git a/Tests/Api/InventoryEndpointStartupTests.cs b/Tests/Api/InventoryEndpointStartupTests.cs new file mode 100644 index 00000000..16a1db93 --- /dev/null +++ b/Tests/Api/InventoryEndpointStartupTests.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Json; +using RackPeek.Domain.Api; +using Xunit.Abstractions; + +namespace Tests.Api; + +/// +/// The inventory API is reached without a browser — by scripts, by CI, and by +/// rpk discover --push on a timer. It therefore has to work on a server that +/// nobody has opened a page on yet. +/// +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(); + + 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()); + } +} diff --git a/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs b/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs index 3d1276bf..95a225e6 100644 --- a/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs +++ b/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs @@ -35,7 +35,7 @@ public async Task accesspoints_cli_workflow_test() { 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 async Task accesspoints_cli_workflow_test() { Assert.Equal("Access Point 'ap02' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: AccessPoint model: Unifi-U6-Lite diff --git a/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs b/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs index 04b6a3a4..7aa8a463 100644 --- a/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs +++ b/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs @@ -41,7 +41,7 @@ public async Task firewalls_cli_workflow_test() { 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 async Task firewalls_cli_workflow_test() { Assert.Equal("Firewall 'fw02' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Firewall model: Fortinet FG-60F diff --git a/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs b/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs index 41f1b0ee..fa0f4e0d 100644 --- a/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs @@ -37,7 +37,7 @@ public async Task other_cli_workflow_test() { 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 async Task other_cli_workflow_test() { Assert.Equal("Other hardware 'bridge01' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Other model: Building-Bridge-XG diff --git a/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs b/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs index 390862ed..8af3a5cc 100644 --- a/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs +++ b/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs @@ -41,7 +41,7 @@ public async Task routers_cli_workflow_test() { 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 async Task routers_cli_workflow_test() { Assert.Equal("Router 'rt02' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Router model: Ubiquiti EdgeRouter 4 diff --git a/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs b/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs index 7508fdd5..d97e3471 100644 --- a/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs +++ b/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs @@ -40,7 +40,7 @@ public async Task servers_cli_workflow_test() { Assert.Equal("Server 'srv01' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Server ram: diff --git a/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs b/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs index 7813c4d1..5415f481 100644 --- a/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs +++ b/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs @@ -46,7 +46,7 @@ public async Task services_cli_workflow_test() { outputHelper.WriteLine(yaml); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: System name: sys01 diff --git a/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs b/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs index afb05678..7120bc01 100644 --- a/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs +++ b/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs @@ -42,7 +42,7 @@ public async Task switches_cli_workflow_test() { Assert.Equal("Switch 'sw01' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Switch model: Netgear GS108 @@ -67,7 +67,7 @@ public async Task switches_cli_workflow_test() { Assert.Equal("Switch 'sw02' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Switch model: Netgear GS108 diff --git a/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs b/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs index 1fc7c80b..d3c432ad 100644 --- a/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs +++ b/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs @@ -46,7 +46,7 @@ public async Task systems_cli_workflow_test() { outputHelper.WriteLine(yaml); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Server name: proxmox-node01 @@ -158,7 +158,7 @@ public async Task systems_cli_workflow_runs_on_hardware_and_systems_test() { // Assert resulting YAML Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Server name: proxmox-node01 diff --git a/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs b/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs index f1512d9e..95c5d233 100644 --- a/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs +++ b/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs @@ -37,7 +37,7 @@ public async Task ups_cli_workflow_test() { 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 async Task ups_cli_workflow_test() { Assert.Equal("UPS 'ups02' updated.\n", output); Assert.Equal(""" - version: 3 + version: 4 resources: - kind: Ups model: APC-SmartUPS-1500 diff --git a/Tests/TestConfigs/v4/01-server.yaml b/Tests/TestConfigs/v4/01-server.yaml new file mode 100644 index 00000000..776736e2 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/02-firewall.yaml b/Tests/TestConfigs/v4/02-firewall.yaml new file mode 100644 index 00000000..4406734e --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/03-router.yaml b/Tests/TestConfigs/v4/03-router.yaml new file mode 100644 index 00000000..8e99a5f3 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/04-switch.yaml b/Tests/TestConfigs/v4/04-switch.yaml new file mode 100644 index 00000000..d9406bdd --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/05-accesspoint.yaml b/Tests/TestConfigs/v4/05-accesspoint.yaml new file mode 100644 index 00000000..a3b16c60 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/06-ups.yaml b/Tests/TestConfigs/v4/06-ups.yaml new file mode 100644 index 00000000..ec14fba4 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/07-desktop.yaml b/Tests/TestConfigs/v4/07-desktop.yaml new file mode 100644 index 00000000..89a534ba --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/08-laptop.yaml b/Tests/TestConfigs/v4/08-laptop.yaml new file mode 100644 index 00000000..cc7e0854 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/09-service.yaml b/Tests/TestConfigs/v4/09-service.yaml new file mode 100644 index 00000000..50cbcd98 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/10-system.yaml b/Tests/TestConfigs/v4/10-system.yaml new file mode 100644 index 00000000..4edcaa3f --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/11-demo-config.yaml b/Tests/TestConfigs/v4/11-demo-config.yaml new file mode 100644 index 00000000..2a5a6aa5 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Tests/TestConfigs/v4/12-other.yaml b/Tests/TestConfigs/v4/12-other.yaml new file mode 100644 index 00000000..ee31cb1a --- /dev/null +++ b/Tests/TestConfigs/v4/12-other.yaml @@ -0,0 +1,8 @@ +version: 4 +resources: + - kind: Other + name: unifi-radio-01 + tags: + - radio + model: Building Bridge XG + description: Microwave radio bridge for site-to-site connectivity diff --git a/Tests/Yaml/SchemaTests.cs b/Tests/Yaml/SchemaTests.cs index 27a1c3d2..ae50fa53 100644 --- a/Tests/Yaml/SchemaTests.cs +++ b/Tests/Yaml/SchemaTests.cs @@ -65,6 +65,7 @@ private static string ConvertYamlNodeToJson(YamlNode node) { [InlineData(1)] [InlineData(2)] [InlineData(3)] + [InlineData(4)] public void All_yaml_files_conform_to_schema(int version) { // Arrange JsonSchema schema = LoadSchema(version); diff --git a/justfile b/justfile index ece81949..8482c241 100644 --- a/justfile +++ b/justfile @@ -61,6 +61,11 @@ build-web: test-cli: _check-dotnet {{ _dotnet }} test Tests/Tests.csproj +[doc("Run discovery tests (fast; no Docker required; matches the discovery-tests CI job)")] +[group("test")] +test-discovery: _check-dotnet + {{ _dotnet }} test Tests.Discovery + [doc("Install Playwright + browsers for E2E (first-time only)")] [group("test")] e2e-setup: _check-dotnet @@ -73,9 +78,9 @@ e2e-setup: _check-dotnet test-e2e: _check-dotnet build-web cd Tests.E2e && {{ _dotnet }} test -[doc("Run CLI + E2E tests (rebuilds Web image)")] +[doc("Run CLI + discovery + E2E tests (rebuilds Web image)")] [group("test")] -test-all: _check-dotnet build-web e2e-setup test-cli test-e2e +test-all: _check-dotnet build-web e2e-setup test-cli test-discovery test-e2e [doc("Run full test suite (alias for test-all; matches CI / pre-PR checklist)")] [group("test")] diff --git a/schemas/v4/schema.v4.json b/schemas/v4/schema.v4.json new file mode 100644 index 00000000..dbaf1934 --- /dev/null +++ b/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 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 + } + } +} From b8f6d234426906df95a7cb041478a768f317c7ae Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Fri, 25 Sep 2026 20:44:07 +0100 Subject: [PATCH 02/29] Fix flaky E2E add: wait for the Blazor circuit before typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blazor Server prerenders every page before the circuit attaches, and input events sent to that static HTML are silently lost — so a fast test run could fill the add form, click add, and be told "name is required" because the server-side model never saw the keystrokes. CI runners lose that race intermittently (seen on OtherCardTests); a fast machine wins it, which is why it never reproduced locally. MainLayout now renders a hidden probe that flips to data-circuit-ready="true" on the first OnAfterRender — which never runs during prerendering, so the flip proves a live circuit. The add page object waits on the probe before typing. Reproduced deterministically first with the existing BlazorLatency helper (300ms per SignalR frame widens the attach window from milliseconds to seconds): fails on the old page object, passes with the wait. That repro stays as a regression test. Co-Authored-By: Claude Fable 5 --- Shared.Rcl/Layout/MainLayout.razor | 14 +++++++ Tests.E2e/AddResourceRaceTests.cs | 38 +++++++++++++++++++ .../PageObjectModels/AddResourceComponent.cs | 5 +++ 3 files changed, 57 insertions(+) create mode 100644 Tests.E2e/AddResourceRaceTests.cs diff --git a/Shared.Rcl/Layout/MainLayout.razor b/Shared.Rcl/Layout/MainLayout.razor index 3816540f..6035e22d 100644 --- a/Shared.Rcl/Layout/MainLayout.razor +++ b/Shared.Rcl/Layout/MainLayout.razor @@ -6,6 +6,11 @@ @inject NavigationManager Nav @inject IJSRuntime JS + + +
@@ -142,6 +147,7 @@ private const string _outsideDismissId = "rpk-mobile-nav"; private const string _containerSelector = "#rpk-mobile-nav"; + private bool _circuitReady; private bool _dropdownOpen; private bool _listenerRegistered; private DotNetObjectReference? _selfRef; @@ -169,6 +175,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { + // OnAfterRender never runs during prerendering, so this re-render proves a + // live circuit to anything watching the probe above. + if (firstRender && !_circuitReady) + { + _circuitReady = true; + StateHasChanged(); + } + // Mirror the dropdown state into the JS dismiss-listener registration // so the listener only exists while it's needed. if (_dropdownOpen && !_listenerRegistered) diff --git a/Tests.E2e/AddResourceRaceTests.cs b/Tests.E2e/AddResourceRaceTests.cs new file mode 100644 index 00000000..06243373 --- /dev/null +++ b/Tests.E2e/AddResourceRaceTests.cs @@ -0,0 +1,38 @@ +using Microsoft.Playwright; +using Tests.E2e.Infra; +using Tests.E2e.PageObjectModels; +using Xunit.Abstractions; + +namespace Tests.E2e; + +/// +/// Blazor Server prerenders every page before the circuit attaches, and anything +/// typed into that static HTML never reaches the server-side model. A fast test — +/// or a fast typist on a slow link — can therefore submit the add form and be told +/// "name is required" despite having filled it in. The injected latency makes the +/// attach window, which CI runners only sometimes lose, wide enough to lose every +/// time; the page object must wait for the circuit before it types. +/// +public class AddResourceRaceTests( + PlaywrightFixture fixture, + ITestOutputHelper output) : E2ETestBase(fixture, output) { + private readonly PlaywrightFixture _fixture = fixture; + + [Fact] + public async Task Adding_A_Resource_Works_Before_The_Circuit_Has_Warmed_Up() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + await BlazorLatency.AddAsync(page, TimeSpan.FromMilliseconds(300)); + + var name = $"e2e-oth-{Guid.NewGuid():N}"[..16]; + + try { + await page.GotoAsync($"{_fixture.BaseUrl}/other/list"); + + var list = new OtherListPom(page); + await list.AddOtherAsync(name); + } + finally { + await context.CloseAsync(); + } + } +} diff --git a/Tests.E2e/PageObjectModels/AddResourceComponent.cs b/Tests.E2e/PageObjectModels/AddResourceComponent.cs index 30ec9af6..6455e3dd 100644 --- a/Tests.E2e/PageObjectModels/AddResourceComponent.cs +++ b/Tests.E2e/PageObjectModels/AddResourceComponent.cs @@ -34,6 +34,11 @@ public ILocator Error public async Task AddAsync(string name) { await Assertions.Expect(Root).ToBeVisibleAsync(); + // Never type into the prerendered page: those input events are lost before + // the circuit attaches, and the submit then fails with an empty name. + await Assertions.Expect(page.GetByTestId("circuit-probe")) + .ToHaveAttributeAsync("data-circuit-ready", "true"); + await Input.FillAsync(name); await Button.ClickAsync(); } From e1a7a6f5ad0ba625e1c9514fbc1674e91275810e Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sat, 26 Sep 2026 10:53:03 +0100 Subject: [PATCH 03/29] Add MCP server: manage, query and build the inventory from AI assistants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RackPeek.Web now hosts a Model Context Protocol server over streamable HTTP at /mcp — no extra process, it runs whenever the server runs, and it sits behind the same X-Api-Key gate as /api/inventory (503 until RPK_API_KEY is set, so it is off by default). 24 consolidated tools in a new RackPeek.Mcp project (Domain-only deps, so the CLI binaries stay MCP-free): - Query: list/get/search resources, summary, containment tree, connections, subnets, and get_schema so agents learn the YAML format before writing. - Editing: upsert_resources (bulk YAML with dry-run diffs — the main write path), delete/rename/clone, tag/label edits, connection add/remove. - Exporters: ansible inventory, ssh config, hosts file, mermaid topology. - Git: status + commit(+push), active on the existing GIT_TOKEN opt-in. - Discovery: docker and proxmox run from the server with preview-then-apply; credentials come from server config, never from the conversation. Every tool reuses the existing use cases, so validation, conflict rules, discovery-id resolution and file locking behave exactly like the CLI and UI. Domain errors surface as actionable tool errors; anything unexpected stays generic. GlobalSearchService moves from Shared.Rcl to RackPeek.Domain/Search so the search tool can reach it without UI dependencies. Testing is end-to-end at two tiers: Tests.Mcp (70 tests) drives a real MCP client over WebApplicationFactory through every tool down to the YAML on disk, including fake docker/proxmox engines on real sockets and schema conformance on everything the tools emit; Tests.E2e gains McpE2eTests running a full session against the shipped Docker image over the network. New mcp-tests CI job and `just test-mcp` target. Also fixes a duplicate lounge-ap resource in the v3/v4 demo configs that crashed `rpk graph topology` (surfaced by the new export tool tests). Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 23 ++ README.md | 3 + .../Search}/GlobalSearchService.cs | 2 +- RackPeek.Mcp/McpSetup.cs | 26 ++ RackPeek.Mcp/RackPeek.Mcp.csproj | 30 ++ RackPeek.Mcp/ResourceKindDispatch.cs | 54 +++ RackPeek.Mcp/ToolErrors.cs | 33 ++ RackPeek.Mcp/Tools/DiscoveryTools.cs | 206 ++++++++++ RackPeek.Mcp/Tools/ExportTools.cs | 83 ++++ RackPeek.Mcp/Tools/GitTools.cs | 95 +++++ RackPeek.Mcp/Tools/MutationTools.cs | 170 ++++++++ RackPeek.Mcp/Tools/QueryTools.cs | 220 +++++++++++ RackPeek.Web/Program.cs | 19 + RackPeek.Web/RackPeek.Web.csproj | 7 +- RackPeek.sln | 99 +++++ Shared.Rcl/Components/GlobalSearch.razor | 2 +- Shared.Rcl/wwwroot/raw_docs/docs-index.json | 1 + Shared.Rcl/wwwroot/raw_docs/mcp-guide.md | 117 ++++++ Tests.E2e/McpE2eTests.cs | 182 +++++++++ Tests.E2e/Tests.E2e.csproj | 11 +- Tests.Mcp/AuthTests.cs | 78 ++++ Tests.Mcp/DiscoveryToolTests.cs | 196 ++++++++++ Tests.Mcp/ExportToolTests.cs | 102 +++++ Tests.Mcp/FakeHttpServer.cs | 65 +++ Tests.Mcp/GitToolTests.cs | 104 +++++ Tests.Mcp/McpFixture.cs | 102 +++++ Tests.Mcp/McpTestExtensions.cs | 57 +++ Tests.Mcp/MutationToolTests.cs | 370 ++++++++++++++++++ Tests.Mcp/ProtocolTests.cs | 78 ++++ Tests.Mcp/QueryToolTests.cs | 288 ++++++++++++++ Tests.Mcp/SchemaAssert.cs | 88 +++++ Tests.Mcp/TestData.cs | 76 ++++ Tests.Mcp/Tests.Mcp.csproj | 46 +++ Tests/TestConfigs/v3/11-demo-config.yaml | 4 - Tests/TestConfigs/v4/11-demo-config.yaml | 4 - justfile | 9 +- 36 files changed, 3032 insertions(+), 18 deletions(-) rename {Shared.Rcl/Services => RackPeek.Domain/Search}/GlobalSearchService.cs (99%) create mode 100644 RackPeek.Mcp/McpSetup.cs create mode 100644 RackPeek.Mcp/RackPeek.Mcp.csproj create mode 100644 RackPeek.Mcp/ResourceKindDispatch.cs create mode 100644 RackPeek.Mcp/ToolErrors.cs create mode 100644 RackPeek.Mcp/Tools/DiscoveryTools.cs create mode 100644 RackPeek.Mcp/Tools/ExportTools.cs create mode 100644 RackPeek.Mcp/Tools/GitTools.cs create mode 100644 RackPeek.Mcp/Tools/MutationTools.cs create mode 100644 RackPeek.Mcp/Tools/QueryTools.cs create mode 100644 Shared.Rcl/wwwroot/raw_docs/mcp-guide.md create mode 100644 Tests.E2e/McpE2eTests.cs create mode 100644 Tests.Mcp/AuthTests.cs create mode 100644 Tests.Mcp/DiscoveryToolTests.cs create mode 100644 Tests.Mcp/ExportToolTests.cs create mode 100644 Tests.Mcp/FakeHttpServer.cs create mode 100644 Tests.Mcp/GitToolTests.cs create mode 100644 Tests.Mcp/McpFixture.cs create mode 100644 Tests.Mcp/McpTestExtensions.cs create mode 100644 Tests.Mcp/MutationToolTests.cs create mode 100644 Tests.Mcp/ProtocolTests.cs create mode 100644 Tests.Mcp/QueryToolTests.cs create mode 100644 Tests.Mcp/SchemaAssert.cs create mode 100644 Tests.Mcp/TestData.cs create mode 100644 Tests.Mcp/Tests.Mcp.csproj diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6507b793..9e272175 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,29 @@ jobs: run: dotnet test Tests.Discovery --configuration Release --verbosity normal + mcp-tests: + name: MCP Tests + runs-on: ubuntu-latest + needs: format + + # Every MCP test is end-to-end: a real MCP client speaking streamable HTTP to the + # real server over WebApplicationFactory, asserting on the YAML that lands on disk. + 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.Mcp + + - name: Run MCP Tests + run: dotnet test Tests.Mcp --configuration Release --verbosity normal + + cli-tests: name: CLI Tests runs-on: ubuntu-latest diff --git a/README.md b/README.md index 8ab291ca..31fdfb3f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ volumes: * [**Auto Discovery Guide**](https://timmoth.github.io/RackPeek/docs/discovery-guide) +* + [**MCP Server Guide**](https://timmoth.github.io/RackPeek/docs/mcp-guide) — let AI assistants query, manage and build your stack over the built-in `/mcp` endpoint + * [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands) diff --git a/Shared.Rcl/Services/GlobalSearchService.cs b/RackPeek.Domain/Search/GlobalSearchService.cs similarity index 99% rename from Shared.Rcl/Services/GlobalSearchService.cs rename to RackPeek.Domain/Search/GlobalSearchService.cs index 5dea1de8..fd9c5c55 100644 --- a/Shared.Rcl/Services/GlobalSearchService.cs +++ b/RackPeek.Domain/Search/GlobalSearchService.cs @@ -2,7 +2,7 @@ using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.SystemResources; -namespace Shared.Rcl.Services; +namespace RackPeek.Domain.Search; public record SearchResult( string Name, diff --git a/RackPeek.Mcp/McpSetup.cs b/RackPeek.Mcp/McpSetup.cs new file mode 100644 index 00000000..24f72627 --- /dev/null +++ b/RackPeek.Mcp/McpSetup.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using RackPeek.Mcp.Tools; + +namespace RackPeek.Mcp; + +public static class McpSetup { + /// The name MCP clients see in the initialize handshake. + public const string ServerName = "rackpeek"; + + public static IMcpServerBuilder WithRackPeekTools(this IMcpServerBuilder builder) { + // MCP tool serialization does not go through ASP.NET's ConfigureHttpJsonOptions, + // so enums-as-strings is opted into again here to match the REST API's JSON. + var json = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions); + json.Converters.Add(new JsonStringEnumConverter()); + + return builder + .WithTools(json) + .WithTools(json) + .WithTools(json) + .WithTools(json) + .WithTools(json); + } +} diff --git a/RackPeek.Mcp/RackPeek.Mcp.csproj b/RackPeek.Mcp/RackPeek.Mcp.csproj new file mode 100644 index 00000000..cfd992ba --- /dev/null +++ b/RackPeek.Mcp/RackPeek.Mcp.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/RackPeek.Mcp/ResourceKindDispatch.cs b/RackPeek.Mcp/ResourceKindDispatch.cs new file mode 100644 index 00000000..a30f60ab --- /dev/null +++ b/RackPeek.Mcp/ResourceKindDispatch.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Helpers; +using RackPeek.Domain.Persistence; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.AccessPoints; +using RackPeek.Domain.Resources.Desktops; +using RackPeek.Domain.Resources.Firewalls; +using RackPeek.Domain.Resources.Laptops; +using RackPeek.Domain.Resources.OtherHardware; +using RackPeek.Domain.Resources.Routers; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.Switches; +using RackPeek.Domain.Resources.SystemResources; +using RackPeek.Domain.Resources.UpsUnits; +using RackPeek.Domain.UseCases; + +namespace RackPeek.Mcp; + +/// +/// Closes the per-kind generic use cases over the concrete resource type at +/// runtime. MCP tools receive a name, not a type — the kind is looked up and the +/// call dispatched so type-sensitive code (the clone's deep copy serialises the +/// real derived type, not the abstract base) runs against the right generic. +/// +internal static class ResourceKindDispatch { + public static async Task CloneAsync( + IServiceProvider services, + IResourceCollection repo, + string originalName, + string cloneName) { + var kind = await repo.GetKind(originalName) + ?? throw new NotFoundException($"Resource '{originalName}' not found."); + + await (kind.Trim().ToLowerInvariant() switch { + "server" => Run(), + "switch" => Run(), + "firewall" => Run(), + "router" => Run(), + "accesspoint" => Run(), + "desktop" => Run(), + "laptop" => Run(), + "ups" => Run(), + "other" => Run(), + "system" => Run(), + "service" => Run(), + _ => throw new NotFoundException($"Resource kind '{kind}' cannot be cloned.") + }); + + Task Run() where T : Resource => + services.GetRequiredService>() + .ExecuteAsync(originalName, cloneName); + } +} diff --git a/RackPeek.Mcp/ToolErrors.cs b/RackPeek.Mcp/ToolErrors.cs new file mode 100644 index 00000000..b834249c --- /dev/null +++ b/RackPeek.Mcp/ToolErrors.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using ModelContextProtocol; +using RackPeek.Domain.Helpers; + +namespace RackPeek.Mcp; + +/// +/// Runs a tool body and rethrows the domain's user-facing exceptions as +/// so their message reaches the calling agent as a +/// tool error it can act on. Anything else falls through: the SDK reports those +/// as a generic error, deliberately, so nothing internal leaks over the wire. +/// +internal static class ToolErrors { + public static async Task RunAsync(Func> action) { + try { + return await action(); + } + catch (ValidationException ex) { + throw new McpException($"Invalid input: {ex.Message}"); + } + catch (NotFoundException ex) { + throw new McpException(ex.Message); + } + catch (ConflictException ex) { + throw new McpException(ex.Message); + } + catch (InvalidOperationException ex) { + // The connection use cases signal user-correctable mistakes with this + // ("cannot connect a port to itself", "resource has no ports"). + throw new McpException(ex.Message); + } + } +} diff --git a/RackPeek.Mcp/Tools/DiscoveryTools.cs b/RackPeek.Mcp/Tools/DiscoveryTools.cs new file mode 100644 index 00000000..4db8bbf7 --- /dev/null +++ b/RackPeek.Mcp/Tools/DiscoveryTools.cs @@ -0,0 +1,206 @@ +using System.ComponentModel; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using RackPeek.Domain.Api; +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Persistence; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Mcp.Tools; + +public sealed record DiscoveryResult( + [property: Description("What was found, as a RackPeek YAML document ready for upsert_resources.")] + string Yaml, + int ResourceCount, + [property: Description("Containers that were skipped because they publish no port reachable from outside the host.")] + int Skipped, + [property: Description("The merge outcome when apply was true; null when only previewing.")] + ImportYamlResponse? Applied); + +/// +/// Discovery run from the server against reachable infrastructure. Credentials +/// are read from the server's own configuration, never from tool parameters, so +/// secrets stay out of the conversation. `rpk discover system` has no tool here +/// on purpose: it probes the machine it runs on, which for the server is its own +/// container — run the CLI on the machine being inventoried instead. +/// +[McpServerToolType] +public sealed class DiscoveryTools(IServiceProvider services) { + [McpServerTool(Name = "discover_docker", UseStructuredContent = true, OpenWorld = true)] + [Description("Reads a Docker (or Podman) engine and maps each container with a published port to a Service. " + + "Preview first (apply=false), then apply to merge into the inventory — merging never removes anything.")] + public Task DiscoverDocker( + [Description("Docker endpoint, e.g. tcp://host:2375 or unix:///var/run/docker.sock. Defaults to DOCKER_HOST, then the local socket.")] + string? dockerHost = null, + [Description("Name of the machine the containers run on. Defaults to the engine's hostname.")] + string? hostName = null, + [Description("Merge the result into the inventory instead of only returning it.")] + bool apply = false, + CancellationToken cancellationToken = default) { + return ToolErrors.RunAsync(async () => { + SystemFacts host = await ReadHostAsync(cancellationToken); + + DockerApiClient client; + try { + client = new DockerApiClient(dockerHost); + } + catch (UriFormatException ex) { + throw new McpException($"'{dockerHost}' is not a usable Docker endpoint. {ex.Message}"); + } + + using DockerApiClient _ = client; + + IReadOnlyList containers; + try { + containers = await client.ListContainersAsync(cancellationToken); + } + catch (Exception ex) when ( + ex is HttpRequestException or IOException or TimeoutException + || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) { + throw new McpException($"Could not reach Docker at {client.Endpoint}. {ex.Message}"); + } + + // Mirrors `rpk discover docker`: over TCP the engine describes itself (its + // daemon id seeds identity, its hostname is what runsOn points at); locally + // the host probe does, and the host System rides along for id-based merging. + DockerEngineInfo? engine = client.IsLocal ? null : await client.GetInfoAsync(cancellationToken); + + SystemResource hostResource = SystemResourceMapper.ToResource(host, hostName); + + var effectiveHost = client.IsLocal + ? hostResource.Name + : hostName ?? engine?.Hostname ?? hostResource.Name; + + var seed = client.IsLocal + ? host.MachineId ?? host.Hostname + : engine?.Id ?? client.Endpoint; + + var serviceIp = client.IsLocal + ? host.Ip + : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip; + + List found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp); + + List resources = client.IsLocal && found.Count > 0 + ? [hostResource, .. found] + : [.. found]; + + return await EmitAsync(resources, containers.Count - found.Count, apply); + }); + } + + [McpServerTool(Name = "discover_proxmox", UseStructuredContent = true, OpenWorld = true)] + [Description("Reads a Proxmox VE estate: each node becomes a Server plus a hypervisor System, each VM/LXC a System " + + "running on it, already wired together. Credentials come from the server's RPK_PVE_TOKEN_ID / " + + "RPK_PVE_TOKEN_SECRET configuration. Preview first (apply=false), then apply to merge.")] + public Task DiscoverProxmox( + [Description("Proxmox host, e.g. https://pve.lan:8006. A bare host name gets https and :8006.")] + string host, + [Description("Accept a self-signed certificate, which Proxmox ships with by default.")] + bool insecure = false, + [Description("Merge the result into the inventory instead of only returning it.")] + bool apply = false, + CancellationToken cancellationToken = default) { + return ToolErrors.RunAsync(async () => { + IConfiguration config = services.GetRequiredService(); + var tokenId = config[ProxmoxApiClient.TokenIdEnvironmentVariable]; + var tokenSecret = config[ProxmoxApiClient.TokenSecretEnvironmentVariable]; + + if (string.IsNullOrWhiteSpace(tokenId) || string.IsNullOrWhiteSpace(tokenSecret)) + throw new McpException( + "Proxmox credentials are not configured on the server. Start it with " + + $"{ProxmoxApiClient.TokenIdEnvironmentVariable} and {ProxmoxApiClient.TokenSecretEnvironmentVariable} set."); + + ProxmoxApiClient client; + try { + client = new ProxmoxApiClient(host, tokenId, tokenSecret, insecure); + } + catch (UriFormatException ex) { + throw new McpException($"'{host}' is not a usable host. {ex.Message}"); + } + + List resources; + try { + resources = await ReadProxmoxAsync(client, cancellationToken); + } + catch (HttpRequestException ex) { + var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException + ? " Proxmox uses a self-signed certificate by default — try insecure=true." + : string.Empty; + + throw new McpException($"Could not read {client.Endpoint}. {ex.Message}{hint}"); + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { + // HttpClient reports its timeout as a cancellation. + throw new McpException($"{client.Endpoint} did not answer within the timeout."); + } + finally { + client.Dispose(); + } + + return await EmitAsync(resources, 0, apply); + }); + } + + private async Task ReadHostAsync(CancellationToken cancellationToken) { + // An unsupported platform is not fatal for docker discovery — the containers can + // still be read; only the host's own facts fall back to basics. + return await SystemProbes.TryReadHostAsync(services.GetServices(), cancellationToken) + ?? SystemFactsParser.Parse(new RawSystemSnapshot { + Hostname = Environment.MachineName, + Cores = Environment.ProcessorCount + }); + } + + /// Same read orchestration as `rpk discover proxmox`. + private static async Task> ReadProxmoxAsync( + IProxmoxClient client, + CancellationToken cancellationToken) { + var scope = await client.GetIdentityScopeAsync(cancellationToken); + IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); + + var nodes = new List(); + var guests = new List(); + + foreach (ProxmoxNode listedNode in listed) { + ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken); + nodes.Add(node); + + foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) { + IReadOnlyList listedGuests = + await client.GetGuestsAsync(node.Name, endpoint, cancellationToken); + + ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g => + client.GetGuestConfigAsync(node.Name, 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); + } + + private async Task EmitAsync(List resources, int skipped, bool apply) { + var yaml = DiscoveryDocument.ToYaml(resources); + + ImportYamlResponse? applied = null; + if (apply && resources.Count > 0) + applied = await MutationTools.RunUpsertAsync(services, new ImportYamlRequest { + Yaml = yaml, + // Discovery can add and update but must never remove what the user wrote. + Mode = MergeMode.Merge + }); + + return new DiscoveryResult(yaml, resources.Count, skipped, applied); + } +} diff --git a/RackPeek.Mcp/Tools/ExportTools.cs b/RackPeek.Mcp/Tools/ExportTools.cs new file mode 100644 index 00000000..4559535d --- /dev/null +++ b/RackPeek.Mcp/Tools/ExportTools.cs @@ -0,0 +1,83 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; +using RackPeek.Domain.Graph; +using RackPeek.Domain.Graph.Serialisers; +using RackPeek.Domain.Graph.UseCases; +using RackPeek.Domain.UseCases.Ansible; +using RackPeek.Domain.UseCases.Hosts; +using RackPeek.Domain.UseCases.SSH; + +namespace RackPeek.Mcp.Tools; + +public enum TopologyView { + Physical, + Logical +} + +[McpServerToolType] +public sealed class ExportTools(IServiceProvider services) { + [McpServerTool(Name = "export_ansible_inventory", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Renders the inventory as an Ansible inventory file. A resource needs an 'ansible_host', 'ip' or " + + "'hostname' label to be included, and hosts are emitted under the groups built from groupByTags / " + + "groupByLabelKeys — pass at least one of those or the inventory comes back empty.")] + public Task ExportAnsibleInventory( + [Description("Output format: Ini or Yaml.")] InventoryFormat format = InventoryFormat.Ini, + [Description("Create a group per listed tag.")] string[]? groupByTags = null, + [Description("Create groups from these label keys, e.g. 'env' groups hosts into env_prod, env_dev.")] + string[]? groupByLabelKeys = null) { + return ToolErrors.RunAsync(() => + services.GetRequiredService().ExecuteAsync(new InventoryOptions { + Format = format, + GroupByTags = groupByTags ?? [], + GroupByLabelKeys = groupByLabelKeys ?? [] + })); + } + + [McpServerTool(Name = "export_ssh_config", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Renders the inventory as an OpenSSH client config (Host blocks).")] + public Task ExportSshConfig( + [Description("Only include resources carrying at least one of these tags.")] + string[]? includeTags = null, + [Description("Default SSH user for every host.")] string? defaultUser = null, + [Description("Default SSH port for every host.")] int defaultPort = 22, + [Description("Default IdentityFile path for every host.")] string? defaultIdentityFile = null) { + return ToolErrors.RunAsync(() => + services.GetRequiredService().ExecuteAsync(new SshExportOptions { + IncludeTags = includeTags ?? [], + DefaultUser = defaultUser, + DefaultPort = defaultPort, + DefaultIdentityFile = defaultIdentityFile + })); + } + + [McpServerTool(Name = "export_hosts_file", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Renders the inventory as /etc/hosts entries.")] + public Task ExportHostsFile( + [Description("Only include resources carrying at least one of these tags.")] + string[]? includeTags = null, + [Description("Domain suffix appended to every host name, e.g. 'home.local'.")] + string? domainSuffix = null, + [Description("Include the localhost entries at the top.")] + bool includeLocalhostDefaults = true) { + return ToolErrors.RunAsync(() => + services.GetRequiredService().ExecuteAsync(new HostsExportOptions { + IncludeTags = includeTags ?? [], + DomainSuffix = domainSuffix, + IncludeLocalhostDefaults = includeLocalhostDefaults + })); + } + + [McpServerTool(Name = "export_topology_mermaid", ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Renders the infrastructure as a Mermaid diagram: Physical shows hardware and its port connections, Logical shows host cards with their services grouped by subnet.")] + public Task ExportTopologyMermaid( + [Description("Physical or Logical.")] TopologyView view = TopologyView.Physical) { + return ToolErrors.RunAsync(async () => { + Graph graph = view == TopologyView.Physical + ? await services.GetRequiredService().ExecuteAsync() + : await services.GetRequiredService().ExecuteAsync(); + + return new MermaidSerialiser().Serialise(graph); + }); + } +} diff --git a/RackPeek.Mcp/Tools/GitTools.cs b/RackPeek.Mcp/Tools/GitTools.cs new file mode 100644 index 00000000..0fd70e7b --- /dev/null +++ b/RackPeek.Mcp/Tools/GitTools.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using RackPeek.Domain.Git; +using RackPeek.Domain.Git.UseCases; + +namespace RackPeek.Mcp.Tools; + +public sealed record GitStatusResult( + [property: Description("False when the server has no GIT_TOKEN configured — every other field is then empty.")] + bool Available, + string? Message, + string? Branch = null, + [property: Description("Clean or Dirty.")] string? Status = null, + string[]? ChangedFiles = null, + bool HasRemote = false, + [property: Description("Commits the remote is missing. Null when there is no remote.")] + int? Ahead = null, + [property: Description("Commits this repo is missing. Null when there is no remote.")] + int? Behind = null, + List? RecentCommits = null); + +/// +/// Version control over the config directory. Only active when the server is +/// started with GIT_TOKEN — otherwise is wired +/// in and these tools explain how to enable it instead of failing obscurely. +/// +[McpServerToolType] +public sealed class GitTools(IGitRepository repo, IServiceProvider services) { + private const string _notConfigured = + "Git integration is not configured. Start the server with GIT_TOKEN " + + "(and optionally GIT_USERNAME) set to enable it."; + + [McpServerTool(Name = "git_status", UseStructuredContent = true, ReadOnly = true, OpenWorld = false)] + [Description("The config repository's branch, dirty/clean state, changed files, remote sync state and recent commits.")] + public Task GitStatus() { + return ToolErrors.RunAsync(() => { + if (!repo.IsAvailable) + return Task.FromResult(new GitStatusResult(false, _notConfigured)); + + try { + var hasRemote = repo.HasRemote(); + GitSyncStatus? sync = hasRemote ? repo.FetchAndGetSyncStatus() : null; + + GitLogEntry[] log; + try { + log = repo.GetLog(5); + } + catch { + // An empty repository has no log yet; that is not an error. + log = []; + } + + return Task.FromResult(new GitStatusResult( + true, + sync?.Error, + repo.GetCurrentBranch(), + repo.GetStatus().ToString(), + repo.GetChangedFiles(), + hasRemote, + sync?.Ahead, + sync?.Behind, + log.Select(e => $"{e.Hash} {e.Date} {e.Author}: {e.Message}").ToList())); + } + catch (Exception ex) when (ex is not McpException) { + throw new McpException($"Git error: {ex.Message}"); + } + }); + } + + [McpServerTool(Name = "git_commit", Idempotent = true, OpenWorld = false)] + [Description("Stages everything in the config directory and commits it. A clean tree commits nothing and still succeeds.")] + public Task GitCommit( + [Description("The commit message.")] string message, + [Description("Also push to the configured remote.")] bool push = false) { + return ToolErrors.RunAsync(async () => { + if (!repo.IsAvailable) + throw new McpException(_notConfigured); + + var error = await services.GetRequiredService().ExecuteAsync(message); + if (error != null) + throw new McpException(error); + + if (!push) + return "Committed."; + + var pushError = await services.GetRequiredService().ExecuteAsync(); + if (pushError != null) + throw new McpException($"Committed, but the push failed: {pushError}"); + + return "Committed and pushed."; + }); + } +} diff --git a/RackPeek.Mcp/Tools/MutationTools.cs b/RackPeek.Mcp/Tools/MutationTools.cs new file mode 100644 index 00000000..acab1c93 --- /dev/null +++ b/RackPeek.Mcp/Tools/MutationTools.cs @@ -0,0 +1,170 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using RackPeek.Domain.Api; +using RackPeek.Domain.Helpers; +using RackPeek.Domain.Persistence; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.UseCases; +using RackPeek.Domain.UseCases.Labels; +using RackPeek.Domain.UseCases.Tags; + +namespace RackPeek.Mcp.Tools; + +public sealed record TagsResult(string Name, string[] Tags); + +public sealed record LabelsResult(string Name, Dictionary Labels); + +[McpServerToolType] +public sealed class MutationTools(IResourceCollection repo, IServiceProvider services) { + [McpServerTool(Name = "upsert_resources", UseStructuredContent = true, Idempotent = true, OpenWorld = false)] + [Description("Creates or updates resources (and connections) from a RackPeek YAML document — the main " + + "way to build and edit the inventory. Call get_schema first to learn the format, and " + + "preview with dryRun before writing. Merge mode only adds and updates; it never removes.")] + public Task UpsertResources( + [Description("A RackPeek YAML document: 'version', 'resources' and optional 'connections'.")] + string yaml, + [Description("Merge folds each incoming resource into the stored one, field by field. " + + "Replace swaps each incoming resource in wholesale (other resources are untouched).")] + MergeMode mode = MergeMode.Merge, + [Description("When true, nothing is written — the response shows what would change.")] + bool dryRun = false) => + RunUpsertAsync(services, new ImportYamlRequest { Yaml = yaml, Mode = mode, DryRun = dryRun }); + + [McpServerTool(Name = "delete_resource", Destructive = true, Idempotent = true, OpenWorld = false)] + [Description("Deletes a resource, detaches everything that ran on it, and removes its connections.")] + public Task DeleteResource( + [Description("The resource's name.")] string name) { + return ToolErrors.RunAsync(async () => { + await services.GetRequiredService>().ExecuteAsync(name); + return $"Deleted '{name}'."; + }); + } + + [McpServerTool(Name = "rename_resource", Idempotent = true, OpenWorld = false)] + [Description("Renames a resource and rewrites every runsOn reference and connection endpoint that pointed at it.")] + public Task RenameResource( + [Description("The resource's current name.")] string name, + [Description("The new name.")] string newName) { + return ToolErrors.RunAsync(async () => { + await services.GetRequiredService>().ExecuteAsync(name, newName); + return $"Renamed '{name}' to '{newName}'."; + }); + } + + [McpServerTool(Name = "clone_resource", OpenWorld = false)] + [Description("Copies a resource under a new name. The copy has no discovery id — it describes new gear, not the original machine.")] + public Task CloneResource( + [Description("The resource to copy.")] string name, + [Description("The copy's name.")] string cloneName) { + return ToolErrors.RunAsync(async () => { + await ResourceKindDispatch.CloneAsync(services, repo, name, cloneName); + return $"Cloned '{name}' to '{cloneName}'."; + }); + } + + [McpServerTool(Name = "edit_tags", UseStructuredContent = true, Idempotent = true, OpenWorld = false)] + [Description("Adds and/or removes tags on a resource and returns the tags it ends up with.")] + public Task EditTags( + [Description("The resource's name.")] string name, + [Description("Tags to add.")] string[]? add = null, + [Description("Tags to remove.")] string[]? remove = null) { + return ToolErrors.RunAsync(async () => { + if (add is not { Length: > 0 } && remove is not { Length: > 0 }) + throw new ValidationException("Pass at least one tag to add or remove."); + + foreach (var tag in add ?? []) + await services.GetRequiredService>().ExecuteAsync(name, tag); + + foreach (var tag in remove ?? []) + await services.GetRequiredService>().ExecuteAsync(name, tag); + + Resource resource = await repo.GetByNameAsync(name) + ?? throw new NotFoundException($"Resource '{name}' not found."); + + return new TagsResult(resource.Name, resource.Tags); + }); + } + + [McpServerTool(Name = "edit_labels", UseStructuredContent = true, Idempotent = true, OpenWorld = false)] + [Description("Sets and/or removes key-value labels on a resource and returns the labels it ends up with.")] + public Task EditLabels( + [Description("The resource's name.")] string name, + [Description("Labels to set — an existing key is overwritten.")] + Dictionary? set = null, + [Description("Label keys to remove.")] string[]? remove = null) { + return ToolErrors.RunAsync(async () => { + if (set is not { Count: > 0 } && remove is not { Length: > 0 }) + throw new ValidationException("Pass at least one label to set or remove."); + + foreach ((var key, var value) in set ?? new Dictionary()) + await services.GetRequiredService>().ExecuteAsync(name, key, value); + + foreach (var key in remove ?? []) + await services.GetRequiredService>().ExecuteAsync(name, key); + + Resource resource = await repo.GetByNameAsync(name) + ?? throw new NotFoundException($"Resource '{name}' not found."); + + return new LabelsResult(resource.Name, resource.Labels); + }); + } + + [McpServerTool(Name = "add_connection", Idempotent = true, OpenWorld = false)] + [Description("Connects a port on one hardware resource to a port on another. A port holds one connection — " + + "connecting an occupied port replaces what was plugged into it. Port groups and indexes are " + + "zero-based against the resource's 'ports' list (a group entry with count 4 has indexes 0-3).")] + public Task AddConnection( + [Description("First endpoint's resource name.")] string resourceA, + [Description("First endpoint's port group index.")] int portGroupA, + [Description("First endpoint's port index within the group.")] int portIndexA, + [Description("Second endpoint's resource name.")] string resourceB, + [Description("Second endpoint's port group index.")] int portGroupB, + [Description("Second endpoint's port index within the group.")] int portIndexB, + [Description("Optional label, e.g. 'uplink'.")] string? label = null, + [Description("Optional notes.")] string? notes = null) { + return ToolErrors.RunAsync(async () => { + var a = new PortReference { Resource = resourceA, PortGroup = portGroupA, PortIndex = portIndexA }; + var b = new PortReference { Resource = resourceB, PortGroup = portGroupB, PortIndex = portIndexB }; + + await services.GetRequiredService().ExecuteAsync(a, b, label, notes); + + return $"Connected {ConnectionMerger.Describe(new Connection { A = a, B = b, Label = label })}."; + }); + } + + [McpServerTool(Name = "remove_connection", Destructive = true, Idempotent = true, OpenWorld = false)] + [Description("Removes whatever connection is plugged into the given port.")] + public Task RemoveConnection( + [Description("The port's resource name.")] string resource, + [Description("The port's group index.")] int portGroup, + [Description("The port's index within the group.")] int portIndex) { + return ToolErrors.RunAsync(async () => { + var port = new PortReference { Resource = resource, PortGroup = portGroup, PortIndex = portIndex }; + await services.GetRequiredService().ExecuteAsync(port); + return $"Removed any connection on {resource} port {portGroup}/{portIndex}."; + }); + } + + /// + /// Upserts get their own error mapping: the use case reports YAML/JSON problems + /// through several exception types, and — matching the REST endpoint's contract — + /// the message is always safe and useful to show the caller. + /// + internal static async Task RunUpsertAsync( + IServiceProvider services, + ImportYamlRequest request) { + try { + return await services.GetRequiredService().ExecuteAsync(request); + } + catch (ValidationException ex) { + throw new McpException($"Invalid input: {ex.Message}"); + } + catch (Exception ex) when (ex is not McpException) { + throw new McpException($"Import failed: {ex.Message}"); + } + } +} diff --git a/RackPeek.Mcp/Tools/QueryTools.cs b/RackPeek.Mcp/Tools/QueryTools.cs new file mode 100644 index 00000000..7fac3f81 --- /dev/null +++ b/RackPeek.Mcp/Tools/QueryTools.cs @@ -0,0 +1,220 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using RackPeek.Domain.Helpers; +using RackPeek.Domain.Persistence; +using RackPeek.Domain.Persistence.Yaml; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.Resources.Hardware; +using RackPeek.Domain.Resources.Services.UseCases; +using RackPeek.Domain.Resources.SystemResources.UseCases; +using RackPeek.Domain.Search; + +namespace RackPeek.Mcp.Tools; + +public sealed record ResourceList(int Count, List Resources); + +public sealed record SearchResults(IReadOnlyList Matches); + +public sealed record TreeResult(List Hardware); + +public sealed record ConnectionList(int Count, IReadOnlyList Connections); + +public sealed record ResourceRow( + string Name, + string Kind, + string? Ip, + List RunsOn, + string[] Tags, + Dictionary Labels); + +public sealed record ResourceDetail( + [property: Description("The resource and its connections as a RackPeek YAML document. " + + "Edit it and pass it back to upsert_resources to change the resource.")] + string Yaml, + [property: Description("Names of resources that run on this one.")] + List Dependants); + +public sealed record InfrastructureSummary( + HardwareSummary Hardware, + SystemSummary Systems, + AllServicesSummary Services, + [property: Description("Every tag in use and how many resources carry it.")] + Dictionary Tags, + [property: Description("Every label key in use and how many resources carry it.")] + Dictionary Labels); + +public sealed record SchemaInfo( + [property: Description("The current config schema version.")] + int Version, + [property: Description("The JSON schema every inventory YAML document must conform to.")] + string JsonSchema, + string Guidance); + +[McpServerToolType] +public sealed class QueryTools(IResourceCollection repo, IServiceProvider services) { + internal const string UpsertGuidance = + "A RackPeek document is YAML with 'version', 'resources' and optional 'connections'. " + + "Every resource has 'kind' (Server, Switch, Firewall, Router, Accesspoint, Desktop, " + + "Laptop, Ups, Other, System, Service), a unique 'name' (max 50 chars), and optional " + + "'tags', 'labels', 'notes' and 'runsOn'. Containment rules: a Service runs on a " + + "System; a System runs on hardware or on another System. Pass documents to " + + "upsert_resources — merge mode only adds and updates, it never removes fields, " + + "so use the dedicated tools (delete_resource, edit_tags, edit_labels, " + + "remove_connection) to take things away."; + + [McpServerTool(Name = "list_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Lists inventory resources with their key facts. All filters are optional and combine.")] + public Task ListResources( + [Description("Only this kind: Server, Switch, Firewall, Router, Accesspoint, Desktop, Laptop, Ups, Other, System or Service.")] + string? kind = null, + [Description("Only resources carrying this tag.")] + string? tag = null, + [Description("Only resources carrying this label key.")] + string? labelKey = null) { + return ToolErrors.RunAsync(async () => { + IReadOnlyList all = await repo.GetAllOfTypeAsync(); + IReadOnlyList<(Resource, string)> ips = await repo.GetResourceIpsAsync(); + + var ipByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((Resource resource, var ip) in ips) ipByName[resource.Name] = ip; + + var rows = all + .Where(r => kind is null || r.Kind.Equals(kind.Trim(), StringComparison.OrdinalIgnoreCase)) + .Where(r => tag is null || r.Tags.Contains(tag.Trim(), StringComparer.OrdinalIgnoreCase)) + .Where(r => labelKey is null || r.Labels.Keys.Contains(labelKey.Trim(), StringComparer.OrdinalIgnoreCase)) + .OrderBy(r => r.Kind, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .Select(r => new ResourceRow( + r.Name, + r.Kind, + ipByName.GetValueOrDefault(r.Name), + r.RunsOn, + r.Tags, + r.Labels)) + .ToList(); + + return new ResourceList(rows.Count, rows); + }); + } + + [McpServerTool(Name = "get_resource", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Gets one resource in full, as a YAML document that can be edited and passed back to upsert_resources.")] + public Task GetResource( + [Description("The resource's name.")] string name) { + return ToolErrors.RunAsync(async () => { + Resource resource = await repo.GetByNameAsync(name) + ?? throw new NotFoundException($"Resource '{name}' not found."); + + IReadOnlyList connections = await repo.GetConnectionsForResourceAsync(resource.Name); + IReadOnlyList dependants = await repo.GetDependantsAsync(resource.Name); + + var yaml = YamlResourceCollection.SerializeRootAsync(new YamlRoot { + Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count, + Resources = [resource], + Connections = connections.ToList() + }); + + return new ResourceDetail(yaml, dependants.Select(d => d.Name).ToList()); + }); + } + + [McpServerTool(Name = "search_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Free-text search over resource names, IPs, tags and labels; returns the best matches first.")] + public Task SearchResources( + [Description("The search text.")] string query, + [Description("Maximum number of matches to return.")] int max = 8) { + return ToolErrors.RunAsync(async () => { + IReadOnlyList all = await repo.GetAllOfTypeAsync(); + return new SearchResults(GlobalSearchService.Search(all, query, max)); + }); + } + + [McpServerTool(Name = "get_summary", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Counts of everything in the inventory: hardware by kind, systems by type and OS, services, tags and labels.")] + public Task GetSummary() { + return ToolErrors.RunAsync(async () => { + HardwareSummary hardware = await services.GetRequiredService().ExecuteAsync(); + SystemSummary systems = await services.GetRequiredService().ExecuteAsync(); + AllServicesSummary allServices = await services.GetRequiredService().ExecuteAsync(); + Dictionary tags = await repo.GetTagsAsync(); + Dictionary labels = await repo.GetLabelsAsync(); + + return new InfrastructureSummary(hardware, systems, allServices, tags, labels); + }); + } + + [McpServerTool(Name = "get_tree", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("The containment forest: each hardware resource, the systems running on it, and the services running on those.")] + public Task GetTree( + [Description("Only the tree under this hardware resource. Omit for the whole forest.")] + string? hardwareName = null) { + return ToolErrors.RunAsync(async () => { + List forest = await services.GetRequiredService().GetTreeAsync(); + + if (hardwareName is null) return new TreeResult(forest); + + var match = forest + .Where(t => t.HardwareName.Equals(hardwareName.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (match.Count == 0) + throw new NotFoundException($"Hardware '{hardwareName}' not found."); + + return new TreeResult(match); + }); + } + + [McpServerTool(Name = "list_connections", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Physical port-to-port connections between hardware resources.")] + public Task ListConnections( + [Description("Only connections touching this resource. Omit for all connections.")] + string? resource = null) { + return ToolErrors.RunAsync(async () => { + IReadOnlyList connections = resource is null + ? await repo.GetConnectionsAsync() + : await repo.GetConnectionsForResourceAsync(resource); + + return new ConnectionList(connections.Count, connections); + }); + } + + [McpServerTool(Name = "get_subnets", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Groups service IPs into subnets, or lists the services inside one CIDR block.")] + public Task GetSubnets( + [Description("A CIDR block like 192.168.1.0/24 to list the services inside it. Omit to group all service IPs into subnets instead.")] + string? cidr = null, + [Description("Prefix length used to group when no CIDR is given (default 24).")] + int? prefix = null, + CancellationToken cancellationToken = default) { + return ToolErrors.RunAsync(async () => { + ServiceSubnetsResult result = await services.GetRequiredService() + .ExecuteAsync(cidr, prefix, cancellationToken); + + if (result.IsInvalidCidr) + throw new McpException($"'{result.InvalidCidrValue}' is not a valid CIDR block. Use e.g. 192.168.1.0/24."); + + return result; + }); + } + + [McpServerTool(Name = "get_schema", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("The JSON schema and authoring rules for RackPeek inventory YAML — read this before writing documents for upsert_resources.")] + public Task GetSchema() { + return ToolErrors.RunAsync(() => { + var version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count; + var resourceName = $"schema.v{version}.json"; + + using Stream? stream = typeof(QueryTools).Assembly.GetManifestResourceStream(resourceName); + if (stream is null) + throw new InvalidOperationException($"Embedded schema '{resourceName}' is missing from the build."); + + using var reader = new StreamReader(stream); + var schema = reader.ReadToEnd(); + + return Task.FromResult(new SchemaInfo(version, schema, UpsertGuidance)); + }); + } +} diff --git a/RackPeek.Web/Program.cs b/RackPeek.Web/Program.cs index 5d29633a..ec4435e1 100644 --- a/RackPeek.Web/Program.cs +++ b/RackPeek.Web/Program.cs @@ -6,6 +6,8 @@ using RackPeek.Domain.Git; using RackPeek.Domain.Persistence; using RackPeek.Domain.Persistence.Yaml; +using ModelContextProtocol.AspNetCore; +using RackPeek.Mcp; using RackPeek.Web.Api; using RackPeek.Web.Components; using Shared.Rcl; @@ -88,6 +90,17 @@ public static async Task BuildApp(WebApplicationBuilder builder) builder.Services.AddCommands(); builder.Services.AddScoped(); + // MCP server, exposed over streamable HTTP at /mcp whenever the web server + // runs. Stateless: every call is a plain POST (no session affinity behind a + // reverse proxy) and tools resolve their services from the request scope, + // exactly like the inventory API does. + builder.Services.AddMcpServer(options => options.ServerInfo = new() { + Name = McpSetup.ServerName, + Version = RpkConstants.Version + }) + .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless) + .WithRackPeekTools(); + // Razor Components builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -126,6 +139,12 @@ public static async Task BuildApp(WebApplicationBuilder builder) app.MapInventoryApi(); + // Same key, same gate as /api/inventory: 503 until RPK_API_KEY is configured, + // so MCP is off by default and never exposes the inventory unauthenticated. + RouteGroupBuilder mcp = app.MapGroup("/mcp"); + mcp.AddEndpointFilter(); + mcp.MapMcp(); + app.MapStaticAssets(); app.MapRazorComponents() diff --git a/RackPeek.Web/RackPeek.Web.csproj b/RackPeek.Web/RackPeek.Web.csproj index 4d928bc4..76737af2 100644 --- a/RackPeek.Web/RackPeek.Web.csproj +++ b/RackPeek.Web/RackPeek.Web.csproj @@ -13,7 +13,8 @@ - + + @@ -22,4 +23,8 @@ + + + + diff --git a/RackPeek.sln b/RackPeek.sln index 9b5fd793..b8b19a0d 100644 --- a/RackPeek.sln +++ b/RackPeek.sln @@ -16,43 +16,142 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2e", "Tests.E2e\Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Discovery", "Tests.Discovery\Tests.Discovery.csproj", "{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RackPeek.Mcp", "RackPeek.Mcp\RackPeek.Mcp.csproj", "{4038A024-B71F-4126-9310-5DC109AD5616}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Mcp", "Tests.Mcp\Tests.Mcp.csproj", "{F82DD17A-0997-44B3-B3A3-15FC9E676427}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x64.Build.0 = Debug|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x86.Build.0 = Debug|Any CPU {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|Any CPU.Build.0 = Release|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x64.ActiveCfg = Release|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x64.Build.0 = Release|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x86.ActiveCfg = Release|Any CPU + {EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x86.Build.0 = Release|Any CPU {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x64.Build.0 = Debug|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x86.Build.0 = Debug|Any CPU {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|Any CPU.Build.0 = Release|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x64.ActiveCfg = Release|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x64.Build.0 = Release|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x86.ActiveCfg = Release|Any CPU + {2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x86.Build.0 = Release|Any CPU {760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x64.ActiveCfg = Debug|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x64.Build.0 = Debug|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x86.ActiveCfg = Debug|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x86.Build.0 = Debug|Any CPU {760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|Any CPU.ActiveCfg = Release|Any CPU {760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|Any CPU.Build.0 = Release|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x64.ActiveCfg = Release|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x64.Build.0 = Release|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x86.ActiveCfg = Release|Any CPU + {760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x86.Build.0 = Release|Any CPU {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x64.Build.0 = Debug|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x86.Build.0 = Debug|Any CPU {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|Any CPU.Build.0 = Release|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x64.ActiveCfg = Release|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x64.Build.0 = Release|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x86.ActiveCfg = Release|Any CPU + {7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x86.Build.0 = Release|Any CPU {5064A98A-A918-46A8-B44C-DB2720994643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5064A98A-A918-46A8-B44C-DB2720994643}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x64.ActiveCfg = Debug|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x64.Build.0 = Debug|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x86.ActiveCfg = Debug|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x86.Build.0 = Debug|Any CPU {5064A98A-A918-46A8-B44C-DB2720994643}.Release|Any CPU.ActiveCfg = Release|Any CPU {5064A98A-A918-46A8-B44C-DB2720994643}.Release|Any CPU.Build.0 = Release|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Release|x64.ActiveCfg = Release|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Release|x64.Build.0 = Release|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Release|x86.ActiveCfg = Release|Any CPU + {5064A98A-A918-46A8-B44C-DB2720994643}.Release|x86.Build.0 = Release|Any CPU {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x64.Build.0 = Debug|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x86.Build.0 = Debug|Any CPU {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|Any CPU.Build.0 = Release|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x64.ActiveCfg = Release|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x64.Build.0 = Release|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x86.ActiveCfg = Release|Any CPU + {C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x86.Build.0 = Release|Any CPU {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x64.ActiveCfg = Debug|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x64.Build.0 = Debug|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x86.ActiveCfg = Debug|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x86.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 + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x64.ActiveCfg = Release|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x64.Build.0 = Release|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x86.ActiveCfg = Release|Any CPU + {47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x64.Build.0 = Debug|Any CPU + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x86.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 + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x64.ActiveCfg = Release|Any CPU + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x64.Build.0 = Release|Any CPU + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x86.ActiveCfg = Release|Any CPU + {EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x86.Build.0 = Release|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x64.ActiveCfg = Debug|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x64.Build.0 = Debug|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x86.ActiveCfg = Debug|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x86.Build.0 = Debug|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Release|Any CPU.Build.0 = Release|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Release|x64.ActiveCfg = Release|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Release|x64.Build.0 = Release|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Release|x86.ActiveCfg = Release|Any CPU + {4038A024-B71F-4126-9310-5DC109AD5616}.Release|x86.Build.0 = Release|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x64.ActiveCfg = Debug|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x64.Build.0 = Debug|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x86.ActiveCfg = Debug|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x86.Build.0 = Debug|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|Any CPU.Build.0 = Release|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x64.ActiveCfg = Release|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x64.Build.0 = Release|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x86.ActiveCfg = Release|Any CPU + {F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/Shared.Rcl/Components/GlobalSearch.razor b/Shared.Rcl/Components/GlobalSearch.razor index 532c096b..a317f13a 100644 --- a/Shared.Rcl/Components/GlobalSearch.razor +++ b/Shared.Rcl/Components/GlobalSearch.razor @@ -7,7 +7,7 @@ @using Microsoft.AspNetCore.Components.Web @using RackPeek.Domain.Persistence @using RackPeek.Domain.Resources -@using Shared.Rcl.Services +@using RackPeek.Domain.Search @inject IResourceCollection Repo @inject NavigationManager Nav diff --git a/Shared.Rcl/wwwroot/raw_docs/docs-index.json b/Shared.Rcl/wwwroot/raw_docs/docs-index.json index 483a76d7..5646cba3 100644 --- a/Shared.Rcl/wwwroot/raw_docs/docs-index.json +++ b/Shared.Rcl/wwwroot/raw_docs/docs-index.json @@ -11,5 +11,6 @@ "ssh-config-export.md", "hosts-file-export.md", "inventory-api.md", + "mcp-guide.md", "versioning.md" ] diff --git a/Shared.Rcl/wwwroot/raw_docs/mcp-guide.md b/Shared.Rcl/wwwroot/raw_docs/mcp-guide.md new file mode 100644 index 00000000..015c178b --- /dev/null +++ b/Shared.Rcl/wwwroot/raw_docs/mcp-guide.md @@ -0,0 +1,117 @@ +# MCP Server Guide + +RackPeek ships a built-in [Model Context Protocol](https://modelcontextprotocol.io) server, so +AI assistants (Claude Code, Claude Desktop, Cursor, VS Code, and anything else that speaks +MCP) can query, manage, maintain and build your inventory for you. + +There is nothing extra to run: whenever the RackPeek web server is up, the MCP server is +listening at **`/mcp`** over streamable HTTP. It uses the same `X-Api-Key` gate as the +[Inventory API](/docs/inventory-api) — until you set `RPK_API_KEY` on the server the +endpoint answers `503` and stays shut. + +--- + +## Quick start + +```bash +# Run the server with an API key +docker run -d -p 8080:8080 \ + -v ./config:/config \ + -e RPK_API_KEY=your-shared-secret \ + aptacode/rackpeek:latest + +# Connect Claude Code to it +claude mcp add --transport http rackpeek http://rack.lan:8080/mcp \ + --header "X-Api-Key: your-shared-secret" +``` + +Then just ask: *"what's running on my proxmox nodes?"*, *"add my new switch and cable it to +the rack server"*, *"generate an ssh config for everything tagged prod"*. + +For clients that only speak stdio, put a stdio→HTTP proxy such as +[`mcp-remote`](https://www.npmjs.com/package/mcp-remote) in front of the same URL. + +--- + +## The tools + +### Query + +| Tool | What it answers | +|---|---| +| `list_resources` | every resource, with optional `kind` / `tag` / `labelKey` filters | +| `get_resource` | one resource in full, as YAML that can be edited and upserted back | +| `search_resources` | free-text search over names, IPs, tags and labels | +| `get_summary` | counts of everything: hardware by kind, systems by type/OS, services, tags, labels | +| `get_tree` | the containment forest: hardware → systems → services | +| `list_connections` | physical port-to-port cabling | +| `get_subnets` | service IPs grouped into subnets, or filtered by a CIDR block | +| `get_schema` | the JSON schema and authoring rules for inventory YAML | + +### Editing + +| Tool | What it does | +|---|---| +| `upsert_resources` | bulk create/update from a YAML document — the main write path, with `dryRun` returning a per-resource diff before anything is written | +| `delete_resource` | removes a resource, detaches dependants, unplugs its connections | +| `rename_resource` | renames and rewrites every `runsOn` link and connection endpoint | +| `clone_resource` | copies a resource under a new name (never the discovery id) | +| `edit_tags` / `edit_labels` | add/remove tags and labels — merge mode can't remove, these can | +| `add_connection` / `remove_connection` | plug and unplug ports | + +### Exporters + +`export_ansible_inventory`, `export_ssh_config`, `export_hosts_file` and +`export_topology_mermaid` render the same outputs as the CLI exporters, straight into the +conversation. + +### Git + +`git_status` and `git_commit` version the config directory. They activate exactly like the +web UI's git integration — start the server with `GIT_TOKEN` (and optionally +`GIT_USERNAME`) — and explain that when they are off. See +[Git integration](/docs/git-integration). + +### Discovery + +| Tool | Reads | +|---|---| +| `discover_docker` | a Docker/Podman engine (`dockerHost`, e.g. `tcp://nas01:2375`) | +| `discover_proxmox` | a Proxmox VE cluster (`host`, e.g. `https://pve.lan:8006`) | + +Both default to a **preview**: they return the discovered YAML for review and write +nothing. Pass `apply: true` to merge the result into the inventory — discovery merging +can add and update but never removes, and re-runs line resources up by +[discovery id](/docs/discovery-guide) so your renames stick. + +Proxmox credentials come from the server's own `RPK_PVE_TOKEN_ID` / +`RPK_PVE_TOKEN_SECRET` configuration, never from the conversation, so tokens stay out of +AI context windows. There is no `discover system` tool on purpose: it probes the machine +it runs on, which for the server is its own container — run `rpk discover system` on the +machine being inventoried instead. + +--- + +## The editing workflow an agent follows + +1. `get_schema` — learn the document format once. +2. `get_resource` / `list_resources` — read the current state. +3. `upsert_resources` with `dryRun: true` — preview the exact per-resource diff. +4. `upsert_resources` — apply. +5. `git_commit` — snapshot the change (when git is configured). + +Merge mode only adds and updates; anything destructive (deleting resources, removing +tags/labels/connections) goes through the dedicated tools, which are annotated as +destructive so well-behaved clients ask before calling them. + +--- + +## Security notes + +- The MCP endpoint is **off until `RPK_API_KEY` is set** — same behaviour as the + inventory API. +- Anyone holding the key can read *and modify* the inventory through MCP. Treat the key + accordingly, and put the server behind TLS (a reverse proxy) before exposing it beyond + your LAN. +- The transport is stateless HTTP: no sessions, no sticky-session requirements behind a + reverse proxy. diff --git a/Tests.E2e/McpE2eTests.cs b/Tests.E2e/McpE2eTests.cs new file mode 100644 index 00000000..d34ebbb4 --- /dev/null +++ b/Tests.E2e/McpE2eTests.cs @@ -0,0 +1,182 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace Tests.E2e; + +/// +/// The full production path: the shipped Docker image, a real network port, and a +/// real MCP client walking one realistic session — learn the schema, preview a +/// change, apply it, query it back, wire a connection, export, delete. Everything +/// the in-process suite (Tests.Mcp) proves is re-proved here against the artefact +/// that is actually distributed. +/// +public class McpE2eTests : IAsyncLifetime { + private const string _dockerImage = "rackpeek:ci"; + private const string _apiKey = "e2e-mcp-key"; + + private IContainer _container = default!; + private HttpClient _http = default!; + private McpClient _client = default!; + + public async Task InitializeAsync() { + _container = new ContainerBuilder(_dockerImage) + .WithPortBinding(8080, true) + .WithEnvironment("RPK_API_KEY", _apiKey) + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r + .ForPort(8080) + .ForPath("/health"))) + .Build(); + + await _container.StartAsync(); + + _http = new HttpClient(); + _http.DefaultRequestHeaders.Add("X-Api-Key", _apiKey); + + var transport = new HttpClientTransport( + new HttpClientTransportOptions { + Endpoint = new Uri($"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/mcp"), + TransportMode = HttpTransportMode.StreamableHttp + }, + _http, + loggerFactory: null, + ownsHttpClient: true); + + _client = await McpClient.CreateAsync(transport); + } + + public async Task DisposeAsync() { + if (_client != null) await _client.DisposeAsync(); + if (_container != null) await _container.DisposeAsync(); + } + + private async Task CallAsync(string tool, Dictionary? args = null) { + CallToolResult result = await _client.CallToolAsync(tool, args); + + var text = string.Join("\n", result.Content.OfType().Select(t => t.Text)); + Assert.False(result.IsError == true, $"'{tool}' failed: {text}"); + + return result.StructuredContent + ?? JsonDocument.Parse(JsonSerializer.Serialize(new { text })).RootElement; + } + + [Fact] + public async Task A_full_session_builds_queries_wires_exports_and_deletes_a_stack() { + // The server advertises itself and its whole tool surface over the wire. + Assert.Equal("rackpeek", _client.ServerInfo.Name); + IList tools = await _client.ListToolsAsync(); + Assert.Contains(tools, t => t.Name == "upsert_resources"); + Assert.Contains(tools, t => t.Name == "discover_docker"); + + // 1. Learn the format. + JsonElement schema = await CallAsync("get_schema"); + Assert.Equal(4, schema.GetProperty("version").GetInt32()); + + // 2. Preview, then apply, a small stack. + const string stack = + """ + version: 4 + resources: + - kind: Server + name: e2e-server + ports: + - type: rj45 + speed: 1 + count: 4 + - kind: Switch + name: e2e-switch + ports: + - type: rj45 + speed: 1 + count: 8 + - kind: System + name: e2e-host + type: baremetal + os: debian + ip: 10.9.0.1 + runsOn: + - e2e-server + - kind: Service + name: e2e-app + runsOn: + - e2e-host + network: + ip: 10.9.0.1 + port: 8080 + protocol: TCP + """; + + JsonElement preview = await CallAsync("upsert_resources", + new Dictionary { ["yaml"] = stack, ["dryRun"] = true }); + Assert.Equal(4, preview.GetProperty("added").GetArrayLength()); + + JsonElement applied = await CallAsync("upsert_resources", + new Dictionary { ["yaml"] = stack }); + Assert.Equal(4, applied.GetProperty("added").GetArrayLength()); + + // 3. Query it back over the wire. + JsonElement list = await CallAsync("list_resources", + new Dictionary { ["kind"] = "Service" }); + Assert.Equal(1, list.GetProperty("count").GetInt32()); + + JsonElement detail = await CallAsync("get_resource", + new Dictionary { ["name"] = "e2e-app" }); + Assert.Contains("kind: Service", detail.GetProperty("yaml").GetString()); + + JsonElement search = await CallAsync("search_resources", + new Dictionary { ["query"] = "10.9.0.1" }); + Assert.True(search.GetProperty("matches").GetArrayLength() >= 1); + + // 4. Wire the hardware together and see it in the tree and the diagram. + await CallAsync("add_connection", new Dictionary { + ["resourceA"] = "e2e-server", + ["portGroupA"] = 0, + ["portIndexA"] = 0, + ["resourceB"] = "e2e-switch", + ["portGroupB"] = 0, + ["portIndexB"] = 0, + ["label"] = "uplink" + }); + + JsonElement tree = await CallAsync("get_tree", + new Dictionary { ["hardwareName"] = "e2e-server" }); + Assert.Equal(1, tree.GetProperty("hardware").GetArrayLength()); + + JsonElement mermaid = await CallAsync("export_topology_mermaid"); + Assert.Contains("e2e-switch", mermaid.GetProperty("text").GetString()); + + // 5. The change shows up on the page a browser would load, not just over MCP. + var home = await _http.GetStringAsync( + $"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/health"); + Assert.Equal("rackpeek", home); + + // 6. Tear the service down again and the inventory agrees. + await CallAsync("delete_resource", new Dictionary { ["name"] = "e2e-app" }); + + JsonElement after = await CallAsync("list_resources", + new Dictionary { ["kind"] = "Service" }); + Assert.Equal(0, after.GetProperty("count").GetInt32()); + } + + [Fact] + public async Task The_gate_holds_over_a_real_network_socket() { + using var bare = new HttpClient(); + + var url = $"http://127.0.0.1:{_container.GetMappedPublicPort(8080)}/mcp"; + using var request = new HttpRequestMessage(HttpMethod.Post, url) { + Content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""", + System.Text.Encoding.UTF8, + "application/json") + }; + request.Headers.Add("Accept", "application/json, text/event-stream"); + + using HttpResponseMessage response = await bare.SendAsync(request); + + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/Tests.E2e/Tests.E2e.csproj b/Tests.E2e/Tests.E2e.csproj index 76ea6b72..4193d466 100644 --- a/Tests.E2e/Tests.E2e.csproj +++ b/Tests.E2e/Tests.E2e.csproj @@ -12,12 +12,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -25,7 +26,7 @@ - + @@ -35,8 +36,8 @@ - - + + \ No newline at end of file diff --git a/Tests.Mcp/AuthTests.cs b/Tests.Mcp/AuthTests.cs new file mode 100644 index 00000000..e2ab9ab2 --- /dev/null +++ b/Tests.Mcp/AuthTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Text; +using ModelContextProtocol.Client; +using RackPeek.Mcp.Tools; + +namespace Tests.Mcp; + +/// +/// /mcp sits behind the same X-Api-Key gate as /api/inventory: 503 until the +/// server has a key configured (MCP is off by default), 401 on a wrong key. +/// The raw-HTTP tests pin the status codes; the client-level tests prove the +/// gate actually stops an MCP session, not just a request. +/// +public class AuthTests { + private const string _initializeBody = + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}} + """; + + private static HttpRequestMessage InitializeRequest() { + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") { + Content = new StringContent(_initializeBody, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Accept", "application/json, text/event-stream"); + + return request; + } + + [Fact] + public async Task Without_a_configured_key_the_endpoint_answers_503_and_stays_shut() { + using var api = new McpFixture(extraConfig: new Dictionary { + ["RPK_API_KEY"] = null + }); + + using HttpClient client = api.CreateHttpClient(apiKey: null); + using HttpResponseMessage response = await client.SendAsync(InitializeRequest()); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + + [Fact] + public async Task A_missing_key_is_rejected_with_401() { + using var api = new McpFixture(); + + using HttpClient client = api.CreateHttpClient(apiKey: null); + using HttpResponseMessage response = await client.SendAsync(InitializeRequest()); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task A_wrong_key_is_rejected_with_401() { + using var api = new McpFixture(); + + using HttpClient client = api.CreateHttpClient("not-the-key"); + using HttpResponseMessage response = await client.SendAsync(InitializeRequest()); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task An_mcp_client_without_the_key_cannot_even_finish_the_handshake() { + using var api = new McpFixture(); + + await Assert.ThrowsAnyAsync(() => api.ConnectAsync(apiKey: null)); + } + + [Fact] + public async Task The_right_key_opens_the_full_tool_surface() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + ResourceList resources = await client.CallOkAsync("list_resources"); + + Assert.Equal(0, resources.Count); + } +} diff --git a/Tests.Mcp/DiscoveryToolTests.cs b/Tests.Mcp/DiscoveryToolTests.cs new file mode 100644 index 00000000..f368566c --- /dev/null +++ b/Tests.Mcp/DiscoveryToolTests.cs @@ -0,0 +1,196 @@ +using ModelContextProtocol.Client; +using RackPeek.Mcp.Tools; + +namespace Tests.Mcp; + +/// +/// The discovery tools against fake engines answering on real sockets: preview +/// returns reviewable YAML, apply merges it, and a second run changes nothing — +/// the discovery-id identity contract, observed through the MCP surface. +/// +public class DiscoveryToolTests { + // -- discover_docker ---------------------------------------------------------------- + + [Fact] + public async Task Docker_discovery_previews_reachable_containers_as_conformant_yaml() { + await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync(); + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + DiscoveryResult result = await client.CallOkAsync( + "discover_docker", new Dictionary { + ["dockerHost"] = $"tcp://{engine.Host}", + ["hostName"] = "nas01" + }); + + // The fixture holds six containers; redis publishes nothing and pgadmin only + // binds loopback, so four become services. + Assert.Equal(4, result.ResourceCount); + Assert.Equal(2, result.Skipped); + Assert.Null(result.Applied); + + Assert.Contains("jellyfin", result.Yaml); + Assert.Contains("wireguard", result.Yaml); + Assert.DoesNotContain("redis", result.Yaml); + Assert.Contains("runsOn", result.Yaml); + Assert.Contains("nas01", result.Yaml); + SchemaAssert.ConformsToSchema(result.Yaml); + + // Preview writes nothing. + Assert.DoesNotContain("jellyfin", api.StoredYaml); + } + + [Fact] + public async Task Applying_docker_discovery_merges_and_a_second_run_changes_nothing() { + await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync(); + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + var args = new Dictionary { + ["dockerHost"] = $"tcp://{engine.Host}", + ["hostName"] = "nas01", + ["apply"] = true + }; + + DiscoveryResult first = await client.CallOkAsync("discover_docker", args); + + Assert.NotNull(first.Applied); + Assert.Equal(4, first.Applied.Added.Count); + Assert.Contains("jellyfin", api.StoredYaml); + Assert.Contains("discoveryId: rpk1:docker:", api.StoredYaml); + SchemaAssert.ConformsToSchema(api.StoredYaml); + + // Same engine, same answers: the ids line everything up, nothing duplicates. + DiscoveryResult second = await client.CallOkAsync("discover_docker", args); + + Assert.NotNull(second.Applied); + Assert.Empty(second.Applied.Added); + Assert.Empty(second.Applied.Updated); + } + + [Fact] + public async Task Discovered_services_survive_a_user_rename_on_the_next_apply() { + // The identity contract, end to end: the user renames a discovered service, + // discovery runs again, and the rename sticks because the id matches. + await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync(); + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + var args = new Dictionary { + ["dockerHost"] = $"tcp://{engine.Host}", + ["hostName"] = "nas01", + ["apply"] = true + }; + + await client.CallOkAsync("discover_docker", args); + + await client.CallTextAsync("rename_resource", new Dictionary { + ["name"] = "jellyfin", + ["newName"] = "media-jellyfin" + }); + + DiscoveryResult again = await client.CallOkAsync("discover_docker", args); + + Assert.NotNull(again.Applied); + Assert.Empty(again.Applied.Added); // not re-added under the old name + Assert.Contains("media-jellyfin", api.StoredYaml); + } + + [Fact] + public async Task An_unreachable_docker_engine_is_a_clean_error_naming_the_endpoint() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync("discover_docker", + new Dictionary { ["dockerHost"] = "tcp://127.0.0.1:1" }); + + Assert.Contains("Could not reach Docker", error); + Assert.Contains("tcp://127.0.0.1:1", error); + } + + [Fact] + public async Task A_malformed_docker_endpoint_is_rejected_before_any_io() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync("discover_docker", + new Dictionary { ["dockerHost"] = "%%not-an-endpoint%%" }); + + Assert.Contains("not a usable Docker endpoint", error); + } + + // -- discover_proxmox --------------------------------------------------------------- + + private static Dictionary PveCredentials() => new() { + ["RPK_PVE_TOKEN_ID"] = "root@pam!rackpeek", + ["RPK_PVE_TOKEN_SECRET"] = "secret-uuid" + }; + + [Fact] + public async Task Proxmox_discovery_previews_nodes_and_guests_wired_together() { + await using FakeHttpServer pve = await FakeHttpServer.StartProxmoxAsync(); + using var api = new McpFixture(extraConfig: PveCredentials()); + await using McpClient client = await api.ConnectAsync(); + + DiscoveryResult result = await client.CallOkAsync( + "discover_proxmox", new Dictionary { ["host"] = pve.BaseUrl }); + + Assert.Null(result.Applied); + SchemaAssert.ConformsToSchema(result.Yaml); + + // Two fixture nodes, each a Server plus its hypervisor System... + Assert.Contains("pve01", result.Yaml); + Assert.Contains("pve02", result.Yaml); + Assert.Contains("kind: Server", result.Yaml); + Assert.Contains("hypervisor", result.Yaml); + + // ...and guests parented onto them, deduped by vmid even though both fake + // nodes reported the same guest lists (the in-flight migration case). + Assert.Contains("docker-01", result.Yaml); + Assert.Contains("pihole", result.Yaml); + Assert.Single( + result.Yaml.Split(Environment.NewLine), + l => l.Contains("name: pihole")); + } + + [Fact] + public async Task Applying_proxmox_discovery_persists_the_estate() { + await using FakeHttpServer pve = await FakeHttpServer.StartProxmoxAsync(); + using var api = new McpFixture(extraConfig: PveCredentials()); + await using McpClient client = await api.ConnectAsync(); + + DiscoveryResult result = await client.CallOkAsync( + "discover_proxmox", + new Dictionary { ["host"] = pve.BaseUrl, ["apply"] = true }); + + Assert.NotNull(result.Applied); + Assert.NotEmpty(result.Applied.Added); + Assert.Contains("pve01", api.StoredYaml); + Assert.Contains("discoveryId: rpk1:pve:", api.StoredYaml); + SchemaAssert.ConformsToSchema(api.StoredYaml); + } + + [Fact] + public async Task Missing_proxmox_credentials_point_at_the_exact_settings_to_set() { + using var api = new McpFixture(); // no RPK_PVE_* configured + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync("discover_proxmox", + new Dictionary { ["host"] = "https://pve.lan:8006" }); + + Assert.Contains("RPK_PVE_TOKEN_ID", error); + Assert.Contains("RPK_PVE_TOKEN_SECRET", error); + } + + [Fact] + public async Task An_unreachable_proxmox_host_is_a_clean_error_naming_the_endpoint() { + using var api = new McpFixture(extraConfig: PveCredentials()); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync("discover_proxmox", + new Dictionary { ["host"] = "http://127.0.0.1:1" }); + + Assert.Contains("Could not read", error); + Assert.Contains("http://127.0.0.1:1", error); + } +} diff --git a/Tests.Mcp/ExportToolTests.cs b/Tests.Mcp/ExportToolTests.cs new file mode 100644 index 00000000..843915c5 --- /dev/null +++ b/Tests.Mcp/ExportToolTests.cs @@ -0,0 +1,102 @@ +using ModelContextProtocol.Client; +using RackPeek.Domain.UseCases.Ansible; +using RackPeek.Domain.UseCases.Hosts; +using RackPeek.Domain.UseCases.SSH; + +namespace Tests.Mcp; + +/// +/// The exporters render the same seed everywhere, so these tests check the shape a +/// downstream consumer (ansible, ssh, /etc/hosts, mermaid) would actually parse. +/// +public class ExportToolTests { + [Fact] + public async Task The_ansible_inventory_groups_labelled_hosts_in_both_formats() { + // The generator addresses hosts by their ansible_host/ip/hostname label and + // emits them under the groups asked for — no grouping, no output. + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var args = new Dictionary { ["groupByTags"] = new[] { "prod" } }; + + InventoryResult ini = await client.CallOkAsync("export_ansible_inventory", args); + InventoryResult yaml = await client.CallOkAsync( + "export_ansible_inventory", + new Dictionary(args) { ["format"] = "Yaml" }); + + Assert.Contains("[prod]", ini.InventoryText); + Assert.Contains("rack-server", ini.InventoryText); + Assert.Contains("ansible_host=10.0.0.2", ini.InventoryText); + Assert.Contains("rack-server", yaml.InventoryText); + Assert.NotEqual(ini.InventoryText, yaml.InventoryText); + } + + [Fact] + public async Task The_ssh_config_writes_host_blocks_with_the_chosen_defaults() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + SshExportResult result = await client.CallOkAsync( + "export_ssh_config", new Dictionary { ["defaultUser"] = "admin" }); + + Assert.Contains("Host host-os", result.ConfigText); + Assert.Contains("HostName 10.0.0.5", result.ConfigText); + Assert.Contains("User admin", result.ConfigText); + } + + [Fact] + public async Task The_hosts_file_maps_addresses_to_names_with_an_optional_suffix() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + HostsExportResult plain = await client.CallOkAsync("export_hosts_file"); + HostsExportResult suffixed = await client.CallOkAsync( + "export_hosts_file", new Dictionary { + ["domainSuffix"] = "home.lan", + ["includeLocalhostDefaults"] = false + }); + + Assert.Contains("127.0.0.1 localhost", plain.HostsText); + Assert.Contains("10.0.0.5 host-os", plain.HostsText); + Assert.Contains("10.0.0.5 host-os.home.lan", suffixed.HostsText); + Assert.DoesNotContain("localhost", suffixed.HostsText); + } + + [Fact] + public async Task The_mermaid_views_draw_the_physical_and_logical_pictures() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var physical = await client.CallTextAsync("export_topology_mermaid"); + var logical = await client.CallTextAsync( + "export_topology_mermaid", new Dictionary { ["view"] = "Logical" }); + + // Physical: hardware nodes and the cabled edge between them. + Assert.Contains("rack-server", physical); + Assert.Contains("rack-switch", physical); + Assert.Contains("uplink", physical); + + // Logical: host cards carrying their services; no cabling. + Assert.Contains("host-os", logical); + Assert.Contains("grafana", logical); + Assert.DoesNotContain("uplink", logical); + } + + [Fact] + public async Task Exports_over_the_demo_inventory_produce_output_for_every_format() { + using var api = new McpFixture(TestData.DemoConfig()); + await using McpClient client = await api.ConnectAsync(); + + InventoryResult ansible = await client.CallOkAsync("export_ansible_inventory"); + SshExportResult ssh = await client.CallOkAsync("export_ssh_config"); + HostsExportResult hosts = await client.CallOkAsync("export_hosts_file"); + var mermaid = await client.CallTextAsync("export_topology_mermaid"); + + // No grouping asked for, so ansible legitimately answers with a warning + // rather than hosts; the call itself must still succeed. + Assert.NotNull(ansible); + Assert.False(string.IsNullOrWhiteSpace(ssh.ConfigText)); + Assert.Contains("pfsense-fw", mermaid); + Assert.NotEmpty(hosts.HostsText); + } +} diff --git a/Tests.Mcp/FakeHttpServer.cs b/Tests.Mcp/FakeHttpServer.cs new file mode 100644 index 00000000..5d28bfb2 --- /dev/null +++ b/Tests.Mcp/FakeHttpServer.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Tests.Mcp; + +/// +/// A real Kestrel server on a random loopback port, serving captured API fixtures. +/// The discovery tools construct their own HttpClients internally, so unlike the +/// unit tests in Tests.Discovery a message-handler stub cannot reach them — the +/// fake engine has to answer on an actual socket. +/// +internal sealed class FakeHttpServer : IAsyncDisposable { + private readonly WebApplication _app; + + private FakeHttpServer(WebApplication app) => _app = app; + + /// e.g. http://127.0.0.1:49213 — no trailing slash. + public string BaseUrl => _app.Urls.First(); + + public string Host => new Uri(BaseUrl).Authority; + + public static async Task StartAsync(Action map) { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + WebApplication app = builder.Build(); + map(app); + await app.StartAsync(); + + return new FakeHttpServer(app); + } + + /// A fake Docker Engine API with the shared captured fixtures. + public static Task StartDockerEngineAsync() => + StartAsync(app => { + app.MapGet("/containers/json", () => Results.Content( + TestData.Fixture("docker-containers.json"), "application/json")); + app.MapGet("/info", () => Results.Content( + TestData.Fixture("docker-info.json"), "application/json")); + }); + + /// + /// A fake Proxmox VE API. Both fixture nodes answer with the same guest lists, + /// which doubles as the migration case: the mapper must dedupe guests by vmid. + /// + public static Task StartProxmoxAsync() => + StartAsync(app => { + string Json(string name) => TestData.Fixture(name); + + app.MapGet("/api2/json/cluster/status", () => Results.Content(Json("pve-cluster-status.json"), "application/json")); + app.MapGet("/api2/json/nodes", () => Results.Content(Json("pve-nodes-full.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/status", () => Results.Content(Json("pve-node-status.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/disks/list", () => Results.Content(Json("pve-disks.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/hardware/pci", () => Results.Content(Json("pve-hardware-pci.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/qemu", () => Results.Content(Json("pve-qemu.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/lxc", () => Results.Content(Json("pve-lxc.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/qemu/{vmid}/config", () => Results.Content(Json("pve-qemu-config.json"), "application/json")); + app.MapGet("/api2/json/nodes/{node}/lxc/{vmid}/config", () => Results.Content(Json("pve-lxc-config.json"), "application/json")); + }); + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +} diff --git a/Tests.Mcp/GitToolTests.cs b/Tests.Mcp/GitToolTests.cs new file mode 100644 index 00000000..27ca949f --- /dev/null +++ b/Tests.Mcp/GitToolTests.cs @@ -0,0 +1,104 @@ +using ModelContextProtocol.Client; +using RackPeek.Mcp.Tools; + +namespace Tests.Mcp; + +/// +/// Git tools ride on the same GIT_TOKEN opt-in as the web UI: without it they say +/// how to turn git on; with it the config directory is a real repository (the +/// server auto-inits it) and commits are observable through git_status. +/// +public class GitToolTests { + private static Dictionary WithGit() => new() { + ["GIT_TOKEN"] = "dummy-token-for-local-repo" + }; + + [Fact] + public async Task Without_a_token_the_tools_explain_how_to_enable_git() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + GitStatusResult status = await client.CallOkAsync("git_status"); + + Assert.False(status.Available); + Assert.Contains("GIT_TOKEN", status.Message); + + var error = await client.CallErrorAsync( + "git_commit", new Dictionary { ["message"] = "won't happen" }); + Assert.Contains("GIT_TOKEN", error); + } + + [Fact] + public async Task A_fresh_config_repo_reports_dirty_then_commits_clean() { + using var api = new McpFixture(TestData.Seed, WithGit()); + await using McpClient client = await api.ConnectAsync(); + + GitStatusResult before = await client.CallOkAsync("git_status"); + Assert.True(before.Available); + Assert.Equal("Dirty", before.Status); // config.yaml is untracked + Assert.False(before.HasRemote); + + var committed = await client.CallTextAsync( + "git_commit", new Dictionary { ["message"] = "inventory snapshot" }); + Assert.Equal("Committed.", committed); + + GitStatusResult after = await client.CallOkAsync("git_status"); + Assert.Equal("Clean", after.Status); + Assert.NotNull(after.RecentCommits); + Assert.Contains(after.RecentCommits, c => c.Contains("inventory snapshot")); + } + + [Fact] + public async Task An_mcp_edit_shows_up_as_a_dirty_tree_ready_to_commit() { + using var api = new McpFixture(TestData.Seed, WithGit()); + await using McpClient client = await api.ConnectAsync(); + + await client.CallTextAsync( + "git_commit", new Dictionary { ["message"] = "baseline" }); + + await client.CallOkAsync("edit_tags", new Dictionary { + ["name"] = "rack-server", + ["add"] = new[] { "audited" } + }); + + GitStatusResult status = await client.CallOkAsync("git_status"); + Assert.Equal("Dirty", status.Status); + Assert.NotNull(status.ChangedFiles); + Assert.Contains(status.ChangedFiles, f => f.Contains("config.yaml")); + } + + [Fact] + public async Task Committing_a_clean_tree_succeeds_without_inventing_a_commit() { + using var api = new McpFixture(TestData.Seed, WithGit()); + await using McpClient client = await api.ConnectAsync(); + + await client.CallTextAsync( + "git_commit", new Dictionary { ["message"] = "first" }); + var second = await client.CallTextAsync( + "git_commit", new Dictionary { ["message"] = "second" }); + + Assert.Equal("Committed.", second); + + GitStatusResult status = await client.CallOkAsync("git_status"); + Assert.NotNull(status.RecentCommits); + var commit = Assert.Single(status.RecentCommits); + Assert.Contains("first", commit); + } + + [Fact] + public async Task Pushing_without_a_remote_is_an_error_that_says_the_commit_happened() { + using var api = new McpFixture(TestData.Seed, WithGit()); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync("git_commit", new Dictionary { + ["message"] = "local only", + ["push"] = true + }); + + Assert.Contains("Committed, but the push failed", error); + Assert.Contains("No remote", error); + + GitStatusResult status = await client.CallOkAsync("git_status"); + Assert.Equal("Clean", status.Status); // the commit itself landed + } +} diff --git a/Tests.Mcp/McpFixture.cs b/Tests.Mcp/McpFixture.cs new file mode 100644 index 00000000..b0f98bc1 --- /dev/null +++ b/Tests.Mcp/McpFixture.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using ModelContextProtocol.Client; +using RackPeek.Web; + +namespace Tests.Mcp; + +/// +/// A real RackPeek server backed by a temporary config file, driven through a real +/// MCP client speaking streamable-HTTP JSON-RPC to /mcp. Every test in this project +/// goes end to end through this: if a behaviour is not observable here, an MCP +/// client cannot observe it either. +/// +public sealed class McpFixture : IDisposable { + public const string ApiKey = "mcp-test-key"; + + private readonly WebApplicationFactory _factory; + + /// + /// Contents to seed config.yaml with before the server first reads it. + /// + /// + /// Extra configuration for the server — e.g. GIT_TOKEN to activate the git + /// tools, or RPK_API_KEY = null to model a server with no key configured. + /// + public McpFixture(string? initialConfig = null, IDictionary? extraConfig = null) { + TempDir = Path.Combine(Path.GetTempPath(), "rackpeek-mcp-tests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(TempDir); + + if (initialConfig != null) + File.WriteAllText(ConfigPath, initialConfig); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => { + builder.UseSetting("RPK_YAML_DIR", TempDir); + + // Settings read during startup (GIT_TOKEN wires services in BuildApp) + // must go through UseSetting: the in-memory collection below lands too + // late for service registration, though request-time reads see it fine. + foreach ((var key, var value) in extraConfig ?? new Dictionary()) + if (value != null) + builder.UseSetting(key, value); + + builder.ConfigureAppConfiguration((_, config) => { + var values = new Dictionary { + ["RPK_YAML_DIR"] = TempDir, + ["RPK_API_KEY"] = ApiKey + }; + + foreach ((var key, var value) in extraConfig ?? new Dictionary()) + values[key] = value; + + config.AddInMemoryCollection(values); + }); + }); + } + + public string TempDir { get; } + + public string ConfigPath => Path.Combine(TempDir, "config.yaml"); + + /// What is actually on disk — the ground truth every mutation test asserts on. + public string StoredYaml => File.ReadAllText(ConfigPath); + + /// An HTTP client for driving /mcp (or anything else) below the MCP layer. + public HttpClient CreateHttpClient(string? apiKey = ApiKey) { + HttpClient client = _factory.CreateClient(); + + if (apiKey != null) + client.DefaultRequestHeaders.Add("X-Api-Key", apiKey); + + return client; + } + + /// A connected MCP client; the initialize handshake has already succeeded. + public async Task ConnectAsync(string? apiKey = ApiKey) { + HttpClient http = CreateHttpClient(apiKey); + + var transport = new HttpClientTransport( + new HttpClientTransportOptions { + Endpoint = new Uri(http.BaseAddress!, "mcp"), + TransportMode = HttpTransportMode.StreamableHttp + }, + http, + loggerFactory: null, + ownsHttpClient: true); + + return await McpClient.CreateAsync(transport); + } + + 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. + } + } +} diff --git a/Tests.Mcp/McpTestExtensions.cs b/Tests.Mcp/McpTestExtensions.cs new file mode 100644 index 00000000..20d60371 --- /dev/null +++ b/Tests.Mcp/McpTestExtensions.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Tests.Mcp; + +internal static class McpTestExtensions { + /// Mirrors the server's tool serialization: web defaults plus enums as strings. + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { + Converters = { new JsonStringEnumConverter() } + }; + + /// Calls a tool that must succeed and deserializes its structured content. + public static async Task CallOkAsync( + this McpClient client, + string tool, + Dictionary? args = null) { + CallToolResult result = await client.CallToolAsync(tool, args); + + AssertOk(tool, result); + Assert.NotNull(result.StructuredContent); + + return result.StructuredContent.Value.Deserialize(Json) + ?? throw new InvalidOperationException($"'{tool}' returned unusable structured content."); + } + + /// Calls a tool that must succeed and returns its text content. + public static async Task CallTextAsync( + this McpClient client, + string tool, + Dictionary? args = null) { + CallToolResult result = await client.CallToolAsync(tool, args); + + AssertOk(tool, result); + + return Text(result); + } + + /// Calls a tool that must fail and returns the error text the agent would see. + public static async Task CallErrorAsync( + this McpClient client, + string tool, + Dictionary? args = null) { + CallToolResult result = await client.CallToolAsync(tool, args); + + Assert.True(result.IsError == true, $"Expected '{tool}' to fail, but it succeeded: {Text(result)}"); + + return Text(result); + } + + public static string Text(CallToolResult result) => + string.Join(Environment.NewLine, result.Content.OfType().Select(t => t.Text)); + + private static void AssertOk(string tool, CallToolResult result) => + Assert.False(result.IsError == true, $"'{tool}' failed: {Text(result)}"); +} diff --git a/Tests.Mcp/MutationToolTests.cs b/Tests.Mcp/MutationToolTests.cs new file mode 100644 index 00000000..6330c785 --- /dev/null +++ b/Tests.Mcp/MutationToolTests.cs @@ -0,0 +1,370 @@ +using ModelContextProtocol.Client; +using RackPeek.Domain.Api; +using RackPeek.Domain.Resources.Connections; +using RackPeek.Mcp.Tools; + +namespace Tests.Mcp; + +/// +/// The write half of the tool surface. Every assertion here is made against what +/// actually lands in config.yaml — the file is the product, not the tool response. +/// +public class MutationToolTests { + private const string _newServerYaml = + """ + version: 4 + resources: + - kind: Server + name: new-server + ram: + size: 64 + ports: + - type: rj45 + speed: 1 + count: 2 + """; + + // -- upsert_resources ---------------------------------------------------------------- + + [Fact] + public async Task Upserting_a_new_resource_persists_it_as_schema_conformant_yaml() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ImportYamlResponse response = await client.CallOkAsync( + "upsert_resources", new Dictionary { ["yaml"] = _newServerYaml }); + + Assert.Equal(["new-server"], response.Added); + Assert.Contains("new-server", api.StoredYaml); + SchemaAssert.ConformsToSchema(api.StoredYaml); + } + + [Fact] + public async Task A_dry_run_reports_the_diff_but_writes_nothing() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var before = api.StoredYaml; + + ImportYamlResponse response = await client.CallOkAsync( + "upsert_resources", + new Dictionary { ["yaml"] = _newServerYaml, ["dryRun"] = true }); + + Assert.Equal(["new-server"], response.Added); + Assert.Contains("new-server", Assert.Contains("new-server", response.NewYaml)); + Assert.Equal(before, api.StoredYaml); + } + + [Fact] + public async Task Merge_updates_fields_and_reports_old_and_new_yaml() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ImportYamlResponse response = await client.CallOkAsync( + "upsert_resources", new Dictionary { + ["yaml"] = + """ + version: 4 + resources: + - kind: System + name: host-os + cores: 16 + """ + }); + + Assert.Equal(["host-os"], response.Updated); + Assert.Contains("cores: 8", response.OldYaml["host-os"]); + Assert.Contains("cores: 16", response.NewYaml["host-os"]); + Assert.Contains("cores: 16", api.StoredYaml); + + // Merge only adds and updates — everything not mentioned stays. + Assert.Contains("env: prod", api.StoredYaml); + Assert.Contains("ip: 10.0.0.5", api.StoredYaml); + } + + [Fact] + public async Task Replace_mode_swaps_the_resource_in_wholesale() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ImportYamlResponse response = await client.CallOkAsync( + "upsert_resources", new Dictionary { + ["yaml"] = + """ + version: 4 + resources: + - kind: System + name: host-os + type: vm + os: alpine + """, + ["mode"] = "Replace" + }); + + Assert.Equal(["host-os"], response.Replaced); + Assert.Contains("os: alpine", api.StoredYaml); + Assert.DoesNotContain("env: prod", api.StoredYaml); // replaced, so the old labels are gone + + // Replace swaps the named resource only; the rest of the file is untouched. + Assert.Contains("grafana", api.StoredYaml); + } + + [Theory] + [InlineData("not: [valid", "Import failed")] + [InlineData("version: 4", "resources")] + [InlineData("", "Invalid input")] + public async Task Broken_documents_are_errors_that_name_the_problem(string yaml, string expected) { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var before = api.StoredYaml; + var error = await client.CallErrorAsync( + "upsert_resources", new Dictionary { ["yaml"] = yaml }); + + Assert.Contains(expected, error); + Assert.Equal(before, api.StoredYaml); + } + + [Fact] + public async Task The_first_mcp_write_after_a_restart_does_not_destroy_the_existing_inventory() { + // Same guarantee the inventory API pins down: the server loads the config before + // serving, so a fresh boot's first merge happens against the user's file rather + // than an empty collection (which would persist and wipe everything else). + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + await client.CallOkAsync( + "upsert_resources", new Dictionary { ["yaml"] = _newServerYaml }); + + Assert.Contains("rack-server", api.StoredYaml); + Assert.Contains("grafana", api.StoredYaml); + Assert.Contains("new-server", api.StoredYaml); + } + + // -- delete_resource ----------------------------------------------------------------- + + [Fact] + public async Task Deleting_hardware_detaches_dependants_and_unplugs_its_connections() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + await client.CallTextAsync( + "delete_resource", new Dictionary { ["name"] = "rack-server" }); + + var stored = api.StoredYaml; + Assert.DoesNotContain("rack-server", stored); + SchemaAssert.ConformsToSchema(stored); + + ConnectionList connections = await client.CallOkAsync("list_connections"); + Assert.Equal(0, connections.Count); + + ResourceDetail hostOs = await client.CallOkAsync( + "get_resource", new Dictionary { ["name"] = "host-os" }); + Assert.DoesNotContain("rack-server", hostOs.Yaml); + } + + [Fact] + public async Task Deleting_something_that_does_not_exist_is_an_error() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync( + "delete_resource", new Dictionary { ["name"] = "ghost" }); + + Assert.Contains("ghost", error); + } + + // -- rename_resource ----------------------------------------------------------------- + + [Fact] + public async Task Renaming_rewrites_runs_on_links_and_connection_endpoints() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + await client.CallTextAsync("rename_resource", new Dictionary { + ["name"] = "rack-server", + ["newName"] = "compute-01" + }); + + var stored = api.StoredYaml; + Assert.DoesNotContain("rack-server", stored); + SchemaAssert.ConformsToSchema(stored); + + ResourceDetail hostOs = await client.CallOkAsync( + "get_resource", new Dictionary { ["name"] = "host-os" }); + Assert.Contains("compute-01", hostOs.Yaml); + + ConnectionList connections = await client.CallOkAsync("list_connections"); + Assert.Equal("compute-01", Assert.Single(connections.Connections).A.Resource); + } + + [Fact] + public async Task Renaming_onto_a_taken_name_is_a_conflict() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync("rename_resource", new Dictionary { + ["name"] = "rack-server", + ["newName"] = "rack-switch" + }); + + Assert.Contains("already exists", error); + } + + // -- clone_resource ------------------------------------------------------------------ + + [Fact] + public async Task A_clone_copies_the_kind_specific_fields_but_never_the_discovery_id() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + await client.CallTextAsync("clone_resource", new Dictionary { + ["name"] = "rack-server", + ["cloneName"] = "compute-02" + }); + + ResourceDetail clone = await client.CallOkAsync( + "get_resource", new Dictionary { ["name"] = "compute-02" }); + + Assert.Contains("kind: Server", clone.Yaml); + // The deep copy went through the concrete Server type: the ports survived. + Assert.Contains("ports:", clone.Yaml); + Assert.Contains("count: 4", clone.Yaml); + // A discoveryId names one machine; a copy of its card is not that machine. + Assert.DoesNotContain("discoveryId", clone.Yaml); + SchemaAssert.ConformsToSchema(api.StoredYaml); + } + + [Fact] + public async Task Cloning_guards_both_names() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + Assert.Contains("not found", await client.CallErrorAsync("clone_resource", + new Dictionary { ["name"] = "ghost", ["cloneName"] = "copy" })); + + Assert.Contains("already exists", await client.CallErrorAsync("clone_resource", + new Dictionary { ["name"] = "rack-server", ["cloneName"] = "rack-switch" })); + } + + // -- edit_tags / edit_labels --------------------------------------------------------- + + [Fact] + public async Task Tags_can_be_added_and_removed_in_one_call() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + TagsResult result = await client.CallOkAsync("edit_tags", + new Dictionary { + ["name"] = "rack-server", + ["add"] = new[] { "rack-a", "critical" }, + ["remove"] = new[] { "prod" } + }); + + Assert.Equal(["rack-a", "critical"], result.Tags); + Assert.Contains("rack-a", api.StoredYaml); + Assert.DoesNotContain("- prod", api.StoredYaml); + } + + [Fact] + public async Task Labels_can_be_set_overwritten_and_removed() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + LabelsResult result = await client.CallOkAsync("edit_labels", + new Dictionary { + ["name"] = "host-os", + ["set"] = new Dictionary { ["env"] = "staging", ["owner"] = "tim" }, + ["remove"] = new[] { "owner" } + }); + + Assert.Equal("staging", Assert.Contains("env", result.Labels)); + Assert.DoesNotContain("owner", result.Labels.Keys); + Assert.Contains("env: staging", api.StoredYaml); + } + + [Fact] + public async Task Editing_tags_or_labels_needs_something_to_do() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + Assert.Contains("at least one", await client.CallErrorAsync("edit_tags", + new Dictionary { ["name"] = "rack-server" })); + + Assert.Contains("at least one", await client.CallErrorAsync("edit_labels", + new Dictionary { ["name"] = "host-os" })); + } + + // -- add_connection / remove_connection ------------------------------------------------ + + private static Dictionary Connect( + string a, int groupA, int indexA, string b, int groupB, int indexB) => new() { + ["resourceA"] = a, + ["portGroupA"] = groupA, + ["portIndexA"] = indexA, + ["resourceB"] = b, + ["portGroupB"] = groupB, + ["portIndexB"] = indexB + }; + + [Fact] + public async Task Connecting_two_free_ports_is_persisted() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + await client.CallTextAsync("add_connection", + Connect("rack-server", 0, 1, "rack-switch", 0, 2)); + + ConnectionList connections = await client.CallOkAsync("list_connections"); + Assert.Equal(2, connections.Count); + SchemaAssert.ConformsToSchema(api.StoredYaml); + } + + [Fact] + public async Task Connecting_an_occupied_port_replaces_what_was_plugged_into_it() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + // rack-server port 0/0 is already cabled to rack-switch 0/0 in the seed. + await client.CallTextAsync("add_connection", + Connect("rack-server", 0, 0, "rack-switch", 0, 5)); + + ConnectionList connections = await client.CallOkAsync("list_connections"); + Connection connection = Assert.Single(connections.Connections); + Assert.Equal(5, connection.B.PortIndex); + } + + [Fact] + public async Task Impossible_connections_are_named_errors() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + Assert.Contains("itself", await client.CallErrorAsync("add_connection", + Connect("rack-server", 0, 0, "rack-server", 0, 0))); + + Assert.Contains("no ports", await client.CallErrorAsync("add_connection", + Connect("host-os", 0, 0, "rack-switch", 0, 1))); + + Assert.Contains("not found", await client.CallErrorAsync("add_connection", + Connect("rack-server", 0, 99, "rack-switch", 0, 1))); + } + + [Fact] + public async Task Removing_a_connection_unplugs_the_port_and_removing_again_is_a_no_op() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var args = new Dictionary { + ["resource"] = "rack-switch", + ["portGroup"] = 0, + ["portIndex"] = 0 + }; + + await client.CallTextAsync("remove_connection", args); + await client.CallTextAsync("remove_connection", args); // idempotent + + ConnectionList connections = await client.CallOkAsync("list_connections"); + Assert.Equal(0, connections.Count); + Assert.DoesNotContain("portIndex", api.StoredYaml); + } +} diff --git a/Tests.Mcp/ProtocolTests.cs b/Tests.Mcp/ProtocolTests.cs new file mode 100644 index 00000000..019a87d1 --- /dev/null +++ b/Tests.Mcp/ProtocolTests.cs @@ -0,0 +1,78 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using RackPeek.Mcp.Tools; + +namespace Tests.Mcp; + +/// +/// The protocol surface itself: the handshake identifies the server, every tool is +/// advertised with enough description for an agent to use it unseen, and a bad tool +/// name is an error rather than a hang or a crash. +/// +public class ProtocolTests { + /// Every tool the server ships. A rename here is a breaking change for clients. + public static readonly string[] ExpectedTools = [ + "list_resources", "get_resource", "search_resources", "get_summary", "get_tree", + "list_connections", "get_subnets", "get_schema", + "upsert_resources", "delete_resource", "rename_resource", "clone_resource", + "edit_tags", "edit_labels", "add_connection", "remove_connection", + "export_ansible_inventory", "export_ssh_config", "export_hosts_file", "export_topology_mermaid", + "git_status", "git_commit", + "discover_docker", "discover_proxmox" + ]; + + [Fact] + public async Task The_handshake_identifies_the_server_by_name_and_version() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + Assert.Equal("rackpeek", client.ServerInfo.Name); + Assert.False(string.IsNullOrWhiteSpace(client.ServerInfo.Version)); + } + + [Fact] + public async Task Every_expected_tool_is_advertised_and_nothing_else() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + IList tools = await client.ListToolsAsync(); + + Assert.Equal( + ExpectedTools.OrderBy(t => t, StringComparer.Ordinal), + tools.Select(t => t.Name).OrderBy(t => t, StringComparer.Ordinal)); + } + + [Fact] + public async Task Every_tool_carries_a_description_an_agent_can_act_on() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + IList tools = await client.ListToolsAsync(); + + foreach (McpClientTool tool in tools) + Assert.False( + string.IsNullOrWhiteSpace(tool.Description), + $"Tool '{tool.Name}' has no description."); + } + + [Fact] + public async Task Calling_a_tool_that_does_not_exist_is_a_clean_error() { + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + await Assert.ThrowsAnyAsync(async () => + await client.CallToolAsync("does_not_exist")); + } + + [Fact] + public async Task Two_clients_can_talk_to_the_same_server_at_once() { + // Stateless streamable HTTP: no session to collide on. + using var api = new McpFixture(); + await using McpClient first = await api.ConnectAsync(); + await using McpClient second = await api.ConnectAsync(); + + await Task.WhenAll( + first.CallOkAsync("list_resources"), + second.CallOkAsync("list_resources")); + } +} diff --git a/Tests.Mcp/QueryToolTests.cs b/Tests.Mcp/QueryToolTests.cs new file mode 100644 index 00000000..7f61b56f --- /dev/null +++ b/Tests.Mcp/QueryToolTests.cs @@ -0,0 +1,288 @@ +using System.Text.Json; +using ModelContextProtocol.Client; +using RackPeek.Domain.Api; +using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.Resources.Hardware; +using RackPeek.Mcp.Tools; + +namespace Tests.Mcp; + +/// +/// The read half of the tool surface, over the seed inventory in +/// . Assertions state exact expectations — the seed is +/// small enough that anything looser would just be hiding a wrong answer. +/// +public class QueryToolTests { + // -- list_resources ----------------------------------------------------------------- + + [Fact] + public async Task Listing_returns_every_resource_with_its_key_facts() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ResourceList list = await client.CallOkAsync("list_resources"); + + Assert.Equal(5, list.Count); + + ResourceRow hostOs = Assert.Single(list.Resources, r => r.Name == "host-os"); + Assert.Equal("System", hostOs.Kind); + Assert.Equal("10.0.0.5", hostOs.Ip); + Assert.Equal(["rack-server"], hostOs.RunsOn); + Assert.Equal("prod", Assert.Contains("env", hostOs.Labels)); + } + + [Theory] + [InlineData("Server", "rack-server")] + [InlineData("server", "rack-server")] // kind matching must not be case-sensitive + [InlineData("Switch", "rack-switch")] + [InlineData("System", "host-os")] + public async Task Listing_filters_by_kind(string kind, string expected) { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ResourceList list = await client.CallOkAsync( + "list_resources", new Dictionary { ["kind"] = kind }); + + ResourceRow row = Assert.Single(list.Resources); + Assert.Equal(expected, row.Name); + } + + [Fact] + public async Task Listing_filters_by_tag_and_label_key() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ResourceList byTag = await client.CallOkAsync( + "list_resources", new Dictionary { ["tag"] = "prod" }); + ResourceList byLabel = await client.CallOkAsync( + "list_resources", new Dictionary { ["labelKey"] = "env" }); + + Assert.Equal("rack-server", Assert.Single(byTag.Resources).Name); + Assert.Equal("host-os", Assert.Single(byLabel.Resources).Name); + } + + [Fact] + public async Task An_unknown_kind_is_an_empty_list_not_an_error() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ResourceList list = await client.CallOkAsync( + "list_resources", new Dictionary { ["kind"] = "mainframe" }); + + Assert.Equal(0, list.Count); + } + + // -- get_resource ------------------------------------------------------------------- + + [Fact] + public async Task A_resource_comes_back_as_schema_conformant_yaml_with_its_connections() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ResourceDetail detail = await client.CallOkAsync( + "get_resource", new Dictionary { ["name"] = "rack-server" }); + + Assert.Contains("kind: Server", detail.Yaml); + Assert.Contains("name: rack-server", detail.Yaml); + Assert.Contains("rack-switch", detail.Yaml); // the connection's far end + Assert.Equal(["host-os"], detail.Dependants); + SchemaAssert.ConformsToSchema(detail.Yaml); + } + + [Fact] + public async Task The_yaml_from_get_resource_round_trips_through_upsert_without_phantom_changes() { + // The read and write halves must agree on the format, or an agent that reads, + // tweaks nothing and writes back would report changes it never made. + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ResourceDetail detail = await client.CallOkAsync( + "get_resource", new Dictionary { ["name"] = "host-os" }); + + ImportYamlResponse response = await client.CallOkAsync( + "upsert_resources", new Dictionary { ["yaml"] = detail.Yaml, ["dryRun"] = true }); + + Assert.Empty(response.Added); + Assert.Empty(response.Updated); + Assert.Empty(response.Replaced); + } + + [Fact] + public async Task Asking_for_a_resource_that_does_not_exist_is_a_useful_error() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync( + "get_resource", new Dictionary { ["name"] = "no-such-box" }); + + Assert.Contains("no-such-box", error); + Assert.Contains("not found", error); + } + + // -- search_resources --------------------------------------------------------------- + + [Fact] + public async Task Search_finds_by_name_ip_tag_and_label() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + SearchResults byName = await client.CallOkAsync( + "search_resources", new Dictionary { ["query"] = "grafana" }); + SearchResults byIp = await client.CallOkAsync( + "search_resources", new Dictionary { ["query"] = "10.0.1.9" }); + + Assert.Equal("grafana", byName.Matches.First().Name); + Assert.Equal("prometheus", byIp.Matches.First().Name); + } + + [Fact] + public async Task Search_respects_the_max_argument() { + using var api = new McpFixture(TestData.DemoConfig()); + await using McpClient client = await api.ConnectAsync(); + + SearchResults matches = await client.CallOkAsync( + "search_resources", new Dictionary { ["query"] = "e", ["max"] = 3 }); + + Assert.Equal(3, matches.Matches.Count); + } + + // -- get_summary --------------------------------------------------------------------- + + [Fact] + public async Task The_summary_counts_everything_in_one_call() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + InfrastructureSummary summary = await client.CallOkAsync("get_summary"); + + Assert.Equal(2, summary.Hardware.TotalHardware); + Assert.Equal(1, summary.Systems.TotalSystems); + Assert.Equal(2, summary.Services.TotalServices); + Assert.Equal(1, Assert.Contains("prod", summary.Tags)); + Assert.Equal(1, Assert.Contains("env", summary.Labels)); + } + + // -- get_tree ------------------------------------------------------------------------ + + [Fact] + public async Task The_tree_nests_services_under_systems_under_hardware() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + TreeResult tree = await client.CallOkAsync("get_tree"); + + HardwareTree server = Assert.Single(tree.Hardware, h => h.HardwareName == "rack-server"); + SystemTree system = Assert.Single(server.Systems); + Assert.Equal("host-os", system.SystemName); + Assert.Equal(["grafana", "prometheus"], system.Services.OrderBy(s => s, StringComparer.Ordinal)); + } + + [Fact] + public async Task The_tree_can_be_narrowed_to_one_hardware_resource() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + TreeResult tree = await client.CallOkAsync( + "get_tree", new Dictionary { ["hardwareName"] = "rack-switch" }); + + Assert.Equal("rack-switch", Assert.Single(tree.Hardware).HardwareName); + + var error = await client.CallErrorAsync( + "get_tree", new Dictionary { ["hardwareName"] = "no-such-rack" }); + Assert.Contains("not found", error); + } + + // -- list_connections ---------------------------------------------------------------- + + [Fact] + public async Task Connections_are_listed_whole_and_per_resource() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + ConnectionList all = await client.CallOkAsync("list_connections"); + ConnectionList forSwitch = await client.CallOkAsync( + "list_connections", new Dictionary { ["resource"] = "rack-switch" }); + ConnectionList forHost = await client.CallOkAsync( + "list_connections", new Dictionary { ["resource"] = "host-os" }); + + Connection connection = Assert.Single(all.Connections); + Assert.Equal("rack-server", connection.A.Resource); + Assert.Equal("rack-switch", connection.B.Resource); + Assert.Equal("uplink", connection.Label); + + Assert.Equal(1, forSwitch.Count); + Assert.Equal(0, forHost.Count); + } + + // -- get_subnets --------------------------------------------------------------------- + + [Fact] + public async Task Service_ips_group_into_subnets_and_filter_by_cidr() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + JsonElement grouped = await client.CallOkAsync("get_subnets"); + JsonElement filtered = await client.CallOkAsync( + "get_subnets", new Dictionary { ["cidr"] = "10.0.0.0/24" }); + + var subnets = grouped.GetProperty("subnets").EnumerateArray() + .Select(s => s.GetProperty("cidr").GetString()) + .ToList(); + Assert.Equal(["10.0.0.0/24", "10.0.1.0/24"], subnets); + + var services = filtered.GetProperty("services").EnumerateArray() + .Select(s => s.GetProperty("name").GetString()) + .ToList(); + Assert.Equal(["grafana"], services); + } + + [Fact] + public async Task A_malformed_cidr_is_an_error_that_shows_the_right_shape() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + var error = await client.CallErrorAsync( + "get_subnets", new Dictionary { ["cidr"] = "not-a-cidr" }); + + Assert.Contains("not-a-cidr", error); + Assert.Contains("192.168.1.0/24", error); + } + + // -- get_schema ---------------------------------------------------------------------- + + [Fact] + public async Task The_schema_tool_serves_the_current_published_schema() { + using var api = new McpFixture(TestData.Seed); + await using McpClient client = await api.ConnectAsync(); + + SchemaInfo info = await client.CallOkAsync("get_schema"); + + Assert.Equal(4, info.Version); + Assert.False(string.IsNullOrWhiteSpace(info.Guidance)); + + // Byte-for-byte the schema the repo publishes, so the tool cannot drift. + var published = await File.ReadAllTextAsync( + Path.Combine(AppContext.BaseDirectory, "schemas", "schema.v4.json")); + Assert.Equal(published, info.JsonSchema); + } + + // -- breadth over the demo inventory --------------------------------------------------- + + [Fact] + public async Task The_whole_demo_inventory_lists_trees_and_reads_back_conformant_yaml() { + using var api = new McpFixture(TestData.DemoConfig()); + await using McpClient client = await api.ConnectAsync(); + + ResourceList list = await client.CallOkAsync("list_resources"); + Assert.Equal(45, list.Count); + + TreeResult tree = await client.CallOkAsync("get_tree"); + Assert.NotEmpty(tree.Hardware); + + foreach (var name in new[] { "proxmox-node01", "pfsense-fw", "plex", "proxmox-cluster-node01" }) { + ResourceDetail detail = await client.CallOkAsync( + "get_resource", new Dictionary { ["name"] = name }); + SchemaAssert.ConformsToSchema(detail.Yaml); + } + } +} diff --git a/Tests.Mcp/SchemaAssert.cs b/Tests.Mcp/SchemaAssert.cs new file mode 100644 index 00000000..def1d20e --- /dev/null +++ b/Tests.Mcp/SchemaAssert.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using Json.Schema; +using YamlDotNet.RepresentationModel; + +namespace Tests.Mcp; + +/// +/// Asserts YAML the MCP tools hand out (or persist) satisfies the published +/// RackPeek schema, so the tool surface cannot drift away from the contract the +/// rest of the world imports. Same approach as Tests.Discovery. +/// +public static class SchemaAssert { + // 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> _schemas = new(); + + public static void ConformsToSchema(string yaml, int version = 4) { + JsonSchema schema = _schemas.GetOrAdd(version, v => new Lazy(() => + 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(); + Collect(results, errors); + + Assert.Fail($"YAML 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 errors) { + if (node.Errors != null) + foreach (KeyValuePair 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"; + } + } +} diff --git a/Tests.Mcp/TestData.cs b/Tests.Mcp/TestData.cs new file mode 100644 index 00000000..0982e03a --- /dev/null +++ b/Tests.Mcp/TestData.cs @@ -0,0 +1,76 @@ +namespace Tests.Mcp; + +internal static class TestData { + /// + /// A small, fully-understood inventory: two pieces of connected hardware, a + /// system on the server, two services on the system, one cabled connection. + /// Small enough that every test can state its expectations exactly. + /// + public const string Seed = + """ + version: 4 + resources: + - kind: Server + name: rack-server + discoveryId: rpk1:sys:aaaaaaaaaaaaaaaa + tags: + - prod + labels: + ansible_host: 10.0.0.2 + ports: + - type: rj45 + speed: 1 + count: 4 + - kind: Switch + name: rack-switch + ports: + - type: rj45 + speed: 1 + count: 8 + - kind: System + name: host-os + type: baremetal + os: debian + cores: 8 + ram: 32 + ip: 10.0.0.5 + runsOn: + - rack-server + labels: + env: prod + - kind: Service + name: grafana + runsOn: + - host-os + network: + ip: 10.0.0.5 + port: 3000 + protocol: TCP + - kind: Service + name: prometheus + runsOn: + - host-os + network: + ip: 10.0.1.9 + port: 9090 + protocol: TCP + connections: + - a: + resource: rack-server + portGroup: 0 + portIndex: 0 + b: + resource: rack-switch + portGroup: 0 + portIndex: 0 + label: uplink + """; + + /// The 46-resource demo inventory shipped with the repo, for breadth tests. + public static string DemoConfig() => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestConfigs", "demo-config.yaml")); + + /// Captured API output shared with Tests.Discovery, for the discovery tools. + public static string Fixture(string name) => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", name)); +} diff --git a/Tests.Mcp/Tests.Mcp.csproj b/Tests.Mcp/Tests.Mcp.csproj new file mode 100644 index 00000000..d2a18e42 --- /dev/null +++ b/Tests.Mcp/Tests.Mcp.csproj @@ -0,0 +1,46 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/TestConfigs/v3/11-demo-config.yaml b/Tests/TestConfigs/v3/11-demo-config.yaml index 44401c35..27c0a9e1 100644 --- a/Tests/TestConfigs/v3/11-demo-config.yaml +++ b/Tests/TestConfigs/v3/11-demo-config.yaml @@ -121,10 +121,6 @@ resources: 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 diff --git a/Tests/TestConfigs/v4/11-demo-config.yaml b/Tests/TestConfigs/v4/11-demo-config.yaml index 2a5a6aa5..b7f70a34 100644 --- a/Tests/TestConfigs/v4/11-demo-config.yaml +++ b/Tests/TestConfigs/v4/11-demo-config.yaml @@ -121,10 +121,6 @@ resources: 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 diff --git a/justfile b/justfile index 8482c241..8b2a2ad3 100644 --- a/justfile +++ b/justfile @@ -66,6 +66,11 @@ test-cli: _check-dotnet test-discovery: _check-dotnet {{ _dotnet }} test Tests.Discovery +[doc("Run MCP tests (fast; no Docker required; matches the mcp-tests CI job)")] +[group("test")] +test-mcp: _check-dotnet + {{ _dotnet }} test Tests.Mcp + [doc("Install Playwright + browsers for E2E (first-time only)")] [group("test")] e2e-setup: _check-dotnet @@ -78,9 +83,9 @@ e2e-setup: _check-dotnet test-e2e: _check-dotnet build-web cd Tests.E2e && {{ _dotnet }} test -[doc("Run CLI + discovery + E2E tests (rebuilds Web image)")] +[doc("Run CLI + discovery + MCP + E2E tests (rebuilds Web image)")] [group("test")] -test-all: _check-dotnet build-web e2e-setup test-cli test-discovery test-e2e +test-all: _check-dotnet build-web e2e-setup test-cli test-discovery test-mcp test-e2e [doc("Run full test suite (alias for test-all; matches CI / pre-PR checklist)")] [group("test")] From c48981786f45f23f4f192c13c7d54bd0f3bfaebc Mon Sep 17 00:00:00 2001 From: WhiteStorm Date: Sat, 26 Sep 2026 18:50:09 +0200 Subject: [PATCH 04/29] Add ports to Laptop, Other and Ups, and a usb port type Laptop, Other and Ups were the only hardware kinds that could not describe their physical connectivity, which made it impossible to model USB-attached peripherals, IPTV decoders or a UPS monitoring link without misusing Desktop as a stand-in. Domain - Laptop, Other and Ups implement IPortResource (List? Ports). The open-generic registration of IAddPortUseCase<> / ISetPortUseCase<> / IRemovePortUseCase<> already covers the new types, so no use-case wiring was needed. - Add "usb" to Nic.ValidNicTypes. That single array drives port validation, CLI suggestions and both UI dropdowns. - Add PortSummaries.Describe and surface ports in the describe use cases: UpsDescription.PortSummary, OtherDescription.PortSummary and LaptopDescription.NicCount (matching DescribeDesktopUseCase). CLI - rpk ups port add|set|del and rpk other port add|set|del, following the --count / --index convention used by switch, router and firewall. - rpk laptops nic add|set|del, following the --ports convention used by servers and desktops. UI - PortGroupEditor on the laptop, other and UPS cards. Schema v4 - ports on the laptop, ups and other definitions; "usb" in the port type enum. All three published copies stay byte-identical. --- RackPeek.Domain/Helpers/PortSummaries.cs | 16 ++++++++ .../Laptops/DescribeLaptopUseCase.cs | 2 + RackPeek.Domain/Resources/Laptops/Laptop.cs | 3 +- .../OtherHardware/DescribeOtherUseCase.cs | 2 + .../Resources/OtherHardware/Other.cs | 6 ++- RackPeek.Domain/Resources/SubResources/Nic.cs | 5 ++- .../Resources/UpsUnits/DescribeUpsUseCase.cs | 2 + RackPeek.Domain/Resources/UpsUnits/Ups.cs | 6 ++- .../wwwroot/schemas/v4/schema.v4.json | 21 +++++++++- .../wwwroot/schemas/v4/schema.v4.json | 21 +++++++++- Shared.Rcl/CliBootstrap.cs | 32 +++++++++++++++ .../Commands/Laptops/LaptopDescribeCommand.cs | 1 + .../Laptops/Nics/LaptopNicAddCommand.cs | 23 +++++++++++ .../Laptops/Nics/LaptopNicAddSettings.cs | 22 ++++++++++ .../Laptops/Nics/LaptopNicRemoveCommand.cs | 23 +++++++++++ .../Laptops/Nics/LaptopNicRemoveSettings.cs | 14 +++++++ .../Laptops/Nics/LaptopNicSetCommand.cs | 23 +++++++++++ .../Laptops/Nics/LaptopNicSetSettings.cs | 26 ++++++++++++ .../OtherHardware/OtherDescribeCommand.cs | 1 + .../Ports/OtherPortAddCommand.cs | 35 ++++++++++++++++ .../Ports/OtherPortRemoveCommand.cs | 28 +++++++++++++ .../Ports/OtherPortUpdateCommand.cs | 40 +++++++++++++++++++ .../Commands/Ups/Ports/UpsPortAddCommand.cs | 35 ++++++++++++++++ .../Ups/Ports/UpsPortRemoveCommand.cs | 27 +++++++++++++ .../Ups/Ports/UpsPortUpdateCommand.cs | 39 ++++++++++++++++++ Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs | 1 + Shared.Rcl/Laptops/LaptopCardComponent.razor | 7 ++++ .../OtherHardware/OtherCardComponent.razor | 7 ++++ Shared.Rcl/Ups/UpsCardComponent.razor | 7 ++++ .../wwwroot/raw_docs/resource-levels.md | 22 ++++++---- schemas/v4/schema.v4.json | 21 +++++++++- 31 files changed, 503 insertions(+), 15 deletions(-) create mode 100644 RackPeek.Domain/Helpers/PortSummaries.cs create mode 100644 Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddCommand.cs create mode 100644 Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddSettings.cs create mode 100644 Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveCommand.cs create mode 100644 Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveSettings.cs create mode 100644 Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetCommand.cs create mode 100644 Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetSettings.cs create mode 100644 Shared.Rcl/Commands/OtherHardware/Ports/OtherPortAddCommand.cs create mode 100644 Shared.Rcl/Commands/OtherHardware/Ports/OtherPortRemoveCommand.cs create mode 100644 Shared.Rcl/Commands/OtherHardware/Ports/OtherPortUpdateCommand.cs create mode 100644 Shared.Rcl/Commands/Ups/Ports/UpsPortAddCommand.cs create mode 100644 Shared.Rcl/Commands/Ups/Ports/UpsPortRemoveCommand.cs create mode 100644 Shared.Rcl/Commands/Ups/Ports/UpsPortUpdateCommand.cs diff --git a/RackPeek.Domain/Helpers/PortSummaries.cs b/RackPeek.Domain/Helpers/PortSummaries.cs new file mode 100644 index 00000000..443ca92f --- /dev/null +++ b/RackPeek.Domain/Helpers/PortSummaries.cs @@ -0,0 +1,16 @@ +using RackPeek.Domain.Resources.SubResources; + +namespace RackPeek.Domain.Helpers; + +public static class PortSummaries { + public static string Describe(List? ports) { + if (ports == null || ports.Count == 0) + return "None"; + + IEnumerable groups = ports + .GroupBy(p => p.Type ?? "Unknown") + .Select(g => $"{g.Key}: {g.Sum(p => p.Count ?? 0)}"); + + return string.Join(", ", groups); + } +} diff --git a/RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs b/RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs index ccd1d35a..9434d9ef 100644 --- a/RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs +++ b/RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs @@ -22,6 +22,7 @@ public async Task ExecuteAsync(string name) { ramSummary, laptop.Drives?.Count ?? 0, laptop.Gpus?.Count ?? 0, + laptop.Ports?.Count ?? 0, laptop.Labels ); } @@ -33,5 +34,6 @@ public record LaptopDescription( string? RamSummary, int DriveCount, int GpuCount, + int NicCount, Dictionary Labels ); diff --git a/RackPeek.Domain/Resources/Laptops/Laptop.cs b/RackPeek.Domain/Resources/Laptops/Laptop.cs index 4242ceb8..1a7e8269 100644 --- a/RackPeek.Domain/Resources/Laptops/Laptop.cs +++ b/RackPeek.Domain/Resources/Laptops/Laptop.cs @@ -3,11 +3,12 @@ namespace RackPeek.Domain.Resources.Laptops; -public class Laptop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource { +public class Laptop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource { public const string KindLabel = "Laptop"; public Ram? Ram { get; set; } public string? Model { get; set; } public List? Cpus { get; set; } public List? Drives { get; set; } public List? Gpus { get; set; } + public List? Ports { get; set; } } diff --git a/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs b/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs index 30943364..56534cf2 100644 --- a/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs +++ b/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs @@ -7,6 +7,7 @@ public record OtherDescription( string Name, string? Model, string? Description, + string PortSummary, Dictionary Labels ); @@ -23,6 +24,7 @@ public async Task ExecuteAsync(string name) { other.Name, other.Model, other.Description, + PortSummaries.Describe(other.Ports), other.Labels ); } diff --git a/RackPeek.Domain/Resources/OtherHardware/Other.cs b/RackPeek.Domain/Resources/OtherHardware/Other.cs index dd226ad4..3bc08c72 100644 --- a/RackPeek.Domain/Resources/OtherHardware/Other.cs +++ b/RackPeek.Domain/Resources/OtherHardware/Other.cs @@ -1,7 +1,11 @@ +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SubResources; + namespace RackPeek.Domain.Resources.OtherHardware; -public class Other : Hardware.Hardware { +public class Other : Hardware.Hardware, IPortResource { public const string KindLabel = "Other"; public string? Model { get; set; } public string? Description { get; set; } + public List? Ports { get; set; } } diff --git a/RackPeek.Domain/Resources/SubResources/Nic.cs b/RackPeek.Domain/Resources/SubResources/Nic.cs index a4979206..5bd12a95 100644 --- a/RackPeek.Domain/Resources/SubResources/Nic.cs +++ b/RackPeek.Domain/Resources/SubResources/Nic.cs @@ -25,7 +25,10 @@ public class Nic { "xfp", "cx4", // Management / special-purpose - "mgmt" // Dedicated management NIC (IPMI/BMC) + "mgmt", // Dedicated management NIC (IPMI/BMC) + + // Peripheral bus + "usb" // USB-attached hardware (dongles, external drives, accelerators) }; public string? Type { get; set; } diff --git a/RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs b/RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs index de72c23a..62406b88 100644 --- a/RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs +++ b/RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs @@ -7,6 +7,7 @@ public record UpsDescription( string Name, string? Model, int? Va, + string PortSummary, Dictionary Labels ); @@ -23,6 +24,7 @@ public async Task ExecuteAsync(string name) { ups.Name, ups.Model, ups.Va, + PortSummaries.Describe(ups.Ports), ups.Labels ); } diff --git a/RackPeek.Domain/Resources/UpsUnits/Ups.cs b/RackPeek.Domain/Resources/UpsUnits/Ups.cs index 29d221f6..32b8164e 100644 --- a/RackPeek.Domain/Resources/UpsUnits/Ups.cs +++ b/RackPeek.Domain/Resources/UpsUnits/Ups.cs @@ -1,7 +1,11 @@ +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SubResources; + namespace RackPeek.Domain.Resources.UpsUnits; -public class Ups : Hardware.Hardware { +public class Ups : Hardware.Hardware, IPortResource { public const string KindLabel = "Ups"; public string? Model { get; set; } public int? Va { get; set; } + public List? Ports { get; set; } } diff --git a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json index dbaf1934..b6a9e92e 100644 --- a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json @@ -275,7 +275,8 @@ "osfp", "xfp", "cx4", - "mgmt" + "mgmt", + "usb" ] }, "speed": { @@ -433,6 +434,12 @@ "items": { "$ref": "#/$defs/drive" } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } @@ -587,6 +594,12 @@ "va": { "type": "integer", "minimum": 1 + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } @@ -609,6 +622,12 @@ }, "description": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } diff --git a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json index dbaf1934..b6a9e92e 100644 --- a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json @@ -275,7 +275,8 @@ "osfp", "xfp", "cx4", - "mgmt" + "mgmt", + "usb" ] }, "speed": { @@ -433,6 +434,12 @@ "items": { "$ref": "#/$defs/drive" } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } @@ -587,6 +594,12 @@ "va": { "type": "integer", "minimum": 1 + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } @@ -609,6 +622,12 @@ }, "description": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } diff --git a/Shared.Rcl/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index c374ef5a..b5532cfb 100644 --- a/Shared.Rcl/CliBootstrap.cs +++ b/Shared.Rcl/CliBootstrap.cs @@ -38,6 +38,7 @@ using Shared.Rcl.Commands.Laptops.Drive; using Shared.Rcl.Commands.Laptops.Gpus; using Shared.Rcl.Commands.Laptops.Labels; +using Shared.Rcl.Commands.Laptops.Nics; using Shared.Rcl.Commands.Laptops.Rename; using Shared.Rcl.Commands.Routers; using Shared.Rcl.Commands.Routers.Labels; @@ -62,10 +63,12 @@ using Shared.Rcl.Commands.Systems.Rename; using Shared.Rcl.Commands.OtherHardware; using Shared.Rcl.Commands.OtherHardware.Labels; +using Shared.Rcl.Commands.OtherHardware.Ports; using Shared.Rcl.Commands.OtherHardware.Rename; using Shared.Rcl.Commands.Tags; using Shared.Rcl.Commands.Ups; using Shared.Rcl.Commands.Ups.Labels; +using Shared.Rcl.Commands.Ups.Ports; using Shared.Rcl.Commands.Ups.Rename; using Spectre.Console; using Spectre.Console.Cli; @@ -512,6 +515,16 @@ public static void BuildApp(CommandApp app) { ups.AddCommand("rename") .WithDescription("Rename a UPS unit to a new name."); + ups.AddBranch("port", port => { + port.SetDescription("Manage ports on a UPS unit."); + + port.AddCommand("add").WithDescription("Add a port to a UPS unit."); + + port.AddCommand("set").WithDescription("Update a UPS unit port."); + + port.AddCommand("del").WithDescription("Remove a port from a UPS unit."); + }); + ups.AddBranch("label", label => { label.SetDescription("Manage labels on a UPS unit."); label.AddCommand("add").WithDescription("Add a label to a UPS unit."); @@ -553,6 +566,17 @@ public static void BuildApp(CommandApp app) { other.AddCommand("rename") .WithDescription("Rename other hardware to a new name."); + other.AddBranch("port", port => { + port.SetDescription("Manage ports on other hardware."); + + port.AddCommand("add").WithDescription("Add a port to other hardware."); + + port.AddCommand("set").WithDescription("Update an other hardware port."); + + port.AddCommand("del") + .WithDescription("Remove a port from other hardware."); + }); + other.AddBranch("label", label => { label.SetDescription("Manage labels on other hardware."); label.AddCommand("add").WithDescription("Add a label to other hardware."); @@ -688,6 +712,14 @@ public static void BuildApp(CommandApp app) { gpu.AddCommand("del").WithDescription("Remove a GPU from a Laptop."); }); + // NICs + laptops.AddBranch("nic", nic => { + nic.SetDescription("Manage network interface cards (NICs) for Laptops."); + nic.AddCommand("add").WithDescription("Add a NIC to a Laptop."); + nic.AddCommand("set").WithDescription("Update a Laptop NIC."); + nic.AddCommand("del").WithDescription("Remove a NIC from a Laptop."); + }); + laptops.AddBranch("label", label => { label.SetDescription("Manage labels on a laptop."); label.AddCommand("add").WithDescription("Add a label to a laptop."); diff --git a/Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs b/Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs index 2e313f72..8f21430d 100644 --- a/Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs +++ b/Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs @@ -23,6 +23,7 @@ protected override async Task ExecuteAsync( grid.AddRow("RAM:", result.RamSummary ?? "None"); grid.AddRow("Drives:", result.DriveCount.ToString()); grid.AddRow("GPUs:", result.GpuCount.ToString()); + grid.AddRow("NICs:", result.NicCount.ToString()); if (result.Labels.Count > 0) grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}"))); diff --git a/Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddCommand.cs b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddCommand.cs new file mode 100644 index 00000000..115c6cb7 --- /dev/null +++ b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddCommand.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Resources.Laptops; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Laptops.Nics; + +public class LaptopNicAddCommand(IServiceProvider provider) + : AsyncCommand { + protected override async Task ExecuteAsync( + CommandContext context, + LaptopNicAddSettings settings, + CancellationToken cancellationToken) { + using IServiceScope scope = provider.CreateScope(); + IAddPortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(settings.LaptopName, settings.Type, settings.Speed, settings.Ports); + + AnsiConsole.MarkupLine($"[green]NIC added to Laptop '{settings.LaptopName}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddSettings.cs b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddSettings.cs new file mode 100644 index 00000000..4cbdd0bb --- /dev/null +++ b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddSettings.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Laptops.Nics; + +public class LaptopNicAddSettings : CommandSettings { + [CommandArgument(0, "")] + [Description("The name of the Laptop.")] + public string LaptopName { get; set; } = default!; + + [CommandOption("--type")] + [Description("The nic port type e.g rj45 / sfp+")] + public string? Type { get; set; } + + [CommandOption("--speed")] + [Description("The port speed.")] + public double? Speed { get; set; } + + [CommandOption("--ports")] + [Description("The number of ports.")] + public int? Ports { get; set; } +} diff --git a/Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveCommand.cs b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveCommand.cs new file mode 100644 index 00000000..9e1319ed --- /dev/null +++ b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveCommand.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Resources.Laptops; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Laptops.Nics; + +public class LaptopNicRemoveCommand(IServiceProvider provider) + : AsyncCommand { + protected override async Task ExecuteAsync( + CommandContext context, + LaptopNicRemoveSettings settings, + CancellationToken cancellationToken) { + using IServiceScope scope = provider.CreateScope(); + IRemovePortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(settings.LaptopName, settings.Index); + + AnsiConsole.MarkupLine($"[green]NIC #{settings.Index} removed from Laptop '{settings.LaptopName}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveSettings.cs b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveSettings.cs new file mode 100644 index 00000000..bfd5de47 --- /dev/null +++ b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveSettings.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Laptops.Nics; + +public class LaptopNicRemoveSettings : CommandSettings { + [CommandArgument(0, "")] + [Description("The Laptop name.")] + public string LaptopName { get; set; } = default!; + + [CommandArgument(1, "")] + [Description("The index of the nic to remove.")] + public int Index { get; set; } +} diff --git a/Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetCommand.cs b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetCommand.cs new file mode 100644 index 00000000..8464aa5b --- /dev/null +++ b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetCommand.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Resources.Laptops; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Laptops.Nics; + +public class LaptopNicSetCommand(IServiceProvider provider) + : AsyncCommand { + protected override async Task ExecuteAsync( + CommandContext context, + LaptopNicSetSettings settings, + CancellationToken cancellationToken) { + using IServiceScope scope = provider.CreateScope(); + IUpdatePortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(settings.LaptopName, settings.Index, settings.Type, settings.Speed, settings.Ports); + + AnsiConsole.MarkupLine($"[green]NIC #{settings.Index} updated on Laptop '{settings.LaptopName}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetSettings.cs b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetSettings.cs new file mode 100644 index 00000000..c451ab2d --- /dev/null +++ b/Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetSettings.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Laptops.Nics; + +public class LaptopNicSetSettings : CommandSettings { + [CommandArgument(0, "")] + [Description("The Laptop name.")] + public string LaptopName { get; set; } = default!; + + [CommandArgument(1, "")] + [Description("The index of the nic to update.")] + public int Index { get; set; } + + [CommandOption("--type")] + [Description("The nic port type e.g rj45 / sfp+")] + public string? Type { get; set; } + + [CommandOption("--speed")] + [Description("The port speed.")] + public double? Speed { get; set; } + + [CommandOption("--ports")] + [Description("The number of ports.")] + public int? Ports { get; set; } +} diff --git a/Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs b/Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs index c676da11..b8e5a7c2 100644 --- a/Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs +++ b/Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs @@ -23,6 +23,7 @@ protected override async Task ExecuteAsync( grid.AddRow("Name:", other.Name.EscapeMarkup()); grid.AddRow("Model:", (other.Model ?? "Unknown").EscapeMarkup()); grid.AddRow("Description:", (other.Description ?? "Unknown").EscapeMarkup()); + grid.AddRow("Ports:", other.PortSummary.EscapeMarkup()); if (other.Labels.Count > 0) grid.AddRow("Labels:", string.Join(", ", other.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}"))); diff --git a/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortAddCommand.cs b/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortAddCommand.cs new file mode 100644 index 00000000..2fede40b --- /dev/null +++ b/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortAddCommand.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Resources.OtherHardware; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.OtherHardware.Ports; + +public class OtherPortAddSettings : OtherNameSettings { + [CommandOption("--type")] + [Description("The port type (e.g., rj45, sfp+).")] + public string? Type { get; set; } + + [CommandOption("--speed")] + [Description("The port speed (e.g., 1, 2.5, 10).")] + public double? Speed { get; set; } + + [CommandOption("--count")] + [Description("Number of ports of this type.")] + public int? Count { get; set; } +} + +public class OtherPortAddCommand(IServiceProvider sp) + : AsyncCommand { + protected override async Task ExecuteAsync(CommandContext ctx, OtherPortAddSettings s, CancellationToken ct) { + using IServiceScope scope = sp.CreateScope(); + IAddPortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(s.Name, s.Type, s.Speed, s.Count); + + AnsiConsole.MarkupLine($"[green]Port added to other hardware '{s.Name}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortRemoveCommand.cs b/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortRemoveCommand.cs new file mode 100644 index 00000000..f71e9822 --- /dev/null +++ b/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortRemoveCommand.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Resources.OtherHardware; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.OtherHardware.Ports; + +public class OtherPortRemoveSettings : OtherNameSettings { + [CommandOption("--index ")] + [Description("The index of the port to remove.")] + public int Index { get; set; } +} + +public class OtherPortRemoveCommand(IServiceProvider sp) + : AsyncCommand { + protected override async Task ExecuteAsync(CommandContext ctx, OtherPortRemoveSettings s, + CancellationToken ct) { + using IServiceScope scope = sp.CreateScope(); + IRemovePortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(s.Name, s.Index); + + AnsiConsole.MarkupLine($"[green]Port #{s.Index} removed from other hardware '{s.Name}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortUpdateCommand.cs b/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortUpdateCommand.cs new file mode 100644 index 00000000..9c7ed624 --- /dev/null +++ b/Shared.Rcl/Commands/OtherHardware/Ports/OtherPortUpdateCommand.cs @@ -0,0 +1,40 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.Resources.OtherHardware; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.OtherHardware.Ports; + +public class OtherPortUpdateSettings : OtherNameSettings { + [CommandOption("--index ")] + [Description("The index of the port to update.")] + public int Index { get; set; } + + [CommandOption("--type")] + [Description("The port type (e.g., rj45, sfp+).")] + public string? Type { get; set; } + + [CommandOption("--speed")] + [Description("The port speed (e.g., 1, 2.5, 10).")] + public double? Speed { get; set; } + + [CommandOption("--count")] + [Description("Number of ports of this type.")] + public int? Count { get; set; } +} + +public class OtherPortUpdateCommand(IServiceProvider sp) + : AsyncCommand { + protected override async Task ExecuteAsync(CommandContext ctx, OtherPortUpdateSettings s, + CancellationToken ct) { + using IServiceScope scope = sp.CreateScope(); + IUpdatePortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(s.Name, s.Index, s.Type, s.Speed, s.Count); + + AnsiConsole.MarkupLine($"[green]Port #{s.Index} updated on other hardware '{s.Name}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Ups/Ports/UpsPortAddCommand.cs b/Shared.Rcl/Commands/Ups/Ports/UpsPortAddCommand.cs new file mode 100644 index 00000000..9e5f8727 --- /dev/null +++ b/Shared.Rcl/Commands/Ups/Ports/UpsPortAddCommand.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; +using UpsUnit = RackPeek.Domain.Resources.UpsUnits.Ups; + +namespace Shared.Rcl.Commands.Ups.Ports; + +public class UpsPortAddSettings : UpsNameSettings { + [CommandOption("--type")] + [Description("The port type (e.g., rj45, usb).")] + public string? Type { get; set; } + + [CommandOption("--speed")] + [Description("The port speed (e.g., 0.1, 1).")] + public double? Speed { get; set; } + + [CommandOption("--count")] + [Description("Number of ports of this type.")] + public int? Count { get; set; } +} + +public class UpsPortAddCommand(IServiceProvider sp) + : AsyncCommand { + protected override async Task ExecuteAsync(CommandContext ctx, UpsPortAddSettings s, CancellationToken ct) { + using IServiceScope scope = sp.CreateScope(); + IAddPortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(s.Name, s.Type, s.Speed, s.Count); + + AnsiConsole.MarkupLine($"[green]Port added to UPS '{s.Name}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Ups/Ports/UpsPortRemoveCommand.cs b/Shared.Rcl/Commands/Ups/Ports/UpsPortRemoveCommand.cs new file mode 100644 index 00000000..74713c77 --- /dev/null +++ b/Shared.Rcl/Commands/Ups/Ports/UpsPortRemoveCommand.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; +using UpsUnit = RackPeek.Domain.Resources.UpsUnits.Ups; + +namespace Shared.Rcl.Commands.Ups.Ports; + +public class UpsPortRemoveSettings : UpsNameSettings { + [CommandOption("--index ")] + [Description("The index of the port to remove.")] + public int Index { get; set; } +} + +public class UpsPortRemoveCommand(IServiceProvider sp) + : AsyncCommand { + protected override async Task ExecuteAsync(CommandContext ctx, UpsPortRemoveSettings s, CancellationToken ct) { + using IServiceScope scope = sp.CreateScope(); + IRemovePortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(s.Name, s.Index); + + AnsiConsole.MarkupLine($"[green]Port #{s.Index} removed from UPS '{s.Name}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Ups/Ports/UpsPortUpdateCommand.cs b/Shared.Rcl/Commands/Ups/Ports/UpsPortUpdateCommand.cs new file mode 100644 index 00000000..ae349ad4 --- /dev/null +++ b/Shared.Rcl/Commands/Ups/Ports/UpsPortUpdateCommand.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using RackPeek.Domain.UseCases.Ports; +using Spectre.Console; +using Spectre.Console.Cli; +using UpsUnit = RackPeek.Domain.Resources.UpsUnits.Ups; + +namespace Shared.Rcl.Commands.Ups.Ports; + +public class UpsPortUpdateSettings : UpsNameSettings { + [CommandOption("--index ")] + [Description("The index of the port to update.")] + public int Index { get; set; } + + [CommandOption("--type")] + [Description("The port type (e.g., rj45, usb).")] + public string? Type { get; set; } + + [CommandOption("--speed")] + [Description("The port speed (e.g., 0.1, 1).")] + public double? Speed { get; set; } + + [CommandOption("--count")] + [Description("Number of ports of this type.")] + public int? Count { get; set; } +} + +public class UpsPortUpdateCommand(IServiceProvider sp) + : AsyncCommand { + protected override async Task ExecuteAsync(CommandContext ctx, UpsPortUpdateSettings s, CancellationToken ct) { + using IServiceScope scope = sp.CreateScope(); + IUpdatePortUseCase useCase = scope.ServiceProvider.GetRequiredService>(); + + await useCase.ExecuteAsync(s.Name, s.Index, s.Type, s.Speed, s.Count); + + AnsiConsole.MarkupLine($"[green]Port #{s.Index} updated on UPS '{s.Name}'.[/]"); + return 0; + } +} diff --git a/Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs b/Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs index 4ba841e8..e4d824a6 100644 --- a/Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs +++ b/Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs @@ -24,6 +24,7 @@ protected override async Task ExecuteAsync( grid.AddRow("Name:", ups.Name.EscapeMarkup()); grid.AddRow("Model:", (ups.Model ?? "Unknown").EscapeMarkup()); grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown"); + grid.AddRow("Ports:", ups.PortSummary.EscapeMarkup()); if (ups.Labels.Count > 0) grid.AddRow("Labels:", string.Join(", ", ups.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}"))); diff --git a/Shared.Rcl/Laptops/LaptopCardComponent.razor b/Shared.Rcl/Laptops/LaptopCardComponent.razor index 47f34b07..8069c684 100644 --- a/Shared.Rcl/Laptops/LaptopCardComponent.razor +++ b/Shared.Rcl/Laptops/LaptopCardComponent.razor @@ -3,6 +3,7 @@ @using RackPeek.Domain.UseCases.Cpus @using RackPeek.Domain.UseCases.Drives @using RackPeek.Domain.UseCases.Gpus +@using Shared.Rcl.Hardware @inject IGetResourceByNameUseCase GetByNameUseCase @inject UpdateLaptopUseCase UpdateUseCase @inject IDeleteResourceUseCase DeleteUseCase @@ -176,6 +177,12 @@ }
+ + + GetByNameUseCase @inject IDeleteResourceUseCase DeleteUseCase @@ -103,6 +104,12 @@ } + + + diff --git a/Shared.Rcl/Ups/UpsCardComponent.razor b/Shared.Rcl/Ups/UpsCardComponent.razor index f5934287..da8943ea 100644 --- a/Shared.Rcl/Ups/UpsCardComponent.razor +++ b/Shared.Rcl/Ups/UpsCardComponent.razor @@ -1,4 +1,5 @@ @using RackPeek.Domain.Resources.UpsUnits +@using Shared.Rcl.Hardware @inject UpdateUpsUseCase UpdateUseCase @inject IGetResourceByNameUseCase GetByNameUseCase @inject IDeleteResourceUseCase DeleteUseCase @@ -111,6 +112,12 @@ } + + + diff --git a/Shared.Rcl/wwwroot/raw_docs/resource-levels.md b/Shared.Rcl/wwwroot/raw_docs/resource-levels.md index ef282c1c..251beeb9 100644 --- a/Shared.Rcl/wwwroot/raw_docs/resource-levels.md +++ b/Shared.Rcl/wwwroot/raw_docs/resource-levels.md @@ -48,14 +48,20 @@ network switches, Wi-Fi access points, UPS units, and workstations. Some hardware types support sub-resources that describe their internal components. -| Sub-Resource | Server | Desktop | Laptop | Switch | Router | Firewall | -|--------------|:------:|:-------:|:------:|:------:|:------:|:--------:| -| CPU | Yes | Yes | Yes | | | | -| Drive | Yes | Yes | Yes | | | | -| GPU | Yes | Yes | Yes | | | | -| NIC | Yes | Yes | | | | | -| Port | | | | Yes | Yes | Yes | -| RAM | Yes | Yes | Yes | | | | +| Sub-Resource | Server | Desktop | Laptop | Switch | Router | Firewall | Access Point | UPS | Other | +|--------------|:------:|:-------:|:------:|:------:|:------:|:--------:|:------------:|:---:|:-----:| +| CPU | Yes | Yes | Yes | | | | | | | +| Drive | Yes | Yes | Yes | | | | | | | +| GPU | Yes | Yes | Yes | | | | | | | +| NIC | Yes | Yes | Yes | | | | | | | +| Port | | | | Yes | Yes | Yes | Yes* | Yes | Yes | +| RAM | Yes | Yes | Yes | | | | | | | + +\* Access Point ports are editable in the web UI and in YAML, but have no `rpk accesspoints port` CLI branch yet. + +NIC and Port are the same underlying sub-resource — they only differ in the CLI branch used to manage them. Compute +kinds expose it as `rpk nic`, network and appliance kinds as `rpk port`. Either way the resource can be +wired up with `rpk connections add` and shows up in `rpk graph topology`. Hardware is the foundation. Nothing runs "on" hardware in the RackPeek sense — hardware just exists. Systems and services cannot be hardware; they live on top of it. diff --git a/schemas/v4/schema.v4.json b/schemas/v4/schema.v4.json index dbaf1934..b6a9e92e 100644 --- a/schemas/v4/schema.v4.json +++ b/schemas/v4/schema.v4.json @@ -275,7 +275,8 @@ "osfp", "xfp", "cx4", - "mgmt" + "mgmt", + "usb" ] }, "speed": { @@ -433,6 +434,12 @@ "items": { "$ref": "#/$defs/drive" } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } @@ -587,6 +594,12 @@ "va": { "type": "integer", "minimum": 1 + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } @@ -609,6 +622,12 @@ }, "description": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } } } } From 2ed50aefe989d3ca571dcc822027c7fe60f212da Mon Sep 17 00:00:00 2001 From: WhiteStorm Date: Sat, 26 Sep 2026 21:26:37 +0200 Subject: [PATCH 05/29] Add CLI and Playwright coverage for the new ports CLI (Tests/): UpsPortWorkflowTests, OtherPortWorkflowTests and LaptopNicWorkflowTests exercise add/set/del/describe with exact YAML asserts, plus four error facts per kind (missing resource, invalid port type, invalid set index, invalid del index) and --help assertions for each new command branch. Playwright (Tests.E2e/): one card test per kind adds two port groups through PortGroupEditor, reloads, and asserts both groups and their individual ports survive the round trip. UpsCardPom, OtherCardPom and LaptopCardPom gained a Ports member and thin wrappers over the existing PortsPom, mirroring AccessPointCardPom. Every test pairs the new usb type with rj45 so the port summary has more than one group to fold. --- Tests.E2e/LaptopCardTests.cs | 44 +++++++ Tests.E2e/OtherCardTests.cs | 45 +++++++ Tests.E2e/PageObjectModels/LaptopCardPom.cs | 24 ++++ Tests.E2e/PageObjectModels/OtherCardPom.cs | 24 ++++ Tests.E2e/PageObjectModels/UpsCardPom.cs | 24 ++++ Tests.E2e/UpsCardTests.cs | 48 +++++++ .../LaptopTests/LaptopCommandTests.cs | 7 ++ .../EndToEnd/LaptopTests/LaptopErrorTests.cs | 49 ++++++++ .../LaptopTests/LaptopNicWorkflowTests.cs | 109 ++++++++++++++++ .../EndToEnd/OtherTests/OtherCommandTests.cs | 13 ++ Tests/EndToEnd/OtherTests/OtherErrorTests.cs | 53 ++++++++ .../OtherTests/OtherPortWorkflowTests.cs | 114 +++++++++++++++++ Tests/EndToEnd/UpsTests/UpsCommandTests.cs | 13 ++ Tests/EndToEnd/UpsTests/UpsErrorTest.cs | 53 ++++++++ .../EndToEnd/UpsTests/UpsPortWorkflowTests.cs | 119 ++++++++++++++++++ 15 files changed, 739 insertions(+) create mode 100644 Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs create mode 100644 Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs create mode 100644 Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs diff --git a/Tests.E2e/LaptopCardTests.cs b/Tests.E2e/LaptopCardTests.cs index 58ef71eb..3b36f99c 100644 --- a/Tests.E2e/LaptopCardTests.cs +++ b/Tests.E2e/LaptopCardTests.cs @@ -326,4 +326,48 @@ public async Task User_Can_Add_And_Remove_Tags_From_Laptop_Card() { await context.CloseAsync(); } } + + // ============================================================= + // NICs (ports) + // ============================================================= + + [Fact] + public async Task User_Can_Add_Nics_To_A_Laptop() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + + var name = $"e2e-lap-{Guid.NewGuid():N}"[..16]; + + try { + var list = new LaptopListPom(page); + await list.GotoAsync(_fixture.BaseUrl); + await list.AssertLoadedAsync(); + + await list.AddLaptopAsync(name); + await page.WaitForURLAsync($"**/resources/hardware/{name}"); + + var card = new LaptopCardPom(page); + await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync(); + + await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync(); + + // Built-in wired NIC plus a USB-attached dock. + await card.AddPortGroupAsync("rj45", "1", 1); + await card.AssertPortGroupVisibleAsync(0); + + await card.AddPortGroupAsync("usb", "10", 2); + await card.AssertPortGroupVisibleAsync(1); + + await page.ReloadAsync(); + await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync(); + + await card.AssertPortVisibleAsync(0, 0); + await card.AssertPortVisibleAsync(1, 0); + await card.AssertPortVisibleAsync(1, 1); + + await card.DeleteLaptopAsync(name); + } + finally { + await context.CloseAsync(); + } + } } diff --git a/Tests.E2e/OtherCardTests.cs b/Tests.E2e/OtherCardTests.cs index c9c3cad6..b340673c 100644 --- a/Tests.E2e/OtherCardTests.cs +++ b/Tests.E2e/OtherCardTests.cs @@ -222,4 +222,49 @@ public async Task User_Can_Add_And_Remove_Tags_From_Other_Card() { await context.CloseAsync(); } } + + // ============================================================= + // Ports + // ============================================================= + + [Fact] + public async Task User_Can_Add_Port_Groups_To_Other_Hardware() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + + var name = $"e2e-oth-{Guid.NewGuid():N}"[..16]; + + try { + await page.GotoAsync($"{_fixture.BaseUrl}/other/list"); + + var list = new OtherListPom(page); + await list.AddOtherAsync(name); + + if (!page.Url.Contains($"/resources/hardware/{name}", + StringComparison.OrdinalIgnoreCase)) + await list.OpenOtherAsync(name); + + var card = new OtherCardPom(page); + await card.AssertVisibleAsync(name); + + await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync(); + + await card.AddPortGroupAsync("rj45", "0.1", 1); + await card.AssertPortGroupVisibleAsync(0); + + await card.AddPortGroupAsync("usb", "0.48", 2); + await card.AssertPortGroupVisibleAsync(1); + + await page.ReloadAsync(); + await card.AssertVisibleAsync(name); + + await card.AssertPortVisibleAsync(0, 0); + await card.AssertPortVisibleAsync(1, 0); + await card.AssertPortVisibleAsync(1, 1); + + await card.DeleteAsync(name); + } + finally { + await context.CloseAsync(); + } + } } diff --git a/Tests.E2e/PageObjectModels/LaptopCardPom.cs b/Tests.E2e/PageObjectModels/LaptopCardPom.cs index ee9e71ee..6558e756 100644 --- a/Tests.E2e/PageObjectModels/LaptopCardPom.cs +++ b/Tests.E2e/PageObjectModels/LaptopCardPom.cs @@ -6,6 +6,10 @@ public class LaptopCardPom(IPage page) { public TagsPom Tags => new(page); public LabelsPom Labels => new(page); + public PortsPom Ports => new(page); + + private const string _portsPrefix = "laptop-ports"; + // ------------------------------------------------- // Modals // ------------------------------------------------- @@ -184,4 +188,24 @@ public async Task CloneLaptopAsync(string currentName, string cloneName) { private static string Sanitize(string value) => value.Replace(" ", "-"); + + // ------------------------------------------------- + // Ports + // ------------------------------------------------- + + public ILocator PortGroupSection => Ports.Root(_portsPrefix); + + public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index); + + public ILocator Port(int groupIndex, int portIndex) + => Ports.Port(_portsPrefix, groupIndex, portIndex); + + public async Task AddPortGroupAsync(string type, string speed, int count) + => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count); + + public async Task AssertPortGroupVisibleAsync(int index) + => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index); + + public async Task AssertPortVisibleAsync(int groupIndex, int portIndex) + => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex); } diff --git a/Tests.E2e/PageObjectModels/OtherCardPom.cs b/Tests.E2e/PageObjectModels/OtherCardPom.cs index cf0df464..afe9e28e 100644 --- a/Tests.E2e/PageObjectModels/OtherCardPom.cs +++ b/Tests.E2e/PageObjectModels/OtherCardPom.cs @@ -6,6 +6,10 @@ public class OtherCardPom(IPage page) { public TagsPom Tags => new(page); public LabelsPom Labels => new(page); + public PortsPom Ports => new(page); + + private const string _portsPrefix = "other-ports"; + // ------------------------------------------------- // Notes // ------------------------------------------------- @@ -139,4 +143,24 @@ public async Task DeleteAsync(string name) { await DeleteButton(name).ClickAsync(); await ConfirmDeleteButton.ClickAsync(); } + + // ------------------------------------------------- + // Ports + // ------------------------------------------------- + + public ILocator PortGroupSection => Ports.Root(_portsPrefix); + + public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index); + + public ILocator Port(int groupIndex, int portIndex) + => Ports.Port(_portsPrefix, groupIndex, portIndex); + + public async Task AddPortGroupAsync(string type, string speed, int count) + => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count); + + public async Task AssertPortGroupVisibleAsync(int index) + => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index); + + public async Task AssertPortVisibleAsync(int groupIndex, int portIndex) + => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex); } diff --git a/Tests.E2e/PageObjectModels/UpsCardPom.cs b/Tests.E2e/PageObjectModels/UpsCardPom.cs index e05eaee5..33c58158 100644 --- a/Tests.E2e/PageObjectModels/UpsCardPom.cs +++ b/Tests.E2e/PageObjectModels/UpsCardPom.cs @@ -6,6 +6,10 @@ public class UpsCardPom(IPage page) { public TagsPom Tags => new(page); public LabelsPom Labels => new(page); + public PortsPom Ports => new(page); + + private const string _portsPrefix = "ups-ports"; + // ------------------------------------------------- // Notes // ------------------------------------------------- @@ -139,4 +143,24 @@ public async Task DeleteAsync(string name) { await DeleteButton(name).ClickAsync(); await ConfirmDeleteButton.ClickAsync(); } + + // ------------------------------------------------- + // Ports + // ------------------------------------------------- + + public ILocator PortGroupSection => Ports.Root(_portsPrefix); + + public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index); + + public ILocator Port(int groupIndex, int portIndex) + => Ports.Port(_portsPrefix, groupIndex, portIndex); + + public async Task AddPortGroupAsync(string type, string speed, int count) + => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count); + + public async Task AssertPortGroupVisibleAsync(int index) + => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index); + + public async Task AssertPortVisibleAsync(int groupIndex, int portIndex) + => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex); } diff --git a/Tests.E2e/UpsCardTests.cs b/Tests.E2e/UpsCardTests.cs index 4d942f5a..b02c7d0a 100644 --- a/Tests.E2e/UpsCardTests.cs +++ b/Tests.E2e/UpsCardTests.cs @@ -222,4 +222,52 @@ public async Task User_Can_Add_And_Remove_Tags_From_Ups_Card() { await context.CloseAsync(); } } + + // ============================================================= + // Ports + // ============================================================= + + [Fact] + public async Task User_Can_Add_Usb_And_Rj45_Port_Groups_To_A_Ups() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + + var name = $"e2e-ups-{Guid.NewGuid():N}"[..16]; + + try { + await page.GotoAsync($"{_fixture.BaseUrl}/ups/list"); + + var list = new UpsListPom(page); + await list.AddUpsAsync(name); + + if (!page.Url.Contains($"/resources/hardware/{name}", + StringComparison.OrdinalIgnoreCase)) + await list.OpenUpsAsync(name); + + var card = new UpsCardPom(page); + await card.AssertVisibleAsync(name); + + await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync(); + + // The monitoring port: physically RJ45-shaped, enumerates as USB. + await card.AddPortGroupAsync("usb", "0.48", 1); + await card.AssertPortGroupVisibleAsync(0); + + // The dataline surge pass-through pair. + await card.AddPortGroupAsync("rj45", "1", 2); + await card.AssertPortGroupVisibleAsync(1); + + // Both groups must survive a round trip through the API. + await page.ReloadAsync(); + await card.AssertVisibleAsync(name); + + await card.AssertPortVisibleAsync(0, 0); + await card.AssertPortVisibleAsync(1, 0); + await card.AssertPortVisibleAsync(1, 1); + + await card.DeleteAsync(name); + } + finally { + await context.CloseAsync(); + } + } } diff --git a/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs b/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs index eb444574..e16dc033 100644 --- a/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs +++ b/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs @@ -49,6 +49,13 @@ public async Task help_commands_do_not_throw() { // GPU help Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1); + + // NIC help + Assert.Contains("Manage network interface cards", (await ExecuteAsync("laptops", "nic", "--help")).Item1); + Assert.Contains("Add a NIC to a Laptop", (await ExecuteAsync("laptops", "nic", "add", "--help")).Item1); + Assert.Contains("Update a Laptop NIC", (await ExecuteAsync("laptops", "nic", "set", "--help")).Item1); + Assert.Contains("Remove a NIC from a Laptop", (await ExecuteAsync("laptops", "nic", "del", "--help")).Item1); + Assert.Contains("Rename a Laptop", (await ExecuteAsync("laptops", "rename", "--help")).Item1); } diff --git a/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs b/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs index 53bd47f7..786bbb45 100644 --- a/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs +++ b/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs @@ -87,4 +87,53 @@ public async Task gpu_set_invalid_index_returns_error() { Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); } + + + // NIC errors + [Fact] + public async Task nic_add_missing_laptop_returns_error() { + (var output, var _) = await ExecuteAsync( + "laptops", "nic", "add", "ghost", + "--type", "rj45", + "--speed", "1", + "--ports", "1" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task nic_add_invalid_type_returns_error() { + await ExecuteAsync("laptops", "add", "lap01"); + + (var output, var _) = await ExecuteAsync( + "laptops", "nic", "add", "lap01", + "--type", "not-a-port-type", + "--speed", "1", + "--ports", "1" + ); + + Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task nic_set_invalid_index_returns_error() { + await ExecuteAsync("laptops", "add", "lap01"); + + (var output, var _) = await ExecuteAsync( + "laptops", "nic", "set", "lap01", "4", + "--type", "rj45" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task nic_del_invalid_index_returns_error() { + await ExecuteAsync("laptops", "add", "lap01"); + + (var output, var _) = await ExecuteAsync("laptops", "nic", "del", "lap01", "2"); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs b/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs new file mode 100644 index 00000000..98bf2a7f --- /dev/null +++ b/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs @@ -0,0 +1,109 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.LaptopTests; + +[Collection("Yaml CLI tests")] +public class LaptopNicWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + private async Task<(string, string)> ExecuteAsync(params string[] args) { + outputHelper.WriteLine($"rpk {string.Join(" ", args)}"); + + var output = await YamlCliTestHost.RunAsync( + args, + fs.Root, + outputHelper, + "config.yaml"); + + outputHelper.WriteLine(output); + + var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml")); + return (output, yaml); + } + + [Fact] + public async Task laptop_nic_cli_workflow_test() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), ""); + + await ExecuteAsync("laptops", "add", "lap01"); + await ExecuteAsync("laptops", "set", "lap01", "--model", "ThinkPad X1 Carbon"); + + // Built-in wired NIC. + (var output, var yaml) = await ExecuteAsync( + "laptops", "nic", "add", "lap01", + "--type", "rj45", + "--speed", "1", + "--ports", "1" + ); + Assert.Equal("NIC added to Laptop 'lap01'.\n", output); + + // Dock, attached over USB. + (output, yaml) = await ExecuteAsync( + "laptops", "nic", "add", "lap01", + "--type", "usb", + "--speed", "10", + "--ports", "2" + ); + Assert.Equal("NIC added to Laptop 'lap01'.\n", output); + + Assert.Equal(""" + version: 4 + resources: + - kind: Laptop + model: ThinkPad X1 Carbon + ports: + - type: rj45 + speed: 1 + count: 1 + - type: usb + speed: 10 + count: 2 + name: lap01 + connections: [] + + """, yaml); + + (output, yaml) = await ExecuteAsync( + "laptops", "nic", "set", "lap01", "1", + "--type", "usb", + "--speed", "20", + "--ports", "2" + ); + Assert.Equal("NIC #1 updated on Laptop 'lap01'.\n", output); + Assert.Contains("speed: 20", yaml); + + // Describe reports the NIC count, matching how desktops report theirs. + (output, yaml) = await ExecuteAsync("laptops", "describe", "lap01"); + Assert.Contains("NICs:", output); + Assert.Contains("2", output); + + (output, yaml) = await ExecuteAsync("laptops", "nic", "del", "lap01", "1"); + Assert.Equal("NIC #1 removed from Laptop 'lap01'.\n", output); + + Assert.Equal(""" + version: 4 + resources: + - kind: Laptop + model: ThinkPad X1 Carbon + ports: + - type: rj45 + speed: 1 + count: 1 + name: lap01 + connections: [] + + """, yaml); + } + + [Fact] + public async Task describe_reports_zero_nics_when_laptop_has_none() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), ""); + + await ExecuteAsync("laptops", "add", "lap-bare"); + + (var output, var _) = await ExecuteAsync("laptops", "describe", "lap-bare"); + + Assert.Contains("NICs:", output); + Assert.Contains("0", output); + } +} diff --git a/Tests/EndToEnd/OtherTests/OtherCommandTests.cs b/Tests/EndToEnd/OtherTests/OtherCommandTests.cs index 3d268a21..878a33ce 100644 --- a/Tests/EndToEnd/OtherTests/OtherCommandTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherCommandTests.cs @@ -55,6 +55,19 @@ public async Task help_outputs_do_not_throw() { Assert.Contains("Delete other hardware", delHelp); (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help"); Assert.Contains("Rename other hardware", renameHelp); + + // Port help + (var portHelp, var _) = await ExecuteAsync("other", "port", "--help"); + Assert.Contains("Manage ports on other hardware", portHelp); + + (var portAddHelp, var _) = await ExecuteAsync("other", "port", "add", "--help"); + Assert.Contains("Add a port to other hardware", portAddHelp); + + (var portSetHelp, var _) = await ExecuteAsync("other", "port", "set", "--help"); + Assert.Contains("Update an other hardware port", portSetHelp); + + (var portDelHelp, var _) = await ExecuteAsync("other", "port", "del", "--help"); + Assert.Contains("Remove a port from other hardware", portDelHelp); } [Fact] diff --git a/Tests/EndToEnd/OtherTests/OtherErrorTests.cs b/Tests/EndToEnd/OtherTests/OtherErrorTests.cs index 6d6ee7df..38908f6b 100644 --- a/Tests/EndToEnd/OtherTests/OtherErrorTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherErrorTests.cs @@ -57,4 +57,57 @@ public async Task rename_missing_other_returns_error() { Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); } + + + // Port errors + [Fact] + public async Task port_add_missing_other_returns_error() { + (var output, var _) = await ExecuteAsync( + "other", "port", "add", "ghost", + "--type", "rj45", + "--speed", "1", + "--count", "1" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task port_add_invalid_type_returns_error() { + await ExecuteAsync("other", "add", "radio01"); + + (var output, var _) = await ExecuteAsync( + "other", "port", "add", "radio01", + "--type", "not-a-port-type", + "--speed", "1", + "--count", "1" + ); + + Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task port_set_invalid_index_returns_error() { + await ExecuteAsync("other", "add", "radio01"); + + (var output, var _) = await ExecuteAsync( + "other", "port", "set", "radio01", + "--index", "5", + "--type", "rj45" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task port_del_invalid_index_returns_error() { + await ExecuteAsync("other", "add", "radio01"); + + (var output, var _) = await ExecuteAsync( + "other", "port", "del", "radio01", + "--index", "3" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs b/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs new file mode 100644 index 00000000..d8423672 --- /dev/null +++ b/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs @@ -0,0 +1,114 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.OtherTests; + +[Collection("Yaml CLI tests")] +public class OtherPortWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + private async Task<(string, string)> ExecuteAsync(params string[] args) { + outputHelper.WriteLine($"rpk {string.Join(" ", args)}"); + + var output = await YamlCliTestHost.RunAsync( + args, + fs.Root, + outputHelper, + "config.yaml"); + + outputHelper.WriteLine(output); + + var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml")); + return (output, yaml); + } + + [Fact] + public async Task other_port_cli_workflow_test() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), ""); + + await ExecuteAsync("other", "add", "decoder01"); + await ExecuteAsync( + "other", "set", "decoder01", + "--model", "TVIP-v605", + "--description", "IPTV set-top box" + ); + + (var output, var yaml) = await ExecuteAsync( + "other", "port", "add", "decoder01", + "--type", "rj45", + "--speed", "0.1", + "--count", "1" + ); + Assert.Equal("Port added to other hardware 'decoder01'.\n", output); + + (output, yaml) = await ExecuteAsync( + "other", "port", "add", "decoder01", + "--type", "usb", + "--speed", "0.48", + "--count", "2" + ); + Assert.Equal("Port added to other hardware 'decoder01'.\n", output); + + Assert.Equal(""" + version: 4 + resources: + - kind: Other + model: TVIP-v605 + description: IPTV set-top box + ports: + - type: rj45 + speed: 0.1 + count: 1 + - type: usb + speed: 0.48 + count: 2 + name: decoder01 + connections: [] + + """, yaml); + + (output, yaml) = await ExecuteAsync( + "other", "port", "set", "decoder01", + "--index", "1", + "--type", "usb", + "--speed", "0.48", + "--count", "3" + ); + Assert.Equal("Port #1 updated on other hardware 'decoder01'.\n", output); + Assert.Contains("count: 3", yaml); + + (output, yaml) = await ExecuteAsync("other", "describe", "decoder01"); + Assert.Contains("Ports:", output); + Assert.Contains("rj45: 1", output); + Assert.Contains("usb: 3", output); + + (output, yaml) = await ExecuteAsync("other", "port", "del", "decoder01", "--index", "0"); + Assert.Equal("Port #0 removed from other hardware 'decoder01'.\n", output); + + Assert.Equal(""" + version: 4 + resources: + - kind: Other + model: TVIP-v605 + description: IPTV set-top box + ports: + - type: usb + speed: 0.48 + count: 3 + name: decoder01 + connections: [] + + """, yaml); + } + + [Fact] + public async Task describe_reports_none_when_other_has_no_ports() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), ""); + + await ExecuteAsync("other", "add", "bare01"); + + (var output, var _) = await ExecuteAsync("other", "describe", "bare01"); + + Assert.Contains("Ports:", output); + Assert.Contains("None", output); + } +} diff --git a/Tests/EndToEnd/UpsTests/UpsCommandTests.cs b/Tests/EndToEnd/UpsTests/UpsCommandTests.cs index cd2a72b5..0d7c2d76 100644 --- a/Tests/EndToEnd/UpsTests/UpsCommandTests.cs +++ b/Tests/EndToEnd/UpsTests/UpsCommandTests.cs @@ -55,6 +55,19 @@ public async Task help_outputs_do_not_throw() { Assert.Contains("Delete a UPS unit", delHelp); (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help"); Assert.Contains("Rename a UPS unit", renameHelp); + + // Port help + (var portHelp, var _) = await ExecuteAsync("ups", "port", "--help"); + Assert.Contains("Manage ports on a UPS unit", portHelp); + + (var portAddHelp, var _) = await ExecuteAsync("ups", "port", "add", "--help"); + Assert.Contains("Add a port to a UPS unit", portAddHelp); + + (var portSetHelp, var _) = await ExecuteAsync("ups", "port", "set", "--help"); + Assert.Contains("Update a UPS unit port", portSetHelp); + + (var portDelHelp, var _) = await ExecuteAsync("ups", "port", "del", "--help"); + Assert.Contains("Remove a port from a UPS unit", portDelHelp); } [Fact] diff --git a/Tests/EndToEnd/UpsTests/UpsErrorTest.cs b/Tests/EndToEnd/UpsTests/UpsErrorTest.cs index 81b8cdc0..d15eca48 100644 --- a/Tests/EndToEnd/UpsTests/UpsErrorTest.cs +++ b/Tests/EndToEnd/UpsTests/UpsErrorTest.cs @@ -62,4 +62,57 @@ public async Task invalid_va_value_returns_error() { Assert.Contains("error", output, StringComparison.OrdinalIgnoreCase); } + + + // Port errors + [Fact] + public async Task port_add_missing_ups_returns_error() { + (var output, var _) = await ExecuteAsync( + "ups", "port", "add", "ghost", + "--type", "usb", + "--speed", "0.48", + "--count", "1" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task port_add_invalid_type_returns_error() { + await ExecuteAsync("ups", "add", "ups01"); + + (var output, var _) = await ExecuteAsync( + "ups", "port", "add", "ups01", + "--type", "not-a-port-type", + "--speed", "1", + "--count", "1" + ); + + Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task port_set_invalid_index_returns_error() { + await ExecuteAsync("ups", "add", "ups01"); + + (var output, var _) = await ExecuteAsync( + "ups", "port", "set", "ups01", + "--index", "5", + "--type", "usb" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task port_del_invalid_index_returns_error() { + await ExecuteAsync("ups", "add", "ups01"); + + (var output, var _) = await ExecuteAsync( + "ups", "port", "del", "ups01", + "--index", "3" + ); + + Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs b/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs new file mode 100644 index 00000000..d34b6242 --- /dev/null +++ b/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs @@ -0,0 +1,119 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.UpsTests; + +[Collection("Yaml CLI tests")] +public class UpsPortWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + private async Task<(string, string)> ExecuteAsync(params string[] args) { + outputHelper.WriteLine($"rpk {string.Join(" ", args)}"); + + var output = await YamlCliTestHost.RunAsync( + args, + fs.Root, + outputHelper, + "config.yaml"); + + outputHelper.WriteLine(output); + + var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml")); + return (output, yaml); + } + + [Fact] + public async Task ups_port_cli_workflow_test() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), ""); + + await ExecuteAsync("ups", "add", "ups01"); + await ExecuteAsync("ups", "set", "ups01", "--model", "APC-BGM2200", "--va", "2200"); + + // A UPS data port that is physically RJ45-shaped but enumerates as USB. + (var output, var yaml) = await ExecuteAsync( + "ups", "port", "add", "ups01", + "--type", "usb", + "--speed", "0.48", + "--count", "1" + ); + Assert.Equal("Port added to UPS 'ups01'.\n", output); + + // The dataline surge pass-through pair. + (output, yaml) = await ExecuteAsync( + "ups", "port", "add", "ups01", + "--type", "rj45", + "--speed", "1", + "--count", "2" + ); + Assert.Equal("Port added to UPS 'ups01'.\n", output); + + Assert.Equal(""" + version: 4 + resources: + - kind: Ups + model: APC-BGM2200 + va: 2200 + ports: + - type: usb + speed: 0.48 + count: 1 + - type: rj45 + speed: 1 + count: 2 + name: ups01 + connections: [] + + """, yaml); + + // Update the second group in place. + (output, yaml) = await ExecuteAsync( + "ups", "port", "set", "ups01", + "--index", "1", + "--type", "rj45", + "--speed", "1", + "--count", "4" + ); + Assert.Equal("Port #1 updated on UPS 'ups01'.\n", output); + Assert.Contains("count: 4", yaml); + + // Describe surfaces the port summary. + (output, yaml) = await ExecuteAsync("ups", "describe", "ups01"); + Assert.Contains("Ports:", output); + Assert.Contains("usb: 1", output); + Assert.Contains("rj45: 4", output); + + // Remove the pass-through pair again. + (output, yaml) = await ExecuteAsync("ups", "port", "del", "ups01", "--index", "1"); + Assert.Equal("Port #1 removed from UPS 'ups01'.\n", output); + + Assert.Equal(""" + version: 4 + resources: + - kind: Ups + model: APC-BGM2200 + va: 2200 + ports: + - type: usb + speed: 0.48 + count: 1 + name: ups01 + connections: [] + + """, yaml); + + (output, yaml) = await ExecuteAsync("ups", "describe", "ups01"); + Assert.Contains("usb: 1", output); + Assert.DoesNotContain("rj45", output); + } + + [Fact] + public async Task describe_reports_none_when_ups_has_no_ports() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), ""); + + await ExecuteAsync("ups", "add", "ups-bare"); + + (var output, var _) = await ExecuteAsync("ups", "describe", "ups-bare"); + + Assert.Contains("Ports:", output); + Assert.Contains("None", output); + } +} From b029745e45f04c89ab4f08735c5f1f78fa619d19 Mon Sep 17 00:00:00 2001 From: WhiteStorm Date: Sat, 26 Sep 2026 21:53:47 +0200 Subject: [PATCH 06/29] Regenerate CLI docs for the new port commands Adds `rpk ups port`, `rpk other port` and `rpk laptops nic` (each with add/set/del) to cli-commands.md and cli-commands-index.md. Generated with generate-docs.sh. Two deviations were needed to run it outside CI, neither of which affects the output: - the publish step emits RackPeek.exe on Windows, so the `-x` probe on the extensionless path never matches - the script writes raw_docs/Commands.md and raw_docs/CommandIndex.md, but the files actually shipped and listed in docs-index.json are raw_docs/cli-commands.md and raw_docs/cli-commands-index.md The diff is purely additive, which confirms the rest of the output matches what is already committed. --- .../wwwroot/raw_docs/cli-commands-index.md | 12 + Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 214 ++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index c57dae0f..79e45936 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md @@ -129,6 +129,10 @@ - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit - [rename](docs/Commands.md#rpk-ups-rename) - Rename a UPS unit to a new name + - [port](docs/Commands.md#rpk-ups-port) - Manage ports on a UPS unit + - [add](docs/Commands.md#rpk-ups-port-add) - Add a port to a UPS unit + - [set](docs/Commands.md#rpk-ups-port-set) - Update a UPS unit port + - [del](docs/Commands.md#rpk-ups-port-del) - Remove a port from a UPS unit - [label](docs/Commands.md#rpk-ups-label) - Manage labels on a UPS unit - [add](docs/Commands.md#rpk-ups-label-add) - Add a label to a UPS unit - [remove](docs/Commands.md#rpk-ups-label-remove) - Remove a label from a UPS unit @@ -144,6 +148,10 @@ - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware - [del](docs/Commands.md#rpk-other-del) - Delete other hardware - [rename](docs/Commands.md#rpk-other-rename) - Rename other hardware to a new name + - [port](docs/Commands.md#rpk-other-port) - Manage ports on other hardware + - [add](docs/Commands.md#rpk-other-port-add) - Add a port to other hardware + - [set](docs/Commands.md#rpk-other-port-set) - Update an other hardware port + - [del](docs/Commands.md#rpk-other-port-del) - Remove a port from other hardware - [label](docs/Commands.md#rpk-other-label) - Manage labels on other hardware - [add](docs/Commands.md#rpk-other-label-add) - Add a label to other hardware - [remove](docs/Commands.md#rpk-other-label-remove) - Remove a label from other hardware @@ -204,6 +212,10 @@ - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop + - [nic](docs/Commands.md#rpk-laptops-nic) - Manage network interface cards (NICs) for Laptops + - [add](docs/Commands.md#rpk-laptops-nic-add) - Add a NIC to a Laptop + - [set](docs/Commands.md#rpk-laptops-nic-set) - Update a Laptop NIC + - [del](docs/Commands.md#rpk-laptops-nic-del) - Remove a NIC from a Laptop - [label](docs/Commands.md#rpk-laptops-label) - Manage labels on a laptop - [add](docs/Commands.md#rpk-laptops-label-add) - Add a label to a laptop - [remove](docs/Commands.md#rpk-laptops-label-remove) - Remove a label from a laptop diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 9fbb7cbf..a2e2be0f 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -2022,6 +2022,7 @@ COMMANDS: set Update properties of a UPS unit del Delete a UPS unit rename Rename a UPS unit to a new name + port Manage ports on a UPS unit label Manage labels on a UPS unit tag Manage tags on a UPS unit ``` @@ -2143,6 +2144,76 @@ OPTIONS: -h, --help Prints help information ``` +## `rpk ups port` +``` +DESCRIPTION: +Manage ports on a UPS unit + +USAGE: + rpk ups port [OPTIONS] + +OPTIONS: + -h, --help Prints help information + +COMMANDS: + add Add a port to a UPS unit + set Update a UPS unit port + del Remove a port from a UPS unit +``` + +## `rpk ups port add` +``` +DESCRIPTION: +Add a port to a UPS unit + +USAGE: + rpk ups port add [OPTIONS] + +ARGUMENTS: + + +OPTIONS: + -h, --help Prints help information + --type The port type (e.g., rj45, usb) + --speed The port speed (e.g., 0.1, 1) + --count Number of ports of this type +``` + +## `rpk ups port set` +``` +DESCRIPTION: +Update a UPS unit port + +USAGE: + rpk ups port set [OPTIONS] + +ARGUMENTS: + + +OPTIONS: + -h, --help Prints help information + --index The index of the port to update + --type The port type (e.g., rj45, usb) + --speed The port speed (e.g., 0.1, 1) + --count Number of ports of this type +``` + +## `rpk ups port del` +``` +DESCRIPTION: +Remove a port from a UPS unit + +USAGE: + rpk ups port del [OPTIONS] + +ARGUMENTS: + + +OPTIONS: + -h, --help Prints help information + --index The index of the port to remove +``` + ## `rpk ups label` ``` DESCRIPTION: @@ -2260,6 +2331,7 @@ COMMANDS: set Update properties of other hardware del Delete other hardware rename Rename other hardware to a new name + port Manage ports on other hardware label Manage labels on other hardware tag Manage tags on other hardware ``` @@ -2381,6 +2453,76 @@ OPTIONS: -h, --help Prints help information ``` +## `rpk other port` +``` +DESCRIPTION: +Manage ports on other hardware + +USAGE: + rpk other port [OPTIONS] + +OPTIONS: + -h, --help Prints help information + +COMMANDS: + add Add a port to other hardware + set Update an other hardware port + del Remove a port from other hardware +``` + +## `rpk other port add` +``` +DESCRIPTION: +Add a port to other hardware + +USAGE: + rpk other port add [OPTIONS] + +ARGUMENTS: + + +OPTIONS: + -h, --help Prints help information + --type The port type (e.g., rj45, sfp+) + --speed The port speed (e.g., 1, 2.5, 10) + --count Number of ports of this type +``` + +## `rpk other port set` +``` +DESCRIPTION: +Update an other hardware port + +USAGE: + rpk other port set [OPTIONS] + +ARGUMENTS: + + +OPTIONS: + -h, --help Prints help information + --index The index of the port to update + --type The port type (e.g., rj45, sfp+) + --speed The port speed (e.g., 1, 2.5, 10) + --count Number of ports of this type +``` + +## `rpk other port del` +``` +DESCRIPTION: +Remove a port from other hardware + +USAGE: + rpk other port del [OPTIONS] + +ARGUMENTS: + + +OPTIONS: + -h, --help Prints help information + --index The index of the port to remove +``` + ## `rpk other label` ``` DESCRIPTION: @@ -3038,6 +3180,8 @@ COMMANDS: cpu Manage CPUs attached to Laptops drive Manage storage drives attached to Laptops gpu Manage GPUs attached to Laptops + nic Manage network interface cards (NICs) for + Laptops label Manage labels on a laptop tag Manage tags on a laptop ``` @@ -3379,6 +3523,76 @@ OPTIONS: -h, --help Prints help information ``` +## `rpk laptops nic` +``` +DESCRIPTION: +Manage network interface cards (NICs) for Laptops + +USAGE: + rpk laptops nic [OPTIONS] + +OPTIONS: + -h, --help Prints help information + +COMMANDS: + add Add a NIC to a Laptop + set Update a Laptop NIC + del Remove a NIC from a Laptop +``` + +## `rpk laptops nic add` +``` +DESCRIPTION: +Add a NIC to a Laptop + +USAGE: + rpk laptops nic add [OPTIONS] + +ARGUMENTS: + The name of the Laptop + +OPTIONS: + -h, --help Prints help information + --type The nic port type e.g rj45 / sfp+ + --speed The port speed + --ports The number of ports +``` + +## `rpk laptops nic set` +``` +DESCRIPTION: +Update a Laptop NIC + +USAGE: + rpk laptops nic set [OPTIONS] + +ARGUMENTS: + The Laptop name + The index of the nic to update + +OPTIONS: + -h, --help Prints help information + --type The nic port type e.g rj45 / sfp+ + --speed The port speed + --ports The number of ports +``` + +## `rpk laptops nic del` +``` +DESCRIPTION: +Remove a NIC from a Laptop + +USAGE: + rpk laptops nic del [OPTIONS] + +ARGUMENTS: + The Laptop name + The index of the nic to remove + +OPTIONS: + -h, --help Prints help information +``` + ## `rpk laptops label` ``` DESCRIPTION: From be0cb36153ee1ed2efa24b7cb2c1796b0b829cf0 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 09:18:51 +0100 Subject: [PATCH 07/29] Add rpk discover network: sweep a subnet, emit answering hosts as Systems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth collector, for machines nothing else can describe — no agent, no API, just an address that answers. Pure .NET (no nmap): a bounded- parallel ICMP ping sweep with TCP connect fallback on a curated port list (a host is alive if either answers — plenty of gear drops ICMP), ARP for MAC identity, reverse DNS for names. Design follows the discovery philosophy: INetworkProbe is the thin IO seam; ArpTableParser, target enumeration and NetworkScanMapper are pure and fixture-tested. Identity is MAC-seeded (rpk1:net:), normalised across platforms because macOS prints unpadded MAC octets where Linux pads them; hosts with no ARP entry (routed segments) fall back to an IP-seeded id and the command says so. Scanned cards are deliberately sparse — ip, mac label, name, id, and nothing else — so a rescan can never overwrite the type/os/cores/ram a user or agent collector filled in on an adopted card. The v4 schema's System definition loses its required [type, os, cores, ram] to allow that; loosening validation is backwards-compatible and the emitters (Proxmox included) could already produce Systems without cores. --cidr defaults to the machine's own subnet and sweeps are capped at /16; --ports, --timeout and --parallel tune the sweep. Tests: 48 new in Tests.Discovery — both ARP formats parse to identical MACs, target enumeration edges (/16, /24, /30, /31, /32, top-of-space wrap), mapper identity contracts, scripted-probe scanner semantics (ping-only, TCP-only, first-answer short-circuit, ARP-after-sweep, concurrency cap), real-probe loopback and dead-block scans, and merge-through-the-real-server e2e: idempotent rescans, renames that survive, DHCP moves updating the same card, never stealing an agent-discovered host's identity, adopting a hand-written one. Plus 11 CLI validation tests that fail before any packet is sent. Proven against a real /24: 7 hosts found in ~10s, all identities stable across consecutive runs, the scanning machine finds itself. Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/ArpTableParser.cs | 96 ++++++++++ RackPeek.Domain/Discovery/DiscoveryId.cs | 1 + RackPeek.Domain/Discovery/INetworkProbe.cs | 33 ++++ RackPeek.Domain/Discovery/NetworkProbe.cs | 98 ++++++++++ RackPeek.Domain/Discovery/NetworkScanFacts.cs | 30 +++ .../Discovery/NetworkScanMapper.cs | 44 +++++ RackPeek.Domain/Discovery/NetworkScanner.cs | 94 ++++++++++ RackPeek.Domain/Discovery/WellKnownPorts.cs | 23 +++ .../ServiceCollectionExtensions.cs | 1 + .../wwwroot/schemas/v4/schema.v4.json | 6 - .../wwwroot/schemas/v4/schema.v4.json | 6 - Shared.Rcl/CliBootstrap.cs | 6 + .../Discovery/DiscoverNetworkCommand.cs | 134 ++++++++++++++ .../wwwroot/raw_docs/cli-commands-index.md | 1 + Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 32 ++++ .../wwwroot/raw_docs/discovery-guide.md | 63 +++++++ Tests.Discovery/ArpTableParserTests.cs | 69 +++++++ Tests.Discovery/Fixtures/linux-arp-table | 5 + Tests.Discovery/Fixtures/macos-arp-output | 5 + Tests.Discovery/NetworkDiscoveryMergeTests.cs | 122 +++++++++++++ Tests.Discovery/NetworkProbeLoopbackTests.cs | 92 ++++++++++ Tests.Discovery/NetworkScanMapperTests.cs | 89 +++++++++ Tests.Discovery/NetworkScanTargetTests.cs | 54 ++++++ Tests.Discovery/NetworkScannerTests.cs | 171 ++++++++++++++++++ .../DiscoverNetworkValidationTests.cs | 61 +++++++ schemas/v4/schema.v4.json | 6 - 26 files changed, 1324 insertions(+), 18 deletions(-) create mode 100644 RackPeek.Domain/Discovery/ArpTableParser.cs create mode 100644 RackPeek.Domain/Discovery/INetworkProbe.cs create mode 100644 RackPeek.Domain/Discovery/NetworkProbe.cs create mode 100644 RackPeek.Domain/Discovery/NetworkScanFacts.cs create mode 100644 RackPeek.Domain/Discovery/NetworkScanMapper.cs create mode 100644 RackPeek.Domain/Discovery/NetworkScanner.cs create mode 100644 RackPeek.Domain/Discovery/WellKnownPorts.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs create mode 100644 Tests.Discovery/ArpTableParserTests.cs create mode 100644 Tests.Discovery/Fixtures/linux-arp-table create mode 100644 Tests.Discovery/Fixtures/macos-arp-output create mode 100644 Tests.Discovery/NetworkDiscoveryMergeTests.cs create mode 100644 Tests.Discovery/NetworkProbeLoopbackTests.cs create mode 100644 Tests.Discovery/NetworkScanMapperTests.cs create mode 100644 Tests.Discovery/NetworkScanTargetTests.cs create mode 100644 Tests.Discovery/NetworkScannerTests.cs create mode 100644 Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs diff --git a/RackPeek.Domain/Discovery/ArpTableParser.cs b/RackPeek.Domain/Discovery/ArpTableParser.cs new file mode 100644 index 00000000..aa0ec16e --- /dev/null +++ b/RackPeek.Domain/Discovery/ArpTableParser.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Sockets; + +namespace RackPeek.Domain.Discovery; + +/// +/// Reads an ARP table into ip → MAC, from either format the probe can produce: +/// Linux's /proc/net/arp or BSD/macOS arp -an output. MACs are +/// normalised (lowercase, zero-padded octets) because macOS prints 1:0:5e:… +/// where Linux prints 01:00:5e:… — and the MAC seeds the discovery id, so +/// the same machine must hash the same from every workstation. Pure; never throws. +/// +public static class ArpTableParser { + public static IReadOnlyDictionary Parse(string? text) { + var result = new Dictionary(StringComparer.Ordinal); + + if (string.IsNullOrWhiteSpace(text)) + return result; + + foreach (var line in text.Split('\n')) { + (string Ip, string Mac)? entry = ParseLine(line.Trim()); + + if (entry != null) + result.TryAdd(entry.Value.Ip, entry.Value.Mac); + } + + return result; + } + + private static (string Ip, string Mac)? ParseLine(string line) { + if (line.Length == 0) + return null; + + // BSD/macOS: "? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]" + var open = line.IndexOf('('); + var close = line.IndexOf(')'); + + if (open >= 0 && close > open) { + var ip = line[(open + 1)..close]; + var at = line.IndexOf(" at ", close, StringComparison.Ordinal); + + if (at < 0 || !IsIpv4(ip)) + return null; + + var rest = line[(at + 4)..]; + var end = rest.IndexOf(' '); + var mac = NormaliseMac(end > 0 ? rest[..end] : rest); + + return mac == null ? null : (ip, mac); + } + + // Linux /proc/net/arp: "192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0" + var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries); + + if (columns.Length < 4 || !IsIpv4(columns[0])) + return null; + + // Flags 0x0 marks an entry the kernel gave up resolving. + if (columns[2] == "0x0") + return null; + + var linuxMac = NormaliseMac(columns[3]); + + return linuxMac == null ? null : (columns[0], linuxMac); + } + + /// Lowercase, zero-padded, or null for anything that is not a usable MAC. + public static string? NormaliseMac(string? raw) { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var parts = raw.Trim().Split(':'); + + if (parts.Length != 6) + return null; + + var octets = new string[6]; + + for (var i = 0; i < 6; i++) { + var part = parts[i]; + + if (part.Length is 0 or > 2 || !part.All(Uri.IsHexDigit)) + return null; + + octets[i] = part.Length == 1 ? "0" + char.ToLowerInvariant(part[0]) : part.ToLowerInvariant(); + } + + var mac = string.Join(':', octets); + + // All-zero means the neighbour never answered — no identity there. + return mac == "00:00:00:00:00:00" ? null : mac; + } + + private static bool IsIpv4(string value) => + IPAddress.TryParse(value, out IPAddress? ip) && ip.AddressFamily == AddressFamily.InterNetwork; +} diff --git a/RackPeek.Domain/Discovery/DiscoveryId.cs b/RackPeek.Domain/Discovery/DiscoveryId.cs index 64259a69..10f26780 100644 --- a/RackPeek.Domain/Discovery/DiscoveryId.cs +++ b/RackPeek.Domain/Discovery/DiscoveryId.cs @@ -15,6 +15,7 @@ public static class DiscoveryId { public const string Prefix = "rpk1"; public const string SystemScheme = "sys"; public const string DockerScheme = "docker"; + public const string NetworkScheme = "net"; public static string Create(string scheme, string seed) { if (string.IsNullOrWhiteSpace(scheme)) diff --git a/RackPeek.Domain/Discovery/INetworkProbe.cs b/RackPeek.Domain/Discovery/INetworkProbe.cs new file mode 100644 index 00000000..08a2636c --- /dev/null +++ b/RackPeek.Domain/Discovery/INetworkProbe.cs @@ -0,0 +1,33 @@ +using RackPeek.Domain.Resources.Services.Networking; + +namespace RackPeek.Domain.Discovery; + +/// +/// Network IO for the sweep. The IO half of network discovery, mirroring +/// / : everything here is +/// untestable-by-design plumbing, and every decision made about what comes back +/// lives in and the pure parsers. +/// +public interface INetworkProbe { + /// True when the host answers an ICMP echo within the timeout. + Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// True when a TCP connect to the port completes within the timeout. + Task TryConnectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// The host's ARP table, raw, in whichever format this platform produces — the + /// sweep's pings populate it, and reads either + /// format. Null when it cannot be read; MACs are an enrichment, not a requirement. + /// + Task ReadArpAsync(CancellationToken cancellationToken = default); + + /// The host's reverse-DNS name, or null when it has none worth keeping. + Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default); + + /// + /// The subnet of the first up, non-loopback IPv4 interface with a gateway — what + /// `--cidr` defaults to. Null when the machine has no such interface. + /// + Cidr? LocalSubnet(); +} diff --git a/RackPeek.Domain/Discovery/NetworkProbe.cs b/RackPeek.Domain/Discovery/NetworkProbe.cs new file mode 100644 index 00000000..bfe2bf86 --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkProbe.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using RackPeek.Domain.Resources.Services.Networking; + +namespace RackPeek.Domain.Discovery; + +/// The real network IO. Deliberately dumb; see . +public sealed class NetworkProbe : INetworkProbe { + public async Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) { + try { + using var ping = new Ping(); + PingReply reply = await ping.SendPingAsync(ip, timeout, cancellationToken: cancellationToken); + + return reply.Status == IPStatus.Success; + } + catch { + // No ICMP privilege, unreachable network, bad address — all mean "no answer". + return false; + } + } + + public async Task TryConnectAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + await socket.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + return true; + } + catch { + // Refused, timed out, filtered — for liveness they are all the same "no". + return false; + } + } + + public async Task ReadArpAsync(CancellationToken cancellationToken = default) { + return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken) + ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken); + } + + public async Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) { + try { + // A resolver with a dead PTR zone can sit on the query far longer than the + // whole sweep took; the cap keeps a pile of dead lookups from stalling it. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + + IPHostEntry entry = await Dns.GetHostEntryAsync(ip, cts.Token); + + // Some resolvers answer a PTR miss by echoing the address back. + return string.IsNullOrWhiteSpace(entry.HostName) || entry.HostName == ip + ? null + : entry.HostName; + } + catch { + return null; + } + } + + public Cidr? LocalSubnet() { + try { + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) { + if (nic.OperationalStatus != OperationalStatus.Up + || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) + continue; + + IPInterfaceProperties properties = nic.GetIPProperties(); + + var hasGateway = properties.GatewayAddresses.Any(g => + g.Address.AddressFamily == AddressFamily.InterNetwork + && !g.Address.Equals(IPAddress.Any)); + + if (!hasGateway) + continue; + + UnicastIPAddressInformation? address = properties.UnicastAddresses.FirstOrDefault(a => + a.Address.AddressFamily == AddressFamily.InterNetwork); + + if (address == null) + continue; + + return Cidr.Parse($"{address.Address}/{address.PrefixLength}"); + } + } + catch { + // Fall through: the caller asks the user for --cidr instead. + } + + return null; + } +} diff --git a/RackPeek.Domain/Discovery/NetworkScanFacts.cs b/RackPeek.Domain/Discovery/NetworkScanFacts.cs new file mode 100644 index 00000000..91704f21 --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkScanFacts.cs @@ -0,0 +1,30 @@ +using RackPeek.Domain.Resources.Services.Networking; + +namespace RackPeek.Domain.Discovery; + +/// +/// One responding host, as the sweep saw it. records only +/// what liveness probing happened to touch — port probing stops at the first answer, +/// so this is evidence the host is alive, never a port inventory. +/// +public sealed record NetworkHostFact( + string Ip, + string? Mac, + string? Hostname, + bool AnsweredPing, + IReadOnlyList OpenPorts); + +/// How to sweep. The defaults suit a quiet home /24. +public sealed record NetworkScanOptions { + public required Cidr Cidr { get; init; } + + /// TCP ports probed to catch hosts that do not answer ping. + public IReadOnlyList Ports { get; init; } = WellKnownPorts.Defaults; + + public TimeSpan PingTimeout { get; init; } = TimeSpan.FromMilliseconds(300); + + public TimeSpan PortTimeout { get; init; } = TimeSpan.FromMilliseconds(500); + + /// How many hosts are probed at once. + public int Concurrency { get; init; } = 128; +} diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs new file mode 100644 index 00000000..1c70885b --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -0,0 +1,44 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// Maps swept hosts onto the System resources RackPeek stores. Pure. +public static class NetworkScanMapper { + public static List ToResources(IReadOnlyList hosts) { + var taken = new HashSet(StringComparer.OrdinalIgnoreCase); + var resources = new List(hosts.Count); + + foreach (NetworkHostFact host in hosts) { + // The MAC is the only identity a scan can see that survives a DHCP re-lease; + // when ARP could not provide one (a routed subnet, say) the IP has to do, + // and the id changes if the address does — documented in the guide. + var discoveryId = DiscoveryId.Create( + DiscoveryId.NetworkScheme, + host.Mac ?? $"ip:{host.Ip}"); + + var system = new SystemResource { + Kind = SystemResource.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest( + DiscoveryNaming.HostLabel(host.Hostname), + "host", + discoveryId), + discoveryId, + taken), + DiscoveryId = discoveryId, + // Deliberately sparse: a scan sees an address, not an OS or a type, and + // whatever it wrote here would overwrite the real values on every rescan + // of a card the user (or an agent collector) has since filled in. + Ip = host.Ip + }; + + if (host.Mac != null) + system.Labels["mac"] = host.Mac; + + resources.Add(system); + } + + return resources; + } +} diff --git a/RackPeek.Domain/Discovery/NetworkScanner.cs b/RackPeek.Domain/Discovery/NetworkScanner.cs new file mode 100644 index 00000000..4ebf525f --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkScanner.cs @@ -0,0 +1,94 @@ +using RackPeek.Domain.Resources.Services.Networking; + +namespace RackPeek.Domain.Discovery; + +/// +/// Sweeps a subnet and reports the hosts that answered. A host counts as alive when +/// it answers ping OR accepts a TCP connect on any probed port — plenty of gear +/// drops ICMP, and plenty of gear with ICMP open runs no interesting service, so +/// neither signal alone is enough. All IO goes through . +/// +public static class NetworkScanner { + /// + /// Every address worth probing in the block: hosts only, so the network and + /// broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point) + /// and /32, where every address is a host. + /// + public static IEnumerable EnumerateTargets(Cidr cidr) { + // 64-bit throughout: 1u << 32 wraps under C#'s masked shift, and a block that + // touches 255.255.255.255 would overflow the loop bound in 32 bits. + var size = 1UL << (32 - cidr.Prefix); + + var first = cidr.Prefix >= 31 ? cidr.Network : (ulong)cidr.Network + 1; + var last = cidr.Prefix >= 31 + ? cidr.Network + size - 1 + : cidr.Network + size - 2; + + for (var ip = first; ip <= last; ip++) + yield return IpHelper.ToIp((uint)ip); + } + + public static async Task> ScanAsync( + INetworkProbe probe, + NetworkScanOptions options, + CancellationToken cancellationToken = default) { + var targets = EnumerateTargets(options.Cidr).ToList(); + + using var gate = new SemaphoreSlim(options.Concurrency); + + (string Ip, bool Ping, List Open)?[] swept = await Task.WhenAll( + targets.Select(ip => SweepHostAsync(probe, options, ip, gate, cancellationToken))); + + var alive = swept.Where(h => h != null).Select(h => h!.Value).ToList(); + + // Read the ARP table only after the sweep: it is the sweep's own pings and + // connects that put the neighbours into it. + IReadOnlyDictionary macByIp = + ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken)); + + var facts = new List(alive.Count); + + foreach ((var ip, var ping, List open) in alive) + facts.Add(new NetworkHostFact( + ip, + macByIp.GetValueOrDefault(ip), + await probe.ReverseDnsAsync(ip, cancellationToken), + ping, + open)); + + return facts + .OrderBy(f => IpHelper.ToUInt32(f.Ip)) + .ToList(); + } + + /// One host's liveness check; null when nothing answered. + private static async Task<(string Ip, bool Ping, List Open)?> SweepHostAsync( + INetworkProbe probe, + NetworkScanOptions options, + string ip, + SemaphoreSlim gate, + CancellationToken cancellationToken) { + await gate.WaitAsync(cancellationToken); + + try { + var ping = await probe.PingAsync(ip, options.PingTimeout, cancellationToken); + var open = new List(); + + // Liveness needs one answer, not a port inventory: a ping reply skips the + // port probes entirely, and probing stops at the first open port. + if (!ping) + foreach (var port in options.Ports) { + if (!await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken)) + continue; + + open.Add(port); + break; + } + + return ping || open.Count > 0 ? (ip, ping, open) : null; + } + finally { + gate.Release(); + } + } +} diff --git a/RackPeek.Domain/Discovery/WellKnownPorts.cs b/RackPeek.Domain/Discovery/WellKnownPorts.cs new file mode 100644 index 00000000..6468ee18 --- /dev/null +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -0,0 +1,23 @@ +namespace RackPeek.Domain.Discovery; + +/// +/// The TCP ports the sweep knocks on when a host ignores ping. Chosen for what a +/// homelab actually runs — one open port anywhere in this list is enough to call +/// the host alive, so breadth matters more than depth. +/// +public static class WellKnownPorts { + public static readonly IReadOnlyList Defaults = [ + 22, // ssh — almost everything + 80, // http + 443, // https + 53, // dns — pi-hole, routers + 445, // smb — nas boxes + 3389, // rdp — windows + 631, // ipp — printers + 8006, // proxmox + 5000, // synology / registries + 8080, // alt http + 8443, // alt https + 9100 // node-exporter / jetdirect + ]; +} diff --git a/RackPeek.Domain/ServiceCollectionExtensions.cs b/RackPeek.Domain/ServiceCollectionExtensions.cs index bf935afe..8bb3fdf4 100644 --- a/RackPeek.Domain/ServiceCollectionExtensions.cs +++ b/RackPeek.Domain/ServiceCollectionExtensions.cs @@ -78,6 +78,7 @@ public static IServiceCollection AddUseCases( // so an unsupported host fails with a message rather than a missing registration. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>)); services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>)); diff --git a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json index b6a9e92e..78a1349a 100644 --- a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json @@ -663,12 +663,6 @@ }, { "type": "object", - "required": [ - "type", - "os", - "cores", - "ram" - ], "properties": { "kind": { "const": "System" diff --git a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json index b6a9e92e..78a1349a 100644 --- a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json @@ -663,12 +663,6 @@ }, { "type": "object", - "required": [ - "type", - "os", - "cores", - "ram" - ], "properties": { "kind": { "const": "System" diff --git a/Shared.Rcl/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index b5532cfb..032e0075 100644 --- a/Shared.Rcl/CliBootstrap.cs +++ b/Shared.Rcl/CliBootstrap.cs @@ -798,6 +798,12 @@ public static void BuildApp(CommandApp app) { .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"); + + discover.AddCommand("network") + .WithDescription("Sweep a subnet and emit every answering host as a System resource.") + .WithExample("discover", "network") + .WithExample("discover", "network", "--cidr", "192.168.1.0/24") + .WithExample("discover", "network", "--cidr", "10.0.0.0/24", "--ports", "22,80,443", "--push"); }); config.AddBranch("ansible", ansible => { diff --git a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs new file mode 100644 index 00000000..fb5ea23a --- /dev/null +++ b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs @@ -0,0 +1,134 @@ +using System.ComponentModel; +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services.Networking; +using Spectre.Console; +using Spectre.Console.Cli; +using NetworkCidr = RackPeek.Domain.Resources.Services.Networking.Cidr; + +namespace Shared.Rcl.Commands.Discovery; + +public sealed class DiscoverNetworkSettings : DiscoverSettings { + /// Sweeping wider than a /16 is 65k+ hosts — a typo, not a homelab. + public const int MinPrefix = 16; + + [CommandOption("--cidr ")] + [Description("Subnet to sweep, e.g. 192.168.1.0/24. Defaults to this machine's own subnet.")] + public string? Cidr { get; init; } + + [CommandOption("--ports ")] + [Description("TCP ports probed to catch hosts that ignore ping, e.g. 22,80,443. " + + "Defaults to a curated homelab list.")] + public string? Ports { get; init; } + + [CommandOption("--timeout ")] + [Description("Milliseconds to wait on each port probe.")] + public int Timeout { get; init; } = 500; + + [CommandOption("--parallel ")] + [Description("How many hosts to probe at once.")] + public int Parallel { get; init; } = 128; + + public IReadOnlyList ResolvedPorts => + string.IsNullOrWhiteSpace(Ports) ? WellKnownPorts.Defaults : ParsePorts(Ports)!; + + public override ValidationResult Validate() { + if (Cidr != null) { + NetworkCidr parsed; + + try { + parsed = NetworkCidr.Parse(Cidr); + } + catch { + return ValidationResult.Error( + $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24"); + } + + if (parsed.Prefix < MinPrefix) + return ValidationResult.Error( + $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller."); + } + + if (Ports != null && ParsePorts(Ports) == null) + return ValidationResult.Error( + $"'{Ports}' is not a usable port list. Use e.g. --ports 22,80,443"); + + if (Timeout is < 1 or > 60_000) + return ValidationResult.Error("--timeout must be between 1 and 60000 milliseconds."); + + if (Parallel is < 1 or > 1024) + return ValidationResult.Error("--parallel must be between 1 and 1024."); + + return base.Validate(); + } + + private static IReadOnlyList? ParsePorts(string list) { + var ports = new List(); + + foreach (var part in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { + if (!int.TryParse(part, out var port) || port is < 1 or > 65_535) + return null; + + ports.Add(port); + } + + return ports.Count == 0 ? null : ports; + } +} + +/// +/// Sweeps a subnet and emits every answering host as a System resource — the +/// collector for machines nothing else can describe: no agent, no API, just an +/// address that answers. +/// +public sealed class DiscoverNetworkCommand(INetworkProbe probe) + : AsyncCommand { + protected override async Task ExecuteAsync( + CommandContext context, + DiscoverNetworkSettings settings, + CancellationToken cancellationToken) { + Cidr cidr; + + if (settings.Cidr != null) { + cidr = Cidr.Parse(settings.Cidr); // Validate() vouched for it + } + else { + Cidr? detected = probe.LocalSubnet(); + + if (detected == null) { + AnsiConsole.MarkupLine( + "[red]Could not detect this machine's subnet.[/] Pass --cidr, e.g. --cidr 192.168.1.0/24"); + + return 1; + } + + cidr = detected.Value; + } + + var options = new NetworkScanOptions { + Cidr = cidr, + Ports = settings.ResolvedPorts, + PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout), + Concurrency = settings.Parallel + }; + + var targets = NetworkScanner.EnumerateTargets(cidr).Count(); + + AnsiConsole.MarkupLine( + $"[grey]Sweeping {Markup.Escape(cidr.ToString())} — {targets} address(es), " + + $"ping + {options.Ports.Count} TCP port(s)…[/]"); + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, options, cancellationToken); + + var withoutMac = hosts.Count(h => h.Mac == null); + + if (withoutMac > 0) + AnsiConsole.MarkupLine( + $"[grey]{withoutMac} host(s) had no ARP entry, so their identity is seeded on the IP " + + "address — a DHCP re-lease will make them look like new machines.[/]"); + + List resources = NetworkScanMapper.ToResources(hosts); + + return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken); + } +} diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index 79e45936..2c670418 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md @@ -242,6 +242,7 @@ - [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 + - [network](docs/Commands.md#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource - [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 diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index a2e2be0f..39622303 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -3970,6 +3970,7 @@ COMMANDS: 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 + network Sweep a subnet and emit every answering host as a System resource ``` ## `rpk discover system` @@ -4060,6 +4061,37 @@ OPTIONS: Proxmox ships with by default ``` +## `rpk discover network` +``` +DESCRIPTION: +Sweep a subnet and emit every answering host as a System resource + +USAGE: + rpk discover network [OPTIONS] + +EXAMPLES: + rpk discover network + rpk discover network --cidr 192.168.1.0/24 + rpk discover network --cidr 10.0.0.0/24 --ports 22,80,443 --push + +OPTIONS: + -h, --help Prints help information + --push Upload the result to a RackPeek server instead of + printing it + --server RackPeek server to upload to. Defaults to the + RPK_SERVER environment variable + --api-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 + --cidr Subnet to sweep, e.g. 192.168.1.0/24. Defaults to + this machine's own subnet + --ports TCP ports probed to catch hosts that ignore ping, + e.g. 22,80,443. Defaults to a curated homelab list + --timeout Milliseconds to wait on each port probe + --parallel How many hosts to probe at once +``` + ## `rpk ansible` ``` DESCRIPTION: diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index faeedd3d..4ad7725c 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -8,6 +8,7 @@ don't have to type in what the machine already knows about itself. | `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 | +| `rpk discover network` | a subnet, from outside | one **System** per host that answers ping or a well-known TCP port | 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. @@ -318,6 +319,68 @@ with no cluster uses its node name as the scope instead. --- +## `rpk discover network` + +The collector for machines nothing else can describe: no agent, no API — just an +address that answers. It sweeps a subnet and emits one **System** per responding host, +with its IP, its reverse-DNS name, and its MAC address as a label. + +```bash +# Sweep this machine's own subnet and look at the result +rpk discover network + +# Sweep a specific block, then merge it into the server +rpk discover network --cidr 192.168.1.0/24 --push +``` + +### What "answering" means + +A host counts as alive when it replies to ping **or** accepts a TCP connection on any +probed port — plenty of gear drops ICMP, so ping alone would miss half a homelab. The +default port list is a curated homelab set (ssh, http/https, dns, smb, rdp, ipp, +proxmox, and friends); `--ports 22,80,443` narrows or widens it. The ports are only a +liveness check: the sweep records that the host exists, not what it serves — pair it +with `rpk discover docker` or hand-written Service cards for that. + +Sweeps are capped at a /16 (65,534 addresses). `--timeout` and `--parallel` tune how +patient and how aggressive the sweep is; the defaults finish a quiet /24 in seconds. + +A network of several VLANs is several sweeps — each merges into the same inventory, +and the ids keep re-runs honest: + +```bash +rpk discover network --cidr 10.0.20.0/24 --push # the LAN +rpk discover network --cidr 10.0.50.0/24 --push # the server VLAN +``` + +### Identity + +A scanned host is identified by its **MAC address**, read from the ARP table the +sweep itself populates — so a DHCP re-lease updates the same resource's address rather +than inventing a new machine. Two caveats: + +- **Hosts beyond the local segment have no ARP entry** (a routed VLAN, a VPN subnet). + Their identity falls back to the IP address, and the command says so — a DHCP + re-lease will then look like a new machine. Scan from a machine on the same segment + when you can. On a statically-addressed subnet — a server VLAN, say — the IP + fallback is stable in practice and nothing more is needed. +- **A scan sees an address, not an operating system.** Scanned cards deliberately carry + no type, OS, cores or RAM, so a re-scan can never overwrite the details you (or an + agent collector) filled in afterwards. + +The known limitation above applies here twice over: a host discovered by `rpk discover +system` (machine-id identity) and by a network scan (MAC identity) becomes two +resources, the second visibly suffixed. Keep whichever card you prefer and delete the +other; the scan will keep updating the one that carries its id. The machine running +the sweep also finds itself — same rule. + +### Being a good citizen + +The sweep is a burst of pings and TCP connection attempts — the polite end of network +scanning, but scan networks you operate, not networks you merely use. + +--- + ## Reviewing before you commit to it `--dry-run` asks the server what would change and writes nothing: diff --git a/Tests.Discovery/ArpTableParserTests.cs b/Tests.Discovery/ArpTableParserTests.cs new file mode 100644 index 00000000..79f44d95 --- /dev/null +++ b/Tests.Discovery/ArpTableParserTests.cs @@ -0,0 +1,69 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// The ARP table is where a scanned host's identity comes from, and the two +/// platforms print it differently — most dangerously, macOS drops leading zeros +/// from MAC octets. If normalisation slips, the same machine gets a different +/// discovery id depending on which workstation ran the scan. +/// +public class ArpTableParserTests { + [Fact] + public void The_linux_proc_file_parses_to_normalised_macs() { + IReadOnlyDictionary table = ArpTableParser.Parse(Fixture.Read("linux-arp-table")); + + Assert.Equal("a4:91:b1:4e:3c:20", table["192.168.1.1"]); + // Uppercase in the fixture, stored lowercase. + Assert.Equal("dc:a6:32:0f:11:22", table["192.168.1.20"]); + } + + [Fact] + public void The_macos_arp_output_parses_to_the_same_macs_as_linux() { + IReadOnlyDictionary linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table")); + IReadOnlyDictionary macos = ArpTableParser.Parse(Fixture.Read("macos-arp-output")); + + // The macOS fixture prints 192.168.1.20 as dc:a6:32:f:11:22 — unpadded. Identity + // must not depend on which of the two formats happened to report the machine. + Assert.Equal(linux["192.168.1.1"], macos["192.168.1.1"]); + Assert.Equal(linux["192.168.1.20"], macos["192.168.1.20"]); + } + + [Fact] + public void Unresolved_neighbours_contribute_nothing() { + IReadOnlyDictionary linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table")); + IReadOnlyDictionary macos = ArpTableParser.Parse(Fixture.Read("macos-arp-output")); + + // Linux marks failures with flags 0x0 or an all-zero MAC; macOS prints "(incomplete)". + Assert.False(linux.ContainsKey("192.168.1.50")); + Assert.False(linux.ContainsKey("192.168.1.60")); + Assert.False(macos.ContainsKey("192.168.1.50")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not an arp table at all")] + [InlineData("IP address HW type Flags HW address Mask Device")] + [InlineData("? (garbage at nothing")] + public void Garbage_input_is_an_empty_table_not_an_exception(string? text) => + Assert.Empty(ArpTableParser.Parse(text)); + + [Theory] + [InlineData("A4:91:B1:4E:3C:20", "a4:91:b1:4e:3c:20")] + [InlineData("1:0:5e:0:0:fb", "01:00:5e:00:00:fb")] + [InlineData("dc:a6:32:f:11:22", "dc:a6:32:0f:11:22")] + public void Macs_normalise_to_lowercase_padded_octets(string raw, string expected) => + Assert.Equal(expected, ArpTableParser.NormaliseMac(raw)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("00:00:00:00:00:00")] // the kernel's "never answered" + [InlineData("a4:91:b1:4e:3c")] // five octets + [InlineData("a4:91:b1:4e:3c:20:ff")] // seven octets + [InlineData("zz:91:b1:4e:3c:20")] // not hex + [InlineData("(incomplete)")] + public void Anything_that_is_not_a_usable_mac_is_null(string? raw) => + Assert.Null(ArpTableParser.NormaliseMac(raw)); +} diff --git a/Tests.Discovery/Fixtures/linux-arp-table b/Tests.Discovery/Fixtures/linux-arp-table new file mode 100644 index 00000000..45f0a8e1 --- /dev/null +++ b/Tests.Discovery/Fixtures/linux-arp-table @@ -0,0 +1,5 @@ +IP address HW type Flags HW address Mask Device +192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0 +192.168.1.20 0x1 0x2 DC:A6:32:0F:11:22 * eth0 +192.168.1.50 0x1 0x0 00:00:00:00:00:00 * eth0 +192.168.1.60 0x1 0x2 00:00:00:00:00:00 * eth0 diff --git a/Tests.Discovery/Fixtures/macos-arp-output b/Tests.Discovery/Fixtures/macos-arp-output new file mode 100644 index 00000000..02b0205e --- /dev/null +++ b/Tests.Discovery/Fixtures/macos-arp-output @@ -0,0 +1,5 @@ +? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet] +? (192.168.1.20) at dc:a6:32:f:11:22 on en0 ifscope [ethernet] +? (192.168.1.50) at (incomplete) on en0 ifscope [ethernet] +? (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet] +? (192.168.1.255) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet] diff --git a/Tests.Discovery/NetworkDiscoveryMergeTests.cs b/Tests.Discovery/NetworkDiscoveryMergeTests.cs new file mode 100644 index 00000000..f78bc6ef --- /dev/null +++ b/Tests.Discovery/NetworkDiscoveryMergeTests.cs @@ -0,0 +1,122 @@ +using RackPeek.Domain.Api; +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Network-scan output through the real server: pushed over HTTP, merged by the +/// real resolver, asserted against what lands on disk. These pin the identity +/// contracts a scan lives or dies by — idempotent re-runs, renames that stick, +/// and never stealing the identity of a host another collector documented. +/// +public class NetworkDiscoveryMergeTests { + private static string ScanYaml(params NetworkHostFact[] hosts) => + DiscoveryDocument.ToYaml(NetworkScanMapper.ToResources(hosts)); + + private static NetworkHostFact Nas(string ip = "192.168.1.20") => + new(ip, "dc:a6:32:0f:11:22", "nas01.lan", true, []); + + [Fact] + public async Task A_scan_lands_on_disk_and_a_rescan_changes_nothing() { + using var api = new DiscoveryApiFixture(); + + ImportYamlResponse first = await api.PublishAsync(ScanYaml(Nas())); + + Assert.Equal(["nas01"], first.Added); + Assert.Contains("discoveryId: rpk1:net:", api.StoredYaml); + Assert.Contains("mac: dc:a6:32:0f:11:22", api.StoredYaml); + Fixture.AssertConformsToSchema(api.StoredYaml); + + ImportYamlResponse second = await api.PublishAsync(ScanYaml(Nas())); + + Assert.Empty(second.Added); + Assert.Empty(second.Updated); + } + + [Fact] + public async Task A_users_rename_survives_the_next_scan() { + // The stored card is a previous scan of the same machine that the user has + // since renamed — the id stayed with it, as the UI keeps it on a rename. + List renamed = NetworkScanMapper.ToResources([Nas()]); + renamed[0].Name = "storage-primary"; + + using var api = new DiscoveryApiFixture(DiscoveryDocument.ToYaml(renamed)); + + ImportYamlResponse rescan = await api.PublishAsync(ScanYaml(Nas())); + + Assert.Empty(rescan.Added); + Assert.Contains("storage-primary", api.StoredYaml); + Assert.DoesNotContain("name: nas01", api.StoredYaml); + } + + [Fact] + public async Task A_dhcp_move_updates_the_address_of_the_same_machine() { + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.20"))); + ImportYamlResponse moved = await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.99"))); + + Assert.Empty(moved.Added); // same MAC, same machine + Assert.Equal(["nas01"], moved.Updated); + Assert.Contains("ip: 192.168.1.99", api.StoredYaml); + Assert.DoesNotContain("192.168.1.20", api.StoredYaml); + } + + [Fact] + public async Task A_scan_never_steals_the_identity_of_an_agent_discovered_host() { + // nas01 already exists with a machine-id identity from `rpk discover system`. + // The scan sees the same box from outside and proposes the same name with a + // MAC identity — the resolver must keep them apart, not merge one over the other. + var agentDiscovered = DiscoveryDocument.ToYaml([ + new SystemResource { + Kind = SystemResource.KindLabel, + Name = "nas01", + DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"), + Type = "baremetal", + Os = "Debian", + Cores = 12 + } + ]); + + using var api = new DiscoveryApiFixture(agentDiscovered); + + ImportYamlResponse response = await api.PublishAsync(ScanYaml(Nas())); + + var scanName = Assert.Single(response.Added); + Assert.StartsWith("nas01-", scanName); // suffixed, not adopted + + var stored = api.StoredYaml; + Assert.Contains("rpk1:sys:", stored); // the agent identity is intact + Assert.Contains("rpk1:net:", stored); // and the scan's card exists beside it + Assert.Contains("os: Debian", stored); // nothing on the original was touched + } + + [Fact] + public async Task A_scan_adopts_a_hand_written_system_of_the_same_name() { + // The inverse case: the user typed the card themselves, so it has no id yet. + // The scan stamps its identity onto it and enriches it instead of duplicating. + using var api = new DiscoveryApiFixture( + """ + version: 4 + resources: + - kind: System + name: nas01 + type: baremetal + os: Debian + """); + + ImportYamlResponse response = await api.PublishAsync(ScanYaml(Nas())); + + Assert.Empty(response.Added); + Assert.Equal(["nas01"], response.Updated); + + var stored = api.StoredYaml; + Assert.Contains("rpk1:net:", stored); + Assert.Contains("ip: 192.168.1.20", stored); + // The scan card is sparse on purpose, so everything the user wrote survives. + Assert.Contains("os: Debian", stored); + Assert.Contains("type: baremetal", stored); + } +} diff --git a/Tests.Discovery/NetworkProbeLoopbackTests.cs b/Tests.Discovery/NetworkProbeLoopbackTests.cs new file mode 100644 index 00000000..85905b70 --- /dev/null +++ b/Tests.Discovery/NetworkProbeLoopbackTests.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Sockets; +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources.Services.Networking; + +namespace Tests.Discovery; + +/// +/// The real probe against loopback — the one network every CI runner has and the +/// tests are allowed to touch. TCP carries these tests on purpose: ICMP needs +/// privileges some runners lack, and the scanner's whole point is that liveness +/// never depends on ping alone. +/// +public class NetworkProbeLoopbackTests { + [Fact] + public async Task A_listening_port_answers_a_connect_probe() { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var probe = new NetworkProbe(); + + Assert.True(await probe.TryConnectAsync("127.0.0.1", port, TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task A_closed_port_says_no_instead_of_throwing() { + // Bind-then-close guarantees the port exists and nothing is listening on it. + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + var probe = new NetworkProbe(); + + Assert.False(await probe.TryConnectAsync("127.0.0.1", port, TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task An_unroutable_address_gives_up_within_the_timeout_budget() { + var probe = new NetworkProbe(); + DateTime started = DateTime.UtcNow; + + // TEST-NET-1 (RFC 5737) is never routed; the connect must die on OUR timer. + var open = await probe.TryConnectAsync("192.0.2.1", 9, TimeSpan.FromMilliseconds(250)); + + Assert.False(open); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(5), + "The connect ignored the timeout and sat on the OS default instead."); + } + + [Fact] + public async Task The_whole_scan_pipeline_finds_a_real_listener_on_loopback() { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + new NetworkProbe(), + new NetworkScanOptions { + Cidr = Cidr.Parse("127.0.0.1/32"), + Ports = [port], + // Loopback ping may be privilege-blocked on the runner; the open port + // must carry the verdict alone, so keep the ping window tiny. + PingTimeout = TimeSpan.FromMilliseconds(50), + PortTimeout = TimeSpan.FromSeconds(2) + }); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Equal("127.0.0.1", host.Ip); + Assert.True(host.AnsweredPing || host.OpenPorts.Contains(port)); + } + + [Fact] + public async Task Scanning_a_dead_block_finds_nothing_and_finishes_quickly() { + // Loopback cannot play the dead host: on Linux the whole 127/8 answers ping. + // TEST-NET-1 (RFC 5737) is reserved and never routed, on every platform. + DateTime started = DateTime.UtcNow; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + new NetworkProbe(), + new NetworkScanOptions { + Cidr = Cidr.Parse("192.0.2.0/30"), + Ports = [9], + PingTimeout = TimeSpan.FromMilliseconds(50), + PortTimeout = TimeSpan.FromMilliseconds(250) + }); + + Assert.Empty(hosts); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(10)); + } +} diff --git a/Tests.Discovery/NetworkScanMapperTests.cs b/Tests.Discovery/NetworkScanMapperTests.cs new file mode 100644 index 00000000..873dcf98 --- /dev/null +++ b/Tests.Discovery/NetworkScanMapperTests.cs @@ -0,0 +1,89 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Swept hosts → the System cards RackPeek stores. The contract that matters most +/// is identity: MAC-seeded, format-independent, IP only as a last resort. +/// +public class NetworkScanMapperTests { + private static NetworkHostFact Host( + string ip = "192.168.1.20", + string? mac = "dc:a6:32:0f:11:22", + string? hostname = "nas01.lan") => + new(ip, mac, hostname, true, []); + + [Fact] + public void A_host_becomes_a_system_card_with_ip_mac_and_its_dns_name() { + List resources = NetworkScanMapper.ToResources([Host()]); + + SystemResource system = Assert.IsType(Assert.Single(resources)); + Assert.Equal("nas01", system.Name); // first label of nas01.lan + Assert.Equal("System", system.Kind); + Assert.Equal("192.168.1.20", system.Ip); + // Deliberately sparse: anything a scan cannot see stays null so a rescan can + // never overwrite what the user or an agent collector filled in. + Assert.Null(system.Type); + Assert.Null(system.Os); + Assert.Null(system.Cores); + Assert.Equal("dc:a6:32:0f:11:22", system.Labels["mac"]); + Assert.StartsWith("rpk1:net:", system.DiscoveryId); + } + + [Fact] + public void Identity_rides_on_the_mac_so_a_dhcp_move_is_the_same_machine() { + List before = NetworkScanMapper.ToResources([Host(ip: "192.168.1.20")]); + List after = NetworkScanMapper.ToResources([Host(ip: "192.168.1.99")]); + + Assert.Equal(before[0].DiscoveryId, after[0].DiscoveryId); + } + + [Fact] + public void Without_a_mac_the_ip_seeds_the_identity_instead() { + List resources = NetworkScanMapper.ToResources([Host(mac: null)]); + + Assert.StartsWith("rpk1:net:", resources[0].DiscoveryId); + Assert.False(Assert.IsType(resources[0]).Labels.ContainsKey("mac")); + + // ...and it is a different identity than the MAC would have produced. + Assert.NotEqual( + NetworkScanMapper.ToResources([Host()])[0].DiscoveryId, + resources[0].DiscoveryId); + } + + [Fact] + public void A_host_with_no_dns_name_gets_a_deterministic_one_from_its_id() { + List resources = NetworkScanMapper.ToResources([Host(hostname: null)]); + + Assert.StartsWith("host-", resources[0].Name); + + // Deterministic: the same machine names itself the same way on every run. + Assert.Equal(resources[0].Name, NetworkScanMapper.ToResources([Host(hostname: null)])[0].Name); + } + + [Fact] + public void Two_hosts_answering_to_the_same_dns_name_stay_distinct() { + // A lazy resolver that answers every PTR with the router's name must not + // collapse the whole network into one card (the import rejects duplicates). + List resources = NetworkScanMapper.ToResources([ + Host(ip: "192.168.1.1", mac: "a4:91:b1:4e:3c:20", hostname: "router.lan"), + Host(ip: "192.168.1.2", mac: "b0:00:00:00:00:02", hostname: "router.lan") + ]); + + Assert.Equal(2, resources.Select(r => r.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal("router", resources[0].Name); + Assert.StartsWith("router-", resources[1].Name); + } + + [Fact] + public void The_emitted_document_conforms_to_the_published_schema() { + List resources = NetworkScanMapper.ToResources([ + Host(), + Host(ip: "192.168.1.30", mac: null, hostname: null) + ]); + + Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(resources)); + } +} diff --git a/Tests.Discovery/NetworkScanTargetTests.cs b/Tests.Discovery/NetworkScanTargetTests.cs new file mode 100644 index 00000000..f3617c77 --- /dev/null +++ b/Tests.Discovery/NetworkScanTargetTests.cs @@ -0,0 +1,54 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources.Services.Networking; + +namespace Tests.Discovery; + +/// +/// Which addresses a block actually sweeps. Getting the edges wrong either wastes +/// probes on the network/broadcast addresses or — worse — skips real hosts on the +/// point-to-point prefixes where every address is a host. +/// +public class NetworkScanTargetTests { + private static List Targets(string cidr) => + NetworkScanner.EnumerateTargets(Cidr.Parse(cidr)).ToList(); + + [Fact] + public void A_24_sweeps_the_254_host_addresses() { + List targets = Targets("192.168.1.0/24"); + + Assert.Equal(254, targets.Count); + Assert.Equal("192.168.1.1", targets.First()); + Assert.Equal("192.168.1.254", targets.Last()); + Assert.DoesNotContain("192.168.1.0", targets); + Assert.DoesNotContain("192.168.1.255", targets); + } + + [Fact] + public void A_30_has_two_hosts_between_network_and_broadcast() => + Assert.Equal(["10.0.0.1", "10.0.0.2"], Targets("10.0.0.0/30")); + + [Fact] + public void A_31_is_point_to_point_where_both_addresses_are_hosts() => + // RFC 3021: /31 has no network or broadcast address. + Assert.Equal(["10.0.0.0", "10.0.0.1"], Targets("10.0.0.0/31")); + + [Fact] + public void A_32_is_exactly_the_one_address() => + Assert.Equal(["127.0.0.1"], Targets("127.0.0.1/32")); + + [Fact] + public void A_16_sweeps_the_full_65534_hosts() => + Assert.Equal(65_534, Targets("10.20.0.0/16").Count); + + [Fact] + public void A_block_at_the_top_of_the_address_space_does_not_wrap() { + List targets = Targets("255.255.255.252/30"); + + Assert.Equal(["255.255.255.253", "255.255.255.254"], targets); + } + + [Fact] + public void The_offered_ip_need_not_be_the_network_address() => + // People type their own address plus a prefix; Cidr.Parse masks it down. + Assert.Equal(254, Targets("192.168.1.37/24").Count); +} diff --git a/Tests.Discovery/NetworkScannerTests.cs b/Tests.Discovery/NetworkScannerTests.cs new file mode 100644 index 00000000..e8e05504 --- /dev/null +++ b/Tests.Discovery/NetworkScannerTests.cs @@ -0,0 +1,171 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources.Services.Networking; + +namespace Tests.Discovery; + +/// +/// The sweep's decisions, driven through a scripted probe: what counts as alive, +/// what IO happens for dead hosts, and that the concurrency cap actually caps. +/// The probe is the IO seam — everything above it is what these tests own. +/// +public class NetworkScannerTests { + private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) => + new() { + Cidr = Cidr.Parse(cidr), + Ports = ports.Length > 0 ? ports : [22, 80], + PingTimeout = TimeSpan.FromMilliseconds(5), + PortTimeout = TimeSpan.FromMilliseconds(5) + }; + + [Fact] + public async Task A_host_that_answers_nothing_is_not_reported() { + var probe = new ScriptedProbe(); + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Empty(hosts); + } + + [Fact] + public async Task A_ping_reply_alone_makes_a_host_alive_and_skips_its_port_probes() { + var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Equal("10.0.0.1", host.Ip); + Assert.True(host.AnsweredPing); + // Liveness is already proven; knocking on ports would just be noise on the wire. + Assert.DoesNotContain(probe.PortProbes, p => p.Ip == "10.0.0.1"); + } + + [Fact] + public async Task A_host_that_drops_ping_but_serves_tcp_is_still_alive() { + var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 80)] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Equal("10.0.0.2", host.Ip); + Assert.False(host.AnsweredPing); + Assert.Equal([80], host.OpenPorts); + } + + [Fact] + public async Task Port_probing_stops_at_the_first_answer() { + var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] }; + + await NetworkScanner.ScanAsync(probe, Options()); + + // 22 answered, so 80 was never asked: the sweep proves liveness, not a port map. + Assert.Equal([("10.0.0.2", 22)], probe.PortProbes.Where(p => p.Ip == "10.0.0.2")); + } + + [Fact] + public async Task The_arp_table_is_read_after_the_sweep_and_names_resolve_only_for_the_living() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + Arp = "? (10.0.0.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]", + Names = { ["10.0.0.1"] = "router.lan" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.True(probe.ArpReadAfterSweep, + "ARP must be read after the sweep — the sweep's own probes populate it."); + Assert.Equal("a4:91:b1:4e:3c:20", hosts[0].Mac); + Assert.Equal("router.lan", hosts[0].Hostname); + Assert.Equal(["10.0.0.1"], probe.DnsLookups); // dead hosts get no PTR queries + } + + [Fact] + public async Task Results_come_back_in_address_order_whatever_order_probes_finished() { + var probe = new ScriptedProbe { PingReplies = ["10.0.0.2", "10.0.0.1"] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Equal(["10.0.0.1", "10.0.0.2"], hosts.Select(h => h.Ip)); + } + + [Fact] + public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() { + var probe = new ScriptedProbe { PingDelay = TimeSpan.FromMilliseconds(20) }; + NetworkScanOptions options = Options("10.0.0.0/24") with { Concurrency = 4 }; + + await NetworkScanner.ScanAsync(probe, options); + + Assert.True(probe.MaxInFlight <= 4, + $"{probe.MaxInFlight} hosts were probed at once; the cap was 4."); + } + + /// Scripted IO: answers what it is told to, records what was asked of it. + private sealed class ScriptedProbe : INetworkProbe { + private readonly Lock _lock = new(); + private int _inFlight; + private bool _sweepDone; + + public List PingReplies { get; init; } = []; + public List<(string Ip, int Port)> OpenPorts { get; init; } = []; + public string? Arp { get; init; } + public Dictionary Names { get; } = []; + public TimeSpan PingDelay { get; init; } = TimeSpan.Zero; + + public List<(string Ip, int Port)> PortProbes { get; } = []; + public List DnsLookups { get; } = []; + public int MaxInFlight { get; private set; } + public bool ArpReadAfterSweep { get; private set; } + + public async Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) { + lock (_lock) { + _inFlight++; + MaxInFlight = Math.Max(MaxInFlight, _inFlight); + } + + try { + if (PingDelay > TimeSpan.Zero) + await Task.Delay(PingDelay, cancellationToken); + + return PingReplies.Contains(ip); + } + finally { + lock (_lock) { + _inFlight--; + } + } + } + + public Task TryConnectAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + PortProbes.Add((ip, port)); + } + + return Task.FromResult(OpenPorts.Contains((ip, port))); + } + + public Task ReadArpAsync(CancellationToken cancellationToken = default) { + lock (_lock) { + _sweepDone = true; + ArpReadAfterSweep = _inFlight == 0; + } + + return Task.FromResult(Arp); + } + + public Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) { + lock (_lock) { + if (!_sweepDone) + throw new InvalidOperationException("Reverse DNS ran before the sweep finished."); + + DnsLookups.Add(ip); + } + + return Task.FromResult(Names.GetValueOrDefault(ip)); + } + + public Cidr? LocalSubnet() => null; + } +} diff --git a/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs b/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs new file mode 100644 index 00000000..ddfe059d --- /dev/null +++ b/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs @@ -0,0 +1,61 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.DiscoveryTests; + +/// +/// `rpk discover network` argument validation. Every case here fails before any +/// probing starts, so these tests never send a packet anywhere. +/// +[Collection("Yaml CLI tests")] +public class DiscoverNetworkValidationTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + private async Task ExecuteAsync(params string[] args) => + await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml"); + + [Theory] + [InlineData("not-a-cidr")] + [InlineData("192.168.1.0")] // no prefix + [InlineData("192.168.1.0/24/7")] + [InlineData("192.168.1.0/notanumber")] + public async Task a_malformed_cidr_is_refused_with_an_example_of_the_right_shape(string cidr) { + var output = await ExecuteAsync("discover", "network", "--cidr", cidr); + + Assert.Contains("not a usable CIDR block", output); + Assert.Contains("192.168.1.0/24", output); + } + + [Theory] + [InlineData("10.0.0.0/8")] + [InlineData("0.0.0.0/0")] + public async Task a_sweep_wider_than_a_16_is_refused(string cidr) { + var output = await ExecuteAsync("discover", "network", "--cidr", cidr); + + Assert.Contains("65,534 hosts", output); + Assert.Contains("/16", output); + } + + [Theory] + [InlineData("eighty")] + [InlineData("0")] // port zero is not a port + [InlineData("65536")] + [InlineData("22;80")] + [InlineData(",")] + public async Task a_malformed_port_list_is_refused(string ports) { + var output = await ExecuteAsync( + "discover", "network", "--cidr", "192.168.1.0/24", "--ports", ports); + + Assert.Contains("not a usable port list", output); + } + + [Theory] + [InlineData("--timeout", "0", "--timeout must be between")] + [InlineData("--timeout", "999999", "--timeout must be between")] + [InlineData("--parallel", "0", "--parallel must be between")] + [InlineData("--parallel", "4096", "--parallel must be between")] + public async Task out_of_range_tuning_flags_are_refused(string flag, string value, string expected) { + var output = await ExecuteAsync("discover", "network", "--cidr", "192.168.1.0/24", flag, value); + + Assert.Contains(expected, output); + } +} diff --git a/schemas/v4/schema.v4.json b/schemas/v4/schema.v4.json index b6a9e92e..78a1349a 100644 --- a/schemas/v4/schema.v4.json +++ b/schemas/v4/schema.v4.json @@ -663,12 +663,6 @@ }, { "type": "object", - "required": [ - "type", - "os", - "cores", - "ram" - ], "properties": { "kind": { "const": "System" From e5ed5cf6de4bc168e2ee399cf9e60b3afc2be9a6 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 09:27:29 +0100 Subject: [PATCH 08/29] Harden network discovery: Windows ARP, shared-MAC hosts, ansible fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three integration fixes from reviewing how the scan sits with the rest of the platform: - Windows ARP support: arp.exe prints dash-separated MACs in a three- column table and only knows `arp -a` — the parser now reads that format (normalising to the same colon form as Linux/macOS, so the same machine hashes to the same id from any platform) and the probe falls back from `arp -an` to `arp -a`. Without this, every scan from the shipped win-x64 binary silently degraded to IP-seeded identity. - One MAC answering on several addresses (a gateway's VIPs/aliases) now folds the address into each card's id seed instead of emitting duplicate ids, which the import rejects with a misleading machine-id hint. Deterministic per (mac, ip). - The Ansible exporter now reads a System's own ip after the address labels, exactly like the ssh and hosts exporters already do — so discovered hosts are addressable in inventories without hand-adding labels, and an explicit ansible_host label still wins. Verified against a real /24 that all previously-emitted discovery ids are byte-identical after the mapper change. Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/ArpTableParser.cs | 29 +++++++++++++------ RackPeek.Domain/Discovery/NetworkProbe.cs | 5 +++- .../Discovery/NetworkScanMapper.cs | 18 ++++++++++-- .../Ansible/AnsibleInventoryGenerator.cs | 7 +++++ .../raw_docs/ansible-generator-guide.md | 13 ++++++--- Tests.Discovery/ArpTableParserTests.cs | 12 ++++++++ Tests.Discovery/Fixtures/windows-arp-output | 6 ++++ Tests.Discovery/NetworkScanMapperTests.cs | 18 ++++++++++++ .../AnsibleInventoryWorkflowTests.cs | 29 +++++++++++++++++++ 9 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 Tests.Discovery/Fixtures/windows-arp-output diff --git a/RackPeek.Domain/Discovery/ArpTableParser.cs b/RackPeek.Domain/Discovery/ArpTableParser.cs index aa0ec16e..81fac034 100644 --- a/RackPeek.Domain/Discovery/ArpTableParser.cs +++ b/RackPeek.Domain/Discovery/ArpTableParser.cs @@ -49,27 +49,38 @@ private static (string Ip, string Mac)? ParseLine(string line) { return mac == null ? null : (ip, mac); } - // Linux /proc/net/arp: "192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0" var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries); - if (columns.Length < 4 || !IsIpv4(columns[0])) + if (columns.Length < 2 || !IsIpv4(columns[0])) return null; - // Flags 0x0 marks an entry the kernel gave up resolving. - if (columns[2] == "0x0") - return null; + // Linux /proc/net/arp: "192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0" + if (columns.Length >= 4 && columns[1].StartsWith("0x", StringComparison.Ordinal)) { + // Flags 0x0 marks an entry the kernel gave up resolving. + if (columns[2] == "0x0") + return null; + + var linuxMac = NormaliseMac(columns[3]); + + return linuxMac == null ? null : (columns[0], linuxMac); + } - var linuxMac = NormaliseMac(columns[3]); + // Windows arp -a: "192.168.1.1 a4-91-b1-4e-3c-20 dynamic" + var windowsMac = NormaliseMac(columns[1]); - return linuxMac == null ? null : (columns[0], linuxMac); + return windowsMac == null ? null : (columns[0], windowsMac); } - /// Lowercase, zero-padded, or null for anything that is not a usable MAC. + /// + /// Lowercase, colon-separated, zero-padded — or null for anything that is not a + /// usable MAC. Accepts Windows' dash separators so the same machine hashes the + /// same from every platform's ARP output. + /// public static string? NormaliseMac(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; - var parts = raw.Trim().Split(':'); + var parts = raw.Trim().Split(':', '-'); if (parts.Length != 6) return null; diff --git a/RackPeek.Domain/Discovery/NetworkProbe.cs b/RackPeek.Domain/Discovery/NetworkProbe.cs index bfe2bf86..bd7b6c5d 100644 --- a/RackPeek.Domain/Discovery/NetworkProbe.cs +++ b/RackPeek.Domain/Discovery/NetworkProbe.cs @@ -41,8 +41,11 @@ public async Task TryConnectAsync( } public async Task ReadArpAsync(CancellationToken cancellationToken = default) { + // Linux reads the kernel's file; BSD/macOS answer `arp -an`; Windows' arp.exe + // only knows `-a`. Each failed attempt is null, so the chain just walks on. return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken) - ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken); + ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken) + ?? await SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken); } public async Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) { diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs index 1c70885b..c4ab791e 100644 --- a/RackPeek.Domain/Discovery/NetworkScanMapper.cs +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -9,13 +9,25 @@ public static List ToResources(IReadOnlyList hosts) { var taken = new HashSet(StringComparer.OrdinalIgnoreCase); var resources = new List(hosts.Count); + // One MAC answering on several addresses is one box with aliases or VIPs — + // gateways do this all the time. Each address still gets its own card, but the + // shared MAC alone cannot identify them: the import rejects duplicate ids. + var macCounts = hosts + .Where(h => h.Mac != null) + .GroupBy(h => h.Mac!) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + foreach (NetworkHostFact host in hosts) { // The MAC is the only identity a scan can see that survives a DHCP re-lease; // when ARP could not provide one (a routed subnet, say) the IP has to do, // and the id changes if the address does — documented in the guide. - var discoveryId = DiscoveryId.Create( - DiscoveryId.NetworkScheme, - host.Mac ?? $"ip:{host.Ip}"); + var seed = host.Mac == null + ? $"ip:{host.Ip}" + : macCounts[host.Mac] > 1 + ? $"{host.Mac}/{host.Ip}" + : host.Mac; + + var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, seed); var system = new SystemResource { Kind = SystemResource.KindLabel, diff --git a/RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs b/RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs index 72c6ce27..61a1ce4b 100644 --- a/RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs +++ b/RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs @@ -1,5 +1,6 @@ using System.Text; using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; namespace RackPeek.Domain.UseCases.Ansible; @@ -180,6 +181,12 @@ private static InventoryResult RenderYaml( if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn)) return hn; + // A System's own address, the way the ssh and hosts exporters already read it — + // this is what makes discovered hosts addressable without hand-adding a label. + // Labels stay first: an explicit ansible_host must always win. + if (r is SystemResource { Ip: not null } system && !string.IsNullOrWhiteSpace(system.Ip)) + return system.Ip; + return null; } diff --git a/Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md b/Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md index 50595c48..432c9799 100644 --- a/Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md @@ -21,10 +21,15 @@ Without this, the resource will not appear in inventory. RackPeek will also accept these alternatives if `ansible_host` is not provided: -| Label | Used As | -| ---------- | ------------ | -| `ip` | ansible_host | -| `hostname` | ansible_host | +| Source | Used As | +| ------------------- | ------------ | +| `ip` label | ansible_host | +| `hostname` label | ansible_host | +| a System's own `ip` | ansible_host | + +So a System that carries an address — hand-written or found by +[`rpk discover network`](/docs/discovery-guide) — is addressable without any labels; +an explicit `ansible_host` label always wins when both are present. Example: diff --git a/Tests.Discovery/ArpTableParserTests.cs b/Tests.Discovery/ArpTableParserTests.cs index 79f44d95..81f2b2bf 100644 --- a/Tests.Discovery/ArpTableParserTests.cs +++ b/Tests.Discovery/ArpTableParserTests.cs @@ -29,6 +29,17 @@ public void The_macos_arp_output_parses_to_the_same_macs_as_linux() { Assert.Equal(linux["192.168.1.20"], macos["192.168.1.20"]); } + [Fact] + public void The_windows_arp_output_parses_to_the_same_macs_as_linux() { + IReadOnlyDictionary linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table")); + IReadOnlyDictionary windows = ArpTableParser.Parse(Fixture.Read("windows-arp-output")); + + // Windows prints dashes and uppercase; the interface/header lines parse to nothing. + Assert.Equal(linux["192.168.1.1"], windows["192.168.1.1"]); + Assert.Equal(linux["192.168.1.20"], windows["192.168.1.20"]); + Assert.False(windows.ContainsKey("Interface:")); + } + [Fact] public void Unresolved_neighbours_contribute_nothing() { IReadOnlyDictionary linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table")); @@ -53,6 +64,7 @@ public void Garbage_input_is_an_empty_table_not_an_exception(string? text) => [InlineData("A4:91:B1:4E:3C:20", "a4:91:b1:4e:3c:20")] [InlineData("1:0:5e:0:0:fb", "01:00:5e:00:00:fb")] [InlineData("dc:a6:32:f:11:22", "dc:a6:32:0f:11:22")] + [InlineData("A4-91-B1-4E-3C-20", "a4:91:b1:4e:3c:20")] // Windows separators public void Macs_normalise_to_lowercase_padded_octets(string raw, string expected) => Assert.Equal(expected, ArpTableParser.NormaliseMac(raw)); diff --git a/Tests.Discovery/Fixtures/windows-arp-output b/Tests.Discovery/Fixtures/windows-arp-output new file mode 100644 index 00000000..08901327 --- /dev/null +++ b/Tests.Discovery/Fixtures/windows-arp-output @@ -0,0 +1,6 @@ +Interface: 192.168.1.100 --- 0xb + Internet Address Physical Address Type + 192.168.1.1 a4-91-b1-4e-3c-20 dynamic + 192.168.1.20 DC-A6-32-0F-11-22 dynamic + 224.0.0.251 01-00-5e-00-00-fb static + 192.168.1.255 ff-ff-ff-ff-ff-ff static diff --git a/Tests.Discovery/NetworkScanMapperTests.cs b/Tests.Discovery/NetworkScanMapperTests.cs index 873dcf98..696780f0 100644 --- a/Tests.Discovery/NetworkScanMapperTests.cs +++ b/Tests.Discovery/NetworkScanMapperTests.cs @@ -77,6 +77,24 @@ public void Two_hosts_answering_to_the_same_dns_name_stay_distinct() { Assert.StartsWith("router-", resources[1].Name); } + [Fact] + public void One_mac_answering_on_several_addresses_yields_distinct_stable_identities() { + // Gateways answer on VIPs and aliases all the time: one MAC, many addresses. + // The shared MAC alone cannot identify the cards — the import rejects duplicate + // ids — so each address folds into the seed, deterministically. + NetworkHostFact[] swept = [ + Host(ip: "192.168.1.1", hostname: "gw.lan"), + Host(ip: "192.168.1.2", hostname: null) + ]; + + List resources = NetworkScanMapper.ToResources(swept); + + Assert.Equal(2, resources.Select(r => r.DiscoveryId).Distinct().Count()); + Assert.Equal( + resources.Select(r => r.DiscoveryId), + NetworkScanMapper.ToResources(swept).Select(r => r.DiscoveryId)); + } + [Fact] public void The_emitted_document_conforms_to_the_published_schema() { List resources = NetworkScanMapper.ToResources([ diff --git a/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs b/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs index 7514f0ae..eccce9ae 100644 --- a/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs +++ b/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs @@ -85,6 +85,35 @@ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """ """, output); } + [Fact] + public async Task a_system_with_only_its_ip_field_is_still_addressable() { + // Discovered hosts carry an ip but no address labels; like the ssh and hosts + // exporters, the inventory reads the System's own address. An explicit + // ansible_host label still wins when both are present. + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """ + version: 4 + resources: + - kind: System + name: scanned-host + ip: 10.0.20.150 + tags: + - lan + - kind: System + name: labelled-host + ip: 10.0.20.151 + tags: + - lan + labels: + ansible_host: vpn.example.com + + """); + + (var output, var _) = await ExecuteAsync("ansible", "inventory", "--group-tags", "lan"); + + Assert.Contains("scanned-host ansible_host=10.0.20.150", output); + Assert.Contains("labelled-host ansible_host=vpn.example.com", output); + } + [Fact] public async Task ansible_inventory_yaml_output_test() { await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """ From 6ce03a7625f664be06ff2c66dad106875467d69b Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 09:41:11 +0100 Subject: [PATCH 09/29] Address code-review findings on network discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of the branch surfaced ten verified findings; all are addressed: - Identity stability (the review's top finding): hosts sharing a MAC now collapse into ONE card (lowest address as its ip, all addresses in an 'ips' label) instead of count-dependent id seeds — a VIP failing over or appearing can no longer move or duplicate a machine's identity. - The /16 sweep cap moved into NetworkScanner itself, so an auto-detected VPN/CGNAT /10 hits the same wall a typed --cidr does. - IpHelper.ToUInt32 range-checks octets: 192.168.256.0/24 is refused instead of silently wrapping into 192.169.0.0 and probing a network the user never named. - The ARP source chain trusts parse results, not exit codes: a source only wins if it yields usable entries, so arp.exe printing usage text with exit 0 can no longer cost Windows scans their MAC identity. - INetworkProbe.IsSupported guards the browser: the WASM viewer console now says scanning is unsupported instead of reporting a false-empty network. - LocalSubnet skips 169.254/16 self-assigned addresses when picking the subnet to auto-sweep. - Reverse DNS resolves in parallel under the same concurrency gate, with the per-lookup cap promoted from a buried constant to NetworkScanOptions.DnsTimeout — a PTR-dropping resolver now costs one timeout, not one per host. - Cidr.TryParse is the single definition of CIDR validity: the settings and ServiceSubnetsUseCase both use it, the command no longer re-parses on faith, and ResolvedPorts caches its parse instead of a null-forgive. - New drift guard: SchemaTests pins the wwwroot schema copies the server and viewer actually serve to the published schemas/ copies (the #310/ #311 failure mode), for every version. 20 new/updated tests; re-verified against the real /24 that every previously emitted discovery id is unchanged. Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/INetworkProbe.cs | 9 ++- RackPeek.Domain/Discovery/NetworkProbe.cs | 40 +++++++++-- RackPeek.Domain/Discovery/NetworkScanFacts.cs | 4 ++ .../Discovery/NetworkScanMapper.cs | 72 ++++++++++++++----- RackPeek.Domain/Discovery/NetworkScanner.cs | 38 ++++++++-- .../Resources/Services/Networking/Cidr.cs | 17 +++++ .../Resources/Services/Networking/IpHelper.cs | 17 +++-- .../UseCases/ServiceSubnetsUseCase.cs | 7 +- .../Discovery/DiscoverNetworkCommand.cs | 47 ++++++++---- .../wwwroot/raw_docs/discovery-guide.md | 5 +- Tests.Discovery/CidrParsingTests.cs | 35 +++++++++ Tests.Discovery/NetworkScanMapperTests.cs | 37 ++++++---- Tests.Discovery/NetworkScannerTests.cs | 14 +++- Tests/Tests.csproj | 4 ++ Tests/Yaml/SchemaTests.cs | 27 +++++++ 15 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 Tests.Discovery/CidrParsingTests.cs diff --git a/RackPeek.Domain/Discovery/INetworkProbe.cs b/RackPeek.Domain/Discovery/INetworkProbe.cs index 08a2636c..2332b2dc 100644 --- a/RackPeek.Domain/Discovery/INetworkProbe.cs +++ b/RackPeek.Domain/Discovery/INetworkProbe.cs @@ -9,6 +9,13 @@ namespace RackPeek.Domain.Discovery; /// lives in and the pure parsers. /// public interface INetworkProbe { + /// + /// True when this platform can sweep at all. The browser (WASM viewer) cannot — + /// its sockets are sandboxed — and without this guard a scan there would report + /// an empty network instead of the truth. Mirrors . + /// + bool IsSupported { get; } + /// True when the host answers an ICMP echo within the timeout. Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default); @@ -23,7 +30,7 @@ public interface INetworkProbe { Task ReadArpAsync(CancellationToken cancellationToken = default); /// The host's reverse-DNS name, or null when it has none worth keeping. - Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default); + Task ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default); /// /// The subnet of the first up, non-loopback IPv4 interface with a gateway — what diff --git a/RackPeek.Domain/Discovery/NetworkProbe.cs b/RackPeek.Domain/Discovery/NetworkProbe.cs index bd7b6c5d..77f498c2 100644 --- a/RackPeek.Domain/Discovery/NetworkProbe.cs +++ b/RackPeek.Domain/Discovery/NetworkProbe.cs @@ -7,6 +7,8 @@ namespace RackPeek.Domain.Discovery; /// The real network IO. Deliberately dumb; see . public sealed class NetworkProbe : INetworkProbe { + public bool IsSupported => !OperatingSystem.IsBrowser(); + public async Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) { try { using var ping = new Ping(); @@ -42,18 +44,32 @@ public async Task TryConnectAsync( public async Task ReadArpAsync(CancellationToken cancellationToken = default) { // Linux reads the kernel's file; BSD/macOS answer `arp -an`; Windows' arp.exe - // only knows `-a`. Each failed attempt is null, so the chain just walks on. - return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken) - ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken) - ?? await SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken); + // only knows `-a`. A source only wins if it yields entries the parser can use — + // an exit code alone is not proof (arp.exe printing usage text could exit 0), + // and trusting one would silently cost every host its MAC identity. + foreach (Func> read in new Func>[] { + () => SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken), + () => SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken), + () => SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken) + }) { + var text = await read(); + + if (text != null && ArpTableParser.Parse(text).Count > 0) + return text; + } + + return null; } - public async Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) { + public async Task ReverseDnsAsync( + string ip, + TimeSpan timeout, + CancellationToken cancellationToken = default) { try { // A resolver with a dead PTR zone can sit on the query far longer than the // whole sweep took; the cap keeps a pile of dead lookups from stalling it. using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(2)); + cts.CancelAfter(timeout); IPHostEntry entry = await Dns.GetHostEntryAsync(ip, cts.Token); @@ -83,8 +99,12 @@ public async Task TryConnectAsync( if (!hasGateway) continue; + // Skip 169.254/16 self-assigned addresses: a NIC mid-DHCP-renewal can + // carry one alongside its real address, and sweeping that block finds + // nothing by definition. UnicastIPAddressInformation? address = properties.UnicastAddresses.FirstOrDefault(a => - a.Address.AddressFamily == AddressFamily.InterNetwork); + a.Address.AddressFamily == AddressFamily.InterNetwork + && !IsLinkLocal(a.Address)); if (address == null) continue; @@ -98,4 +118,10 @@ public async Task TryConnectAsync( return null; } + + private static bool IsLinkLocal(IPAddress address) { + var bytes = address.GetAddressBytes(); + + return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254; + } } diff --git a/RackPeek.Domain/Discovery/NetworkScanFacts.cs b/RackPeek.Domain/Discovery/NetworkScanFacts.cs index 91704f21..6d963cdb 100644 --- a/RackPeek.Domain/Discovery/NetworkScanFacts.cs +++ b/RackPeek.Domain/Discovery/NetworkScanFacts.cs @@ -25,6 +25,10 @@ public sealed record NetworkScanOptions { public TimeSpan PortTimeout { get; init; } = TimeSpan.FromMilliseconds(500); + /// Cap on each alive host's reverse-DNS lookup — resolvers that silently + /// drop PTR queries would otherwise stall the whole result on the OS default. + public TimeSpan DnsTimeout { get; init; } = TimeSpan.FromSeconds(2); + /// How many hosts are probed at once. public int Concurrency { get; init; } = 128; } diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs index c4ab791e..511f335b 100644 --- a/RackPeek.Domain/Discovery/NetworkScanMapper.cs +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -1,4 +1,5 @@ using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services.Networking; using RackPeek.Domain.Resources.SystemResources; namespace RackPeek.Domain.Discovery; @@ -7,27 +8,15 @@ namespace RackPeek.Domain.Discovery; public static class NetworkScanMapper { public static List ToResources(IReadOnlyList hosts) { var taken = new HashSet(StringComparer.OrdinalIgnoreCase); - var resources = new List(hosts.Count); + var resources = new List(); - // One MAC answering on several addresses is one box with aliases or VIPs — - // gateways do this all the time. Each address still gets its own card, but the - // shared MAC alone cannot identify them: the import rejects duplicate ids. - var macCounts = hosts - .Where(h => h.Mac != null) - .GroupBy(h => h.Mac!) - .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); - - foreach (NetworkHostFact host in hosts) { + foreach ((NetworkHostFact host, IReadOnlyList allIps) in Collapse(hosts)) { // The MAC is the only identity a scan can see that survives a DHCP re-lease; // when ARP could not provide one (a routed subnet, say) the IP has to do, // and the id changes if the address does — documented in the guide. - var seed = host.Mac == null - ? $"ip:{host.Ip}" - : macCounts[host.Mac] > 1 - ? $"{host.Mac}/{host.Ip}" - : host.Mac; - - var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, seed); + var discoveryId = DiscoveryId.Create( + DiscoveryId.NetworkScheme, + host.Mac ?? $"ip:{host.Ip}"); var system = new SystemResource { Kind = SystemResource.KindLabel, @@ -48,9 +37,58 @@ public static List ToResources(IReadOnlyList hosts) { if (host.Mac != null) system.Labels["mac"] = host.Mac; + if (allIps.Count > 1) + system.Labels["ips"] = string.Join(",", allIps); + resources.Add(system); } return resources; } + + /// + /// One MAC answering on several addresses — a gateway's VIPs and aliases — is + /// still one machine, so it becomes one card: the lowest address as the card's + /// ip (deterministic), every address in an "ips" label. Anything else would make + /// the machine's identity depend on how many of its addresses happened to answer + /// a particular scan, and identity must never move between scans. + /// + private static IEnumerable<(NetworkHostFact Host, IReadOnlyList AllIps)> Collapse( + IReadOnlyList hosts) { + var byMac = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (NetworkHostFact host in hosts) + if (host.Mac != null) { + if (!byMac.TryGetValue(host.Mac, out List? group)) + byMac[host.Mac] = group = []; + + group.Add(host); + } + + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (NetworkHostFact host in hosts) { + if (host.Mac == null) { + yield return (host, [host.Ip]); + + continue; + } + + if (!emitted.Add(host.Mac)) + continue; + + var group = byMac[host.Mac] + .OrderBy(h => IpHelper.ToUInt32(h.Ip)) + .ToList(); + + NetworkHostFact primary = group[0]; + + // Any name in the group beats none: a VIP rarely has its own PTR record. + var hostname = group.Select(h => h.Hostname).FirstOrDefault(n => n != null); + + yield return ( + primary with { Hostname = hostname }, + group.Select(h => h.Ip).ToList()); + } + } } diff --git a/RackPeek.Domain/Discovery/NetworkScanner.cs b/RackPeek.Domain/Discovery/NetworkScanner.cs index 4ebf525f..0785b3f9 100644 --- a/RackPeek.Domain/Discovery/NetworkScanner.cs +++ b/RackPeek.Domain/Discovery/NetworkScanner.cs @@ -9,6 +9,13 @@ namespace RackPeek.Domain.Discovery; /// neither signal alone is enough. All IO goes through . /// public static class NetworkScanner { + /// + /// The widest block a sweep accepts, wherever the block came from — typed by the + /// user or auto-detected off a NIC. Wider than this is 65k+ hosts: a typo or a + /// CGNAT/VPN prefix, not a homelab. + /// + public const int MinPrefix = 16; + /// /// Every address worth probing in the block: hosts only, so the network and /// broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point) @@ -32,6 +39,13 @@ public static async Task> ScanAsync( INetworkProbe probe, NetworkScanOptions options, CancellationToken cancellationToken = default) { + // Enforced here rather than only at a front end, so every caller — CLI flag, + // auto-detected subnet, future MCP tool — hits the same wall. + if (options.Cidr.Prefix < MinPrefix) + throw new ArgumentOutOfRangeException( + nameof(options), + $"/{options.Cidr.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller."); + var targets = EnumerateTargets(options.Cidr).ToList(); using var gate = new SemaphoreSlim(options.Concurrency); @@ -46,15 +60,25 @@ public static async Task> ScanAsync( IReadOnlyDictionary macByIp = ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken)); + // Names resolve in parallel too — a resolver that drops PTR queries burns the + // full timeout per lookup, and paying that once beats paying it per host. + var names = await Task.WhenAll(alive.Select(async h => { + await gate.WaitAsync(cancellationToken); + + try { + return await probe.ReverseDnsAsync(h.Ip, options.DnsTimeout, cancellationToken); + } + finally { + gate.Release(); + } + })); + var facts = new List(alive.Count); - foreach ((var ip, var ping, List open) in alive) - facts.Add(new NetworkHostFact( - ip, - macByIp.GetValueOrDefault(ip), - await probe.ReverseDnsAsync(ip, cancellationToken), - ping, - open)); + for (var i = 0; i < alive.Count; i++) { + (var ip, var ping, List open) = alive[i]; + facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open)); + } return facts .OrderBy(f => IpHelper.ToUInt32(f.Ip)) diff --git a/RackPeek.Domain/Resources/Services/Networking/Cidr.cs b/RackPeek.Domain/Resources/Services/Networking/Cidr.cs index ded106f9..b3a35520 100644 --- a/RackPeek.Domain/Resources/Services/Networking/Cidr.cs +++ b/RackPeek.Domain/Resources/Services/Networking/Cidr.cs @@ -28,4 +28,21 @@ public static Cidr Parse(string cidr) { return new Cidr(network, mask, prefix); } + + /// The one definition of "is this a usable CIDR" for validation paths. + public static bool TryParse(string? value, out Cidr cidr) { + cidr = default; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + try { + cidr = Parse(value); + + return true; + } + catch { + return false; + } + } } diff --git a/RackPeek.Domain/Resources/Services/Networking/IpHelper.cs b/RackPeek.Domain/Resources/Services/Networking/IpHelper.cs index a01f18de..b3bf5fe7 100644 --- a/RackPeek.Domain/Resources/Services/Networking/IpHelper.cs +++ b/RackPeek.Domain/Resources/Services/Networking/IpHelper.cs @@ -6,11 +6,18 @@ public static uint ToUInt32(string ip) { if (parts.Length != 4) throw new ArgumentException($"Invalid IPv4 address: {ip}"); - return (uint)( - (int.Parse(parts[0]) << 24) | - (int.Parse(parts[1]) << 16) | - (int.Parse(parts[2]) << 8) | - int.Parse(parts[3])); + uint result = 0; + + foreach (var part in parts) { + // Range-checked: unchecked shifts would fold 192.168.256.0 into + // 192.169.0.0 and quietly point a caller at the wrong network. + if (!int.TryParse(part, out var octet) || octet is < 0 or > 255) + throw new ArgumentException($"Invalid IPv4 address: {ip}"); + + result = (result << 8) | (uint)octet; + } + + return result; } public static string ToIp(uint ip) { diff --git a/RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs b/RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs index c82a81c2..1a53d772 100644 --- a/RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs +++ b/RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs @@ -9,13 +9,8 @@ public async Task ExecuteAsync(string? cidr, int? prefix, // If CIDR is provided → filter mode if (cidr is not null) { - Cidr parsed; - try { - parsed = Cidr.Parse(cidr); - } - catch { + if (!Cidr.TryParse(cidr, out Cidr parsed)) return ServiceSubnetsResult.InvalidCidr(cidr); - } var matches = services .Where(s => s.Network?.Ip != null) diff --git a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs index fb5ea23a..374a069d 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs @@ -9,8 +9,7 @@ namespace Shared.Rcl.Commands.Discovery; public sealed class DiscoverNetworkSettings : DiscoverSettings { - /// Sweeping wider than a /16 is 65k+ hosts — a typo, not a homelab. - public const int MinPrefix = 16; + private IReadOnlyList? _resolvedPorts; [CommandOption("--cidr ")] [Description("Subnet to sweep, e.g. 192.168.1.0/24. Defaults to this machine's own subnet.")] @@ -29,24 +28,24 @@ public sealed class DiscoverNetworkSettings : DiscoverSettings { [Description("How many hosts to probe at once.")] public int Parallel { get; init; } = 128; + /// The parsed --cidr, or null when it was omitted or does not parse. + public NetworkCidr? ParsedCidr => + NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null; + public IReadOnlyList ResolvedPorts => - string.IsNullOrWhiteSpace(Ports) ? WellKnownPorts.Defaults : ParsePorts(Ports)!; + _resolvedPorts ??= string.IsNullOrWhiteSpace(Ports) + ? WellKnownPorts.Defaults + : ParsePorts(Ports) ?? WellKnownPorts.Defaults; public override ValidationResult Validate() { if (Cidr != null) { - NetworkCidr parsed; - - try { - parsed = NetworkCidr.Parse(Cidr); - } - catch { + if (ParsedCidr is not { } parsed) return ValidationResult.Error( $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24"); - } - if (parsed.Prefix < MinPrefix) + if (parsed.Prefix < NetworkScanner.MinPrefix) return ValidationResult.Error( - $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller."); + $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{NetworkScanner.MinPrefix} or smaller."); } if (Ports != null && ParsePorts(Ports) == null) @@ -87,10 +86,20 @@ protected override async Task ExecuteAsync( CommandContext context, DiscoverNetworkSettings settings, CancellationToken cancellationToken) { + if (!probe.IsSupported) { + // Without this, the browser console's sandboxed sockets would swallow every + // probe and the command would report an empty network as if it were true. + AnsiConsole.MarkupLine( + "[red]Network scanning is not supported on this platform.[/] " + + "Run rpk on a machine attached to the network instead."); + + return 1; + } + Cidr cidr; - if (settings.Cidr != null) { - cidr = Cidr.Parse(settings.Cidr); // Validate() vouched for it + if (settings.ParsedCidr is { } requested) { + cidr = requested; } else { Cidr? detected = probe.LocalSubnet(); @@ -102,6 +111,16 @@ protected override async Task ExecuteAsync( return 1; } + // The same cap --cidr gets: a VPN or CGNAT interface can carry a /10, and + // auto-detection must never be the way around the sweep limit. + if (detected.Value.Prefix < NetworkScanner.MinPrefix) { + AnsiConsole.MarkupLine( + $"[red]This machine's subnet is {Markup.Escape(detected.Value.ToString())} — more than " + + $"65,534 hosts.[/] Pass --cidr with a narrower block, e.g. --cidr 192.168.1.0/24"); + + return 1; + } + cidr = detected.Value; } diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 4ad7725c..10680164 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -357,7 +357,10 @@ rpk discover network --cidr 10.0.50.0/24 --push # the server VLAN A scanned host is identified by its **MAC address**, read from the ARP table the sweep itself populates — so a DHCP re-lease updates the same resource's address rather -than inventing a new machine. Two caveats: +than inventing a new machine. One MAC answering on several addresses (a gateway's +VIPs and aliases) is still one machine and becomes **one card**: the lowest address as +its `ip`, every address in an `ips` label — so a VIP failing over never moves the +machine's identity. Two caveats: - **Hosts beyond the local segment have no ARP entry** (a routed VLAN, a VPN subnet). Their identity falls back to the IP address, and the command says so — a DHCP diff --git a/Tests.Discovery/CidrParsingTests.cs b/Tests.Discovery/CidrParsingTests.cs new file mode 100644 index 00000000..225fb47e --- /dev/null +++ b/Tests.Discovery/CidrParsingTests.cs @@ -0,0 +1,35 @@ +using RackPeek.Domain.Resources.Services.Networking; + +namespace Tests.Discovery; + +/// +/// CIDR parsing feeds the sweep its targets, so leniency here means probing a +/// network the user never named: unchecked octet arithmetic used to fold +/// 192.168.256.0 into 192.169.0.0 and call it usable. +/// +public class CidrParsingTests { + [Theory] + [InlineData("192.168.1.0/24", "192.168.1.0/24")] + [InlineData("192.168.1.37/24", "192.168.1.0/24")] // a host address masks down + [InlineData("10.0.0.0/8", "10.0.0.0/8")] + [InlineData("127.0.0.1/32", "127.0.0.1/32")] + public void Valid_blocks_parse_and_mask_to_their_network(string input, string expected) { + Assert.True(Cidr.TryParse(input, out Cidr cidr)); + Assert.Equal(expected, cidr.ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-cidr")] + [InlineData("192.168.1.0")] // no prefix + [InlineData("192.168.1.0/24/7")] + [InlineData("192.168.1.0/notanumber")] + [InlineData("192.168.1.0/33")] + [InlineData("192.168.256.0/24")] // octet overflow must not wrap into .169 + [InlineData("192.-1.1.0/24")] + [InlineData("300.1.1.1/24")] + [InlineData("1.2.3/24")] + public void Anything_else_is_refused_rather_than_reinterpreted(string? input) => + Assert.False(Cidr.TryParse(input, out _)); +} diff --git a/Tests.Discovery/NetworkScanMapperTests.cs b/Tests.Discovery/NetworkScanMapperTests.cs index 696780f0..f3dcff6e 100644 --- a/Tests.Discovery/NetworkScanMapperTests.cs +++ b/Tests.Discovery/NetworkScanMapperTests.cs @@ -78,21 +78,34 @@ public void Two_hosts_answering_to_the_same_dns_name_stay_distinct() { } [Fact] - public void One_mac_answering_on_several_addresses_yields_distinct_stable_identities() { - // Gateways answer on VIPs and aliases all the time: one MAC, many addresses. - // The shared MAC alone cannot identify the cards — the import rejects duplicate - // ids — so each address folds into the seed, deterministically. - NetworkHostFact[] swept = [ + public void One_mac_answering_on_several_addresses_is_one_machine_with_one_card() { + // Gateways answer on VIPs and aliases all the time: one MAC, many addresses — + // still one box. Collapsing keeps the import happy (duplicate ids are rejected) + // AND keeps identity independent of how many addresses answered this scan. + List resources = NetworkScanMapper.ToResources([ + Host(ip: "192.168.1.2", hostname: null), // the VIP, deliberately first + Host(ip: "192.168.1.1", hostname: "gw.lan") + ]); + + SystemResource card = Assert.IsType(Assert.Single(resources)); + Assert.Equal("192.168.1.1", card.Ip); // the lowest address, deterministically + Assert.Equal("gw", card.Name); // the one name anywhere in the group + Assert.Equal("192.168.1.1,192.168.1.2", card.Labels["ips"]); + } + + [Fact] + public void A_vip_appearing_or_disappearing_never_moves_the_machines_identity() { + // The regression that motivated the collapse: an id seeded on scan-local + // address counts flips when a keepalived VIP fails over. MAC alone, always. + List alone = NetworkScanMapper.ToResources([ + Host(ip: "192.168.1.1", hostname: "gw.lan") + ]); + List withVip = NetworkScanMapper.ToResources([ Host(ip: "192.168.1.1", hostname: "gw.lan"), Host(ip: "192.168.1.2", hostname: null) - ]; - - List resources = NetworkScanMapper.ToResources(swept); + ]); - Assert.Equal(2, resources.Select(r => r.DiscoveryId).Distinct().Count()); - Assert.Equal( - resources.Select(r => r.DiscoveryId), - NetworkScanMapper.ToResources(swept).Select(r => r.DiscoveryId)); + Assert.Equal(alone[0].DiscoveryId, Assert.Single(withVip).DiscoveryId); } [Fact] diff --git a/Tests.Discovery/NetworkScannerTests.cs b/Tests.Discovery/NetworkScannerTests.cs index e8e05504..b34b2946 100644 --- a/Tests.Discovery/NetworkScannerTests.cs +++ b/Tests.Discovery/NetworkScannerTests.cs @@ -98,6 +98,14 @@ public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() { $"{probe.MaxInFlight} hosts were probed at once; the cap was 4."); } + [Fact] + public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() { + // The floor lives in the scanner, not a front end: an auto-detected VPN /10 + // must hit the same wall a typed --cidr does. + await Assert.ThrowsAsync(() => + NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8"))); + } + /// Scripted IO: answers what it is told to, records what was asked of it. private sealed class ScriptedProbe : INetworkProbe { private readonly Lock _lock = new(); @@ -112,6 +120,7 @@ private sealed class ScriptedProbe : INetworkProbe { public List<(string Ip, int Port)> PortProbes { get; } = []; public List DnsLookups { get; } = []; + public bool IsSupported => true; public int MaxInFlight { get; private set; } public bool ArpReadAfterSweep { get; private set; } @@ -155,7 +164,10 @@ public Task TryConnectAsync( return Task.FromResult(Arp); } - public Task ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) { + public Task ReverseDnsAsync( + string ip, + TimeSpan timeout, + CancellationToken cancellationToken = default) { lock (_lock) { if (!_sweepDone) throw new InvalidOperationException("Reverse DNS ran before the sweep finished."); diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 13a2487b..457b5bb2 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -42,6 +42,10 @@ + + + PreserveNewest diff --git a/Tests/Yaml/SchemaTests.cs b/Tests/Yaml/SchemaTests.cs index ae50fa53..e78e6111 100644 --- a/Tests/Yaml/SchemaTests.cs +++ b/Tests/Yaml/SchemaTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.Json; +using System.Text.Json.Nodes; using Json.Schema; using YamlDotNet.RepresentationModel; @@ -61,6 +62,32 @@ private static string ConvertYamlNodeToJson(YamlNode node) { return "null"; } + /// + /// The schema is published three times: the repo root copy tests validate + /// against, and the copies the web app and the viewer serve at + /// /schemas/v{n}/schema.v{n}.json. They are hand-synced, and #310/#311 were + /// what happens when a sync is missed — this pins them together for good. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void The_served_schema_copies_never_drift_from_the_published_one(int version) { + var published = JsonNode.Parse( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "schemas", $"schema.v{version}.json"))); + + foreach (var host in new[] { "web", "viewer" }) { + var served = JsonNode.Parse(File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "wwwroot-schemas", host, $"schema.v{version}.json"))); + + Assert.True( + JsonNode.DeepEquals(published, served), + $"The {host} wwwroot copy of schema.v{version}.json differs from schemas/ — " + + "update both together, or documents RackPeek writes will fail the schema it serves."); + } + } + [Theory] [InlineData(1)] [InlineData(2)] From f89a2a6fb0ad84e4d748327124fbccfb49fc2d2e Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 10:31:16 +0100 Subject: [PATCH 10/29] Unify system and network discovery through the MAC bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same physical machine seen by two collectors used to become two cards, because their identity schemes cannot derive each other: the agent identifies by machine-id, a scan by MAC. Now they meet in the middle — one machine, one card, whichever collector ran first. The agent's side of the bridge: `rpk discover system` records the machine's physical-NIC MACs (loopback and virtual interfaces excluded) as a `macs` label, normalised by the same code that reads ARP tables so both sides always agree on the spelling. The local docker collector builds its host card through the same mapper, so it participates for free. The resolver's side: after an id lookup misses, a MAC shared with exactly one stored card of the same kind unifies onto that card. The stronger identity wins — an agent id replaces a scan id; a scan id is nulled before the merge so it can never downgrade one. Ambiguity never unifies: a MAC claimed by two stored cards identifies nothing, and two agent-grade identities sharing a MAC (cloned VMs) stay apart — that is what machine-ids are for. Names remain user-owned: the stored card keeps its name, so a scan-first card keeps its generated name until renamed once, after which every collector follows it. Proxmox remains outside the bridge (its API view carries no host MACs); the existing suffix protection still applies there, and the no-MAC-label case keeps its dedicated regression test. 18 new tests: resolver-level contracts (claim, enrich, ambiguity, cloned-VM refusal, kind mismatch, id-beats-MAC, spelling-independent matching, hand-written adoption), facts/mapper coverage, and both headline flows e2e through the real server. Proven live on this machine: scan-first created host-e64e5425, `rpk discover system` updated that same card to the sys identity with OS/cores/RAM, and a rescan reported "no changes". Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/DiscoveryId.cs | 10 ++ .../Discovery/DiscoveryIdResolver.cs | 103 +++++++++++- RackPeek.Domain/Discovery/SystemFacts.cs | 14 +- .../Discovery/SystemFactsParser.cs | 17 ++ .../Discovery/SystemProbeCommon.cs | 17 +- .../Discovery/SystemResourceMapper.cs | 9 +- .../wwwroot/raw_docs/discovery-guide.md | 26 ++- Tests.Discovery/MacUnificationTests.cs | 155 ++++++++++++++++++ Tests.Discovery/NetworkDiscoveryMergeTests.cs | 76 ++++++++- 9 files changed, 413 insertions(+), 14 deletions(-) create mode 100644 Tests.Discovery/MacUnificationTests.cs diff --git a/RackPeek.Domain/Discovery/DiscoveryId.cs b/RackPeek.Domain/Discovery/DiscoveryId.cs index 10f26780..1af5aed5 100644 --- a/RackPeek.Domain/Discovery/DiscoveryId.cs +++ b/RackPeek.Domain/Discovery/DiscoveryId.cs @@ -29,6 +29,16 @@ public static string Create(string scheme, string seed) { return $"{Prefix}:{scheme}:{Convert.ToHexString(hash, 0, 8).ToLowerInvariant()}"; } + /// The scheme segment of an id — "sys" for rpk1:sys:… — or null for anything malformed. + public static string? Scheme(string? discoveryId) { + if (string.IsNullOrWhiteSpace(discoveryId)) + return null; + + var parts = discoveryId.Split(':'); + + return parts.Length == 3 ? parts[1] : null; + } + /// Short, stable fragment used to disambiguate generated names. public static string ShortSuffix(string discoveryId) { var lastColon = discoveryId.LastIndexOf(':'); diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index be0498dd..0cae8102 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -38,6 +38,8 @@ public static void ResolveNames( .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId)) .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase); + Dictionary existingByMac = BuildMacMap(existing); + // 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(StringComparer.OrdinalIgnoreCase); @@ -48,7 +50,7 @@ public static void ResolveNames( var renames = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (Resource resource in incomingWithId) { - var resolved = ResolveName(resource, existingById, existingByName); + var resolved = ResolveName(resource, existingById, existingByName, existingByMac); if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)) continue; @@ -81,8 +83,11 @@ private static void PreserveStoredRunsOn( var incomingNames = new HashSet(incoming.Select(r => r.Name), StringComparer.OrdinalIgnoreCase); foreach (Resource resource in incomingWithId) { + // MAC unification may have nulled a scan card's id so the merge cannot + // downgrade the stored identity — such a card has nothing to look up here. if (resource.RunsOn.Count == 0 - || !existingById.TryGetValue(resource.DiscoveryId!, out Resource? stored) + || string.IsNullOrWhiteSpace(resource.DiscoveryId) + || !existingById.TryGetValue(resource.DiscoveryId, out Resource? stored) || stored.RunsOn.Count == 0) continue; @@ -97,11 +102,17 @@ private static void PreserveStoredRunsOn( private static string ResolveName( Resource resource, Dictionary existingById, - Dictionary existingByName) { + Dictionary existingByName, + Dictionary existingByMac) { // 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, but a MAC in common with exactly one stored card: the same + // physical machine seen by two collectors, unified onto the stored card. + if (TryUnifyByMac(resource, existingByMac, out var unifiedName)) + return unifiedName; + // Unknown id and the name is free: nothing to reconcile. if (!existingByName.TryGetValue(resource.Name, out Resource? sameName)) return resource.Name; @@ -128,6 +139,92 @@ private static string ResolveName( return resource.Name; } + /// + /// The bridge between collectors that cannot derive each other's ids: the agent + /// records the machine's MACs (a "macs" label), the scan identifies it by one (a + /// "mac" label). A shared MAC on a stored card of the same kind means the same + /// box — the incoming card adopts the stored card's name so the merge lands on + /// it, and the stronger identity wins: an agent id replaces a scan id, a scan id + /// never replaces anything (it is nulled here so the merge cannot downgrade). + /// Ids from two agent-grade collectors sharing a MAC (cloned VMs, or Proxmox's + /// view of a guest) are never unified — that is what machine-ids are for. + /// + private static bool TryUnifyByMac( + Resource resource, + Dictionary existingByMac, + out string unifiedName) { + unifiedName = string.Empty; + + foreach (var mac in MacsOf(resource)) { + if (!existingByMac.TryGetValue(mac, out Resource? stored)) + continue; + + // The box the user documented as a Server and the OS a scan saw on it are + // different cards on purpose; unification is for same-kind cards only. + if (stored.GetType() != resource.GetType()) + continue; + + var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme; + + // A stored card with a MAC but no id yet: adoption, same as the name-based + // adoption case — the incoming id gets stamped onto it by the merge. + if (string.IsNullOrWhiteSpace(stored.DiscoveryId)) { + unifiedName = stored.Name; + + return true; + } + + var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme; + + // Both scan-grade or both agent-grade: not safe to unify on a MAC alone. + if (incomingIsNet == storedIsNet) + continue; + + if (incomingIsNet) + resource.DiscoveryId = null; + + unifiedName = stored.Name; + + return true; + } + + return false; + } + + /// The MACs a resource claims, from its "mac" and "macs" labels, normalised. + private static IEnumerable MacsOf(Resource resource) { + IEnumerable raw = [ + resource.Labels.GetValueOrDefault("mac"), + .. (resource.Labels.GetValueOrDefault("macs") ?? string.Empty).Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ]; + + return raw + .Select(ArpTableParser.NormaliseMac) + .Where(mac => mac != null) + .Select(mac => mac!) + .Distinct(); + } + + /// + /// mac → the one stored resource claiming it. A MAC claimed by two stored + /// resources identifies nothing and is dropped: ambiguity never unifies. + /// + private static Dictionary BuildMacMap(IReadOnlyList existing) { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Resource resource in existing) + foreach (var mac in MacsOf(resource)) + if (!ambiguous.Contains(mac) && !map.TryAdd(mac, resource) && !ReferenceEquals(map[mac], resource)) { + map.Remove(mac); + ambiguous.Add(mac); + } + + return map; + } + private static void RewriteRunsOn(IReadOnlyList incoming, Dictionary renames) { foreach (Resource resource in incoming) for (var i = 0; i < resource.RunsOn.Count; i++) diff --git a/RackPeek.Domain/Discovery/SystemFacts.cs b/RackPeek.Domain/Discovery/SystemFacts.cs index eedbd64c..7ebd6e76 100644 --- a/RackPeek.Domain/Discovery/SystemFacts.cs +++ b/RackPeek.Domain/Discovery/SystemFacts.cs @@ -15,7 +15,13 @@ public static class DiscoveryUnits { public sealed record BlockDeviceFact(string Name, long SizeBytes, bool Rotational); /// A network interface, reduced to the parts that pick a primary address. -public sealed record NicFact(string Name, bool IsUp, bool IsLoopback, bool HasGateway, string? Ipv4); +public sealed record NicFact( + string Name, + bool IsUp, + bool IsLoopback, + bool HasGateway, + string? Ipv4, + string? Mac = null); /// /// Everything a probe managed to read off the host, still in its raw form. @@ -64,6 +70,12 @@ public sealed record SystemFacts { public string? Ip { get; init; } public IReadOnlyList Drives { get; init; } = []; + + /// + /// The machine's physical-NIC MACs, normalised. What lets an agent-discovered + /// card and a network-scanned card of the same box find each other. + /// + public IReadOnlyList Macs { get; init; } = []; } public sealed record DriveFact(string Type, int SizeGb); diff --git a/RackPeek.Domain/Discovery/SystemFactsParser.cs b/RackPeek.Domain/Discovery/SystemFactsParser.cs index da1cd3aa..c4302f9f 100644 --- a/RackPeek.Domain/Discovery/SystemFactsParser.cs +++ b/RackPeek.Domain/Discovery/SystemFactsParser.cs @@ -28,6 +28,7 @@ public static SystemFacts Parse(RawSystemSnapshot raw) { RamGb = ParseRamGb(raw), Type = type, Ip = SelectPrimaryIp(raw.Nics), + Macs = SelectMacs(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 @@ -105,6 +106,22 @@ internal static string ParseType(RawSystemSnapshot raw) { ?? usable.FirstOrDefault()?.Ipv4; } + /// + /// The MACs a network scan could see this machine by: real interfaces only — a + /// docker bridge's MAC never crosses the wire, so recording it could only cause + /// a false unification. Normalised by the same code that reads ARP tables, so + /// both sides of the bridge always agree on the spelling. + /// + internal static List SelectMacs(IReadOnlyList nics) { + return nics + .Where(n => n is { IsUp: true, IsLoopback: false } && !IsVirtual(n.Name)) + .Select(n => ArpTableParser.NormaliseMac(n.Mac)) + .Where(mac => mac != null) + .Select(mac => mac!) + .Distinct() + .ToList(); + } + internal static bool IsVirtual(string name) { string[] prefixes = ["docker", "br-", "veth", "virbr", "tailscale", "utun", "tun", "tap", "cni", "flannel"]; diff --git a/RackPeek.Domain/Discovery/SystemProbeCommon.cs b/RackPeek.Domain/Discovery/SystemProbeCommon.cs index ba056b58..01ddfb3d 100644 --- a/RackPeek.Domain/Discovery/SystemProbeCommon.cs +++ b/RackPeek.Domain/Discovery/SystemProbeCommon.cs @@ -48,7 +48,22 @@ private static NicFact ToFact(NetworkInterface nic) { nic.OperationalStatus == OperationalStatus.Up, nic.NetworkInterfaceType == NetworkInterfaceType.Loopback, hasGateway, - ipv4); + ipv4, + FormatMac(nic)); + } + + /// Lowercase colon-separated, matching what ARP tables report — or null. + private static string? FormatMac(NetworkInterface nic) { + try { + var bytes = nic.GetPhysicalAddress().GetAddressBytes(); + + return bytes.Length == 6 + ? string.Join(':', bytes.Select(b => b.ToString("x2"))) + : null; + } + catch { + return null; + } } /// Reads a file, returning null for anything unreadable rather than throwing. diff --git a/RackPeek.Domain/Discovery/SystemResourceMapper.cs b/RackPeek.Domain/Discovery/SystemResourceMapper.cs index a31212b9..4ae94c00 100644 --- a/RackPeek.Domain/Discovery/SystemResourceMapper.cs +++ b/RackPeek.Domain/Discovery/SystemResourceMapper.cs @@ -15,7 +15,7 @@ public static SystemResource ToResource(SystemFacts facts, string? nameOverride DiscoveryId.SystemScheme, facts.MachineId ?? facts.Hostname); - return new SystemResource { + var resource = new SystemResource { Kind = SystemResource.KindLabel, Name = DiscoveryNaming.Suggest( nameOverride ?? DiscoveryNaming.HostLabel(facts.Hostname), @@ -31,5 +31,12 @@ public static SystemResource ToResource(SystemFacts facts, string? nameOverride ? null : facts.Drives.Select(d => new Drive { Type = d.Type, Size = d.SizeGb }).ToList() }; + + // The bridge to network discovery: a scan identifies this machine by one of + // these, so carrying them lets the resolver land both collectors on one card. + if (facts.Macs.Count > 0) + resource.Labels["macs"] = string.Join(",", facts.Macs); + + return resource; } } diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 10680164..550b7645 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -371,11 +371,27 @@ machine's identity. Two caveats: no type, OS, cores or RAM, so a re-scan can never overwrite the details you (or an agent collector) filled in afterwards. -The known limitation above applies here twice over: a host discovered by `rpk discover -system` (machine-id identity) and by a network scan (MAC identity) becomes two -resources, the second visibly suffixed. Keep whichever card you prefer and delete the -other; the scan will keep updating the one that carries its id. The machine running -the sweep also finds itself — same rule. +### One machine, one card — across collectors + +`rpk discover system` records the machine's physical MAC addresses (a `macs` label), +and a scan identifies machines by exactly those MACs — so **the two collectors land on +the same card**, whichever ran first: + +- Scan first: the sweep creates the card; when the agent later runs on that box, it + claims the card, fills in the OS/cores/RAM, and upgrades its identity to the + machine-id. Every rescan afterwards keeps updating that same card via the MAC. +- Agent first: a later sweep recognises the box and just refreshes its address — + never touching the identity or anything you or the agent wrote. + +The card keeps whatever name it already had (names are always user-owned), so a +scan-first card keeps its generated `host-…` name until you rename it once. A MAC that +two stored cards both claim unifies nothing — ambiguity always falls back to separate +cards — and agent-grade identities never unify with each other on a MAC alone (cloned +VMs can share one; that is what machine-ids are for). The machine running the sweep +finds itself, and unifies with its own `rpk discover system` card the same way. + +Proxmox remains the exception: its API view carries no host MACs, so the known +limitation above still applies between `discover proxmox` and the other collectors. ### Being a good citizen diff --git a/Tests.Discovery/MacUnificationTests.cs b/Tests.Discovery/MacUnificationTests.cs new file mode 100644 index 00000000..55092d23 --- /dev/null +++ b/Tests.Discovery/MacUnificationTests.cs @@ -0,0 +1,155 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// The MAC bridge between collectors: an agent card carries the machine's MACs +/// (a "macs" label), a scan card carries the one it was found by (a "mac" label), +/// and a shared MAC means the same box — so both collectors land on one card, +/// whichever arrived first. These pin the resolver's side of that contract. +/// +public class MacUnificationTests { + private const string _mac = "dc:a6:32:0f:11:22"; + + private static SystemResource ScanCard(string name = "host-595109fb", string mac = _mac) => new() { + Kind = SystemResource.KindLabel, + Name = name, + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, mac), + Ip = "192.168.1.20", + Labels = { ["mac"] = mac } + }; + + private static SystemResource AgentCard( + string name = "nas01", + string machineId = "machine-a", + string macs = _mac) => new() { + Kind = SystemResource.KindLabel, + Name = name, + DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, machineId), + Type = "baremetal", + Os = "Debian", + Cores = 12, + Labels = { ["macs"] = macs } + }; + + [Fact] + public void An_agent_claims_the_scan_card_and_upgrades_its_identity() { + // Scan ran first; now `rpk discover system` reports the same box. + List existing = [ScanCard()]; + List incoming = [AgentCard()]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + // The stored card's name wins (names are user-owned), and the agent's id + // survives so the merge upgrades the card to the stronger identity. + Assert.Equal("host-595109fb", incoming[0].Name); + Assert.StartsWith("rpk1:sys:", incoming[0].DiscoveryId); + } + + [Fact] + public void A_scan_enriches_the_agents_card_without_touching_its_identity() { + // Agent ran first; now a sweep sees the same box from outside. + List existing = [AgentCard()]; + List incoming = [ScanCard()]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("nas01", incoming[0].Name); + // The weak scan id is dropped so the merge cannot downgrade the sys id. + Assert.Null(incoming[0].DiscoveryId); + } + + [Fact] + public void The_macs_are_matched_however_each_side_spells_them() { + // The agent records padded lowercase; suppose a stored label was hand-edited + // to Windows-style dashes — normalisation makes them the same machine anyway. + SystemResource stored = ScanCard(); + stored.Labels["mac"] = "DC-A6-32-0F-11-22"; + List existing = [stored]; + + List incoming = [AgentCard()]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-595109fb", incoming[0].Name); + } + + [Fact] + public void An_id_match_always_beats_a_mac_match() { + // The scan card was renamed by the user; a rescan must follow its own id to + // the rename, not rediscover it via the MAC of some other card. + SystemResource renamed = ScanCard(name: "storage-primary"); + List existing = [renamed, AgentCard(macs: _mac)]; + List incoming = [ScanCard()]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("storage-primary", incoming[0].Name); + Assert.NotNull(incoming[0].DiscoveryId); + } + + [Fact] + public void A_mac_claimed_by_two_stored_cards_identifies_nothing() { + // Ambiguity never unifies: fall through to the ordinary name rules. + List existing = [ + AgentCard(name: "clone-a", machineId: "machine-a"), + AgentCard(name: "clone-b", machineId: "machine-b") + ]; + List incoming = [ScanCard(name: "host-xyz")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-xyz", incoming[0].Name); + Assert.NotNull(incoming[0].DiscoveryId); + } + + [Fact] + public void Two_agent_grade_identities_sharing_a_mac_never_unify() { + // Cloned VMs can share a NIC MAC while having distinct machine-ids; the + // machine-id is the authority between agent-grade collectors. + List existing = [AgentCard(name: "vm-a", machineId: "machine-a")]; + List incoming = [AgentCard(name: "vm-b", machineId: "machine-b")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("vm-b", incoming[0].Name); + } + + [Fact] + public void A_mac_on_a_different_kind_of_card_is_not_a_bridge() { + // The user put a mac label on the Server card describing the box's hardware; + // the scan's System card is a different kind of thing and stays separate. + var server = new Server { + Kind = "Server", + Name = "rack-server", + Labels = { ["mac"] = _mac } + }; + + List incoming = [ScanCard(name: "host-xyz")]; + + DiscoveryIdResolver.ResolveNames([server], incoming); + + Assert.Equal("host-xyz", incoming[0].Name); + } + + [Fact] + public void A_hand_written_card_with_a_mac_label_is_adopted_like_a_name_match() { + // No id on the stored card: whoever arrives first with an identity stamps it. + var handWritten = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "nas01", + Type = "baremetal", + Labels = { ["mac"] = _mac } + }; + + List incoming = [ScanCard(name: "host-xyz")]; + + DiscoveryIdResolver.ResolveNames([handWritten], incoming); + + Assert.Equal("nas01", incoming[0].Name); + Assert.NotNull(incoming[0].DiscoveryId); // the scan id gets stamped on + } +} diff --git a/Tests.Discovery/NetworkDiscoveryMergeTests.cs b/Tests.Discovery/NetworkDiscoveryMergeTests.cs index f78bc6ef..4b13da37 100644 --- a/Tests.Discovery/NetworkDiscoveryMergeTests.cs +++ b/Tests.Discovery/NetworkDiscoveryMergeTests.cs @@ -66,9 +66,9 @@ public async Task A_dhcp_move_updates_the_address_of_the_same_machine() { [Fact] public async Task A_scan_never_steals_the_identity_of_an_agent_discovered_host() { - // nas01 already exists with a machine-id identity from `rpk discover system`. - // The scan sees the same box from outside and proposes the same name with a - // MAC identity — the resolver must keep them apart, not merge one over the other. + // nas01 exists with a machine-id identity but WITHOUT the macs label an agent + // records (an older agent, or a hand-stripped label) — so there is no MAC + // bridge, and the resolver must keep the cards apart rather than guess. var agentDiscovered = DiscoveryDocument.ToYaml([ new SystemResource { Kind = SystemResource.KindLabel, @@ -93,6 +93,76 @@ public async Task A_scan_never_steals_the_identity_of_an_agent_discovered_host() Assert.Contains("os: Debian", stored); // nothing on the original was touched } + [Fact] + public async Task An_agent_claims_a_scanned_card_and_every_collector_lands_on_it_after() { + // The unification headline, scan-first: the sweep found the box, then + // `rpk discover system` runs on it. Same MAC, so it is the same card — the + // agent's identity and detail land on the scan's card instead of duplicating. + using var api = new DiscoveryApiFixture(); + + await api.PublishAsync(ScanYaml(Nas())); + + SystemResource agent = SystemResourceMapper.ToResource(new SystemFacts { + Hostname = "nas01.lan", + MachineId = "machine-a", + Os = "Debian 12", + Cores = 12, + RamGb = 64, + Type = "baremetal", + Ip = "192.168.1.20", + Macs = ["dc:a6:32:0f:11:22"] + }); + + ImportYamlResponse claim = await api.PublishAsync(DiscoveryDocument.ToYaml([agent])); + + // Nothing added: the agent updated the scan's card (which keeps its name). + Assert.Empty(claim.Added); + Assert.Equal(["nas01"], claim.Updated); + + var stored = api.StoredYaml; + Assert.Contains("rpk1:sys:", stored); // identity upgraded to the agent's + Assert.DoesNotContain("rpk1:net:", stored); + Assert.Contains("os: Debian 12", stored); + Assert.Contains("mac: dc:a6:32:0f:11:22", stored); + Assert.Contains("macs: dc:a6:32:0f:11:22", stored); + Fixture.AssertConformsToSchema(stored); + + // ...and a rescan afterwards still lands on that same card via the MAC. + ImportYamlResponse rescan = await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.99"))); + + Assert.Empty(rescan.Added); + Assert.Contains("rpk1:sys:", api.StoredYaml); // never downgraded + Assert.Contains("ip: 192.168.1.99", api.StoredYaml); // but freshly addressed + } + + [Fact] + public async Task A_scan_enriches_an_agent_discovered_card_instead_of_duplicating_it() { + // The reverse order: the agent documented the box first, then a sweep sees it. + SystemResource agent = SystemResourceMapper.ToResource(new SystemFacts { + Hostname = "nas01", + MachineId = "machine-a", + Os = "Debian 12", + Cores = 12, + RamGb = 64, + Type = "baremetal", + Macs = ["dc:a6:32:0f:11:22"] + }); + + using var api = new DiscoveryApiFixture(DiscoveryDocument.ToYaml([agent])); + + ImportYamlResponse scan = await api.PublishAsync(ScanYaml(Nas())); + + Assert.Empty(scan.Added); + Assert.Equal(["nas01"], scan.Updated); + + var stored = api.StoredYaml; + Assert.Contains("rpk1:sys:", stored); // the agent identity is untouched + Assert.DoesNotContain("rpk1:net:", stored); + Assert.Contains("ip: 192.168.1.20", stored); // the scan contributed the address + Assert.Contains("os: Debian 12", stored); + Fixture.AssertConformsToSchema(stored); + } + [Fact] public async Task A_scan_adopts_a_hand_written_system_of_the_same_name() { // The inverse case: the user typed the card themselves, so it has no id yet. From 09226b3918145efb901557269cbc052ecac3c693 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 10:42:06 +0100 Subject: [PATCH 11/29] Bring Proxmox guests onto the MAC bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest's config already names the NIC MACs Proxmox assigned it — net0: virtio=BC:24:11:… for VMs, hwaddr=… for containers — in the very response the collector fetches for the OS and disks, so guests join the collector-unification bridge with zero extra API calls: the parser lifts every netN MAC (normalised by the shared ARP normaliser), the guest cards carry them as a macs label, and the resolver's existing rule does the rest. A VM found by a network sweep and the same guest reported by `rpk discover proxmox` are now one card, in either order, with the vmid identity winning over the scan's. Nodes stay outside the bridge (the API exposes no host MACs we read), and Proxmox-vs-agent-inside-the-guest remains two cards by design: vmid and machine-id are both agent-grade identities and MACs alone never unify those. 10 new tests: netN parsing across VM/container/dhcp configs and multi-NIC guests, non-MAC net lines yielding nothing, the macs label on mapped guest cards, and the scan↔proxmox unification e2e through the real server including the rescan round-trip. Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/ProxmoxModels.cs | 54 +++++++- .../Discovery/ProxmoxResourceMapper.cs | 9 +- .../Discovery/DiscoverProxmoxCommand.cs | 3 +- .../wwwroot/raw_docs/discovery-guide.md | 8 +- Tests.Discovery/ProxmoxMacBridgeTests.cs | 121 ++++++++++++++++++ 5 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 Tests.Discovery/ProxmoxMacBridgeTests.cs diff --git a/RackPeek.Domain/Discovery/ProxmoxModels.cs b/RackPeek.Domain/Discovery/ProxmoxModels.cs index c426988c..d23264c1 100644 --- a/RackPeek.Domain/Discovery/ProxmoxModels.cs +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -66,6 +66,12 @@ public sealed record ProxmoxGuest { public string? Os { get; init; } public string? Ip { get; init; } + + /// + /// The guest's NIC MACs, from its config's netN lines — the bridge that lets a + /// network scan and this collector agree they are looking at the same guest. + /// + public IReadOnlyList Macs { get; init; } = []; } /// @@ -218,15 +224,56 @@ 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(null, null, [], [], []); return new ProxmoxGuestConfig( DescribeOs(GetString(data, "ostype")), ParseStaticIp(GetString(data, "net0")), ParseDiskSizes(data), - ParsePassthrough(data)); + ParsePassthrough(data), + ParseMacs(data)); } + /// + /// The MACs in a guest's netN lines. QEMU spells them as the NIC model's value + /// (virtio=BC:24:11:…), containers as hwaddr=BC:24:11:… — so any + /// part whose value normalises to a MAC counts, and nothing else can (bridge + /// names, ip=, tags never survive normalisation). Normalised by the same code + /// that reads ARP tables, so a scan and this collector always agree. + /// + public static List ParseMacs(JsonElement config) { + var macs = new List(); + + foreach (JsonProperty property in config.EnumerateObject()) { + if (!IsNetSlot(property.Name)) + continue; + + var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null; + + if (value == null) + continue; + + foreach (var part in value.Split(',', StringSplitOptions.TrimEntries)) { + var separator = part.IndexOf('='); + + if (separator <= 0) + continue; + + var mac = ArpTableParser.NormaliseMac(part[(separator + 1)..]); + + if (mac != null) + macs.Add(mac); + } + } + + return macs.Distinct().ToList(); + } + + private static bool IsNetSlot(string key) => + key.StartsWith("net", StringComparison.OrdinalIgnoreCase) + && key.Length > 3 + && key[3..].All(char.IsAsciiDigit); + /// /// Every disk attached to a guest. The guest list only carries maxdisk, /// which is the boot disk alone — a VM with a small root and a large data volume @@ -424,4 +471,5 @@ public sealed record ProxmoxGuestConfig( string? Os, string? Ip, IReadOnlyList DiskBytes, - IReadOnlyList PassthroughAddresses); + IReadOnlyList PassthroughAddresses, + IReadOnlyList? Macs = null); diff --git a/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs b/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs index 018f7f21..1708fba9 100644 --- a/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs +++ b/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs @@ -146,6 +146,13 @@ private static SystemResource ToResource( // between nodes, which is exactly what an identity needs to do. var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}"); + Dictionary labels = PassthroughLabels(guest, gpusByNode); + + // The bridge to network discovery: a scan identifies this guest by one of + // these, so carrying them lets the resolver land both collectors on one card. + if (guest.Macs.Count > 0) + labels["macs"] = string.Join(",", guest.Macs); + return new SystemResource { Kind = SystemResource.KindLabel, Name = DiscoveryNaming.Unique( @@ -160,7 +167,7 @@ private static SystemResource ToResource( Ip = guest.Ip, Drives = ToGuestDrives(guest), Tags = guest.Tags.ToArray(), - Labels = PassthroughLabels(guest, gpusByNode), + Labels = labels, RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : [] }; } diff --git a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs index 2971c862..5103835e 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs @@ -131,7 +131,8 @@ private static async Task> ReadAsync( Os = configs[i].Os, Ip = configs[i].Ip, Disks = configs[i].DiskBytes, - PassthroughAddresses = configs[i].PassthroughAddresses + PassthroughAddresses = configs[i].PassthroughAddresses, + Macs = configs[i].Macs ?? [] }); } } diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 550b7645..a4f20723 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -390,8 +390,12 @@ cards — and agent-grade identities never unify with each other on a MAC alone VMs can share one; that is what machine-ids are for). The machine running the sweep finds itself, and unifies with its own `rpk discover system` card the same way. -Proxmox remains the exception: its API view carries no host MACs, so the known -limitation above still applies between `discover proxmox` and the other collectors. +`rpk discover proxmox` joins the bridge for **guests**: a guest's config names the +NIC MACs Proxmox assigned it, so a VM or container found by a sweep and the same guest +reported by the Proxmox collector become one card too. Nodes stay outside the bridge +(the API exposes no host MACs we read), and a guest documented both by Proxmox and by +`rpk discover system` *inside* it remains two cards — vmid and machine-id are both +agent-grade identities, and MACs alone never unify those. ### Being a good citizen diff --git a/Tests.Discovery/ProxmoxMacBridgeTests.cs b/Tests.Discovery/ProxmoxMacBridgeTests.cs new file mode 100644 index 00000000..b5f2c6d7 --- /dev/null +++ b/Tests.Discovery/ProxmoxMacBridgeTests.cs @@ -0,0 +1,121 @@ +using RackPeek.Domain.Api; +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Proxmox's side of the MAC bridge: guest configs carry the NIC MACs Proxmox +/// assigned, a network scan sees exactly those MACs on the wire, and the resolver +/// lands both collectors on one card — the same contract the system collector has. +/// +public class ProxmoxMacBridgeTests { + // -- parsing ------------------------------------------------------------------------ + + [Theory] + [InlineData("pve-qemu-config.json", "bc:24:11:12:34:56")] // virtio=BC:24:11:… + [InlineData("pve-lxc-config.json", "bc:24:11:aa:bb:cc")] // hwaddr=BC:24:11:… + [InlineData("pve-lxc-config-dhcp.json", "bc:24:11:dd:ee:ff")] // dhcp still has a MAC + public void A_guests_config_yields_its_normalised_mac(string fixture, string expected) { + ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read(fixture)); + + Assert.Equal([expected], config.Macs); + } + + [Theory] + [InlineData("""{"data":{}}""")] + [InlineData("""{"data":{"ostype":"l26","scsi0":"local-lvm:vm-1-disk-0,size=64G"}}""")] + [InlineData("""{"data":{"net0":"bridge=vmbr0,firewall=1"}}""")] // a net line with no MAC + [InlineData("""{"data":{"network":"virtio=BC:24:11:12:34:56"}}""")] // not a netN slot + public void A_config_without_nic_macs_yields_none(string json) { + ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(json); + + Assert.Empty(config.Macs ?? []); + } + + [Fact] + public void Every_nic_of_a_multi_homed_guest_is_recorded() { + ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig( + """ + {"data":{ + "net0":"virtio=BC:24:11:12:34:56,bridge=vmbr0", + "net1":"e1000=BC:24:11:99:88:77,bridge=vmbr1,tag=50" + }} + """); + + Assert.Equal(["bc:24:11:12:34:56", "bc:24:11:99:88:77"], config.Macs); + } + + // -- mapping ------------------------------------------------------------------------ + + [Fact] + public void A_guest_card_carries_its_macs_label() { + List resources = ProxmoxResourceMapper.ToResources( + "homelab", + [new ProxmoxNode { Name = "pve01" }], + [ + new ProxmoxGuest { + VmId = 104, + Node = "pve01", + Name = "docker-01", + Type = "vm", + Macs = ["bc:24:11:12:34:56"] + } + ]); + + SystemResource guest = resources.OfType().Single(r => r.Name == "docker-01"); + Assert.Equal("bc:24:11:12:34:56", guest.Labels["macs"]); + } + + // -- the bridge, end to end through the real server ---------------------------------- + + [Fact] + public async Task A_scanned_guest_and_its_proxmox_card_become_one() { + // The sweep found the VM on the LAN first — by the very MAC Proxmox assigned it. + var scanned = DiscoveryDocument.ToYaml(NetworkScanMapper.ToResources([ + new NetworkHostFact("192.168.1.178", "bc:24:11:12:34:56", null, true, []) + ])); + + using var api = new DiscoveryApiFixture(scanned); + + // Now `rpk discover proxmox` reports the estate, including that guest. + List estate = ProxmoxResourceMapper.ToResources( + "homelab", + [new ProxmoxNode { Name = "pve01" }], + [ + new ProxmoxGuest { + VmId = 104, + Node = "pve01", + Name = "docker-01", + Type = "vm", + Cores = 4, + Os = "Linux", + Macs = ["bc:24:11:12:34:56"] + } + ]); + + ImportYamlResponse response = await api.PublishAsync(DiscoveryDocument.ToYaml(estate)); + + var stored = api.StoredYaml; + + // The guest landed on the scan's card: identity upgraded to the vmid-based id, + // the scan's address kept, no duplicate for the same machine. + Assert.DoesNotContain("rpk1:net:", stored); + Assert.Contains("rpk1:pve:", stored); + Assert.Contains("ip: 192.168.1.178", stored); + Assert.Contains("os: Linux", stored); + Assert.DoesNotContain(response.Added, name => name.StartsWith("docker-01")); + Fixture.AssertConformsToSchema(stored); + + // And a rescan afterwards still lands on that same card via the MAC. + ImportYamlResponse rescan = await api.PublishAsync(DiscoveryDocument.ToYaml( + NetworkScanMapper.ToResources([ + new NetworkHostFact("192.168.1.179", "bc:24:11:12:34:56", null, true, []) + ]))); + + Assert.Empty(rescan.Added); + Assert.Contains("rpk1:pve:", api.StoredYaml); + Assert.Contains("ip: 192.168.1.179", api.StoredYaml); + } +} From 598b2231ff81aa1e244b761cd6682a79c892c7d9 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 11:40:25 +0100 Subject: [PATCH 12/29] Fix flaky YAML-import E2E: wait for the Blazor circuit before typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebUI job on PR #339 failed on YamlImportTests: the "Apply" button stayed disabled for the full 15s timeout. Same prerender race b8f6d23 fixed for the add form — YamlImportPom.GotoAsync navigated and returned as soon as the textarea was visible, so a FillAsync that landed before the circuit attached was lost to the static prerendered HTML. The oninput diff then never ran and Apply never enabled. CI lost this race intermittently; a fast machine won it, which is why it didn't reproduce on every run. GotoAsync now waits for MainLayout's data-circuit-ready probe (the same signal the add form waits on) before returning, so every subsequent PasteAsync reaches a live circuit. Added Importing_Works_Before_The_Circuit_Has_Warmed_Up, mirroring AddResourceRaceTests: 300ms injected SignalR latency widens the attach window so the race loses every time. Verified it fails (16s timeout, matching CI) with the wait removed and passes with it in place. Not an MCP regression — a pre-existing flake in a test added on staging (97e7efd) that this PR's CI run happened to trigger. Co-Authored-By: Claude Fable 5 --- Tests.E2e/PageObjectModels/YamlImportPom.cs | 6 ++++ Tests.E2e/YamlImportTests.cs | 40 +++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Tests.E2e/PageObjectModels/YamlImportPom.cs b/Tests.E2e/PageObjectModels/YamlImportPom.cs index 8b317eec..4480233a 100644 --- a/Tests.E2e/PageObjectModels/YamlImportPom.cs +++ b/Tests.E2e/PageObjectModels/YamlImportPom.cs @@ -48,6 +48,12 @@ public ILocator ConnectionsRemoved public async Task GotoAsync(string baseUrl) { await page.GotoAsync($"{baseUrl}/yaml/import"); await Assertions.Expect(Input).ToBeVisibleAsync(); + + // Never type into the prerendered page: input events are lost before the + // circuit attaches, so the oninput diff never runs and Apply stays disabled. + // Same race the Add form hit (b8f6d23) — wait for the live circuit first. + await Assertions.Expect(page.GetByTestId("circuit-probe")) + .ToHaveAttributeAsync("data-circuit-ready", "true"); } public async Task PasteAsync(string yaml) diff --git a/Tests.E2e/YamlImportTests.cs b/Tests.E2e/YamlImportTests.cs index 3d801c3e..005b1627 100644 --- a/Tests.E2e/YamlImportTests.cs +++ b/Tests.E2e/YamlImportTests.cs @@ -152,6 +152,46 @@ await import.PasteAsync(""" } } + // ============================================================= + // The prerender race (regression guard for the CI flake on #339) + // ============================================================= + + /// + /// The import page has the same prerender race the add form had (b8f6d23): + /// text filled before the circuit attaches never fires the oninput diff, so the + /// Apply button stays disabled forever. CI lost this race intermittently; the + /// injected latency loses it every time, proving GotoAsync's circuit wait fixes it. + /// + [Fact] + public async Task Importing_Works_Before_The_Circuit_Has_Warmed_Up() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + await BlazorLatency.AddAsync(page, TimeSpan.FromMilliseconds(300)); + + var switchA = $"e2e-ra-{Guid.NewGuid():N}"[..14]; + var switchB = $"e2e-rb-{Guid.NewGuid():N}"[..14]; + + try { + var import = new YamlImportPom(page); + await import.GotoAsync(_fixture.BaseUrl); + + await import.PasteAsync(TwoSwitchesWithConnection(switchA, switchB)); + + // The regression: without GotoAsync waiting for the circuit, the fill above + // is lost to the prerendered page, the diff never runs, and Apply stays + // disabled. Asserting it becomes enabled is the whole point — persistence is + // already covered by the non-latency test, and a post-apply navigation would + // only reintroduce flakiness under the injected latency. + await Assertions.Expect(import.ApplyButton).ToBeEnabledAsync(); + } + catch (Exception) { + await DumpAsync(page); + throw; + } + finally { + await context.CloseAsync(); + } + } + private async Task DumpAsync(IPage page) { _output.WriteLine("TEST FAILED — Capturing diagnostics"); _output.WriteLine($"Current URL: {page.Url}"); From 33a2a1a4cf479dd56281abea1ebd3417c50c6893 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 16:36:33 +0100 Subject: [PATCH 13/29] Prep the v2.2.0 release: bump versions, repair docs plumbing, add release guide - Bump version to 2.2.0 in RpkConstants, AssemblyVersion, and the README badge - Fix generate-docs.sh and the generate-docs workflow to write the real cli-commands{,-index}.md files with /docs/cli-commands#... links that resolve in the web docs viewer; regenerate the index - Delete stale pre-rename docs/Commands.md + docs/CommandIndex.md - Deduplicate versioning.md and correct the nightly branch (staging, not main) - Refresh overview.md/README prose (drop beta wording, mention discovery + MCP) - Update install-guide.md download URLs from the 0.0.3 release to 2.2.0 - Update publish workflow dispatch defaults to 2.2.0 / v2.2.0 - Add docs/development/release-guide.md documenting the end-to-end release flow Co-Authored-By: Claude Fable 5 --- .github/workflows/generate-docs.yaml | 2 +- .github/workflows/publish-cli.yml | 4 +- .github/workflows/publish-docker.yaml | 4 +- AGENTS.md | 5 +- README.md | 4 +- RackPeek.Domain/RpkConstants.cs | 2 +- RackPeek/RackPeek.csproj | 2 +- .../wwwroot/raw_docs/cli-commands-index.md | 518 +-- Shared.Rcl/wwwroot/raw_docs/install-guide.md | 4 +- Shared.Rcl/wwwroot/raw_docs/overview.md | 11 +- Shared.Rcl/wwwroot/raw_docs/versioning.md | 53 +- docs/CommandIndex.md | 243 - docs/Commands.md | 3999 ----------------- docs/development/release-guide.md | 87 + generate-docs.sh | 6 +- 15 files changed, 374 insertions(+), 4570 deletions(-) delete mode 100644 docs/CommandIndex.md delete mode 100644 docs/Commands.md create mode 100644 docs/development/release-guide.md diff --git a/.github/workflows/generate-docs.yaml b/.github/workflows/generate-docs.yaml index 811e890b..8f8358da 100644 --- a/.github/workflows/generate-docs.yaml +++ b/.github/workflows/generate-docs.yaml @@ -35,6 +35,6 @@ jobs: run: | git config user.name "github-actions" git config user.email "github-actions@github.com" - git add Shared.Rcl/wwwroot/raw_docs/CommandIndex.md Shared.Rcl/wwwroot/raw_docs/Commands.md + git add Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md Shared.Rcl/wwwroot/raw_docs/cli-commands.md git commit -m "Update CLI docs" || echo "No changes to commit" git push diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 7eabd111..930f002c 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: version: - description: "RackPeek version (e.g. 0.1.0)" + description: "RackPeek version (e.g. 2.2.0)" required: true - default: "0.0.3" + default: "2.2.0" permissions: contents: write diff --git a/.github/workflows/publish-docker.yaml b/.github/workflows/publish-docker.yaml index 9869335c..574896a9 100644 --- a/.github/workflows/publish-docker.yaml +++ b/.github/workflows/publish-docker.yaml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: version: - description: "RackPeek version (e.g. v1.0.0)" + description: "RackPeek version (e.g. v2.2.0)" required: true - default: "v0.0.12" + default: "v2.2.0" permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index b74015a6..ee70eb78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ When you change persisted YAML shape, the PR **must** include: ## 7. CLI surface -The full command tree is documented in `docs/Commands.md` and `docs/CommandIndex.md` (auto-generated by `generate-docs.sh`). At a glance: +The full command tree is documented in `Shared.Rcl/wwwroot/raw_docs/cli-commands.md` and `Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md` (auto-generated by `generate-docs.sh`). At a glance: ``` rpk [name] [flags] @@ -311,8 +311,9 @@ Default branches: feature work targets `staging`; releases flow `staging → mai | `docs/development/contribution-guidelines.md` | PR process | | `docs/development/dev-cheat-sheet.md` | Build / release / Docker / Playwright details | | `docs/development/dev-setup.md` | First-time environment setup | +| `docs/development/release-guide.md` | End-to-end release process (version bump, docs regen, publish workflows) | | `docs/development/testing-guidelines.md` | Testing philosophy + examples | -| `docs/Commands.md` / `docs/CommandIndex.md` | Auto-generated CLI reference | +| `Shared.Rcl/wwwroot/raw_docs/cli-commands.md` / `cli-commands-index.md` | Auto-generated CLI reference | | `schemas/v1,v2,v3/` | Versioned YAML schemas | | `README.md` | User-facing overview, Docker install, links | | `LICENSE` | License terms | diff --git a/README.md b/README.md index 31fdfb3f..45367ee1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ [![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png) -![Version](https://img.shields.io/badge/Version-2.1.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success) +![Version](https://img.shields.io/badge/Version-2.2.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success) [![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/) RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure. -It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process. +It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process. It can auto-discover what's already running (`rpk discover system / docker / proxmox / network`), and its built-in MCP server lets AI assistants query and manage your inventory. ### The roadmap for the next wave of features is actively being discussed, please make your voice heard! diff --git a/RackPeek.Domain/RpkConstants.cs b/RackPeek.Domain/RpkConstants.cs index 12c50217..5dd64848 100644 --- a/RackPeek.Domain/RpkConstants.cs +++ b/RackPeek.Domain/RpkConstants.cs @@ -1,7 +1,7 @@ namespace RackPeek.Domain; public static class RpkConstants { - public const string Version = "v2.1.0"; + public const string Version = "v2.2.0"; public static bool HasGitServices { get; set; } } diff --git a/RackPeek/RackPeek.csproj b/RackPeek/RackPeek.csproj index 28eea81d..716f3cec 100644 --- a/RackPeek/RackPeek.csproj +++ b/RackPeek/RackPeek.csproj @@ -5,7 +5,7 @@ net10.0 enable enable - 2.1.0 + 2.2.0 diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index 2c670418..915ff824 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md @@ -1,260 +1,260 @@ -- [rpk](docs/Commands.md#rpk) - - [summary](docs/Commands.md#rpk-summary) - Show a summarized report of all resources in the system - - [servers](docs/Commands.md#rpk-servers) - Manage servers and their components - - [summary](docs/Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers - - [add](docs/Commands.md#rpk-servers-add) - Add a new server to the inventory - - [get](docs/Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name - - [describe](docs/Commands.md#rpk-servers-describe) - Display detailed information about a specific server - - [set](docs/Commands.md#rpk-servers-set) - Update properties of an existing server - - [del](docs/Commands.md#rpk-servers-del) - Delete a server from the inventory - - [rename](docs/Commands.md#rpk-servers-rename) - Rename a server to a new name - - [tree](docs/Commands.md#rpk-servers-tree) - Display the dependency tree of a server - - [cpu](docs/Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server - - [add](docs/Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server - - [set](docs/Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU - - [del](docs/Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server - - [drive](docs/Commands.md#rpk-servers-drive) - Manage drives attached to a server - - [add](docs/Commands.md#rpk-servers-drive-add) - Add a storage drive to a server - - [set](docs/Commands.md#rpk-servers-drive-set) - Update properties of a server drive - - [del](docs/Commands.md#rpk-servers-drive-del) - Remove a drive from a server - - [gpu](docs/Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server - - [add](docs/Commands.md#rpk-servers-gpu-add) - Add a GPU to a server - - [set](docs/Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU - - [del](docs/Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server - - [nic](docs/Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server - - [add](docs/Commands.md#rpk-servers-nic-add) - Add a NIC to a server - - [set](docs/Commands.md#rpk-servers-nic-set) - Update properties of a server NIC - - [del](docs/Commands.md#rpk-servers-nic-del) - Remove a NIC from a server - - [label](docs/Commands.md#rpk-servers-label) - Manage labels on a server - - [add](docs/Commands.md#rpk-servers-label-add) - Add a label to a server - - [remove](docs/Commands.md#rpk-servers-label-remove) - Remove a label from a server - - [tag](docs/Commands.md#rpk-servers-tag) - Manage tags on a server - - [add](docs/Commands.md#rpk-servers-tag-add) - Add a tag to a server - - [remove](docs/Commands.md#rpk-servers-tag-remove) - Remove a tag from a server - - [switches](docs/Commands.md#rpk-switches) - Manage network switches - - [summary](docs/Commands.md#rpk-switches-summary) - Show a hardware report for all switches - - [add](docs/Commands.md#rpk-switches-add) - Add a new network switch to the inventory - - [list](docs/Commands.md#rpk-switches-list) - List all switches in the system - - [get](docs/Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name - - [describe](docs/Commands.md#rpk-switches-describe) - Show detailed information about a switch - - [set](docs/Commands.md#rpk-switches-set) - Update properties of a switch - - [del](docs/Commands.md#rpk-switches-del) - Delete a switch from the inventory - - [rename](docs/Commands.md#rpk-switches-rename) - Rename a switch to a new name - - [port](docs/Commands.md#rpk-switches-port) - Manage ports on a network switch - - [add](docs/Commands.md#rpk-switches-port-add) - Add a port to a switch - - [set](docs/Commands.md#rpk-switches-port-set) - Update a switch port - - [del](docs/Commands.md#rpk-switches-port-del) - Remove a port from a switch - - [label](docs/Commands.md#rpk-switches-label) - Manage labels on a switch - - [add](docs/Commands.md#rpk-switches-label-add) - Add a label to a switch - - [remove](docs/Commands.md#rpk-switches-label-remove) - Remove a label from a switch - - [tag](docs/Commands.md#rpk-switches-tag) - Manage tags on a switch - - [add](docs/Commands.md#rpk-switches-tag-add) - Add a tag to a switch - - [remove](docs/Commands.md#rpk-switches-tag-remove) - Remove a tag from a switch - - [routers](docs/Commands.md#rpk-routers) - Manage network routers - - [summary](docs/Commands.md#rpk-routers-summary) - Show a hardware report for all routers - - [add](docs/Commands.md#rpk-routers-add) - Add a new network router to the inventory - - [list](docs/Commands.md#rpk-routers-list) - List all routers in the system - - [get](docs/Commands.md#rpk-routers-get) - Retrieve details of a specific router by name - - [describe](docs/Commands.md#rpk-routers-describe) - Show detailed information about a router - - [set](docs/Commands.md#rpk-routers-set) - Update properties of a router - - [del](docs/Commands.md#rpk-routers-del) - Delete a router from the inventory - - [rename](docs/Commands.md#rpk-routers-rename) - Rename a router to a new name - - [port](docs/Commands.md#rpk-routers-port) - Manage ports on a router - - [add](docs/Commands.md#rpk-routers-port-add) - Add a port to a router - - [set](docs/Commands.md#rpk-routers-port-set) - Update a router port - - [del](docs/Commands.md#rpk-routers-port-del) - Remove a port from a router - - [label](docs/Commands.md#rpk-routers-label) - Manage labels on a router - - [add](docs/Commands.md#rpk-routers-label-add) - Add a label to a router - - [remove](docs/Commands.md#rpk-routers-label-remove) - Remove a label from a router - - [tag](docs/Commands.md#rpk-routers-tag) - Manage tags on a router - - [add](docs/Commands.md#rpk-routers-tag-add) - Add a tag to a router - - [remove](docs/Commands.md#rpk-routers-tag-remove) - Remove a tag from a router - - [firewalls](docs/Commands.md#rpk-firewalls) - Manage firewalls - - [summary](docs/Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls - - [add](docs/Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory - - [list](docs/Commands.md#rpk-firewalls-list) - List all firewalls in the system - - [get](docs/Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name - - [describe](docs/Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall - - [set](docs/Commands.md#rpk-firewalls-set) - Update properties of a firewall - - [del](docs/Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory - - [rename](docs/Commands.md#rpk-firewalls-rename) - Rename a firewall to a new name - - [port](docs/Commands.md#rpk-firewalls-port) - Manage ports on a firewall - - [add](docs/Commands.md#rpk-firewalls-port-add) - Add a port to a firewall - - [set](docs/Commands.md#rpk-firewalls-port-set) - Update a firewall port - - [del](docs/Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall - - [label](docs/Commands.md#rpk-firewalls-label) - Manage labels on a firewall - - [add](docs/Commands.md#rpk-firewalls-label-add) - Add a label to a firewall - - [remove](docs/Commands.md#rpk-firewalls-label-remove) - Remove a label from a firewall - - [tag](docs/Commands.md#rpk-firewalls-tag) - Manage tags on a firewall - - [add](docs/Commands.md#rpk-firewalls-tag-add) - Add a tag to a firewall - - [remove](docs/Commands.md#rpk-firewalls-tag-remove) - Remove a tag from a firewall - - [systems](docs/Commands.md#rpk-systems) - Manage systems and their dependencies - - [summary](docs/Commands.md#rpk-systems-summary) - Show a summary report for all systems - - [add](docs/Commands.md#rpk-systems-add) - Add a new system to the inventory - - [list](docs/Commands.md#rpk-systems-list) - List all systems - - [get](docs/Commands.md#rpk-systems-get) - Retrieve a system by name - - [describe](docs/Commands.md#rpk-systems-describe) - Display detailed information about a system - - [set](docs/Commands.md#rpk-systems-set) - Update properties of a system - - [del](docs/Commands.md#rpk-systems-del) - Delete a system from the inventory - - [rename](docs/Commands.md#rpk-systems-rename) - Rename a system to a new name - - [tree](docs/Commands.md#rpk-systems-tree) - Display the dependency tree for a system - - [label](docs/Commands.md#rpk-systems-label) - Manage labels on a system - - [add](docs/Commands.md#rpk-systems-label-add) - Add a label to a system - - [remove](docs/Commands.md#rpk-systems-label-remove) - Remove a label from a system - - [tag](docs/Commands.md#rpk-systems-tag) - Manage tags on a system - - [add](docs/Commands.md#rpk-systems-tag-add) - Add a tag to a system - - [remove](docs/Commands.md#rpk-systems-tag-remove) - Remove a tag from a system - - [accesspoints](docs/Commands.md#rpk-accesspoints) - Manage access points - - [summary](docs/Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points - - [add](docs/Commands.md#rpk-accesspoints-add) - Add a new access point - - [list](docs/Commands.md#rpk-accesspoints-list) - List all access points - - [get](docs/Commands.md#rpk-accesspoints-get) - Retrieve an access point by name - - [describe](docs/Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point - - [set](docs/Commands.md#rpk-accesspoints-set) - Update properties of an access point - - [del](docs/Commands.md#rpk-accesspoints-del) - Delete an access point - - [rename](docs/Commands.md#rpk-accesspoints-rename) - Rename an access point to a new name - - [label](docs/Commands.md#rpk-accesspoints-label) - Manage labels on an access point - - [add](docs/Commands.md#rpk-accesspoints-label-add) - Add a label to an access point - - [remove](docs/Commands.md#rpk-accesspoints-label-remove) - Remove a label from an access point - - [tag](docs/Commands.md#rpk-accesspoints-tag) - Manage tags on an access point - - [add](docs/Commands.md#rpk-accesspoints-tag-add) - Add a tag to an access point - - [remove](docs/Commands.md#rpk-accesspoints-tag-remove) - Remove a tag from an access point - - [ups](docs/Commands.md#rpk-ups) - Manage UPS units - - [summary](docs/Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units - - [add](docs/Commands.md#rpk-ups-add) - Add a new UPS unit - - [list](docs/Commands.md#rpk-ups-list) - List all UPS units - - [get](docs/Commands.md#rpk-ups-get) - Retrieve a UPS unit by name - - [describe](docs/Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit - - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit - - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit - - [rename](docs/Commands.md#rpk-ups-rename) - Rename a UPS unit to a new name - - [port](docs/Commands.md#rpk-ups-port) - Manage ports on a UPS unit - - [add](docs/Commands.md#rpk-ups-port-add) - Add a port to a UPS unit - - [set](docs/Commands.md#rpk-ups-port-set) - Update a UPS unit port - - [del](docs/Commands.md#rpk-ups-port-del) - Remove a port from a UPS unit - - [label](docs/Commands.md#rpk-ups-label) - Manage labels on a UPS unit - - [add](docs/Commands.md#rpk-ups-label-add) - Add a label to a UPS unit - - [remove](docs/Commands.md#rpk-ups-label-remove) - Remove a label from a UPS unit - - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit - - [add](docs/Commands.md#rpk-ups-tag-add) - Add a tag to a UPS unit - - [remove](docs/Commands.md#rpk-ups-tag-remove) - Remove a tag from a UPS unit - - [other](docs/Commands.md#rpk-other) - Manage other hardware that doesn't fit an existing category - - [summary](docs/Commands.md#rpk-other-summary) - Show a hardware report for all other hardware - - [add](docs/Commands.md#rpk-other-add) - Add new other hardware - - [list](docs/Commands.md#rpk-other-list) - List all other hardware - - [get](docs/Commands.md#rpk-other-get) - Retrieve other hardware by name - - [describe](docs/Commands.md#rpk-other-describe) - Show detailed information about other hardware - - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware - - [del](docs/Commands.md#rpk-other-del) - Delete other hardware - - [rename](docs/Commands.md#rpk-other-rename) - Rename other hardware to a new name - - [port](docs/Commands.md#rpk-other-port) - Manage ports on other hardware - - [add](docs/Commands.md#rpk-other-port-add) - Add a port to other hardware - - [set](docs/Commands.md#rpk-other-port-set) - Update an other hardware port - - [del](docs/Commands.md#rpk-other-port-del) - Remove a port from other hardware - - [label](docs/Commands.md#rpk-other-label) - Manage labels on other hardware - - [add](docs/Commands.md#rpk-other-label-add) - Add a label to other hardware - - [remove](docs/Commands.md#rpk-other-label-remove) - Remove a label from other hardware - - [tag](docs/Commands.md#rpk-other-tag) - Manage tags on other hardware - - [add](docs/Commands.md#rpk-other-tag-add) - Add a tag to other hardware - - [remove](docs/Commands.md#rpk-other-tag-remove) - Remove a tag from other hardware - - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components - - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop - - [list](docs/Commands.md#rpk-desktops-list) - List all desktops - - [get](docs/Commands.md#rpk-desktops-get) - Retrieve a desktop by name - - [describe](docs/Commands.md#rpk-desktops-describe) - Show detailed information about a desktop - - [set](docs/Commands.md#rpk-desktops-set) - Update properties of a desktop - - [del](docs/Commands.md#rpk-desktops-del) - Delete a desktop from the inventory - - [rename](docs/Commands.md#rpk-desktops-rename) - Rename a desktop to a new name - - [summary](docs/Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops - - [tree](docs/Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop - - [cpu](docs/Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops - - [add](docs/Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop - - [set](docs/Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU - - [del](docs/Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop - - [drive](docs/Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops - - [add](docs/Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop - - [set](docs/Commands.md#rpk-desktops-drive-set) - Update a desktop drive - - [del](docs/Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop - - [gpu](docs/Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops - - [add](docs/Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop - - [set](docs/Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU - - [del](docs/Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop - - [nic](docs/Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops - - [add](docs/Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop - - [set](docs/Commands.md#rpk-desktops-nic-set) - Update a desktop NIC - - [del](docs/Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop - - [label](docs/Commands.md#rpk-desktops-label) - Manage labels on a desktop - - [add](docs/Commands.md#rpk-desktops-label-add) - Add a label to a desktop - - [remove](docs/Commands.md#rpk-desktops-label-remove) - Remove a label from a desktop - - [tag](docs/Commands.md#rpk-desktops-tag) - Manage tags on a desktop - - [add](docs/Commands.md#rpk-desktops-tag-add) - Add a tag to a desktop - - [remove](docs/Commands.md#rpk-desktops-tag-remove) - Remove a tag from a desktop - - [laptops](docs/Commands.md#rpk-laptops) - Manage Laptop computers and their components - - [add](docs/Commands.md#rpk-laptops-add) - Add a new Laptop - - [list](docs/Commands.md#rpk-laptops-list) - List all Laptops - - [get](docs/Commands.md#rpk-laptops-get) - Retrieve a Laptop by name - - [describe](docs/Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop - - [set](docs/Commands.md#rpk-laptops-set) - Update properties of a laptop - - [del](docs/Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory - - [rename](docs/Commands.md#rpk-laptops-rename) - Rename a Laptop to a new name - - [summary](docs/Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops - - [tree](docs/Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop - - [cpu](docs/Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops - - [add](docs/Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop - - [set](docs/Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU - - [del](docs/Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop - - [drive](docs/Commands.md#rpk-laptops-drive) - Manage storage drives attached to Laptops - - [add](docs/Commands.md#rpk-laptops-drive-add) - Add a drive to a Laptop - - [set](docs/Commands.md#rpk-laptops-drive-set) - Update a Laptop drive - - [del](docs/Commands.md#rpk-laptops-drive-del) - Remove a drive from a Laptop - - [gpu](docs/Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops - - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop - - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU - - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop - - [nic](docs/Commands.md#rpk-laptops-nic) - Manage network interface cards (NICs) for Laptops - - [add](docs/Commands.md#rpk-laptops-nic-add) - Add a NIC to a Laptop - - [set](docs/Commands.md#rpk-laptops-nic-set) - Update a Laptop NIC - - [del](docs/Commands.md#rpk-laptops-nic-del) - Remove a NIC from a Laptop - - [label](docs/Commands.md#rpk-laptops-label) - Manage labels on a laptop - - [add](docs/Commands.md#rpk-laptops-label-add) - Add a label to a laptop - - [remove](docs/Commands.md#rpk-laptops-label-remove) - Remove a label from a laptop - - [tag](docs/Commands.md#rpk-laptops-tag) - Manage tags on a laptop - - [add](docs/Commands.md#rpk-laptops-tag-add) - Add a tag to a laptop - - [remove](docs/Commands.md#rpk-laptops-tag-remove) - Remove a tag from a laptop - - [services](docs/Commands.md#rpk-services) - Manage services and their configurations - - [summary](docs/Commands.md#rpk-services-summary) - Show a summary report for all services - - [add](docs/Commands.md#rpk-services-add) - Add a new service - - [list](docs/Commands.md#rpk-services-list) - List all services - - [get](docs/Commands.md#rpk-services-get) - Retrieve a service by name - - [describe](docs/Commands.md#rpk-services-describe) - Show detailed information about a service - - [set](docs/Commands.md#rpk-services-set) - Update properties of a service - - [del](docs/Commands.md#rpk-services-del) - Delete a service - - [rename](docs/Commands.md#rpk-services-rename) - Rename a service to a new name - - [subnets](docs/Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered by CIDR - - [label](docs/Commands.md#rpk-services-label) - Manage labels on a service - - [add](docs/Commands.md#rpk-services-label-add) - Add a label to a service - - [remove](docs/Commands.md#rpk-services-label-remove) - Remove a label from a service - - [tag](docs/Commands.md#rpk-services-tag) - Manage tags on a service - - [add](docs/Commands.md#rpk-services-tag-add) - Add a tag to a service - - [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 - - [network](docs/Commands.md#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource - - [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 - - [export](docs/Commands.md#rpk-ssh-export) - Generate an SSH config file - - [hosts](docs/Commands.md#rpk-hosts) - Generate a hosts file from infrastructure - - [export](docs/Commands.md#rpk-hosts-export) - Generate a /etc/hosts compatible file - - [graph](docs/Commands.md#rpk-graph) - Render inventory as graph diagrams - - [topology](docs/Commands.md#rpk-graph-topology) - Emit a Mermaid flowchart of the physical topology (hardware + connections) - - [logical](docs/Commands.md#rpk-graph-logical) - Emit a Mermaid flowchart of services & systems grouped by subnet and host - - [tags](docs/Commands.md#rpk-tags) - Discover tags across resources - - [list](docs/Commands.md#rpk-tags-list) - List all tags in use with usage counts - - [show](docs/Commands.md#rpk-tags-show) - List resources carrying a specific tag - - [connections](docs/Commands.md#rpk-connections) - Manage physical or logical port connections - - [add](docs/Commands.md#rpk-connections-add) - Create a connection between two ports - - [remove](docs/Commands.md#rpk-connections-remove) - Remove the connection from a specific port +- [rpk](/docs/cli-commands#rpk) + - [summary](/docs/cli-commands#rpk-summary) - Show a summarized report of all resources in the system + - [servers](/docs/cli-commands#rpk-servers) - Manage servers and their components + - [summary](/docs/cli-commands#rpk-servers-summary) - Show a summarized hardware report for all servers + - [add](/docs/cli-commands#rpk-servers-add) - Add a new server to the inventory + - [get](/docs/cli-commands#rpk-servers-get) - List all servers or retrieve a specific server by name + - [describe](/docs/cli-commands#rpk-servers-describe) - Display detailed information about a specific server + - [set](/docs/cli-commands#rpk-servers-set) - Update properties of an existing server + - [del](/docs/cli-commands#rpk-servers-del) - Delete a server from the inventory + - [rename](/docs/cli-commands#rpk-servers-rename) - Rename a server to a new name + - [tree](/docs/cli-commands#rpk-servers-tree) - Display the dependency tree of a server + - [cpu](/docs/cli-commands#rpk-servers-cpu) - Manage CPUs attached to a server + - [add](/docs/cli-commands#rpk-servers-cpu-add) - Add a CPU to a specific server + - [set](/docs/cli-commands#rpk-servers-cpu-set) - Update configuration of a server CPU + - [del](/docs/cli-commands#rpk-servers-cpu-del) - Remove a CPU from a server + - [drive](/docs/cli-commands#rpk-servers-drive) - Manage drives attached to a server + - [add](/docs/cli-commands#rpk-servers-drive-add) - Add a storage drive to a server + - [set](/docs/cli-commands#rpk-servers-drive-set) - Update properties of a server drive + - [del](/docs/cli-commands#rpk-servers-drive-del) - Remove a drive from a server + - [gpu](/docs/cli-commands#rpk-servers-gpu) - Manage GPUs attached to a server + - [add](/docs/cli-commands#rpk-servers-gpu-add) - Add a GPU to a server + - [set](/docs/cli-commands#rpk-servers-gpu-set) - Update properties of a server GPU + - [del](/docs/cli-commands#rpk-servers-gpu-del) - Remove a GPU from a server + - [nic](/docs/cli-commands#rpk-servers-nic) - Manage network interface cards (NICs) for a server + - [add](/docs/cli-commands#rpk-servers-nic-add) - Add a NIC to a server + - [set](/docs/cli-commands#rpk-servers-nic-set) - Update properties of a server NIC + - [del](/docs/cli-commands#rpk-servers-nic-del) - Remove a NIC from a server + - [label](/docs/cli-commands#rpk-servers-label) - Manage labels on a server + - [add](/docs/cli-commands#rpk-servers-label-add) - Add a label to a server + - [remove](/docs/cli-commands#rpk-servers-label-remove) - Remove a label from a server + - [tag](/docs/cli-commands#rpk-servers-tag) - Manage tags on a server + - [add](/docs/cli-commands#rpk-servers-tag-add) - Add a tag to a server + - [remove](/docs/cli-commands#rpk-servers-tag-remove) - Remove a tag from a server + - [switches](/docs/cli-commands#rpk-switches) - Manage network switches + - [summary](/docs/cli-commands#rpk-switches-summary) - Show a hardware report for all switches + - [add](/docs/cli-commands#rpk-switches-add) - Add a new network switch to the inventory + - [list](/docs/cli-commands#rpk-switches-list) - List all switches in the system + - [get](/docs/cli-commands#rpk-switches-get) - Retrieve details of a specific switch by name + - [describe](/docs/cli-commands#rpk-switches-describe) - Show detailed information about a switch + - [set](/docs/cli-commands#rpk-switches-set) - Update properties of a switch + - [del](/docs/cli-commands#rpk-switches-del) - Delete a switch from the inventory + - [rename](/docs/cli-commands#rpk-switches-rename) - Rename a switch to a new name + - [port](/docs/cli-commands#rpk-switches-port) - Manage ports on a network switch + - [add](/docs/cli-commands#rpk-switches-port-add) - Add a port to a switch + - [set](/docs/cli-commands#rpk-switches-port-set) - Update a switch port + - [del](/docs/cli-commands#rpk-switches-port-del) - Remove a port from a switch + - [label](/docs/cli-commands#rpk-switches-label) - Manage labels on a switch + - [add](/docs/cli-commands#rpk-switches-label-add) - Add a label to a switch + - [remove](/docs/cli-commands#rpk-switches-label-remove) - Remove a label from a switch + - [tag](/docs/cli-commands#rpk-switches-tag) - Manage tags on a switch + - [add](/docs/cli-commands#rpk-switches-tag-add) - Add a tag to a switch + - [remove](/docs/cli-commands#rpk-switches-tag-remove) - Remove a tag from a switch + - [routers](/docs/cli-commands#rpk-routers) - Manage network routers + - [summary](/docs/cli-commands#rpk-routers-summary) - Show a hardware report for all routers + - [add](/docs/cli-commands#rpk-routers-add) - Add a new network router to the inventory + - [list](/docs/cli-commands#rpk-routers-list) - List all routers in the system + - [get](/docs/cli-commands#rpk-routers-get) - Retrieve details of a specific router by name + - [describe](/docs/cli-commands#rpk-routers-describe) - Show detailed information about a router + - [set](/docs/cli-commands#rpk-routers-set) - Update properties of a router + - [del](/docs/cli-commands#rpk-routers-del) - Delete a router from the inventory + - [rename](/docs/cli-commands#rpk-routers-rename) - Rename a router to a new name + - [port](/docs/cli-commands#rpk-routers-port) - Manage ports on a router + - [add](/docs/cli-commands#rpk-routers-port-add) - Add a port to a router + - [set](/docs/cli-commands#rpk-routers-port-set) - Update a router port + - [del](/docs/cli-commands#rpk-routers-port-del) - Remove a port from a router + - [label](/docs/cli-commands#rpk-routers-label) - Manage labels on a router + - [add](/docs/cli-commands#rpk-routers-label-add) - Add a label to a router + - [remove](/docs/cli-commands#rpk-routers-label-remove) - Remove a label from a router + - [tag](/docs/cli-commands#rpk-routers-tag) - Manage tags on a router + - [add](/docs/cli-commands#rpk-routers-tag-add) - Add a tag to a router + - [remove](/docs/cli-commands#rpk-routers-tag-remove) - Remove a tag from a router + - [firewalls](/docs/cli-commands#rpk-firewalls) - Manage firewalls + - [summary](/docs/cli-commands#rpk-firewalls-summary) - Show a hardware report for all firewalls + - [add](/docs/cli-commands#rpk-firewalls-add) - Add a new firewall to the inventory + - [list](/docs/cli-commands#rpk-firewalls-list) - List all firewalls in the system + - [get](/docs/cli-commands#rpk-firewalls-get) - Retrieve details of a specific firewall by name + - [describe](/docs/cli-commands#rpk-firewalls-describe) - Show detailed information about a firewall + - [set](/docs/cli-commands#rpk-firewalls-set) - Update properties of a firewall + - [del](/docs/cli-commands#rpk-firewalls-del) - Delete a firewall from the inventory + - [rename](/docs/cli-commands#rpk-firewalls-rename) - Rename a firewall to a new name + - [port](/docs/cli-commands#rpk-firewalls-port) - Manage ports on a firewall + - [add](/docs/cli-commands#rpk-firewalls-port-add) - Add a port to a firewall + - [set](/docs/cli-commands#rpk-firewalls-port-set) - Update a firewall port + - [del](/docs/cli-commands#rpk-firewalls-port-del) - Remove a port from a firewall + - [label](/docs/cli-commands#rpk-firewalls-label) - Manage labels on a firewall + - [add](/docs/cli-commands#rpk-firewalls-label-add) - Add a label to a firewall + - [remove](/docs/cli-commands#rpk-firewalls-label-remove) - Remove a label from a firewall + - [tag](/docs/cli-commands#rpk-firewalls-tag) - Manage tags on a firewall + - [add](/docs/cli-commands#rpk-firewalls-tag-add) - Add a tag to a firewall + - [remove](/docs/cli-commands#rpk-firewalls-tag-remove) - Remove a tag from a firewall + - [systems](/docs/cli-commands#rpk-systems) - Manage systems and their dependencies + - [summary](/docs/cli-commands#rpk-systems-summary) - Show a summary report for all systems + - [add](/docs/cli-commands#rpk-systems-add) - Add a new system to the inventory + - [list](/docs/cli-commands#rpk-systems-list) - List all systems + - [get](/docs/cli-commands#rpk-systems-get) - Retrieve a system by name + - [describe](/docs/cli-commands#rpk-systems-describe) - Display detailed information about a system + - [set](/docs/cli-commands#rpk-systems-set) - Update properties of a system + - [del](/docs/cli-commands#rpk-systems-del) - Delete a system from the inventory + - [rename](/docs/cli-commands#rpk-systems-rename) - Rename a system to a new name + - [tree](/docs/cli-commands#rpk-systems-tree) - Display the dependency tree for a system + - [label](/docs/cli-commands#rpk-systems-label) - Manage labels on a system + - [add](/docs/cli-commands#rpk-systems-label-add) - Add a label to a system + - [remove](/docs/cli-commands#rpk-systems-label-remove) - Remove a label from a system + - [tag](/docs/cli-commands#rpk-systems-tag) - Manage tags on a system + - [add](/docs/cli-commands#rpk-systems-tag-add) - Add a tag to a system + - [remove](/docs/cli-commands#rpk-systems-tag-remove) - Remove a tag from a system + - [accesspoints](/docs/cli-commands#rpk-accesspoints) - Manage access points + - [summary](/docs/cli-commands#rpk-accesspoints-summary) - Show a hardware report for all access points + - [add](/docs/cli-commands#rpk-accesspoints-add) - Add a new access point + - [list](/docs/cli-commands#rpk-accesspoints-list) - List all access points + - [get](/docs/cli-commands#rpk-accesspoints-get) - Retrieve an access point by name + - [describe](/docs/cli-commands#rpk-accesspoints-describe) - Show detailed information about an access point + - [set](/docs/cli-commands#rpk-accesspoints-set) - Update properties of an access point + - [del](/docs/cli-commands#rpk-accesspoints-del) - Delete an access point + - [rename](/docs/cli-commands#rpk-accesspoints-rename) - Rename an access point to a new name + - [label](/docs/cli-commands#rpk-accesspoints-label) - Manage labels on an access point + - [add](/docs/cli-commands#rpk-accesspoints-label-add) - Add a label to an access point + - [remove](/docs/cli-commands#rpk-accesspoints-label-remove) - Remove a label from an access point + - [tag](/docs/cli-commands#rpk-accesspoints-tag) - Manage tags on an access point + - [add](/docs/cli-commands#rpk-accesspoints-tag-add) - Add a tag to an access point + - [remove](/docs/cli-commands#rpk-accesspoints-tag-remove) - Remove a tag from an access point + - [ups](/docs/cli-commands#rpk-ups) - Manage UPS units + - [summary](/docs/cli-commands#rpk-ups-summary) - Show a hardware report for all UPS units + - [add](/docs/cli-commands#rpk-ups-add) - Add a new UPS unit + - [list](/docs/cli-commands#rpk-ups-list) - List all UPS units + - [get](/docs/cli-commands#rpk-ups-get) - Retrieve a UPS unit by name + - [describe](/docs/cli-commands#rpk-ups-describe) - Show detailed information about a UPS unit + - [set](/docs/cli-commands#rpk-ups-set) - Update properties of a UPS unit + - [del](/docs/cli-commands#rpk-ups-del) - Delete a UPS unit + - [rename](/docs/cli-commands#rpk-ups-rename) - Rename a UPS unit to a new name + - [port](/docs/cli-commands#rpk-ups-port) - Manage ports on a UPS unit + - [add](/docs/cli-commands#rpk-ups-port-add) - Add a port to a UPS unit + - [set](/docs/cli-commands#rpk-ups-port-set) - Update a UPS unit port + - [del](/docs/cli-commands#rpk-ups-port-del) - Remove a port from a UPS unit + - [label](/docs/cli-commands#rpk-ups-label) - Manage labels on a UPS unit + - [add](/docs/cli-commands#rpk-ups-label-add) - Add a label to a UPS unit + - [remove](/docs/cli-commands#rpk-ups-label-remove) - Remove a label from a UPS unit + - [tag](/docs/cli-commands#rpk-ups-tag) - Manage tags on a UPS unit + - [add](/docs/cli-commands#rpk-ups-tag-add) - Add a tag to a UPS unit + - [remove](/docs/cli-commands#rpk-ups-tag-remove) - Remove a tag from a UPS unit + - [other](/docs/cli-commands#rpk-other) - Manage other hardware that doesn't fit an existing category + - [summary](/docs/cli-commands#rpk-other-summary) - Show a hardware report for all other hardware + - [add](/docs/cli-commands#rpk-other-add) - Add new other hardware + - [list](/docs/cli-commands#rpk-other-list) - List all other hardware + - [get](/docs/cli-commands#rpk-other-get) - Retrieve other hardware by name + - [describe](/docs/cli-commands#rpk-other-describe) - Show detailed information about other hardware + - [set](/docs/cli-commands#rpk-other-set) - Update properties of other hardware + - [del](/docs/cli-commands#rpk-other-del) - Delete other hardware + - [rename](/docs/cli-commands#rpk-other-rename) - Rename other hardware to a new name + - [port](/docs/cli-commands#rpk-other-port) - Manage ports on other hardware + - [add](/docs/cli-commands#rpk-other-port-add) - Add a port to other hardware + - [set](/docs/cli-commands#rpk-other-port-set) - Update an other hardware port + - [del](/docs/cli-commands#rpk-other-port-del) - Remove a port from other hardware + - [label](/docs/cli-commands#rpk-other-label) - Manage labels on other hardware + - [add](/docs/cli-commands#rpk-other-label-add) - Add a label to other hardware + - [remove](/docs/cli-commands#rpk-other-label-remove) - Remove a label from other hardware + - [tag](/docs/cli-commands#rpk-other-tag) - Manage tags on other hardware + - [add](/docs/cli-commands#rpk-other-tag-add) - Add a tag to other hardware + - [remove](/docs/cli-commands#rpk-other-tag-remove) - Remove a tag from other hardware + - [desktops](/docs/cli-commands#rpk-desktops) - Manage desktop computers and their components + - [add](/docs/cli-commands#rpk-desktops-add) - Add a new desktop + - [list](/docs/cli-commands#rpk-desktops-list) - List all desktops + - [get](/docs/cli-commands#rpk-desktops-get) - Retrieve a desktop by name + - [describe](/docs/cli-commands#rpk-desktops-describe) - Show detailed information about a desktop + - [set](/docs/cli-commands#rpk-desktops-set) - Update properties of a desktop + - [del](/docs/cli-commands#rpk-desktops-del) - Delete a desktop from the inventory + - [rename](/docs/cli-commands#rpk-desktops-rename) - Rename a desktop to a new name + - [summary](/docs/cli-commands#rpk-desktops-summary) - Show a summarized hardware report for all desktops + - [tree](/docs/cli-commands#rpk-desktops-tree) - Display the dependency tree for a desktop + - [cpu](/docs/cli-commands#rpk-desktops-cpu) - Manage CPUs attached to desktops + - [add](/docs/cli-commands#rpk-desktops-cpu-add) - Add a CPU to a desktop + - [set](/docs/cli-commands#rpk-desktops-cpu-set) - Update a desktop CPU + - [del](/docs/cli-commands#rpk-desktops-cpu-del) - Remove a CPU from a desktop + - [drive](/docs/cli-commands#rpk-desktops-drive) - Manage storage drives attached to desktops + - [add](/docs/cli-commands#rpk-desktops-drive-add) - Add a drive to a desktop + - [set](/docs/cli-commands#rpk-desktops-drive-set) - Update a desktop drive + - [del](/docs/cli-commands#rpk-desktops-drive-del) - Remove a drive from a desktop + - [gpu](/docs/cli-commands#rpk-desktops-gpu) - Manage GPUs attached to desktops + - [add](/docs/cli-commands#rpk-desktops-gpu-add) - Add a GPU to a desktop + - [set](/docs/cli-commands#rpk-desktops-gpu-set) - Update a desktop GPU + - [del](/docs/cli-commands#rpk-desktops-gpu-del) - Remove a GPU from a desktop + - [nic](/docs/cli-commands#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops + - [add](/docs/cli-commands#rpk-desktops-nic-add) - Add a NIC to a desktop + - [set](/docs/cli-commands#rpk-desktops-nic-set) - Update a desktop NIC + - [del](/docs/cli-commands#rpk-desktops-nic-del) - Remove a NIC from a desktop + - [label](/docs/cli-commands#rpk-desktops-label) - Manage labels on a desktop + - [add](/docs/cli-commands#rpk-desktops-label-add) - Add a label to a desktop + - [remove](/docs/cli-commands#rpk-desktops-label-remove) - Remove a label from a desktop + - [tag](/docs/cli-commands#rpk-desktops-tag) - Manage tags on a desktop + - [add](/docs/cli-commands#rpk-desktops-tag-add) - Add a tag to a desktop + - [remove](/docs/cli-commands#rpk-desktops-tag-remove) - Remove a tag from a desktop + - [laptops](/docs/cli-commands#rpk-laptops) - Manage Laptop computers and their components + - [add](/docs/cli-commands#rpk-laptops-add) - Add a new Laptop + - [list](/docs/cli-commands#rpk-laptops-list) - List all Laptops + - [get](/docs/cli-commands#rpk-laptops-get) - Retrieve a Laptop by name + - [describe](/docs/cli-commands#rpk-laptops-describe) - Show detailed information about a Laptop + - [set](/docs/cli-commands#rpk-laptops-set) - Update properties of a laptop + - [del](/docs/cli-commands#rpk-laptops-del) - Delete a Laptop from the inventory + - [rename](/docs/cli-commands#rpk-laptops-rename) - Rename a Laptop to a new name + - [summary](/docs/cli-commands#rpk-laptops-summary) - Show a summarized hardware report for all Laptops + - [tree](/docs/cli-commands#rpk-laptops-tree) - Display the dependency tree for a Laptop + - [cpu](/docs/cli-commands#rpk-laptops-cpu) - Manage CPUs attached to Laptops + - [add](/docs/cli-commands#rpk-laptops-cpu-add) - Add a CPU to a Laptop + - [set](/docs/cli-commands#rpk-laptops-cpu-set) - Update a Laptop CPU + - [del](/docs/cli-commands#rpk-laptops-cpu-del) - Remove a CPU from a Laptop + - [drive](/docs/cli-commands#rpk-laptops-drive) - Manage storage drives attached to Laptops + - [add](/docs/cli-commands#rpk-laptops-drive-add) - Add a drive to a Laptop + - [set](/docs/cli-commands#rpk-laptops-drive-set) - Update a Laptop drive + - [del](/docs/cli-commands#rpk-laptops-drive-del) - Remove a drive from a Laptop + - [gpu](/docs/cli-commands#rpk-laptops-gpu) - Manage GPUs attached to Laptops + - [add](/docs/cli-commands#rpk-laptops-gpu-add) - Add a GPU to a Laptop + - [set](/docs/cli-commands#rpk-laptops-gpu-set) - Update a Laptop GPU + - [del](/docs/cli-commands#rpk-laptops-gpu-del) - Remove a GPU from a Laptop + - [nic](/docs/cli-commands#rpk-laptops-nic) - Manage network interface cards (NICs) for Laptops + - [add](/docs/cli-commands#rpk-laptops-nic-add) - Add a NIC to a Laptop + - [set](/docs/cli-commands#rpk-laptops-nic-set) - Update a Laptop NIC + - [del](/docs/cli-commands#rpk-laptops-nic-del) - Remove a NIC from a Laptop + - [label](/docs/cli-commands#rpk-laptops-label) - Manage labels on a laptop + - [add](/docs/cli-commands#rpk-laptops-label-add) - Add a label to a laptop + - [remove](/docs/cli-commands#rpk-laptops-label-remove) - Remove a label from a laptop + - [tag](/docs/cli-commands#rpk-laptops-tag) - Manage tags on a laptop + - [add](/docs/cli-commands#rpk-laptops-tag-add) - Add a tag to a laptop + - [remove](/docs/cli-commands#rpk-laptops-tag-remove) - Remove a tag from a laptop + - [services](/docs/cli-commands#rpk-services) - Manage services and their configurations + - [summary](/docs/cli-commands#rpk-services-summary) - Show a summary report for all services + - [add](/docs/cli-commands#rpk-services-add) - Add a new service + - [list](/docs/cli-commands#rpk-services-list) - List all services + - [get](/docs/cli-commands#rpk-services-get) - Retrieve a service by name + - [describe](/docs/cli-commands#rpk-services-describe) - Show detailed information about a service + - [set](/docs/cli-commands#rpk-services-set) - Update properties of a service + - [del](/docs/cli-commands#rpk-services-del) - Delete a service + - [rename](/docs/cli-commands#rpk-services-rename) - Rename a service to a new name + - [subnets](/docs/cli-commands#rpk-services-subnets) - List subnets associated with a service, optionally filtered by CIDR + - [label](/docs/cli-commands#rpk-services-label) - Manage labels on a service + - [add](/docs/cli-commands#rpk-services-label-add) - Add a label to a service + - [remove](/docs/cli-commands#rpk-services-label-remove) - Remove a label from a service + - [tag](/docs/cli-commands#rpk-services-tag) - Manage tags on a service + - [add](/docs/cli-commands#rpk-services-tag-add) - Add a tag to a service + - [remove](/docs/cli-commands#rpk-services-tag-remove) - Remove a tag from a service + - [discover](/docs/cli-commands#rpk-discover) - Read infrastructure and emit it as RackPeek YAML + - [system](/docs/cli-commands#rpk-discover-system) - Inspect this machine and emit it as a System resource + - [docker](/docs/cli-commands#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this + - [proxmox](/docs/cli-commands#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems + - [network](/docs/cli-commands#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource + - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage Ansible inventory + - [inventory](/docs/cli-commands#rpk-ansible-inventory) - Generate an Ansible inventory + - [ssh](/docs/cli-commands#rpk-ssh) - Generate SSH configuration from infrastructure + - [export](/docs/cli-commands#rpk-ssh-export) - Generate an SSH config file + - [hosts](/docs/cli-commands#rpk-hosts) - Generate a hosts file from infrastructure + - [export](/docs/cli-commands#rpk-hosts-export) - Generate a /etc/hosts compatible file + - [graph](/docs/cli-commands#rpk-graph) - Render inventory as graph diagrams + - [topology](/docs/cli-commands#rpk-graph-topology) - Emit a Mermaid flowchart of the physical topology (hardware + connections) + - [logical](/docs/cli-commands#rpk-graph-logical) - Emit a Mermaid flowchart of services & systems grouped by subnet and host + - [tags](/docs/cli-commands#rpk-tags) - Discover tags across resources + - [list](/docs/cli-commands#rpk-tags-list) - List all tags in use with usage counts + - [show](/docs/cli-commands#rpk-tags-show) - List resources carrying a specific tag + - [connections](/docs/cli-commands#rpk-connections) - Manage physical or logical port connections + - [add](/docs/cli-commands#rpk-connections-add) - Create a connection between two ports + - [remove](/docs/cli-commands#rpk-connections-remove) - Remove the connection from a specific port diff --git a/Shared.Rcl/wwwroot/raw_docs/install-guide.md b/Shared.Rcl/wwwroot/raw_docs/install-guide.md index 36031e83..dbaea7bd 100644 --- a/Shared.Rcl/wwwroot/raw_docs/install-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/install-guide.md @@ -132,13 +132,13 @@ If you prefer running RackPeek directly on Linux: ## Download ```bash -wget https://github.com/Timmoth/RackPeek/releases/download/RackPeek-0.0.3/rackpeek_0_0_3_linux-x64 -O rackpeek +wget https://github.com/Timmoth/RackPeek/releases/download/RackPeek-2.2.0/rackpeek_2_2_0_linux-x64 -O rackpeek ``` Or: ```bash -curl -L https://github.com/Timmoth/RackPeek/releases/download/RackPeek-0.0.3/rackpeek_0_0_3_linux-x64 -o rackpeek +curl -L https://github.com/Timmoth/RackPeek/releases/download/RackPeek-2.2.0/rackpeek_2_2_0_linux-x64 -o rackpeek ``` --- diff --git a/Shared.Rcl/wwwroot/raw_docs/overview.md b/Shared.Rcl/wwwroot/raw_docs/overview.md index ff499436..c086a2cb 100644 --- a/Shared.Rcl/wwwroot/raw_docs/overview.md +++ b/Shared.Rcl/wwwroot/raw_docs/overview.md @@ -19,6 +19,8 @@ RackPeek helps you: - Keep infrastructure knowledge versionable and portable - Treat your lab “as code” using simple YAML - Turn documentation into automation (e.g. Ansible inventory) +- Auto-discover what is already running (`rpk discover system / docker / proxmox / network`) +- Connect AI assistants to your inventory through the built-in MCP server It is intentionally focused on homelabs and self-hosted environments, not enterprise CMDBs. @@ -52,17 +54,16 @@ Optimized for real-world home lab use — not corporate documentation workflows. ## Project Status -RackPeek is actively developed and currently in beta. +RackPeek is stable and actively developed. The focus is on: - Stability -- Core feature completeness - Clean UX -- Strong automation integrations -- Community feedback before v1.0.0 +- Strong automation integrations (Ansible, discovery, MCP) +- Community feedback shaping the roadmap -Post-1.0, expansion areas include deeper automation support, diagramming, and infrastructure integrations. +Expansion areas include deeper automation support, diagramming, and infrastructure integrations. --- diff --git a/Shared.Rcl/wwwroot/raw_docs/versioning.md b/Shared.Rcl/wwwroot/raw_docs/versioning.md index aca3c824..c2f0bdbc 100644 --- a/Shared.Rcl/wwwroot/raw_docs/versioning.md +++ b/Shared.Rcl/wwwroot/raw_docs/versioning.md @@ -9,49 +9,6 @@ Example: 1.2.3 ### MAJOR (X.0.0) -* Sweeping changes to the CLI / WebUi -* Large schema changes -* Breaking Changes - -### MINOR (1.X.0) - -Backward-compatible features: - -* New CLI commands or flags -* New WebUI features -* New config options -* Performance improvements - -### PATCH (1.0.X) - -Backward-compatible fixes: - -* Bug fixes -* Security patches -* Docs or minor UX improvements - -### CLI & Docker - -The CLI and Docker image share the **same version number**. - -Docker tags: - -* `latest` → newest stable -* `v1.2.3` → Major Minor Patch - -For production, pin to a specific version instead of `latest`. - -## Versioning - -RackPeek follows **Semantic Versioning (SemVer)** for both the CLI and Docker images: - -``` -MAJOR.MINOR.PATCH -Example: 1.2.3 -``` - -### MAJOR (X.0.0) - Breaking changes: * CLI command/flag changes @@ -83,15 +40,15 @@ The CLI binary and Docker image share the **same version number**. Docker tags: * `latest` → newest stable release -* `1` → latest major -* `1.2` → latest patch in that minor line -* `1.2.3` → exact immutable version (recommended for production) +* `v1.2.3` → exact immutable version (recommended for production) + +For production, pin to a specific version instead of `latest`. ## Nightly Docker Builds -In addition to stable releases, RackPeek publishes a **nightly Docker image** from the `main` branch. +In addition to stable releases, RackPeek publishes a **nightly Docker image** from the `staging` branch. -* Triggered on every push to `main` +* Triggered on every push to `staging` * Built for `linux/amd64` * Tagged as: diff --git a/docs/CommandIndex.md b/docs/CommandIndex.md deleted file mode 100644 index aa325387..00000000 --- a/docs/CommandIndex.md +++ /dev/null @@ -1,243 +0,0 @@ - -- [rpk](docs/Commands.md#rpk) - - [summary](docs/Commands.md#rpk-summary) - Show a summarized report of all resources in the system - - [servers](docs/Commands.md#rpk-servers) - Manage servers and their components - - [summary](docs/Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers - - [add](docs/Commands.md#rpk-servers-add) - Add a new server to the inventory - - [get](docs/Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name - - [describe](docs/Commands.md#rpk-servers-describe) - Display detailed information about a specific server - - [set](docs/Commands.md#rpk-servers-set) - Update properties of an existing server - - [del](docs/Commands.md#rpk-servers-del) - Delete a server from the inventory - - [rename](docs/Commands.md#rpk-servers-rename) - Rename a server to a new name - - [tree](docs/Commands.md#rpk-servers-tree) - Display the dependency tree of a server - - [cpu](docs/Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server - - [add](docs/Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server - - [set](docs/Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU - - [del](docs/Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server - - [drive](docs/Commands.md#rpk-servers-drive) - Manage drives attached to a server - - [add](docs/Commands.md#rpk-servers-drive-add) - Add a storage drive to a server - - [set](docs/Commands.md#rpk-servers-drive-set) - Update properties of a server drive - - [del](docs/Commands.md#rpk-servers-drive-del) - Remove a drive from a server - - [gpu](docs/Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server - - [add](docs/Commands.md#rpk-servers-gpu-add) - Add a GPU to a server - - [set](docs/Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU - - [del](docs/Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server - - [nic](docs/Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server - - [add](docs/Commands.md#rpk-servers-nic-add) - Add a NIC to a server - - [set](docs/Commands.md#rpk-servers-nic-set) - Update properties of a server NIC - - [del](docs/Commands.md#rpk-servers-nic-del) - Remove a NIC from a server - - [label](docs/Commands.md#rpk-servers-label) - Manage labels on a server - - [add](docs/Commands.md#rpk-servers-label-add) - Add a label to a server - - [remove](docs/Commands.md#rpk-servers-label-remove) - Remove a label from a server - - [tag](docs/Commands.md#rpk-servers-tag) - Manage tags on a server - - [add](docs/Commands.md#rpk-servers-tag-add) - Add a tag to a server - - [remove](docs/Commands.md#rpk-servers-tag-remove) - Remove a tag from a server - - [switches](docs/Commands.md#rpk-switches) - Manage network switches - - [summary](docs/Commands.md#rpk-switches-summary) - Show a hardware report for all switches - - [add](docs/Commands.md#rpk-switches-add) - Add a new network switch to the inventory - - [list](docs/Commands.md#rpk-switches-list) - List all switches in the system - - [get](docs/Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name - - [describe](docs/Commands.md#rpk-switches-describe) - Show detailed information about a switch - - [set](docs/Commands.md#rpk-switches-set) - Update properties of a switch - - [del](docs/Commands.md#rpk-switches-del) - Delete a switch from the inventory - - [rename](docs/Commands.md#rpk-switches-rename) - Rename a switch to a new name - - [port](docs/Commands.md#rpk-switches-port) - Manage ports on a network switch - - [add](docs/Commands.md#rpk-switches-port-add) - Add a port to a switch - - [set](docs/Commands.md#rpk-switches-port-set) - Update a switch port - - [del](docs/Commands.md#rpk-switches-port-del) - Remove a port from a switch - - [label](docs/Commands.md#rpk-switches-label) - Manage labels on a switch - - [add](docs/Commands.md#rpk-switches-label-add) - Add a label to a switch - - [remove](docs/Commands.md#rpk-switches-label-remove) - Remove a label from a switch - - [tag](docs/Commands.md#rpk-switches-tag) - Manage tags on a switch - - [add](docs/Commands.md#rpk-switches-tag-add) - Add a tag to a switch - - [remove](docs/Commands.md#rpk-switches-tag-remove) - Remove a tag from a switch - - [routers](docs/Commands.md#rpk-routers) - Manage network routers - - [summary](docs/Commands.md#rpk-routers-summary) - Show a hardware report for all routers - - [add](docs/Commands.md#rpk-routers-add) - Add a new network router to the inventory - - [list](docs/Commands.md#rpk-routers-list) - List all routers in the system - - [get](docs/Commands.md#rpk-routers-get) - Retrieve details of a specific router by name - - [describe](docs/Commands.md#rpk-routers-describe) - Show detailed information about a router - - [set](docs/Commands.md#rpk-routers-set) - Update properties of a router - - [del](docs/Commands.md#rpk-routers-del) - Delete a router from the inventory - - [rename](docs/Commands.md#rpk-routers-rename) - Rename a router to a new name - - [port](docs/Commands.md#rpk-routers-port) - Manage ports on a router - - [add](docs/Commands.md#rpk-routers-port-add) - Add a port to a router - - [set](docs/Commands.md#rpk-routers-port-set) - Update a router port - - [del](docs/Commands.md#rpk-routers-port-del) - Remove a port from a router - - [label](docs/Commands.md#rpk-routers-label) - Manage labels on a router - - [add](docs/Commands.md#rpk-routers-label-add) - Add a label to a router - - [remove](docs/Commands.md#rpk-routers-label-remove) - Remove a label from a router - - [tag](docs/Commands.md#rpk-routers-tag) - Manage tags on a router - - [add](docs/Commands.md#rpk-routers-tag-add) - Add a tag to a router - - [remove](docs/Commands.md#rpk-routers-tag-remove) - Remove a tag from a router - - [firewalls](docs/Commands.md#rpk-firewalls) - Manage firewalls - - [summary](docs/Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls - - [add](docs/Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory - - [list](docs/Commands.md#rpk-firewalls-list) - List all firewalls in the system - - [get](docs/Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name - - [describe](docs/Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall - - [set](docs/Commands.md#rpk-firewalls-set) - Update properties of a firewall - - [del](docs/Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory - - [rename](docs/Commands.md#rpk-firewalls-rename) - Rename a firewall to a new name - - [port](docs/Commands.md#rpk-firewalls-port) - Manage ports on a firewall - - [add](docs/Commands.md#rpk-firewalls-port-add) - Add a port to a firewall - - [set](docs/Commands.md#rpk-firewalls-port-set) - Update a firewall port - - [del](docs/Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall - - [label](docs/Commands.md#rpk-firewalls-label) - Manage labels on a firewall - - [add](docs/Commands.md#rpk-firewalls-label-add) - Add a label to a firewall - - [remove](docs/Commands.md#rpk-firewalls-label-remove) - Remove a label from a firewall - - [tag](docs/Commands.md#rpk-firewalls-tag) - Manage tags on a firewall - - [add](docs/Commands.md#rpk-firewalls-tag-add) - Add a tag to a firewall - - [remove](docs/Commands.md#rpk-firewalls-tag-remove) - Remove a tag from a firewall - - [systems](docs/Commands.md#rpk-systems) - Manage systems and their dependencies - - [summary](docs/Commands.md#rpk-systems-summary) - Show a summary report for all systems - - [add](docs/Commands.md#rpk-systems-add) - Add a new system to the inventory - - [list](docs/Commands.md#rpk-systems-list) - List all systems - - [get](docs/Commands.md#rpk-systems-get) - Retrieve a system by name - - [describe](docs/Commands.md#rpk-systems-describe) - Display detailed information about a system - - [set](docs/Commands.md#rpk-systems-set) - Update properties of a system - - [del](docs/Commands.md#rpk-systems-del) - Delete a system from the inventory - - [rename](docs/Commands.md#rpk-systems-rename) - Rename a system to a new name - - [tree](docs/Commands.md#rpk-systems-tree) - Display the dependency tree for a system - - [label](docs/Commands.md#rpk-systems-label) - Manage labels on a system - - [add](docs/Commands.md#rpk-systems-label-add) - Add a label to a system - - [remove](docs/Commands.md#rpk-systems-label-remove) - Remove a label from a system - - [tag](docs/Commands.md#rpk-systems-tag) - Manage tags on a system - - [add](docs/Commands.md#rpk-systems-tag-add) - Add a tag to a system - - [remove](docs/Commands.md#rpk-systems-tag-remove) - Remove a tag from a system - - [accesspoints](docs/Commands.md#rpk-accesspoints) - Manage access points - - [summary](docs/Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points - - [add](docs/Commands.md#rpk-accesspoints-add) - Add a new access point - - [list](docs/Commands.md#rpk-accesspoints-list) - List all access points - - [get](docs/Commands.md#rpk-accesspoints-get) - Retrieve an access point by name - - [describe](docs/Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point - - [set](docs/Commands.md#rpk-accesspoints-set) - Update properties of an access point - - [del](docs/Commands.md#rpk-accesspoints-del) - Delete an access point - - [rename](docs/Commands.md#rpk-accesspoints-rename) - Rename an access point to a new name - - [label](docs/Commands.md#rpk-accesspoints-label) - Manage labels on an access point - - [add](docs/Commands.md#rpk-accesspoints-label-add) - Add a label to an access point - - [remove](docs/Commands.md#rpk-accesspoints-label-remove) - Remove a label from an access point - - [tag](docs/Commands.md#rpk-accesspoints-tag) - Manage tags on an access point - - [add](docs/Commands.md#rpk-accesspoints-tag-add) - Add a tag to an access point - - [remove](docs/Commands.md#rpk-accesspoints-tag-remove) - Remove a tag from an access point - - [ups](docs/Commands.md#rpk-ups) - Manage UPS units - - [summary](docs/Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units - - [add](docs/Commands.md#rpk-ups-add) - Add a new UPS unit - - [list](docs/Commands.md#rpk-ups-list) - List all UPS units - - [get](docs/Commands.md#rpk-ups-get) - Retrieve a UPS unit by name - - [describe](docs/Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit - - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit - - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit - - [rename](docs/Commands.md#rpk-ups-rename) - Rename a UPS unit to a new name - - [label](docs/Commands.md#rpk-ups-label) - Manage labels on a UPS unit - - [add](docs/Commands.md#rpk-ups-label-add) - Add a label to a UPS unit - - [remove](docs/Commands.md#rpk-ups-label-remove) - Remove a label from a UPS unit - - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit - - [add](docs/Commands.md#rpk-ups-tag-add) - Add a tag to a UPS unit - - [remove](docs/Commands.md#rpk-ups-tag-remove) - Remove a tag from a UPS unit - - [other](docs/Commands.md#rpk-other) - Manage other hardware that doesn't fit an existing category - - [summary](docs/Commands.md#rpk-other-summary) - Show a hardware report for all other hardware - - [add](docs/Commands.md#rpk-other-add) - Add new other hardware - - [list](docs/Commands.md#rpk-other-list) - List all other hardware - - [get](docs/Commands.md#rpk-other-get) - Retrieve other hardware by name - - [describe](docs/Commands.md#rpk-other-describe) - Show detailed information about other hardware - - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware - - [del](docs/Commands.md#rpk-other-del) - Delete other hardware - - [rename](docs/Commands.md#rpk-other-rename) - Rename other hardware to a new name - - [label](docs/Commands.md#rpk-other-label) - Manage labels on other hardware - - [add](docs/Commands.md#rpk-other-label-add) - Add a label to other hardware - - [remove](docs/Commands.md#rpk-other-label-remove) - Remove a label from other hardware - - [tag](docs/Commands.md#rpk-other-tag) - Manage tags on other hardware - - [add](docs/Commands.md#rpk-other-tag-add) - Add a tag to other hardware - - [remove](docs/Commands.md#rpk-other-tag-remove) - Remove a tag from other hardware - - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components - - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop - - [list](docs/Commands.md#rpk-desktops-list) - List all desktops - - [get](docs/Commands.md#rpk-desktops-get) - Retrieve a desktop by name - - [describe](docs/Commands.md#rpk-desktops-describe) - Show detailed information about a desktop - - [set](docs/Commands.md#rpk-desktops-set) - Update properties of a desktop - - [del](docs/Commands.md#rpk-desktops-del) - Delete a desktop from the inventory - - [rename](docs/Commands.md#rpk-desktops-rename) - Rename a desktop to a new name - - [summary](docs/Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops - - [tree](docs/Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop - - [cpu](docs/Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops - - [add](docs/Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop - - [set](docs/Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU - - [del](docs/Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop - - [drive](docs/Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops - - [add](docs/Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop - - [set](docs/Commands.md#rpk-desktops-drive-set) - Update a desktop drive - - [del](docs/Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop - - [gpu](docs/Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops - - [add](docs/Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop - - [set](docs/Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU - - [del](docs/Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop - - [nic](docs/Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops - - [add](docs/Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop - - [set](docs/Commands.md#rpk-desktops-nic-set) - Update a desktop NIC - - [del](docs/Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop - - [label](docs/Commands.md#rpk-desktops-label) - Manage labels on a desktop - - [add](docs/Commands.md#rpk-desktops-label-add) - Add a label to a desktop - - [remove](docs/Commands.md#rpk-desktops-label-remove) - Remove a label from a desktop - - [tag](docs/Commands.md#rpk-desktops-tag) - Manage tags on a desktop - - [add](docs/Commands.md#rpk-desktops-tag-add) - Add a tag to a desktop - - [remove](docs/Commands.md#rpk-desktops-tag-remove) - Remove a tag from a desktop - - [laptops](docs/Commands.md#rpk-laptops) - Manage Laptop computers and their components - - [add](docs/Commands.md#rpk-laptops-add) - Add a new Laptop - - [list](docs/Commands.md#rpk-laptops-list) - List all Laptops - - [get](docs/Commands.md#rpk-laptops-get) - Retrieve a Laptop by name - - [describe](docs/Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop - - [set](docs/Commands.md#rpk-laptops-set) - Update properties of a laptop - - [del](docs/Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory - - [rename](docs/Commands.md#rpk-laptops-rename) - Rename a Laptop to a new name - - [summary](docs/Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops - - [tree](docs/Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop - - [cpu](docs/Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops - - [add](docs/Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop - - [set](docs/Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU - - [del](docs/Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop - - [drive](docs/Commands.md#rpk-laptops-drive) - Manage storage drives attached to Laptops - - [add](docs/Commands.md#rpk-laptops-drive-add) - Add a drive to a Laptop - - [set](docs/Commands.md#rpk-laptops-drive-set) - Update a Laptop drive - - [del](docs/Commands.md#rpk-laptops-drive-del) - Remove a drive from a Laptop - - [gpu](docs/Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops - - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop - - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU - - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop - - [label](docs/Commands.md#rpk-laptops-label) - Manage labels on a laptop - - [add](docs/Commands.md#rpk-laptops-label-add) - Add a label to a laptop - - [remove](docs/Commands.md#rpk-laptops-label-remove) - Remove a label from a laptop - - [tag](docs/Commands.md#rpk-laptops-tag) - Manage tags on a laptop - - [add](docs/Commands.md#rpk-laptops-tag-add) - Add a tag to a laptop - - [remove](docs/Commands.md#rpk-laptops-tag-remove) - Remove a tag from a laptop - - [services](docs/Commands.md#rpk-services) - Manage services and their configurations - - [summary](docs/Commands.md#rpk-services-summary) - Show a summary report for all services - - [add](docs/Commands.md#rpk-services-add) - Add a new service - - [list](docs/Commands.md#rpk-services-list) - List all services - - [get](docs/Commands.md#rpk-services-get) - Retrieve a service by name - - [describe](docs/Commands.md#rpk-services-describe) - Show detailed information about a service - - [set](docs/Commands.md#rpk-services-set) - Update properties of a service - - [del](docs/Commands.md#rpk-services-del) - Delete a service - - [rename](docs/Commands.md#rpk-services-rename) - Rename a service to a new name - - [subnets](docs/Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered by CIDR - - [label](docs/Commands.md#rpk-services-label) - Manage labels on a service - - [add](docs/Commands.md#rpk-services-label-add) - Add a label to a service - - [remove](docs/Commands.md#rpk-services-label-remove) - Remove a label from a service - - [tag](docs/Commands.md#rpk-services-tag) - Manage tags on a service - - [add](docs/Commands.md#rpk-services-tag-add) - Add a tag to a service - - [remove](docs/Commands.md#rpk-services-tag-remove) - Remove a tag from a service - - [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 - - [export](docs/Commands.md#rpk-ssh-export) - Generate an SSH config file - - [hosts](docs/Commands.md#rpk-hosts) - Generate a hosts file from infrastructure - - [export](docs/Commands.md#rpk-hosts-export) - Generate a /etc/hosts compatible file - - [graph](docs/Commands.md#rpk-graph) - Render inventory as graph diagrams - - [topology](docs/Commands.md#rpk-graph-topology) - Emit a Mermaid flowchart of the physical topology (hardware + connections) - - [logical](docs/Commands.md#rpk-graph-logical) - Emit a Mermaid flowchart of services & systems grouped by subnet and host - - [tags](docs/Commands.md#rpk-tags) - Discover tags across resources - - [list](docs/Commands.md#rpk-tags-list) - List all tags in use with usage counts - - [show](docs/Commands.md#rpk-tags-show) - List resources carrying a specific tag - - [connections](docs/Commands.md#rpk-connections) - Manage physical or logical port connections - - [add](docs/Commands.md#rpk-connections-add) - Create a connection between two ports - - [remove](docs/Commands.md#rpk-connections-remove) - Remove the connection from a specific port diff --git a/docs/Commands.md b/docs/Commands.md deleted file mode 100644 index 319bdb35..00000000 --- a/docs/Commands.md +++ /dev/null @@ -1,3999 +0,0 @@ -# CLI Commands - -## `rpk` -``` -USAGE: - rpk [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -v, --version Prints version information - -COMMANDS: - summary Show a summarized report of all resources in the system - servers Manage servers and their components - switches Manage network switches - routers Manage network routers - firewalls Manage firewalls - systems Manage systems and their dependencies - accesspoints Manage access points - ups Manage UPS units - other Manage other hardware that doesn't fit an existing category - desktops Manage desktop computers and their components - laptops Manage Laptop computers and their components - services Manage services and their configurations - ansible Generate and manage Ansible inventory - ssh Generate SSH configuration from infrastructure - hosts Generate a hosts file from infrastructure - graph Render inventory as graph diagrams - tags Discover tags across resources - connections Manage physical or logical port connections -``` - -## `rpk summary` -``` -DESCRIPTION: -Show a summarized report of all resources in the system - -USAGE: - rpk summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers` -``` -DESCRIPTION: -Manage servers and their components - -USAGE: - rpk servers [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a summarized hardware report for all - servers - add Add a new server to the inventory - get List all servers or retrieve a specific server - by name - describe Display detailed information about a specific - server - set Update properties of an existing server - del Delete a server from the inventory - rename Rename a server to a new name - tree Display the dependency tree of a server - cpu Manage CPUs attached to a server - drive Manage drives attached to a server - gpu Manage GPUs attached to a server - nic Manage network interface cards (NICs) for a - server - label Manage labels on a server - tag Manage tags on a server -``` - -## `rpk servers summary` -``` -DESCRIPTION: -Show a summarized hardware report for all servers - -USAGE: - rpk servers summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers add` -``` -DESCRIPTION: -Add a new server to the inventory - -USAGE: - rpk servers add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers get` -``` -DESCRIPTION: -List all servers or retrieve a specific server by name - -USAGE: - rpk servers get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers describe` -``` -DESCRIPTION: -Display detailed information about a specific server - -USAGE: - rpk servers describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers set` -``` -DESCRIPTION: -Update properties of an existing server - -USAGE: - rpk servers set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --ram - --ram_mts - --ipmi -``` - -## `rpk servers del` -``` -DESCRIPTION: -Delete a server from the inventory - -USAGE: - rpk servers del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers rename` -``` -DESCRIPTION: -Rename a server to a new name - -USAGE: - rpk servers rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers tree` -``` -DESCRIPTION: -Display the dependency tree of a server - -USAGE: - rpk servers tree [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers cpu` -``` -DESCRIPTION: -Manage CPUs attached to a server - -USAGE: - rpk servers cpu [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a CPU to a specific server - set Update configuration of a server CPU - del Remove a CPU from a server -``` - -## `rpk servers cpu add` -``` -DESCRIPTION: -Add a CPU to a specific server - -USAGE: - rpk servers cpu add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model - --cores - --threads -``` - -## `rpk servers cpu set` -``` -DESCRIPTION: -Update configuration of a server CPU - -USAGE: - rpk servers cpu set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --model - --cores - --threads -``` - -## `rpk servers cpu del` -``` -DESCRIPTION: -Remove a CPU from a server - -USAGE: - rpk servers cpu del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk servers drive` -``` -DESCRIPTION: -Manage drives attached to a server - -USAGE: - rpk servers drive [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a storage drive to a server - set Update properties of a server drive - del Remove a drive from a server -``` - -## `rpk servers drive add` -``` -DESCRIPTION: -Add a storage drive to a server - -USAGE: - rpk servers drive add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --type The drive type e.g hdd / ssd - --size The drive capacity in GB -``` - -## `rpk servers drive set` -``` -DESCRIPTION: -Update properties of a server drive - -USAGE: - rpk servers drive set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --type - --size -``` - -## `rpk servers drive del` -``` -DESCRIPTION: -Remove a drive from a server - -USAGE: - rpk servers drive del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk servers gpu` -``` -DESCRIPTION: -Manage GPUs attached to a server - -USAGE: - rpk servers gpu [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a GPU to a server - set Update properties of a server GPU - del Remove a GPU from a server -``` - -## `rpk servers gpu add` -``` -DESCRIPTION: -Add a GPU to a server - -USAGE: - rpk servers gpu add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model - --vram -``` - -## `rpk servers gpu set` -``` -DESCRIPTION: -Update properties of a server GPU - -USAGE: - rpk servers gpu set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --model - --vram -``` - -## `rpk servers gpu del` -``` -DESCRIPTION: -Remove a GPU from a server - -USAGE: - rpk servers gpu del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk servers nic` -``` -DESCRIPTION: -Manage network interface cards (NICs) for a server - -USAGE: - rpk servers nic [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a NIC to a server - set Update properties of a server NIC - del Remove a NIC from a server -``` - -## `rpk servers nic add` -``` -DESCRIPTION: -Add a NIC to a server - -USAGE: - rpk servers nic add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --type - --speed - --ports -``` - -## `rpk servers nic set` -``` -DESCRIPTION: -Update properties of a server NIC - -USAGE: - rpk servers nic set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --type - --speed - --ports -``` - -## `rpk servers nic del` -``` -DESCRIPTION: -Remove a NIC from a server - -USAGE: - rpk servers nic del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk servers label` -``` -DESCRIPTION: -Manage labels on a server - -USAGE: - rpk servers label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a server - remove Remove a label from a server -``` - -## `rpk servers label add` -``` -DESCRIPTION: -Add a label to a server - -USAGE: - rpk servers label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk servers label remove` -``` -DESCRIPTION: -Remove a label from a server - -USAGE: - rpk servers label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk servers tag` -``` -DESCRIPTION: -Manage tags on a server - -USAGE: - rpk servers tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a server - remove Remove a tag from a server -``` - -## `rpk servers tag add` -``` -DESCRIPTION: -Add a tag to a server - -USAGE: - rpk servers tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk servers tag remove` -``` -DESCRIPTION: -Remove a tag from a server - -USAGE: - rpk servers tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches` -``` -DESCRIPTION: -Manage network switches - -USAGE: - rpk switches [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a hardware report for all switches - add Add a new network switch to the inventory - list List all switches in the system - get Retrieve details of a specific switch by name - describe Show detailed information about a switch - set Update properties of a switch - del Delete a switch from the inventory - rename Rename a switch to a new name - port Manage ports on a network switch - label Manage labels on a switch - tag Manage tags on a switch -``` - -## `rpk switches summary` -``` -DESCRIPTION: -Show a hardware report for all switches - -USAGE: - rpk switches summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches add` -``` -DESCRIPTION: -Add a new network switch to the inventory - -USAGE: - rpk switches add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches list` -``` -DESCRIPTION: -List all switches in the system - -USAGE: - rpk switches list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches get` -``` -DESCRIPTION: -Retrieve details of a specific switch by name - -USAGE: - rpk switches get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches describe` -``` -DESCRIPTION: -Show detailed information about a switch - -USAGE: - rpk switches describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches set` -``` -DESCRIPTION: -Update properties of a switch - -USAGE: - rpk switches set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --Model - --managed - --poe -``` - -## `rpk switches del` -``` -DESCRIPTION: -Delete a switch from the inventory - -USAGE: - rpk switches del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches rename` -``` -DESCRIPTION: -Rename a switch to a new name - -USAGE: - rpk switches rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches port` -``` -DESCRIPTION: -Manage ports on a network switch - -USAGE: - rpk switches port [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a port to a switch - set Update a switch port - del Remove a port from a switch -``` - -## `rpk switches port add` -``` -DESCRIPTION: -Add a port to a switch - -USAGE: - rpk switches port add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --type The port type (e.g., rj45, sfp+) - --speed The port speed (e.g., 1, 2.5, 10) - --count Number of ports of this type -``` - -## `rpk switches port set` -``` -DESCRIPTION: -Update a switch port - -USAGE: - rpk switches port set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --type - --speed - --count -``` - -## `rpk switches port del` -``` -DESCRIPTION: -Remove a port from a switch - -USAGE: - rpk switches port del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk switches label` -``` -DESCRIPTION: -Manage labels on a switch - -USAGE: - rpk switches label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a switch - remove Remove a label from a switch -``` - -## `rpk switches label add` -``` -DESCRIPTION: -Add a label to a switch - -USAGE: - rpk switches label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk switches label remove` -``` -DESCRIPTION: -Remove a label from a switch - -USAGE: - rpk switches label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk switches tag` -``` -DESCRIPTION: -Manage tags on a switch - -USAGE: - rpk switches tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a switch - remove Remove a tag from a switch -``` - -## `rpk switches tag add` -``` -DESCRIPTION: -Add a tag to a switch - -USAGE: - rpk switches tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk switches tag remove` -``` -DESCRIPTION: -Remove a tag from a switch - -USAGE: - rpk switches tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers` -``` -DESCRIPTION: -Manage network routers - -USAGE: - rpk routers [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a hardware report for all routers - add Add a new network router to the inventory - list List all routers in the system - get Retrieve details of a specific router by name - describe Show detailed information about a router - set Update properties of a router - del Delete a router from the inventory - rename Rename a router to a new name - port Manage ports on a router - label Manage labels on a router - tag Manage tags on a router -``` - -## `rpk routers summary` -``` -DESCRIPTION: -Show a hardware report for all routers - -USAGE: - rpk routers summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers add` -``` -DESCRIPTION: -Add a new network router to the inventory - -USAGE: - rpk routers add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers list` -``` -DESCRIPTION: -List all routers in the system - -USAGE: - rpk routers list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers get` -``` -DESCRIPTION: -Retrieve details of a specific router by name - -USAGE: - rpk routers get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers describe` -``` -DESCRIPTION: -Show detailed information about a router - -USAGE: - rpk routers describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers set` -``` -DESCRIPTION: -Update properties of a router - -USAGE: - rpk routers set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --Model - --managed - --poe -``` - -## `rpk routers del` -``` -DESCRIPTION: -Delete a router from the inventory - -USAGE: - rpk routers del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers rename` -``` -DESCRIPTION: -Rename a router to a new name - -USAGE: - rpk routers rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers port` -``` -DESCRIPTION: -Manage ports on a router - -USAGE: - rpk routers port [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a port to a router - set Update a router port - del Remove a port from a router -``` - -## `rpk routers port add` -``` -DESCRIPTION: -Add a port to a router - -USAGE: - rpk routers port add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --type - --speed - --count -``` - -## `rpk routers port set` -``` -DESCRIPTION: -Update a router port - -USAGE: - rpk routers port set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --type - --speed - --count -``` - -## `rpk routers port del` -``` -DESCRIPTION: -Remove a port from a router - -USAGE: - rpk routers port del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk routers label` -``` -DESCRIPTION: -Manage labels on a router - -USAGE: - rpk routers label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a router - remove Remove a label from a router -``` - -## `rpk routers label add` -``` -DESCRIPTION: -Add a label to a router - -USAGE: - rpk routers label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk routers label remove` -``` -DESCRIPTION: -Remove a label from a router - -USAGE: - rpk routers label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk routers tag` -``` -DESCRIPTION: -Manage tags on a router - -USAGE: - rpk routers tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a router - remove Remove a tag from a router -``` - -## `rpk routers tag add` -``` -DESCRIPTION: -Add a tag to a router - -USAGE: - rpk routers tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk routers tag remove` -``` -DESCRIPTION: -Remove a tag from a router - -USAGE: - rpk routers tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls` -``` -DESCRIPTION: -Manage firewalls - -USAGE: - rpk firewalls [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a hardware report for all firewalls - add Add a new firewall to the inventory - list List all firewalls in the system - get Retrieve details of a specific firewall by name - describe Show detailed information about a firewall - set Update properties of a firewall - del Delete a firewall from the inventory - rename Rename a firewall to a new name - port Manage ports on a firewall - label Manage labels on a firewall - tag Manage tags on a firewall -``` - -## `rpk firewalls summary` -``` -DESCRIPTION: -Show a hardware report for all firewalls - -USAGE: - rpk firewalls summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls add` -``` -DESCRIPTION: -Add a new firewall to the inventory - -USAGE: - rpk firewalls add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls list` -``` -DESCRIPTION: -List all firewalls in the system - -USAGE: - rpk firewalls list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls get` -``` -DESCRIPTION: -Retrieve details of a specific firewall by name - -USAGE: - rpk firewalls get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls describe` -``` -DESCRIPTION: -Show detailed information about a firewall - -USAGE: - rpk firewalls describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls set` -``` -DESCRIPTION: -Update properties of a firewall - -USAGE: - rpk firewalls set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --Model - --managed - --poe -``` - -## `rpk firewalls del` -``` -DESCRIPTION: -Delete a firewall from the inventory - -USAGE: - rpk firewalls del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls rename` -``` -DESCRIPTION: -Rename a firewall to a new name - -USAGE: - rpk firewalls rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls port` -``` -DESCRIPTION: -Manage ports on a firewall - -USAGE: - rpk firewalls port [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a port to a firewall - set Update a firewall port - del Remove a port from a firewall -``` - -## `rpk firewalls port add` -``` -DESCRIPTION: -Add a port to a firewall - -USAGE: - rpk firewalls port add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --type - --speed - --count -``` - -## `rpk firewalls port set` -``` -DESCRIPTION: -Update a firewall port - -USAGE: - rpk firewalls port set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index - --type - --speed - --count -``` - -## `rpk firewalls port del` -``` -DESCRIPTION: -Remove a port from a firewall - -USAGE: - rpk firewalls port del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --index -``` - -## `rpk firewalls label` -``` -DESCRIPTION: -Manage labels on a firewall - -USAGE: - rpk firewalls label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a firewall - remove Remove a label from a firewall -``` - -## `rpk firewalls label add` -``` -DESCRIPTION: -Add a label to a firewall - -USAGE: - rpk firewalls label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk firewalls label remove` -``` -DESCRIPTION: -Remove a label from a firewall - -USAGE: - rpk firewalls label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk firewalls tag` -``` -DESCRIPTION: -Manage tags on a firewall - -USAGE: - rpk firewalls tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a firewall - remove Remove a tag from a firewall -``` - -## `rpk firewalls tag add` -``` -DESCRIPTION: -Add a tag to a firewall - -USAGE: - rpk firewalls tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk firewalls tag remove` -``` -DESCRIPTION: -Remove a tag from a firewall - -USAGE: - rpk firewalls tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems` -``` -DESCRIPTION: -Manage systems and their dependencies - -USAGE: - rpk systems [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a summary report for all systems - add Add a new system to the inventory - list List all systems - get Retrieve a system by name - describe Display detailed information about a system - set Update properties of a system - del Delete a system from the inventory - rename Rename a system to a new name - tree Display the dependency tree for a system - label Manage labels on a system - tag Manage tags on a system -``` - -## `rpk systems summary` -``` -DESCRIPTION: -Show a summary report for all systems - -USAGE: - rpk systems summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems add` -``` -DESCRIPTION: -Add a new system to the inventory - -USAGE: - rpk systems add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems list` -``` -DESCRIPTION: -List all systems - -USAGE: - rpk systems list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems get` -``` -DESCRIPTION: -Retrieve a system by name - -USAGE: - rpk systems get [OPTIONS] - -ARGUMENTS: - The name of the system - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems describe` -``` -DESCRIPTION: -Display detailed information about a system - -USAGE: - rpk systems describe [OPTIONS] - -ARGUMENTS: - The name of the system - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems set` -``` -DESCRIPTION: -Update properties of a system - -USAGE: - rpk systems set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --type - --os - --cores - --ram - --runs-on The physical machine(s) the service is running on - --ip The ip address of the system -``` - -## `rpk systems del` -``` -DESCRIPTION: -Delete a system from the inventory - -USAGE: - rpk systems del [OPTIONS] - -ARGUMENTS: - The name of the system - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems rename` -``` -DESCRIPTION: -Rename a system to a new name - -USAGE: - rpk systems rename [OPTIONS] - -ARGUMENTS: - The name of the system - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems tree` -``` -DESCRIPTION: -Display the dependency tree for a system - -USAGE: - rpk systems tree [OPTIONS] - -ARGUMENTS: - The name of the system - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems label` -``` -DESCRIPTION: -Manage labels on a system - -USAGE: - rpk systems label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a system - remove Remove a label from a system -``` - -## `rpk systems label add` -``` -DESCRIPTION: -Add a label to a system - -USAGE: - rpk systems label add [OPTIONS] - -ARGUMENTS: - The name of the system - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk systems label remove` -``` -DESCRIPTION: -Remove a label from a system - -USAGE: - rpk systems label remove [OPTIONS] - -ARGUMENTS: - The name of the system - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk systems tag` -``` -DESCRIPTION: -Manage tags on a system - -USAGE: - rpk systems tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a system - remove Remove a tag from a system -``` - -## `rpk systems tag add` -``` -DESCRIPTION: -Add a tag to a system - -USAGE: - rpk systems tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk systems tag remove` -``` -DESCRIPTION: -Remove a tag from a system - -USAGE: - rpk systems tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints` -``` -DESCRIPTION: -Manage access points - -USAGE: - rpk accesspoints [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a hardware report for all access points - add Add a new access point - list List all access points - get Retrieve an access point by name - describe Show detailed information about an access point - set Update properties of an access point - del Delete an access point - rename Rename an access point to a new name - label Manage labels on an access point - tag Manage tags on an access point -``` - -## `rpk accesspoints summary` -``` -DESCRIPTION: -Show a hardware report for all access points - -USAGE: - rpk accesspoints summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints add` -``` -DESCRIPTION: -Add a new access point - -USAGE: - rpk accesspoints add [OPTIONS] - -ARGUMENTS: - The access point name - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints list` -``` -DESCRIPTION: -List all access points - -USAGE: - rpk accesspoints list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints get` -``` -DESCRIPTION: -Retrieve an access point by name - -USAGE: - rpk accesspoints get [OPTIONS] - -ARGUMENTS: - The access point name - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints describe` -``` -DESCRIPTION: -Show detailed information about an access point - -USAGE: - rpk accesspoints describe [OPTIONS] - -ARGUMENTS: - The access point name - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints set` -``` -DESCRIPTION: -Update properties of an access point - -USAGE: - rpk accesspoints set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model The access point model name - --speed The speed of the access point in Gb -``` - -## `rpk accesspoints del` -``` -DESCRIPTION: -Delete an access point - -USAGE: - rpk accesspoints del [OPTIONS] - -ARGUMENTS: - The access point name - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints rename` -``` -DESCRIPTION: -Rename an access point to a new name - -USAGE: - rpk accesspoints rename [OPTIONS] - -ARGUMENTS: - The access point name - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints label` -``` -DESCRIPTION: -Manage labels on an access point - -USAGE: - rpk accesspoints label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to an access point - remove Remove a label from an access point -``` - -## `rpk accesspoints label add` -``` -DESCRIPTION: -Add a label to an access point - -USAGE: - rpk accesspoints label add [OPTIONS] - -ARGUMENTS: - The access point name - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk accesspoints label remove` -``` -DESCRIPTION: -Remove a label from an access point - -USAGE: - rpk accesspoints label remove [OPTIONS] - -ARGUMENTS: - The access point name - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk accesspoints tag` -``` -DESCRIPTION: -Manage tags on an access point - -USAGE: - rpk accesspoints tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to an access point - remove Remove a tag from an access point -``` - -## `rpk accesspoints tag add` -``` -DESCRIPTION: -Add a tag to an access point - -USAGE: - rpk accesspoints tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk accesspoints tag remove` -``` -DESCRIPTION: -Remove a tag from an access point - -USAGE: - rpk accesspoints tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups` -``` -DESCRIPTION: -Manage UPS units - -USAGE: - rpk ups [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a hardware report for all UPS units - add Add a new UPS unit - list List all UPS units - get Retrieve a UPS unit by name - describe Show detailed information about a UPS unit - set Update properties of a UPS unit - del Delete a UPS unit - rename Rename a UPS unit to a new name - label Manage labels on a UPS unit - tag Manage tags on a UPS unit -``` - -## `rpk ups summary` -``` -DESCRIPTION: -Show a hardware report for all UPS units - -USAGE: - rpk ups summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups add` -``` -DESCRIPTION: -Add a new UPS unit - -USAGE: - rpk ups add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups list` -``` -DESCRIPTION: -List all UPS units - -USAGE: - rpk ups list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups get` -``` -DESCRIPTION: -Retrieve a UPS unit by name - -USAGE: - rpk ups get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups describe` -``` -DESCRIPTION: -Show detailed information about a UPS unit - -USAGE: - rpk ups describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups set` -``` -DESCRIPTION: -Update properties of a UPS unit - -USAGE: - rpk ups set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model - --va -``` - -## `rpk ups del` -``` -DESCRIPTION: -Delete a UPS unit - -USAGE: - rpk ups del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups rename` -``` -DESCRIPTION: -Rename a UPS unit to a new name - -USAGE: - rpk ups rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups label` -``` -DESCRIPTION: -Manage labels on a UPS unit - -USAGE: - rpk ups label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a UPS unit - remove Remove a label from a UPS unit -``` - -## `rpk ups label add` -``` -DESCRIPTION: -Add a label to a UPS unit - -USAGE: - rpk ups label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk ups label remove` -``` -DESCRIPTION: -Remove a label from a UPS unit - -USAGE: - rpk ups label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk ups tag` -``` -DESCRIPTION: -Manage tags on a UPS unit - -USAGE: - rpk ups tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a UPS unit - remove Remove a tag from a UPS unit -``` - -## `rpk ups tag add` -``` -DESCRIPTION: -Add a tag to a UPS unit - -USAGE: - rpk ups tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ups tag remove` -``` -DESCRIPTION: -Remove a tag from a UPS unit - -USAGE: - rpk ups tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other` -``` -DESCRIPTION: -Manage other hardware that doesn't fit an existing category - -USAGE: - rpk other [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a hardware report for all other hardware - add Add new other hardware - list List all other hardware - get Retrieve other hardware by name - describe Show detailed information about other hardware - set Update properties of other hardware - del Delete other hardware - rename Rename other hardware to a new name - label Manage labels on other hardware - tag Manage tags on other hardware -``` - -## `rpk other summary` -``` -DESCRIPTION: -Show a hardware report for all other hardware - -USAGE: - rpk other summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other add` -``` -DESCRIPTION: -Add new other hardware - -USAGE: - rpk other add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other list` -``` -DESCRIPTION: -List all other hardware - -USAGE: - rpk other list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other get` -``` -DESCRIPTION: -Retrieve other hardware by name - -USAGE: - rpk other get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other describe` -``` -DESCRIPTION: -Show detailed information about other hardware - -USAGE: - rpk other describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other set` -``` -DESCRIPTION: -Update properties of other hardware - -USAGE: - rpk other set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model - --description -``` - -## `rpk other del` -``` -DESCRIPTION: -Delete other hardware - -USAGE: - rpk other del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other rename` -``` -DESCRIPTION: -Rename other hardware to a new name - -USAGE: - rpk other rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other label` -``` -DESCRIPTION: -Manage labels on other hardware - -USAGE: - rpk other label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to other hardware - remove Remove a label from other hardware -``` - -## `rpk other label add` -``` -DESCRIPTION: -Add a label to other hardware - -USAGE: - rpk other label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk other label remove` -``` -DESCRIPTION: -Remove a label from other hardware - -USAGE: - rpk other label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk other tag` -``` -DESCRIPTION: -Manage tags on other hardware - -USAGE: - rpk other tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to other hardware - remove Remove a tag from other hardware -``` - -## `rpk other tag add` -``` -DESCRIPTION: -Add a tag to other hardware - -USAGE: - rpk other tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk other tag remove` -``` -DESCRIPTION: -Remove a tag from other hardware - -USAGE: - rpk other tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops` -``` -DESCRIPTION: -Manage desktop computers and their components - -USAGE: - rpk desktops [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a new desktop - list List all desktops - get Retrieve a desktop by name - describe Show detailed information about a desktop - set Update properties of a desktop - del Delete a desktop from the inventory - rename Rename a desktop to a new name - summary Show a summarized hardware report for all - desktops - tree Display the dependency tree for a desktop - cpu Manage CPUs attached to desktops - drive Manage storage drives attached to desktops - gpu Manage GPUs attached to desktops - nic Manage network interface cards (NICs) for - desktops - label Manage labels on a desktop - tag Manage tags on a desktop -``` - -## `rpk desktops add` -``` -DESCRIPTION: -Add a new desktop - -USAGE: - rpk desktops add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops list` -``` -DESCRIPTION: -List all desktops - -USAGE: - rpk desktops list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops get` -``` -DESCRIPTION: -Retrieve a desktop by name - -USAGE: - rpk desktops get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops describe` -``` -DESCRIPTION: -Show detailed information about a desktop - -USAGE: - rpk desktops describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops set` -``` -DESCRIPTION: -Update properties of a desktop - -USAGE: - rpk desktops set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model -``` - -## `rpk desktops del` -``` -DESCRIPTION: -Delete a desktop from the inventory - -USAGE: - rpk desktops del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops rename` -``` -DESCRIPTION: -Rename a desktop to a new name - -USAGE: - rpk desktops rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops summary` -``` -DESCRIPTION: -Show a summarized hardware report for all desktops - -USAGE: - rpk desktops summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops tree` -``` -DESCRIPTION: -Display the dependency tree for a desktop - -USAGE: - rpk desktops tree [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops cpu` -``` -DESCRIPTION: -Manage CPUs attached to desktops - -USAGE: - rpk desktops cpu [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a CPU to a desktop - set Update a desktop CPU - del Remove a CPU from a desktop -``` - -## `rpk desktops cpu add` -``` -DESCRIPTION: -Add a CPU to a desktop - -USAGE: - rpk desktops cpu add [OPTIONS] - -ARGUMENTS: - The desktop name - -OPTIONS: - -h, --help Prints help information - --model The model name - --cores The number of cpu cores - --threads The number of cpu threads -``` - -## `rpk desktops cpu set` -``` -DESCRIPTION: -Update a desktop CPU - -USAGE: - rpk desktops cpu set [OPTIONS] - -ARGUMENTS: - The desktop name - The index of the desktop cpu - -OPTIONS: - -h, --help Prints help information - --model The cpu model - --cores The number of cpu cores - --threads The number of cpu threads -``` - -## `rpk desktops cpu del` -``` -DESCRIPTION: -Remove a CPU from a desktop - -USAGE: - rpk desktops cpu del [OPTIONS] - -ARGUMENTS: - The name of the desktop - The index of the desktop cpu to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops drive` -``` -DESCRIPTION: -Manage storage drives attached to desktops - -USAGE: - rpk desktops drive [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a drive to a desktop - set Update a desktop drive - del Remove a drive from a desktop -``` - -## `rpk desktops drive add` -``` -DESCRIPTION: -Add a drive to a desktop - -USAGE: - rpk desktops drive add [OPTIONS] - -ARGUMENTS: - The name of the desktop - -OPTIONS: - -h, --help Prints help information - --type The drive type e.g hdd / ssd - --size The drive capacity in GB -``` - -## `rpk desktops drive set` -``` -DESCRIPTION: -Update a desktop drive - -USAGE: - rpk desktops drive set [OPTIONS] - -ARGUMENTS: - The desktop name - The drive index to update - -OPTIONS: - -h, --help Prints help information - --type The drive type e.g hdd / ssd - --size The drive capacity in Gb -``` - -## `rpk desktops drive del` -``` -DESCRIPTION: -Remove a drive from a desktop - -USAGE: - rpk desktops drive del [OPTIONS] - -ARGUMENTS: - The name of the desktop - The index of the drive to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops gpu` -``` -DESCRIPTION: -Manage GPUs attached to desktops - -USAGE: - rpk desktops gpu [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a GPU to a desktop - set Update a desktop GPU - del Remove a GPU from a desktop -``` - -## `rpk desktops gpu add` -``` -DESCRIPTION: -Add a GPU to a desktop - -USAGE: - rpk desktops gpu add [OPTIONS] - -ARGUMENTS: - The name of the desktop - -OPTIONS: - -h, --help Prints help information - --model The Gpu model - --vram The amount of gpu vram in Gb -``` - -## `rpk desktops gpu set` -``` -DESCRIPTION: -Update a desktop GPU - -USAGE: - rpk desktops gpu set [OPTIONS] - -ARGUMENTS: - The desktop name - The index of the gpu to update - -OPTIONS: - -h, --help Prints help information - --model The gpu model name - --vram The amount of gpu vram in Gb -``` - -## `rpk desktops gpu del` -``` -DESCRIPTION: -Remove a GPU from a desktop - -USAGE: - rpk desktops gpu del [OPTIONS] - -ARGUMENTS: - The desktop name - The index of the Gpu to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops nic` -``` -DESCRIPTION: -Manage network interface cards (NICs) for desktops - -USAGE: - rpk desktops nic [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a NIC to a desktop - set Update a desktop NIC - del Remove a NIC from a desktop -``` - -## `rpk desktops nic add` -``` -DESCRIPTION: -Add a NIC to a desktop - -USAGE: - rpk desktops nic add [OPTIONS] - -ARGUMENTS: - The desktop name - -OPTIONS: - -h, --help Prints help information - --type The nic port type e.g rj45 / sfp+ - --speed The port speed - --ports The number of ports -``` - -## `rpk desktops nic set` -``` -DESCRIPTION: -Update a desktop NIC - -USAGE: - rpk desktops nic set [OPTIONS] - -ARGUMENTS: - The desktop name - The index of the nic to remove - -OPTIONS: - -h, --help Prints help information - --type The nic port type e.g rj45 / sfp+ - --speed The speed of the nic in Gb/s - --ports The number of ports -``` - -## `rpk desktops nic del` -``` -DESCRIPTION: -Remove a NIC from a desktop - -USAGE: - rpk desktops nic del [OPTIONS] - -ARGUMENTS: - The desktop name - The index of the nic to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops label` -``` -DESCRIPTION: -Manage labels on a desktop - -USAGE: - rpk desktops label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a desktop - remove Remove a label from a desktop -``` - -## `rpk desktops label add` -``` -DESCRIPTION: -Add a label to a desktop - -USAGE: - rpk desktops label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk desktops label remove` -``` -DESCRIPTION: -Remove a label from a desktop - -USAGE: - rpk desktops label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk desktops tag` -``` -DESCRIPTION: -Manage tags on a desktop - -USAGE: - rpk desktops tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a desktop - remove Remove a tag from a desktop -``` - -## `rpk desktops tag add` -``` -DESCRIPTION: -Add a tag to a desktop - -USAGE: - rpk desktops tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk desktops tag remove` -``` -DESCRIPTION: -Remove a tag from a desktop - -USAGE: - rpk desktops tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops` -``` -DESCRIPTION: -Manage Laptop computers and their components - -USAGE: - rpk laptops [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a new Laptop - list List all Laptops - get Retrieve a Laptop by name - describe Show detailed information about a Laptop - set Update properties of a laptop - del Delete a Laptop from the inventory - rename Rename a Laptop to a new name - summary Show a summarized hardware report for all - Laptops - tree Display the dependency tree for a Laptop - cpu Manage CPUs attached to Laptops - drive Manage storage drives attached to Laptops - gpu Manage GPUs attached to Laptops - label Manage labels on a laptop - tag Manage tags on a laptop -``` - -## `rpk laptops add` -``` -DESCRIPTION: -Add a new Laptop - -USAGE: - rpk laptops add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops list` -``` -DESCRIPTION: -List all Laptops - -USAGE: - rpk laptops list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops get` -``` -DESCRIPTION: -Retrieve a Laptop by name - -USAGE: - rpk laptops get [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops describe` -``` -DESCRIPTION: -Show detailed information about a Laptop - -USAGE: - rpk laptops describe [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops set` -``` -DESCRIPTION: -Update properties of a laptop - -USAGE: - rpk laptops set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --model -``` - -## `rpk laptops del` -``` -DESCRIPTION: -Delete a Laptop from the inventory - -USAGE: - rpk laptops del [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops rename` -``` -DESCRIPTION: -Rename a Laptop to a new name - -USAGE: - rpk laptops rename [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops summary` -``` -DESCRIPTION: -Show a summarized hardware report for all Laptops - -USAGE: - rpk laptops summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops tree` -``` -DESCRIPTION: -Display the dependency tree for a Laptop - -USAGE: - rpk laptops tree [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops cpu` -``` -DESCRIPTION: -Manage CPUs attached to Laptops - -USAGE: - rpk laptops cpu [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a CPU to a Laptop - set Update a Laptop CPU - del Remove a CPU from a Laptop -``` - -## `rpk laptops cpu add` -``` -DESCRIPTION: -Add a CPU to a Laptop - -USAGE: - rpk laptops cpu add [OPTIONS] - -ARGUMENTS: - The Laptop name - -OPTIONS: - -h, --help Prints help information - --model The model name - --cores The number of cpu cores - --threads The number of cpu threads -``` - -## `rpk laptops cpu set` -``` -DESCRIPTION: -Update a Laptop CPU - -USAGE: - rpk laptops cpu set [OPTIONS] - -ARGUMENTS: - The Laptop name - The index of the Laptop cpu - -OPTIONS: - -h, --help Prints help information - --model The cpu model - --cores The number of cpu cores - --threads The number of cpu threads -``` - -## `rpk laptops cpu del` -``` -DESCRIPTION: -Remove a CPU from a Laptop - -USAGE: - rpk laptops cpu del [OPTIONS] - -ARGUMENTS: - The name of the Laptop - The index of the Laptop cpu to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops drive` -``` -DESCRIPTION: -Manage storage drives attached to Laptops - -USAGE: - rpk laptops drive [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a drive to a Laptop - set Update a Laptop drive - del Remove a drive from a Laptop -``` - -## `rpk laptops drive add` -``` -DESCRIPTION: -Add a drive to a Laptop - -USAGE: - rpk laptops drive add [OPTIONS] - -ARGUMENTS: - The name of the Laptop - -OPTIONS: - -h, --help Prints help information - --type The drive type e.g hdd / ssd - --size The drive capacity in GB: -``` - -## `rpk laptops drive set` -``` -DESCRIPTION: -Update a Laptop drive - -USAGE: - rpk laptops drive set [OPTIONS] - -ARGUMENTS: - The Laptop name - The drive index to update - -OPTIONS: - -h, --help Prints help information - --type The drive type e.g hdd / ssd - --size The drive capacity in Gb -``` - -## `rpk laptops drive del` -``` -DESCRIPTION: -Remove a drive from a Laptop - -USAGE: - rpk laptops drive del [OPTIONS] - -ARGUMENTS: - The name of the Laptop - The index of the drive to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops gpu` -``` -DESCRIPTION: -Manage GPUs attached to Laptops - -USAGE: - rpk laptops gpu [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a GPU to a Laptop - set Update a Laptop GPU - del Remove a GPU from a Laptop -``` - -## `rpk laptops gpu add` -``` -DESCRIPTION: -Add a GPU to a Laptop - -USAGE: - rpk laptops gpu add [OPTIONS] - -ARGUMENTS: - The name of the Laptop - -OPTIONS: - -h, --help Prints help information - --model The Gpu model - --vram The amount of gpu vram in Gb -``` - -## `rpk laptops gpu set` -``` -DESCRIPTION: -Update a Laptop GPU - -USAGE: - rpk laptops gpu set [OPTIONS] - -ARGUMENTS: - The Laptop name - The index of the gpu to update - -OPTIONS: - -h, --help Prints help information - --model The gpu model name - --vram The amount of gpu vram in Gb -``` - -## `rpk laptops gpu del` -``` -DESCRIPTION: -Remove a GPU from a Laptop - -USAGE: - rpk laptops gpu del [OPTIONS] - -ARGUMENTS: - The Laptop name - The index of the Gpu to remove - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops label` -``` -DESCRIPTION: -Manage labels on a laptop - -USAGE: - rpk laptops label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a laptop - remove Remove a label from a laptop -``` - -## `rpk laptops label add` -``` -DESCRIPTION: -Add a label to a laptop - -USAGE: - rpk laptops label add [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk laptops label remove` -``` -DESCRIPTION: -Remove a label from a laptop - -USAGE: - rpk laptops label remove [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk laptops tag` -``` -DESCRIPTION: -Manage tags on a laptop - -USAGE: - rpk laptops tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a laptop - remove Remove a tag from a laptop -``` - -## `rpk laptops tag add` -``` -DESCRIPTION: -Add a tag to a laptop - -USAGE: - rpk laptops tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk laptops tag remove` -``` -DESCRIPTION: -Remove a tag from a laptop - -USAGE: - rpk laptops tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services` -``` -DESCRIPTION: -Manage services and their configurations - -USAGE: - rpk services [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - summary Show a summary report for all services - add Add a new service - list List all services - get Retrieve a service by name - describe Show detailed information about a service - set Update properties of a service - del Delete a service - rename Rename a service to a new name - subnets List subnets associated with a service, - optionally filtered by CIDR - label Manage labels on a service - tag Manage tags on a service -``` - -## `rpk services summary` -``` -DESCRIPTION: -Show a summary report for all services - -USAGE: - rpk services summary [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services add` -``` -DESCRIPTION: -Add a new service - -USAGE: - rpk services add [OPTIONS] - -ARGUMENTS: - The name of the service - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services list` -``` -DESCRIPTION: -List all services - -USAGE: - rpk services list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services get` -``` -DESCRIPTION: -Retrieve a service by name - -USAGE: - rpk services get [OPTIONS] - -ARGUMENTS: - The name of the service - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services describe` -``` -DESCRIPTION: -Show detailed information about a service - -USAGE: - rpk services describe [OPTIONS] - -ARGUMENTS: - The name of the service - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services set` -``` -DESCRIPTION: -Update properties of a service - -USAGE: - rpk services set [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information - --ip The ip address of the service - --port The port the service is running on - --protocol The service protocol - --url The service URL - --runs-on The system(s) the service is running on -``` - -## `rpk services del` -``` -DESCRIPTION: -Delete a service - -USAGE: - rpk services del [OPTIONS] - -ARGUMENTS: - The name of the service - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services rename` -``` -DESCRIPTION: -Rename a service to a new name - -USAGE: - rpk services rename [OPTIONS] - -ARGUMENTS: - The name of the service - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services subnets` -``` -DESCRIPTION: -List subnets associated with a service, optionally filtered by CIDR - -USAGE: - rpk services subnets [OPTIONS] - -OPTIONS: - -h, --help Prints help information - --cidr - --prefix -``` - -## `rpk services label` -``` -DESCRIPTION: -Manage labels on a service - -USAGE: - rpk services label [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a label to a service - remove Remove a label from a service -``` - -## `rpk services label add` -``` -DESCRIPTION: -Add a label to a service - -USAGE: - rpk services label add [OPTIONS] - -ARGUMENTS: - The name of the service - -OPTIONS: - -h, --help Prints help information - --key - --value -``` - -## `rpk services label remove` -``` -DESCRIPTION: -Remove a label from a service - -USAGE: - rpk services label remove [OPTIONS] - -ARGUMENTS: - The name of the service - -OPTIONS: - -h, --help Prints help information - --key -``` - -## `rpk services tag` -``` -DESCRIPTION: -Manage tags on a service - -USAGE: - rpk services tag [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Add a tag to a service - remove Remove a tag from a service -``` - -## `rpk services tag add` -``` -DESCRIPTION: -Add a tag to a service - -USAGE: - rpk services tag add [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk services tag remove` -``` -DESCRIPTION: -Remove a tag from a service - -USAGE: - rpk services tag remove [OPTIONS] - -ARGUMENTS: - - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk ansible` -``` -DESCRIPTION: -Generate and manage Ansible inventory - -USAGE: - rpk ansible [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - inventory Generate an Ansible inventory -``` - -## `rpk ansible inventory` -``` -DESCRIPTION: -Generate an Ansible inventory - -USAGE: - rpk ansible inventory [OPTIONS] - -OPTIONS: - DEFAULT - -h, --help Prints help information - --group-tags Comma-separated list of tags to group by - (e.g. prod,staging) - --group-labels Comma-separated list of label keys to group - by (e.g. env,site) - --global-var Global variable (repeatable). Format: - key=value - --format ini Inventory format: ini (default) or yaml - -o, --output Write inventory to file instead of stdout -``` - -## `rpk ssh` -``` -DESCRIPTION: -Generate SSH configuration from infrastructure - -USAGE: - rpk ssh [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - export Generate an SSH config file -``` - -## `rpk ssh export` -``` -DESCRIPTION: -Generate an SSH config file - -USAGE: - rpk ssh export [OPTIONS] - -OPTIONS: - DEFAULT - -h, --help Prints help information - --include-tags Comma-separated list of tags to include - (e.g. prod,linux) - --default-user Default SSH user if not defined in - labels - --default-port 22 Default SSH port if not defined in - labels (default: 22) - --default-identity Default SSH identity file (e.g. - ~/.ssh/id_rsa) - -o, --output Write SSH config to file instead of - stdout -``` - -## `rpk hosts` -``` -DESCRIPTION: -Generate a hosts file from infrastructure - -USAGE: - rpk hosts [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - export Generate a /etc/hosts compatible file -``` - -## `rpk hosts export` -``` -DESCRIPTION: -Generate a /etc/hosts compatible file - -USAGE: - rpk hosts export [OPTIONS] - -OPTIONS: - -h, --help Prints help information - --include-tags Comma-separated list of tags to include (e.g. - prod,staging) - --domain-suffix Optional domain suffix to append (e.g. home.local) - --no-localhost Do not include localhost defaults - -o, --output Write hosts file to file instead of stdout -``` - -## `rpk graph` -``` -DESCRIPTION: -Render inventory as graph diagrams - -USAGE: - rpk graph [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - topology Emit a Mermaid flowchart of the physical topology (hardware + - connections) - logical Emit a Mermaid flowchart of services & systems grouped by subnet - and host -``` - -## `rpk graph topology` -``` -DESCRIPTION: -Emit a Mermaid flowchart of the physical topology (hardware + connections) - -USAGE: - rpk graph topology [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk graph logical` -``` -DESCRIPTION: -Emit a Mermaid flowchart of services & systems grouped by subnet and host - -USAGE: - rpk graph logical [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk tags` -``` -DESCRIPTION: -Discover tags across resources - -USAGE: - rpk tags [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - list List all tags in use with usage counts - show List resources carrying a specific tag -``` - -## `rpk tags list` -``` -DESCRIPTION: -List all tags in use with usage counts - -USAGE: - rpk tags list [OPTIONS] - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk tags show` -``` -DESCRIPTION: -List resources carrying a specific tag - -USAGE: - rpk tags show [OPTIONS] - -ARGUMENTS: - - -OPTIONS: - -h, --help Prints help information -``` - -## `rpk connections` -``` -DESCRIPTION: -Manage physical or logical port connections - -USAGE: - rpk connections [OPTIONS] - -OPTIONS: - -h, --help Prints help information - -COMMANDS: - add Cre - ate - a - con - nec - tio - n - bet - wee - n - two - por - ts - remove Rem - ove - the - con - nec - tio - n - fro - m a - spe - cif - ic - por - t -``` - -## `rpk connections add` -``` -DESCRIPTION: -Create a connection between two ports - -USAGE: - rpk connections add - [OPTIONS] - -ARGUMENTS: - Resource name for endpoint A - Port group index for endpoint A - Port index for endpoint A - Resource name for endpoint B - Port group index for endpoint B - Port index for endpoint B - -OPTIONS: - -h, --help Prints help information - --label Optional label for the connection - --notes Optional notes for the connection -``` - -## `rpk connections remove` -``` -DESCRIPTION: -Remove the connection from a specific port - -USAGE: - rpk connections remove [OPTIONS] - -ARGUMENTS: - Resource name - Port group index - Port index - -OPTIONS: - -h, --help Prints help information -``` - diff --git a/docs/development/release-guide.md b/docs/development/release-guide.md new file mode 100644 index 00000000..cc3338c7 --- /dev/null +++ b/docs/development/release-guide.md @@ -0,0 +1,87 @@ +# Release Guide + +How to cut a RackPeek release, end to end. The flow is: +features → `staging` (nightly Docker auto-publishes) → `main` → tagged release. + +Everything below happens on `staging` until the "Merge & publish" step. + +--- + +## 1. Pre-release checks + +- [ ] `staging` CI is green. +- [ ] Full suite passes locally: `just ci` (CLI + discovery + MCP + E2E; rebuilds the Web image). +- [ ] Review the release diff: `git diff origin/main...origin/staging --stat`. + +## 2. Bump the version + +Pick the new version per [versioning.md](../../Shared.Rcl/wwwroot/raw_docs/versioning.md) (SemVer). + +The version is declared in **three places** — all must agree: + +| File | Format | +|------|--------| +| `RackPeek.Domain/RpkConstants.cs` (`Version`) | `v2.2.0` — feeds `rpk --version`, the web UI header chip, and MCP `ServerInfo` | +| `RackPeek/RackPeek.csproj` (``) | `2.2.0` | +| `README.md` version badge | `2.2.0` | + +Also update the version-adjacent extras: + +- [ ] `Shared.Rcl/wwwroot/raw_docs/install-guide.md` — pinned release download URLs (tag `RackPeek-X.Y.Z`, asset `rackpeek_X_Y_Z_`). +- [ ] `.github/workflows/publish-cli.yml` and `publish-docker.yaml` — `workflow_dispatch` version defaults. + +## 3. Regenerate the CLI docs + +```bash +rm -rf RackPeek/publish # REQUIRED: the script reuses a stale binary if one exists +./generate-docs.sh +``` + +> ⚠ `generate-docs.sh` only publishes the CLI when `RackPeek/publish/RackPeek` is +> missing. Skipping the `rm` regenerates docs from whatever binary is lying +> around — this has silently dropped new commands before. + +Verify: + +- [ ] `git diff Shared.Rcl/wwwroot/raw_docs/cli-commands.md` — only expected command changes. +- [ ] `grep -c '^## ' Shared.Rcl/wwwroot/raw_docs/cli-commands.md` matches the header count in `cli-commands-index.md`. + +## 4. Docs freshness pass + +- [ ] New user-facing features are mentioned in `README.md` and `Shared.Rcl/wwwroot/raw_docs/overview.md`. +- [ ] New doc pages are listed in `Shared.Rcl/wwwroot/raw_docs/docs-index.json` and linked from the README Docs section. +- [ ] `resource-levels.md` sub-resource matrix reflects any new kinds/sub-resources. + +Commit everything to `staging` and let CI run. + +## 5. Merge & publish + +1. Open a PR `staging` → `main`, wait for CI, merge. +2. From `main`, dispatch the publish workflows (Actions tab → Run workflow). **The + version input formats differ per workflow:** + +| Workflow | Version input | Produces | +|----------|---------------|----------| +| `publish-cli.yml` | `2.2.0` (no `v`) | **Draft** GitHub Release tagged `RackPeek-2.2.0` with `rackpeek_2_2_0_{win-x64,linux-x64,linux-arm64,osx-x64,osx-arm64}` binaries | +| `publish-docker.yaml` | `v2.2.0` (`v` required, regex-validated) | `aptacode/rackpeek:v2.2.0` + `:latest` (multi-arch) | +| `publish-webui.yml` | — | Redeploys the GitHub Pages demo + docs viewer | + +3. Edit the draft GitHub Release: write the release notes (features, fixes, + breaking changes/migrations), then publish it. + +## 6. Post-release verification + +- [ ] `docker pull aptacode/rackpeek:v2.2.0 && docker run --rm aptacode/rackpeek:v2.2.0 rpk --version` → `v2.2.0`. +- [ ] Web UI header shows the new version; `/mcp` still returns 503 without `RPK_API_KEY`. +- [ ] Release-page binary download runs (`chmod +x`, `./rackpeek --version`). +- [ ] Docs site reflects the new pages: https://timmoth.github.io/RackPeek/docs/overview + +## Gotchas + +- The **nightly** Docker image (`:nightly`, `:nightly-`) builds automatically + from every push to `staging` — it is not part of the release flow and is not + gated on tests. +- `workflow_dispatch` runs against the branch you select in the Actions UI — + make sure it's `main` for a release. +- The publish workflows do not read the in-repo version; the operator-typed + input is authoritative. Double-check it matches `RpkConstants.Version`. diff --git a/generate-docs.sh b/generate-docs.sh index b7a2edf2..9d25baff 100755 --- a/generate-docs.sh +++ b/generate-docs.sh @@ -136,7 +136,7 @@ fi local anchor_link=$(echo "$anchor_text" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') # Format: - [label](link) - Description - local tree_entry="${indent}- [${tree_label}](docs/Commands.md#${anchor_link})" + local tree_entry="${indent}- [${tree_label}](/docs/cli-commands#${anchor_link})" if [[ -n "$description" ]]; then tree_entry="${tree_entry} - ${description}" fi @@ -195,12 +195,12 @@ generate_help_recursive "" { echo "" cat "$TREE_TEMP" -} > "Shared.Rcl/wwwroot/raw_docs/CommandIndex.md" +} > "Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md" { echo "# CLI Commands" echo "" cat "$BODY_TEMP" -} > "Shared.Rcl/wwwroot/raw_docs/Commands.md" +} > "Shared.Rcl/wwwroot/raw_docs/cli-commands.md" echo "Generated Successfully." \ No newline at end of file From eee4e0bda9394440c61d87d883d428b02ca99840 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 16:56:39 +0100 Subject: [PATCH 14/29] Add failing E2E repros for #337: damaged config.yaml is accepted silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests fail on staging, pinning the load-side contract of the issue: - A config truncated at a resource boundary (simulating an interrupted in-place save) is served as a plausible smaller inventory with no diagnostic — rpk summary shows 2 of 3 servers, exit 0. - A config cut mid-token is swallowed entirely: rpk summary reports an EMPTY inventory with exit 0 and no error naming the file. This is quieter than the YamlDotNet stack trace the issue observed on 2.x — the boot-load catch now hides the failure, so the next write would persist the empty inventory over the damaged-but-recoverable file. The writer-side fix (atomic + durable saves in PhysicalTextFileStore: temp file, flush, rename) is not black-box observable; these tests cover the belt-and-braces load-side behaviour that must accompany it. Co-Authored-By: Claude Fable 5 --- Tests/EndToEnd/CorruptConfigTests.cs | 90 ++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Tests/EndToEnd/CorruptConfigTests.cs diff --git a/Tests/EndToEnd/CorruptConfigTests.cs b/Tests/EndToEnd/CorruptConfigTests.cs new file mode 100644 index 00000000..a51059eb --- /dev/null +++ b/Tests/EndToEnd/CorruptConfigTests.cs @@ -0,0 +1,90 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd; + +// Reproduces the load-side half of +// https://github.com/Timmoth/RackPeek/issues/337: saves rewrite config.yaml in +// place (File.WriteAllTextAsync truncates before writing, and never flushes), +// so an interrupted save leaves a truncated file behind — and a truncated file +// is then accepted without complaint on the next load. +// +// The writer-side half (make PhysicalTextFileStore write atomically and +// durably: temp file + flush + rename) is not observable from a black-box +// test; these tests pin the user-facing contract that a damaged file must not +// be served silently or crash with a raw stack trace. +[Collection("Yaml CLI tests")] +public class CorruptConfigTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + // Exactly what the serializer writes for three bare servers. + private const string _fullConfig = """ + version: 4 + resources: + - kind: Server + name: srv-a + - kind: Server + name: srv-b + - kind: Server + name: srv-c + connections: [] + + """; + + private async Task ExecuteAsync(params string[] args) { + outputHelper.WriteLine($"rpk {string.Join(" ", args)}"); + + var output = await YamlCliTestHost.RunAsync( + args, + fs.Root, + outputHelper, + "config.yaml"); + + outputHelper.WriteLine(output); + return output; + } + + [Fact] + public async Task a_config_truncated_at_a_resource_boundary_is_not_served_silently() { + // Simulate an interrupted in-place save: the file ends mid-way through + // the resources list. This still parses — as a plausible, smaller + // inventory missing srv-c and the connections section. + var truncated = _fullConfig[.._fullConfig.IndexOf("- kind: Server\n name: srv-c", StringComparison.Ordinal)]; + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), truncated); + + var output = await ExecuteAsync("summary"); + + // Serving a structurally incomplete file (a resources document with no + // connections section — something RackPeek's own serializer never + // writes) with no diagnostic at all is how a truncation becomes silent + // data loss: the next save persists the smaller inventory as if it + // were intentional. + var hasDiagnostic = + output.Contains("error", StringComparison.OrdinalIgnoreCase) + || output.Contains("warn", StringComparison.OrdinalIgnoreCase) + || output.Contains("corrupt", StringComparison.OrdinalIgnoreCase) + || output.Contains("incomplete", StringComparison.OrdinalIgnoreCase) + || output.Contains("truncated", StringComparison.OrdinalIgnoreCase); + + Assert.True(hasDiagnostic, + $"A truncated config was served with no diagnostic. Output:\n{output}"); + } + + [Fact] + public async Task a_config_cut_mid_token_fails_with_a_friendly_error() { + // Simulate a save that died mid-write inside a YAML token. + var truncated = _fullConfig[.._fullConfig.IndexOf("nd: Server\n name: srv-c", StringComparison.Ordinal)]; + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), truncated); + + var output = await ExecuteAsync("summary"); + + // Observed on staging: the unparseable file is swallowed entirely and + // `rpk summary` reports an EMPTY inventory (Hardware (0)) with exit 0 — + // no error, no mention of the file. That is the worst outcome for + // #337: a later write would persist the empty inventory over the + // damaged-but-recoverable file. The user should instead get an + // actionable message naming the config file, and no stack dump. + Assert.DoesNotContain("at RackPeek.", output); + Assert.DoesNotContain("YamlDotNet.Core", output); + Assert.Contains("config.yaml", output); + } +} From c22af6d97d4422fb0b2de84b3a7ba2015824bfd6 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 17:00:28 +0100 Subject: [PATCH 15/29] Fix install-guide bind-mount ownership to the real container UID (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide told users to chown 1000:1000, but the image runs as 1654:1654 (APP_UID) — the exact confusion reported in #210. README was already corrected; this aligns the served install guide with it. Co-Authored-By: Claude Fable 5 --- Shared.Rcl/wwwroot/raw_docs/install-guide.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shared.Rcl/wwwroot/raw_docs/install-guide.md b/Shared.Rcl/wwwroot/raw_docs/install-guide.md index dbaea7bd..2edccc76 100644 --- a/Shared.Rcl/wwwroot/raw_docs/install-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/install-guide.md @@ -92,16 +92,16 @@ If you see: Access to the path '/app/config/config.yaml' is denied. ``` -Fix ownership: +Fix ownership — RackPeek runs as UID/GID **1654:1654** inside the container: ```bash -sudo chown -R 1000:1000 /path/on/host/rackpeek +sudo chown -R 1654:1654 /path/on/host/rackpeek ``` -Or explicitly set the container user: +You can verify the container user with: -```yaml -user: "1000:1000" +```bash +docker exec rackpeek id ``` RackPeek must be able to: From 676529e6ede48da7ff4fb7fa5ca6b792f0f59270 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 18:12:00 +0100 Subject: [PATCH 16/29] Make config saves atomic and refuse to read a damaged config (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving config.yaml was neither atomic nor durable, and a damaged file was then accepted without complaint on the next load. Together those could lose an inventory. Writes went through File.WriteAllTextAsync, which truncates the file to zero before writing a byte and never flushes — so an interrupted save could leave a truncated config, and a save that had returned successfully could still be lost to power failure. Migration backups shared that path, so the one existing safety net was written unsafely too. The store now writes a temp file, flushes it to disk, and renames over the destination; a failed write cleans up and leaves the original untouched. Reads were worse than the issue reported: on staging an unparseable config loaded as an EMPTY inventory with exit 0, because the boot-time catch for unreadable stores was swallowing parse failures too. A ConfigLoadException is now latched on the collection when the config exists but cannot be understood, and every read and write refuses while it is set — the CLI reports "Config error:" with exit 5, MCP forwards the message. Two cases are detected: the file fails to parse, and the file parses with no schema version (truncated before the version line, or not a RackPeek config). An empty file is still a legitimately empty inventory, and legacy versionless configs still migrate. Routes.razor gated the whole app on a successful load, so a damaged config left the Web UI stuck on "Loading…" — including the YAML editor that is how you fix it. Both hosts now render anyway, and the editor reports a still-broken save inline instead of tearing down the circuit. A save interrupted at a clean resource boundary leaves valid YAML that is indistinguishable from a smaller inventory; nothing at load time can detect that, which is why the atomic-write half is the primary remedy. Tests: 5 store tests (the concurrency one fails on the parent commit with a 12288-byte partial read), 6 CLI e2e tests, 2 Playwright tests covering repair through the YAML editor. Co-Authored-By: Claude Fable 5 --- .../Helpers/ConfigLoadException.cs | 18 +++ .../Persistence/Yaml/ITextFileStore.cs | 50 ++++++- .../Yaml/YamlResourceCollection.cs | 106 ++++++++++++-- RackPeek.Mcp/ToolErrors.cs | 5 + RackPeek.Web.Viewer/App.razor | 15 +- RackPeek.Web/Components/Routes.razor | 14 +- Shared.Rcl/CliBootstrap.cs | 11 ++ Shared.Rcl/YamlFileComponent.razor | 13 +- Shared.Rcl/wwwroot/raw_docs/install-guide.md | 6 + Tests.E2e/DamagedConfigTests.cs | 113 +++++++++++++++ Tests.E2e/Infra/PlaywrightFixture.cs | 17 +++ Tests/EndToEnd/CorruptConfigTests.cs | 120 ++++++++++------ Tests/Yaml/PhysicalTextFileStoreTests.cs | 131 ++++++++++++++++++ 13 files changed, 559 insertions(+), 60 deletions(-) create mode 100644 RackPeek.Domain/Helpers/ConfigLoadException.cs create mode 100644 Tests.E2e/DamagedConfigTests.cs create mode 100644 Tests/Yaml/PhysicalTextFileStoreTests.cs diff --git a/RackPeek.Domain/Helpers/ConfigLoadException.cs b/RackPeek.Domain/Helpers/ConfigLoadException.cs new file mode 100644 index 00000000..865bb82c --- /dev/null +++ b/RackPeek.Domain/Helpers/ConfigLoadException.cs @@ -0,0 +1,18 @@ +namespace RackPeek.Domain.Helpers; + +/// +/// The config file exists but cannot be read as a RackPeek document — damaged, +/// truncated, or not YAML. Distinct from an unreadable store (IO errors), which is +/// tolerated at boot: a damaged file must fail loudly on every read and write so a +/// partial or empty inventory is never served, and never persisted over the +/// user's file (#337). +/// +public sealed class ConfigLoadException : Exception { + public ConfigLoadException(string message) + : base(message) { + } + + public ConfigLoadException(string message, Exception innerException) + : base(message, innerException) { + } +} diff --git a/RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs b/RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs index 80d1212b..02fd534a 100644 --- a/RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs +++ b/RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace RackPeek.Domain.Persistence.Yaml; public interface ITextFileStore { @@ -11,5 +13,51 @@ public sealed class PhysicalTextFileStore : ITextFileStore { public Task ReadAllTextAsync(string path) => File.ReadAllTextAsync(path); - public Task WriteAllTextAsync(string path, string contents) => File.WriteAllTextAsync(path, contents); + /// + /// Atomic and durable replacement for File.WriteAllTextAsync, which truncates + /// the destination before writing and never flushes to disk — so a crash or + /// power loss mid-save could leave a truncated config, and a save that had + /// "succeeded" could still be lost (#337). The content is written to a + /// temporary file in the same directory, flushed to disk, then moved over the + /// destination — a rename, so readers only ever see the old or the new file, + /// never a partial one. + /// + public async Task WriteAllTextAsync(string path, string contents) { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath) + ?? throw new IOException($"'{path}' has no parent directory."); + + var tempPath = Path.Combine( + directory, + $"{Path.GetFileName(fullPath)}.tmp-{Guid.NewGuid():N}"); + + try { + await using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None)) { + var bytes = Encoding.UTF8.GetBytes(contents); + await stream.WriteAsync(bytes); + + // Flush through the OS cache to the disk itself, so the rename + // below never publishes a file whose bytes could still vanish. + stream.Flush(true); + } + + File.Move(tempPath, fullPath, true); + } + catch { + // Never leave temp files behind on a failed write; the destination + // is untouched by construction. + try { + File.Delete(tempPath); + } + catch (IOException) { + // Best effort — the stray temp file is harmless. + } + + throw; + } + } } diff --git a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs index 8a6b62a1..747528eb 100644 --- a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs +++ b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs @@ -2,6 +2,7 @@ using System.Collections.Specialized; using System.Diagnostics; using RackPeek.Domain.Discovery; +using RackPeek.Domain.Helpers; using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.AccessPoints; using RackPeek.Domain.Resources.Connections; @@ -33,6 +34,14 @@ public class ResourceCollection { /// the user's file. /// public bool Loaded { get; set; } + + /// + /// Set when the config exists but could not be understood — damaged, truncated, + /// or not YAML. The process is still allowed to boot (the web UI is how someone + /// fixes the file), but every read and write must fail loudly rather than serve + /// or persist an empty inventory (#337). + /// + public ConfigLoadException? LoadFailure { get; set; } } public sealed class YamlResourceCollection( @@ -45,16 +54,19 @@ public sealed class YamlResourceCollection( private static readonly int _currentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count; public Task Exists(string name) { + ThrowIfLoadFailed(); return Task.FromResult(resourceCollection.Resources.Exists(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))); } public Task GetKind(string? name) { + ThrowIfLoadFailed(); return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind); } public Task> GetByLabelAsync(string name) { + ThrowIfLoadFailed(); ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources .Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _)) .Select(r => (r, r.Labels![name])) @@ -65,6 +77,7 @@ public Task Exists(string name) { } public Task> GetLabelsAsync() { + ThrowIfLoadFailed(); var result = resourceCollection.Resources .SelectMany(r => r.Labels ?? Enumerable.Empty>()) .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) @@ -75,6 +88,7 @@ public Task> GetLabelsAsync() { } public Task> GetResourceIpsAsync() { + ThrowIfLoadFailed(); var result = new List<(Resource, string)>(); List allResources = resourceCollection.Resources; @@ -108,6 +122,7 @@ public Task> GetLabelsAsync() { } public Task> GetTagsAsync() { + ThrowIfLoadFailed(); var result = resourceCollection.Resources .SelectMany(r => r.Tags) // flatten all tag arrays .Where(t => !string.IsNullOrWhiteSpace(t)) @@ -117,10 +132,13 @@ public Task> GetTagsAsync() { return Task.FromResult(result); } - public Task> GetAllOfTypeAsync() => - Task.FromResult>(resourceCollection.Resources.OfType().ToList()); + public Task> GetAllOfTypeAsync() { + ThrowIfLoadFailed(); + return Task.FromResult>(resourceCollection.Resources.OfType().ToList()); + } public Task> GetDependantsAsync(string name) { + ThrowIfLoadFailed(); var result = resourceCollection.Resources .Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase))) .ToList(); @@ -177,6 +195,7 @@ public async Task Merge(string incomingYaml, MergeMode mode) { } public Task> GetByTagAsync(string name) { + ThrowIfLoadFailed(); return Task.FromResult>( resourceCollection.Resources .Where(r => r.Tags.Contains(name)) @@ -184,27 +203,42 @@ public Task> GetByTagAsync(string name) { ); } - public IReadOnlyList HardwareResources => - resourceCollection.Resources.OfType().ToList(); + public IReadOnlyList HardwareResources { + get { + ThrowIfLoadFailed(); + return resourceCollection.Resources.OfType().ToList(); + } + } - public IReadOnlyList SystemResources => - resourceCollection.Resources.OfType().ToList(); + public IReadOnlyList SystemResources { + get { + ThrowIfLoadFailed(); + return resourceCollection.Resources.OfType().ToList(); + } + } - public IReadOnlyList ServiceResources => - resourceCollection.Resources.OfType().ToList(); + public IReadOnlyList ServiceResources { + get { + ThrowIfLoadFailed(); + return resourceCollection.Resources.OfType().ToList(); + } + } public Task GetByNameAsync(string name) { + ThrowIfLoadFailed(); return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))); } public Task GetByNameAsync(string name) where T : Resource { + ThrowIfLoadFailed(); Resource? resource = resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(resource as T); } public Resource? GetByName(string name) { + ThrowIfLoadFailed(); return resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } @@ -227,11 +261,42 @@ public async Task LoadAsync() { private async Task LoadUnderLockAsync() { var yaml = await fileStore.ReadAllTextAsync(filePath); - YamlRoot root = await migrationService.DeserializeAsync( - yaml, - async originalYaml => await BackupOriginalAsync(originalYaml), - async migratedRoot => await SaveRootAsync(migratedRoot) - ); + YamlRoot root; + + try { + root = await migrationService.DeserializeAsync( + yaml, + async originalYaml => await BackupOriginalAsync(originalYaml), + async migratedRoot => await SaveRootAsync(migratedRoot) + ); + } + catch (Exception ex) when (ex is not ConfigLoadException + and not IOException + and not UnauthorizedAccessException) { + // A file that exists but cannot be understood. Record it so that every + // later read and write refuses, rather than quietly serving — and then + // persisting — an empty inventory over a recoverable file (#337). + resourceCollection.LoadFailure = new ConfigLoadException( + $"The config at {filePath} could not be read: {ex.Message} " + + "Fix or restore the file (recent schema migrations leave .bak copies " + + "beside it); nothing has been changed.", + ex); + + throw resourceCollection.LoadFailure; + } + + // A RackPeek document always carries a schema version. Its absence means the + // file was cut before the version line was written, or is not a RackPeek + // config at all — either way the parse "succeeding" with an empty document is + // not evidence of an empty inventory. + if (!string.IsNullOrWhiteSpace(yaml) && root.Version <= 0) { + resourceCollection.LoadFailure = new ConfigLoadException( + $"The config at {filePath} is missing its schema version, so it is " + + "incomplete or not a RackPeek config. Fix or restore the file; " + + "nothing has been changed."); + + throw resourceCollection.LoadFailure; + } resourceCollection.Resources.Clear(); @@ -243,9 +308,21 @@ private async Task LoadUnderLockAsync() { if (root.Connections != null) resourceCollection.Connections.AddRange(root.Connections); + resourceCollection.LoadFailure = null; resourceCollection.Loaded = true; } + /// + /// Called at the top of every read path. When the config exists but could not be + /// understood, the in-memory collection is empty for a reason that has nothing to + /// do with the user's inventory — serving it would report "0 resources" for a + /// recoverable file, and scripted consumers would treat that as the truth (#337). + /// + private void ThrowIfLoadFailed() { + if (resourceCollection.LoadFailure != null) + throw resourceCollection.LoadFailure; + } + /// /// 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 @@ -302,6 +379,7 @@ public Task RemoveConnectionsForPortAsync(PortReference port) { } public Task> GetConnectionsAsync() { + ThrowIfLoadFailed(); IReadOnlyList result = resourceCollection.Connections .ToList() @@ -311,6 +389,7 @@ public Task> GetConnectionsAsync() { } public Task> GetConnectionsForResourceAsync(string resource) { + ThrowIfLoadFailed(); IReadOnlyList result = resourceCollection.Connections .Where(c => @@ -323,6 +402,7 @@ public Task> GetConnectionsForResourceAsync(string res } public Task GetConnectionForPortAsync(PortReference port) { + ThrowIfLoadFailed(); Connection? connection = resourceCollection.Connections .FirstOrDefault(c => diff --git a/RackPeek.Mcp/ToolErrors.cs b/RackPeek.Mcp/ToolErrors.cs index b834249c..03b08f99 100644 --- a/RackPeek.Mcp/ToolErrors.cs +++ b/RackPeek.Mcp/ToolErrors.cs @@ -21,6 +21,11 @@ public static async Task RunAsync(Func> action) { catch (NotFoundException ex) { throw new McpException(ex.Message); } + catch (ConfigLoadException ex) { + // The config exists but cannot be read. Say so plainly rather than letting + // the agent see a generic failure and conclude the inventory is empty. + throw new McpException(ex.Message); + } catch (ConflictException ex) { throw new McpException(ex.Message); } diff --git a/RackPeek.Web.Viewer/App.razor b/RackPeek.Web.Viewer/App.razor index 2354a8a9..faa9baf3 100644 --- a/RackPeek.Web.Viewer/App.razor +++ b/RackPeek.Web.Viewer/App.razor @@ -1,4 +1,5 @@ -@using RackPeek.Domain.Persistence +@using RackPeek.Domain.Helpers +@using RackPeek.Domain.Persistence @using RackPeek.Web.Viewer.Pages @using Shared.Rcl.Servers @inject IResourceCollection Resources @@ -23,7 +24,17 @@ else protected override async Task OnInitializedAsync() { - await Resources.LoadAsync(); + try + { + await Resources.LoadAsync(); + } + catch (ConfigLoadException) + { + // Same contract as the server host: a config that cannot be read must not + // leave the app stuck on "Loading…" — the YAML editor is how it is fixed. + // Here the store is browser storage, so this is a bad import rather than + // an interrupted write (#337). + } _ready = true; } diff --git a/RackPeek.Web/Components/Routes.razor b/RackPeek.Web/Components/Routes.razor index 34f1b59b..40291b79 100644 --- a/RackPeek.Web/Components/Routes.razor +++ b/RackPeek.Web/Components/Routes.razor @@ -1,4 +1,5 @@ -@using RackPeek.Domain.Persistence +@using RackPeek.Domain.Helpers +@using RackPeek.Domain.Persistence @using RackPeek.Web.Components.Pages @using Shared.Rcl.Servers @inject IResourceCollection Resources @@ -23,7 +24,16 @@ else protected override async Task OnInitializedAsync() { - await Resources.LoadAsync(); + try + { + await Resources.LoadAsync(); + } + catch (ConfigLoadException) + { + // A damaged config must not leave the app stuck on "Loading…" — the YAML + // editor is how someone repairs it. Pages that read the inventory surface + // the failure themselves; they can no longer report it as empty (#337). + } _ready = true; } diff --git a/Shared.Rcl/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index 032e0075..fa120baf 100644 --- a/Shared.Rcl/CliBootstrap.cs +++ b/Shared.Rcl/CliBootstrap.cs @@ -138,6 +138,13 @@ await System.Console.Error.WriteLineAsync( // 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})."); } + catch (ConfigLoadException) { + // A damaged config must not stop the process starting — `rpk discover` and + // `--help` do not need the inventory, and the web UI is how someone fixes + // the file. The failure is recorded on the collection, so every command + // that does touch the inventory fails with it instead of reporting an + // empty one (#337). + } services.AddSingleton(collection); // Infrastructure @@ -879,6 +886,10 @@ private static int HandleException(Exception ex, ITypeResolver? arg2) { AnsiConsole.MarkupLine($"[red]Not found:[/] {ne.Message}"); return 4; + case ConfigLoadException cle: + AnsiConsole.MarkupLine($"[red]Config error:[/] {Markup.Escape(cle.Message)}"); + return 5; + case CommandParseException pe: if (_showingHelp) return 1; // suppress errors during help lookup AnsiConsole.MarkupLine($"[red]Invalid command:[/] {pe.Message}"); diff --git a/Shared.Rcl/YamlFileComponent.razor b/Shared.Rcl/YamlFileComponent.razor index defc6595..26f08798 100644 --- a/Shared.Rcl/YamlFileComponent.razor +++ b/Shared.Rcl/YamlFileComponent.razor @@ -168,7 +168,18 @@ await FileStore.WriteAllTextAsync(Path, _editText); - await Resources.LoadAsync(); + try + { + await Resources.LoadAsync(); + } + catch (RackPeek.Domain.Helpers.ConfigLoadException ex) + { + // The edit was saved but the app still cannot read it. Report it here + // rather than tearing down the circuit — this editor is the repair tool. + _error = new YamlEditError(ex.Message, null, null, null); + _currentText = _editText; + return; + } _currentText = _editText; _isEditing = false; diff --git a/Shared.Rcl/wwwroot/raw_docs/install-guide.md b/Shared.Rcl/wwwroot/raw_docs/install-guide.md index dbaea7bd..1e8c7f78 100644 --- a/Shared.Rcl/wwwroot/raw_docs/install-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/install-guide.md @@ -8,6 +8,12 @@ RackPeek can run in two ways: RackPeek stores everything in a writable `config/` directory as YAML (including automatic backups). Wherever you run it, that directory must be writable. +Saves are atomic: the config is written to a temporary file, flushed to disk, then renamed +over `config.yaml`. An interrupted save therefore leaves the previous config intact rather +than a half-written one. If the config ever does become unreadable — a damaged disk, a bad +hand edit, a sync conflict — RackPeek refuses to read or write it rather than reporting an +empty inventory, and the Web UI's YAML editor (`/yaml`) still loads so you can repair it. + --- # Docker (Recommended) diff --git a/Tests.E2e/DamagedConfigTests.cs b/Tests.E2e/DamagedConfigTests.cs new file mode 100644 index 00000000..f95d98c9 --- /dev/null +++ b/Tests.E2e/DamagedConfigTests.cs @@ -0,0 +1,113 @@ +using Microsoft.Playwright; +using Tests.E2e.Infra; +using Xunit.Abstractions; + +namespace Tests.E2e; + +/// +/// Web half of https://github.com/Timmoth/RackPeek/issues/337. A config that +/// exists but cannot be read must not leave the app stuck on "Loading…" — the +/// YAML editor is how someone repairs it — and must never be reported as an +/// empty inventory. These tests stage a damaged config in the container, then +/// repair it through the UI. +/// +public class DamagedConfigTests( + PlaywrightFixture fixture, + ITestOutputHelper output) : E2ETestBase(fixture, output) { + private readonly PlaywrightFixture _fixture = fixture; + private readonly ITestOutputHelper _output = output; + + // Cut mid-token, exactly as an interrupted in-place write would leave it. + private const string _damaged = """ + version: 4 + resources: + - kind: Server + name: srv-a + - ki + """; + + private const string _healthy = """ + version: 4 + resources: + - kind: Server + name: repaired-srv + connections: [] + """; + + [Fact] + public async Task The_App_Still_Loads_And_Can_Repair_A_Damaged_Config() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + + try { + await _fixture.WriteConfigAsync(_damaged); + + // 1. The app renders rather than hanging on "Loading…". + await page.GotoAsync($"{_fixture.BaseUrl}/yaml"); + + await Assertions.Expect(page.GetByTestId("circuit-probe")) + .ToHaveAttributeAsync("data-circuit-ready", "true"); + + // 2. The editor shows the damaged file, so it can be fixed in place. + ILocator content = page.GetByTestId("yaml-file-content"); + await Assertions.Expect(content).ToBeVisibleAsync(); + await Assertions.Expect(content).ToContainTextAsync("srv-a"); + + // 3. Repair it through the editor. + await page.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync(); + + ILocator textarea = page.Locator("textarea"); + await Assertions.Expect(textarea).ToBeVisibleAsync(); + await textarea.FillAsync(_healthy); + + await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); + + await Assertions.Expect(page.GetByTestId("yaml-file-error")).ToHaveCountAsync(0); + + // 4. The inventory reads correctly again. + await page.GotoAsync($"{_fixture.BaseUrl}/servers/list"); + await Assertions.Expect(page.GetByText("repaired-srv").First).ToBeVisibleAsync(); + + Assert.Contains("repaired-srv", await _fixture.ReadConfigAsync()); + } + catch (Exception) { + _output.WriteLine($"TEST FAILED — URL: {page.Url}"); + _output.WriteLine(await page.ContentAsync()); + throw; + } + finally { + // Leave the container usable for any other test in this class. + await _fixture.WriteConfigAsync(_healthy); + await context.CloseAsync(); + } + } + + [Fact] + public async Task A_Damaged_Config_Is_Never_Reported_As_An_Empty_Inventory() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + + try { + await _fixture.WriteConfigAsync(_damaged); + + await page.GotoAsync($"{_fixture.BaseUrl}/servers/list"); + + // The inventory pages read through the collection, which now refuses a + // config it could not parse. What must never happen is the page + // rendering a confident, empty list over a recoverable file. + var body = await page.InnerTextAsync("body"); + Assert.DoesNotContain("srv-a", body); + Assert.DoesNotContain("No servers", body, StringComparison.OrdinalIgnoreCase); + + // And the damaged file is still on disk, untouched by the failed read. + Assert.Equal(_damaged, (await _fixture.ReadConfigAsync()).TrimEnd('\n')); + } + catch (Exception) { + _output.WriteLine($"TEST FAILED — URL: {page.Url}"); + _output.WriteLine(await page.ContentAsync()); + throw; + } + finally { + await _fixture.WriteConfigAsync(_healthy); + await context.CloseAsync(); + } + } +} diff --git a/Tests.E2e/Infra/PlaywrightFixture.cs b/Tests.E2e/Infra/PlaywrightFixture.cs index 94d6d8e0..4c494441 100644 --- a/Tests.E2e/Infra/PlaywrightFixture.cs +++ b/Tests.E2e/Infra/PlaywrightFixture.cs @@ -1,3 +1,4 @@ +using System.Text; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Microsoft.Playwright; @@ -46,6 +47,22 @@ public async Task InitializeAsync() { Assertions.SetDefaultExpectTimeout(15000); } + /// + /// Replaces the container's config.yaml wholesale. Used to stage a damaged + /// config, which cannot be produced through the UI (the editor validates + /// before saving) but is exactly what an interrupted write leaves behind. + /// + public async Task WriteConfigAsync(string contents) { + await _container.CopyAsync( + Encoding.UTF8.GetBytes(contents), + "/app/config/config.yaml"); + } + + public async Task ReadConfigAsync() { + var bytes = await _container.ReadFileAsync("/app/config/config.yaml"); + return Encoding.UTF8.GetString(bytes); + } + public async Task DisposeAsync() { if (Browser != null) await Browser.DisposeAsync(); diff --git a/Tests/EndToEnd/CorruptConfigTests.cs b/Tests/EndToEnd/CorruptConfigTests.cs index a51059eb..bbbf1969 100644 --- a/Tests/EndToEnd/CorruptConfigTests.cs +++ b/Tests/EndToEnd/CorruptConfigTests.cs @@ -3,16 +3,18 @@ namespace Tests.EndToEnd; -// Reproduces the load-side half of -// https://github.com/Timmoth/RackPeek/issues/337: saves rewrite config.yaml in -// place (File.WriteAllTextAsync truncates before writing, and never flushes), -// so an interrupted save leaves a truncated file behind — and a truncated file -// is then accepted without complaint on the next load. +// Load-side half of https://github.com/Timmoth/RackPeek/issues/337. // -// The writer-side half (make PhysicalTextFileStore write atomically and -// durably: temp file + flush + rename) is not observable from a black-box -// test; these tests pin the user-facing contract that a damaged file must not -// be served silently or crash with a raw stack trace. +// Before the fix a damaged config was accepted without complaint: an unparseable +// file loaded as an EMPTY inventory with exit 0, and the next write persisted that +// emptiness over a recoverable file. These tests pin the contract that a config +// which exists but cannot be understood fails loudly on every read, and — the part +// that actually loses data — is never overwritten. +// +// Note on what is NOT testable here: a save interrupted at a clean resource boundary +// leaves valid YAML that is indistinguishable from a smaller inventory. Nothing at +// load time can detect it, which is precisely why the writer-side fix (atomic, +// durable saves — see PhysicalTextFileStoreTests) is the primary remedy. [Collection("Yaml CLI tests")] public class CorruptConfigTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { @@ -43,48 +45,84 @@ private async Task ExecuteAsync(params string[] args) { return output; } + private string ConfigPath => Path.Combine(fs.Root, "config.yaml"); + [Fact] - public async Task a_config_truncated_at_a_resource_boundary_is_not_served_silently() { - // Simulate an interrupted in-place save: the file ends mid-way through - // the resources list. This still parses — as a plausible, smaller - // inventory missing srv-c and the connections section. - var truncated = _fullConfig[.._fullConfig.IndexOf("- kind: Server\n name: srv-c", StringComparison.Ordinal)]; - await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), truncated); + public async Task a_config_cut_mid_token_fails_with_a_friendly_error() { + // A save that died mid-write inside a YAML token. + var truncated = _fullConfig[.._fullConfig.IndexOf("nd: Server\n name: srv-c", StringComparison.Ordinal)]; + await File.WriteAllTextAsync(ConfigPath, truncated); var output = await ExecuteAsync("summary"); - // Serving a structurally incomplete file (a resources document with no - // connections section — something RackPeek's own serializer never - // writes) with no diagnostic at all is how a truncation becomes silent - // data loss: the next save persists the smaller inventory as if it - // were intentional. - var hasDiagnostic = - output.Contains("error", StringComparison.OrdinalIgnoreCase) - || output.Contains("warn", StringComparison.OrdinalIgnoreCase) - || output.Contains("corrupt", StringComparison.OrdinalIgnoreCase) - || output.Contains("incomplete", StringComparison.OrdinalIgnoreCase) - || output.Contains("truncated", StringComparison.OrdinalIgnoreCase); - - Assert.True(hasDiagnostic, - $"A truncated config was served with no diagnostic. Output:\n{output}"); + // An actionable message naming the config file — not a stack dump, and above + // all not a cheerful "Hardware (0)". + Assert.Contains("config.yaml", output); + Assert.DoesNotContain("at RackPeek.", output); + Assert.DoesNotContain("YamlDotNet.Core", output); + Assert.DoesNotContain("Hardware (0)", output); } [Fact] - public async Task a_config_cut_mid_token_fails_with_a_friendly_error() { - // Simulate a save that died mid-write inside a YAML token. - var truncated = _fullConfig[.._fullConfig.IndexOf("nd: Server\n name: srv-c", StringComparison.Ordinal)]; - await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), truncated); + public async Task a_file_that_is_not_a_rackpeek_config_is_rejected() { + // Parses as YAML, carries no schema version: not our document. + await File.WriteAllTextAsync(ConfigPath, "hello: world\n"); var output = await ExecuteAsync("summary"); - // Observed on staging: the unparseable file is swallowed entirely and - // `rpk summary` reports an EMPTY inventory (Hardware (0)) with exit 0 — - // no error, no mention of the file. That is the worst outcome for - // #337: a later write would persist the empty inventory over the - // damaged-but-recoverable file. The user should instead get an - // actionable message naming the config file, and no stack dump. - Assert.DoesNotContain("at RackPeek.", output); - Assert.DoesNotContain("YamlDotNet.Core", output); Assert.Contains("config.yaml", output); + Assert.DoesNotContain("Hardware (0)", output); + } + + [Fact] + public async Task a_damaged_config_is_never_overwritten_by_a_later_write() { + // The data-loss path: read the damaged file, then try to write. The write + // must refuse rather than persist the empty in-memory collection over it. + var truncated = _fullConfig[.._fullConfig.IndexOf("nd: Server\n name: srv-c", StringComparison.Ordinal)]; + await File.WriteAllTextAsync(ConfigPath, truncated); + + var output = await ExecuteAsync("servers", "add", "srv-d"); + + Assert.DoesNotContain("added", output, StringComparison.OrdinalIgnoreCase); + + var onDisk = await File.ReadAllTextAsync(ConfigPath); + Assert.Equal(truncated, onDisk); + } + + [Fact] + public async Task an_empty_config_is_still_a_valid_empty_inventory() { + // The file the CLI itself creates on first run. Must not be mistaken for damage. + await File.WriteAllTextAsync(ConfigPath, ""); + + var output = await ExecuteAsync("servers", "add", "srv-a"); + + Assert.Contains("added", output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name: srv-a", await File.ReadAllTextAsync(ConfigPath)); + } + + [Fact] + public async Task a_legacy_config_without_a_version_key_still_migrates() { + // Pre-v1 files carry no version key at all. The migration chain stamps one, + // so they must not trip the "missing schema version" guard. + await File.WriteAllTextAsync( + ConfigPath, + "resources:\n - kind: Server\n name: legacy-srv\n"); + + var output = await ExecuteAsync("summary"); + + Assert.Contains("Server: 1", output); + Assert.Contains("version: 4", await File.ReadAllTextAsync(ConfigPath)); + } + + [Fact] + public async Task a_healthy_config_still_loads_and_writes() { + await File.WriteAllTextAsync(ConfigPath, _fullConfig); + + var output = await ExecuteAsync("summary"); + Assert.Contains("Server: 3", output); + + output = await ExecuteAsync("servers", "add", "srv-d"); + Assert.Contains("added", output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name: srv-d", await File.ReadAllTextAsync(ConfigPath)); } } diff --git a/Tests/Yaml/PhysicalTextFileStoreTests.cs b/Tests/Yaml/PhysicalTextFileStoreTests.cs new file mode 100644 index 00000000..c2295b21 --- /dev/null +++ b/Tests/Yaml/PhysicalTextFileStoreTests.cs @@ -0,0 +1,131 @@ +using RackPeek.Domain.Persistence.Yaml; + +namespace Tests.Yaml; + +/// +/// Writer-side half of https://github.com/Timmoth/RackPeek/issues/337. +/// The store used to be a bare File.WriteAllTextAsync, which truncates the +/// destination before writing a byte and never flushes — so an interrupted save +/// could leave a truncated config, and a save that had returned successfully +/// could still be lost to power failure. It now writes to a temp file, flushes +/// to disk, and renames over the destination. +/// +public class PhysicalTextFileStoreTests : IDisposable { + private readonly string _dir = Path.Combine( + Path.GetTempPath(), + "rackpeek-store-tests", + Guid.NewGuid().ToString("N")); + + private readonly PhysicalTextFileStore _store = new(); + + public PhysicalTextFileStoreTests() => Directory.CreateDirectory(_dir); + + public void Dispose() { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, true); + + GC.SuppressFinalize(this); + } + + private string Path_(string name) => Path.Combine(_dir, name); + + [Fact] + public async Task writing_a_new_file_round_trips_the_content() { + var path = Path_("config.yaml"); + + await _store.WriteAllTextAsync(path, "version: 4\n"); + + Assert.Equal("version: 4\n", await _store.ReadAllTextAsync(path)); + } + + [Fact] + public async Task overwriting_replaces_the_whole_file() { + var path = Path_("config.yaml"); + + await _store.WriteAllTextAsync(path, new string('a', 4096)); + await _store.WriteAllTextAsync(path, "short"); + + // A rename replaces the file wholesale; a partial in-place write would + // leave the tail of the longer content behind. + Assert.Equal("short", await _store.ReadAllTextAsync(path)); + } + + [Fact] + public async Task writing_leaves_no_temp_files_behind() { + var path = Path_("config.yaml"); + + for (var i = 0; i < 5; i++) + await _store.WriteAllTextAsync(path, $"version: 4 # {i}\n"); + + Assert.Equal(new[] { "config.yaml" }, + Directory.GetFiles(_dir).Select(System.IO.Path.GetFileName).OrderBy(n => n).ToArray()); + } + + [Fact] + public async Task a_reader_never_observes_a_truncated_file_during_writes() { + var path = Path_("config.yaml"); + + // Two sizes, neither a prefix of the other: any partially written state is + // detectable as "not equal to either of the two valid contents". + var big = "version: 4\n" + new string('b', 200_000); + var small = "version: 4\n" + new string('s', 50_000); + + await _store.WriteAllTextAsync(path, big); + + using var cts = new CancellationTokenSource(); + + var writer = Task.Run(async () => { + for (var i = 0; i < 40; i++) + await _store.WriteAllTextAsync(path, i % 2 == 0 ? small : big); + + await cts.CancelAsync(); + }); + + var observations = 0; + + while (!cts.IsCancellationRequested) { + string seen; + + try { + seen = await File.ReadAllTextAsync(path); + } + catch (IOException) { + // The rename can momentarily deny sharing on Windows; not a torn read. + continue; + } + + observations++; + + Assert.True(seen == big || seen == small, + $"Observed a partially written config ({seen.Length} bytes; expected {big.Length} or {small.Length})."); + } + + await writer; + + Assert.True(observations > 0, "The reader never managed to sample the file."); + } + + [Fact] + public async Task a_failed_write_leaves_the_original_intact() { + // A directory standing where the temp file wants to be makes the write fail + // after the destination would have been truncated by the old implementation. + var path = Path_("config.yaml"); + await _store.WriteAllTextAsync(path, "version: 4\nresources: []\n"); + + var readOnlyDir = Path_("locked"); + Directory.CreateDirectory(readOnlyDir); + var nested = Path.Combine(readOnlyDir, "config.yaml"); + await _store.WriteAllTextAsync(nested, "version: 4\n"); + + // Writing to a path that is itself a directory always fails. + var directoryPath = Path_("a-directory"); + Directory.CreateDirectory(directoryPath); + + await Assert.ThrowsAnyAsync( + () => _store.WriteAllTextAsync(directoryPath, "anything")); + + // The unrelated config is untouched, and no temp debris was left anywhere. + Assert.Equal("version: 4\nresources: []\n", await _store.ReadAllTextAsync(path)); + Assert.DoesNotContain(Directory.GetFiles(_dir), f => f.Contains(".tmp-", StringComparison.Ordinal)); + } +} From 89c92aae5211d1382bc2554e4f68a6ad8db5cfbe Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 21:43:28 +0100 Subject: [PATCH 17/29] Show a system's type in the breadcrumb, not its storage kind A guest on a hypervisor on a server read nebula (server) / nebula-pve (system) / immich (system) so the chain that exists to show the nesting hid what each layer actually is. A System now reports its own type where it has one: nebula (Server) / nebula-pve (Hypervisor) / immich (VM) Kinds and types are stored lower-case, so they are title-cased for display with the initialisms that would otherwise look wrong (VM, UPS) spelled properly. The single-crumb view lower-cased its label separately; it now shares the same description. The Playwright test seeds a Server -> hypervisor -> vm chain and fails on the parent commit. Co-Authored-By: Claude Fable 5 --- Shared.Rcl/Components/CrumbLevel.razor | 4 +- .../ResourceBreadCrumbComponent.razor | 60 +++++++++++--- Tests.E2e/BreadcrumbTypeTests.cs | 78 +++++++++++++++++++ 3 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 Tests.E2e/BreadcrumbTypeTests.cs diff --git a/Shared.Rcl/Components/CrumbLevel.razor b/Shared.Rcl/Components/CrumbLevel.razor index ae6be0ce..2deb3b33 100644 --- a/Shared.Rcl/Components/CrumbLevel.razor +++ b/Shared.Rcl/Components/CrumbLevel.razor @@ -9,7 +9,7 @@ href="@Items[0].Href"> @Items[0].Label - (@Items[0].Kind.ToLower()) + (@Items[0].Description) @@ -31,7 +31,7 @@ href="@crumb.Href"> @crumb.Label - (@crumb.Kind) + (@crumb.Description) diff --git a/Shared.Rcl/Components/ResourceBreadCrumbComponent.razor b/Shared.Rcl/Components/ResourceBreadCrumbComponent.razor index 0588cc57..1d892e33 100644 --- a/Shared.Rcl/Components/ResourceBreadCrumbComponent.razor +++ b/Shared.Rcl/Components/ResourceBreadCrumbComponent.razor @@ -1,5 +1,6 @@ @using RackPeek.Domain.Persistence @using RackPeek.Domain.Resources +@using RackPeek.Domain.Resources.SystemResources @inject IResourceCollection Repo
@@ -35,7 +36,7 @@ case ResourceType.Hardware: AddLevel(new Breadcrumb( ResourceName, - Kind, + Humanise(Kind), Resource.GetResourceUrl("hardware", ResourceName))); break; @@ -60,11 +61,45 @@ AddLevel(new Breadcrumb( name, - kind, + DescribeKind(kind, resource), Resource.GetResourceUrl(kind, name))); } - private void RenderLevels(Dictionary> byDistance) + /// + /// What to show in brackets after a crumb. "System" is the storage kind, not a + /// useful description — a chain reading Server / System / System hides the very + /// thing the chain exists to show, so a system reports its own type instead: + /// Server / Hypervisor / VM. + /// + private static string DescribeKind(string kind, Resource? resource) + { + var type = (resource as SystemResource)?.Type; + + return Humanise(string.IsNullOrWhiteSpace(type) ? kind : type); + } + + /// + /// Kinds and types are stored lower-case. Title-case them for display, keeping + /// the initialisms that would look wrong that way. + /// + private static string Humanise(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var trimmed = value.Trim(); + + return trimmed.ToLowerInvariant() switch + { + "vm" => "VM", + "ups" => "UPS", + "accesspoint" => "Access Point", + "baremetal" => "Bare metal", + _ => char.ToUpperInvariant(trimmed[0]) + trimmed[1..].ToLowerInvariant() + }; + } + + private void RenderLevels(Dictionary> byDistance) { foreach (var dist in byDistance.Keys.OrderByDescending(x => x)) { @@ -75,7 +110,7 @@ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .Select(x => new Breadcrumb( x.Name, - x.Kind, + x.Description, Resource.GetResourceUrl(x.Kind, x.Name))); var systems = items @@ -83,7 +118,7 @@ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .Select(x => new Breadcrumb( x.Name, - x.Kind, + x.Description, Resource.GetResourceUrl(x.Kind, x.Name))); AddLevel(hardware); @@ -91,10 +126,10 @@ } } - private async Task>> + private async Task>> BuildAncestorGraph(IEnumerable startingNodes) { - var byDistance = new Dictionary>(); + var byDistance = new Dictionary>(); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); var queue = new Queue<(string Name, int Dist)>(); @@ -119,15 +154,16 @@ if (!byDistance.TryGetValue(dist, out var list)) { - list = new List<(string, string)>(); + list = new List<(string, string, string)>(); byDistance[dist] = list; } - list.Add((name, kind)); + var res = await Repo.GetByNameAsync(name); + + list.Add((name, kind, DescribeKind(kind, res))); if (kind == "system") { - var res = await Repo.GetByNameAsync(name); foreach (var parent in (res?.RunsOn ?? Enumerable.Empty()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase)) @@ -152,6 +188,8 @@ Levels.Add(list); } - public record Breadcrumb(string Label, string Kind, string Href); + /// Description is what shows in brackets: a system's type + /// (Hypervisor, VM) where it has one, otherwise its kind. + public record Breadcrumb(string Label, string Description, string Href); } \ No newline at end of file diff --git a/Tests.E2e/BreadcrumbTypeTests.cs b/Tests.E2e/BreadcrumbTypeTests.cs new file mode 100644 index 00000000..eaa2ff33 --- /dev/null +++ b/Tests.E2e/BreadcrumbTypeTests.cs @@ -0,0 +1,78 @@ +using Microsoft.Playwright; +using Tests.E2e.Infra; +using Tests.E2e.PageObjectModels; +using Xunit.Abstractions; + +namespace Tests.E2e; + +/// +/// The breadcrumb used to label every System with its storage kind, so a guest on a +/// hypervisor on a server read "nebula (server) / nebula-pve (system) / immich +/// (system)" — the chain existed to show the nesting and then hid what each layer +/// actually was. A System now reports its own type instead. +/// +public class BreadcrumbTypeTests( + PlaywrightFixture fixture, + ITestOutputHelper output) : E2ETestBase(fixture, output) { + private readonly PlaywrightFixture _fixture = fixture; + private readonly ITestOutputHelper _output = output; + + private static string HypervisorStack(string server, string hypervisor, string guest) => + $""" + version: 4 + resources: + - kind: Server + name: {server} + - kind: System + name: {hypervisor} + type: hypervisor + runsOn: + - {server} + - kind: System + name: {guest} + type: vm + runsOn: + - {hypervisor} + connections: [] + """; + + [Fact] + public async Task A_breadcrumb_names_each_layers_type_not_its_storage_kind() { + (IBrowserContext context, IPage page) = await CreatePageAsync(); + + var server = $"e2e-bcs-{Guid.NewGuid():N}"[..14]; + var hypervisor = $"e2e-bch-{Guid.NewGuid():N}"[..14]; + var guest = $"e2e-bcg-{Guid.NewGuid():N}"[..14]; + + try { + var import = new YamlImportPom(page); + await import.GotoAsync(_fixture.BaseUrl); + await import.PasteAsync(HypervisorStack(server, hypervisor, guest)); + await import.AssertNoErrorAsync(); + await import.ApplyAsync(); + + await page.GotoAsync( + $"{_fixture.BaseUrl}/resources/systems/{Uri.EscapeDataString(guest)}"); + + await Assertions.Expect(page.GetByTestId("circuit-probe")) + .ToHaveAttributeAsync("data-circuit-ready", "true"); + + var body = await page.InnerTextAsync("body"); + + Assert.Contains("(Server)", body); + Assert.Contains("(Hypervisor)", body); + Assert.Contains("(VM)", body); + + // The kind must no longer stand in for the two systems' types. + Assert.DoesNotContain("(system)", body, StringComparison.OrdinalIgnoreCase); + } + catch (Exception) { + _output.WriteLine($"TEST FAILED — URL: {page.Url}"); + _output.WriteLine(await page.ContentAsync()); + throw; + } + finally { + await context.CloseAsync(); + } + } +} From 5f99c515838e179f7277e0550469930d5e2578f9 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 22:16:27 +0100 Subject: [PATCH 18/29] Use invented host names in the breadcrumb test's documentation The comment illustrated the bug with host names from a real network. The example reads the same with invented ones, and test fixtures should never carry identifiers from anyone's actual infrastructure. Co-Authored-By: Claude Fable 5 --- Tests.E2e/BreadcrumbTypeTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests.E2e/BreadcrumbTypeTests.cs b/Tests.E2e/BreadcrumbTypeTests.cs index eaa2ff33..e82525c8 100644 --- a/Tests.E2e/BreadcrumbTypeTests.cs +++ b/Tests.E2e/BreadcrumbTypeTests.cs @@ -7,9 +7,9 @@ namespace Tests.E2e; /// /// The breadcrumb used to label every System with its storage kind, so a guest on a -/// hypervisor on a server read "nebula (server) / nebula-pve (system) / immich -/// (system)" — the chain existed to show the nesting and then hid what each layer -/// actually was. A System now reports its own type instead. +/// hypervisor on a server read "host (server) / host-pve (system) / guest (system)" — +/// the chain existed to show the nesting and then hid what each layer actually was. +/// A System now reports its own type instead. /// public class BreadcrumbTypeTests( PlaywrightFixture fixture, From f0199d21f98acefc5d255b3b6af2dda8e212ba74 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 22:15:03 +0100 Subject: [PATCH 19/29] Identify swept hosts by service banner, MAC vendor and open ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep of a multi-VLAN homelab produced 26 anonymous "host-" cards out of 27: ARP is link-local so remote subnets yield no MAC, and few home networks have PTR records. The sweep found everything and recognised nothing. Three sources close most of that gap without a new dependency. Service banners. Once a host is known alive it is asked what it is: a TLS certificate's common name (443/8006/8443), an HTTP page title or Server header, then an SSH greeting. This is the identification half of `nmap -sV` in about 150 lines of BCL sockets. Only open ports are asked — a handshake with a closed port buys nothing but a timeout. The three answers are different claims, so they are treated differently. An X.509 common name is a host name by construction, so a certificate names the machine. A page title names an application, and a host may run several, so each becomes a Service resource hanging off the card rather than renaming it. An SSH greeting names only the daemon — every Linux box on a subnet answers "OpenSSH" — so it annotates and never names. An appliance's own management page is not a service on itself, so a title matching the host's own name is skipped. Open ports. Liveness stops at the first answer, which left pingable hosts with no port evidence at all. A living host is now checked against a wider list (554 cameras, 1883 brokers, 8123, 9000, 3000, 32400 and friends) and the result lands in an open-ports label. These are observations, not conclusions: "554 is open" is a fact, "this is a camera" is an inference the reader is better placed to draw, and a port number is a convention rather than a guarantee — so nothing is named from one. They are, however, the best possible targets for the banner probes. MAC vendor. Where a MAC is known the card gains the organisation IEEE assigned that OUI to, from a curated 10,868-entry subset of the registry generated by generate-oui-table.py. A hypervisor's own prefix beats the locally-administered bit so a KVM guest reads as QEMU/KVM rather than anonymous, while an address a phone invented for itself is reported as randomised instead of attributed to whoever owns the block. Services are also anchored by address: one whose runsOn resolves to nothing takes the system at its own IP, which fixes the dangling link a docker collector leaves when it can only guess its host's name. Narrow by design — only an unresolvable link, only when exactly one system claims that address, and never for systems, where a shared address means the same machine rather than a parent. Cards stay sparse and identity stays put: nothing learned here writes an OS or a core count, and a name from a service never moves a discoveryId. --no-identify restores a pure liveness sweep. All names, addresses and MAC suffixes in tests and docs are invented; the OUI prefixes are public IEEE registry data. Co-Authored-By: Claude Fable 5 --- .../Discovery/DiscoveryIdResolver.cs | 67 ++ RackPeek.Domain/Discovery/INetworkProbe.cs | 21 + RackPeek.Domain/Discovery/MacVendorLookup.cs | 106 +++ RackPeek.Domain/Discovery/MacVendorTable.g.cs | 653 ++++++++++++++++++ RackPeek.Domain/Discovery/NetworkProbe.cs | 132 ++++ RackPeek.Domain/Discovery/NetworkScanFacts.cs | 39 +- .../Discovery/NetworkScanMapper.cs | 82 ++- RackPeek.Domain/Discovery/NetworkScanner.cs | 173 ++++- .../Discovery/ServiceIdentityParser.cs | 211 ++++++ RackPeek.Domain/Discovery/WellKnownPorts.cs | 27 + RackPeek.Domain/RackPeek.Domain.csproj | 7 + .../Discovery/DiscoverNetworkCommand.cs | 7 +- Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 2 + .../wwwroot/raw_docs/discovery-guide.md | 114 ++- Tests.Discovery/MacVendorLookupTests.cs | 96 +++ Tests.Discovery/NetworkIdentityTests.cs | 216 ++++++ Tests.Discovery/NetworkScannerTests.cs | 205 +++++- Tests.Discovery/RunsOnByIpTests.cs | 128 ++++ Tests.Discovery/ServiceIdentityParserTests.cs | 138 ++++ generate-oui-table.py | 209 ++++++ 20 files changed, 2615 insertions(+), 18 deletions(-) create mode 100644 RackPeek.Domain/Discovery/MacVendorLookup.cs create mode 100644 RackPeek.Domain/Discovery/MacVendorTable.g.cs create mode 100644 RackPeek.Domain/Discovery/ServiceIdentityParser.cs create mode 100644 Tests.Discovery/MacVendorLookupTests.cs create mode 100644 Tests.Discovery/NetworkIdentityTests.cs create mode 100644 Tests.Discovery/RunsOnByIpTests.cs create mode 100644 Tests.Discovery/ServiceIdentityParserTests.cs create mode 100755 generate-oui-table.py diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index 0cae8102..dcb4eeec 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; namespace RackPeek.Domain.Discovery; @@ -65,6 +67,71 @@ public static void ResolveNames( } PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName); + AnchorRunsOnByIp(existing, incoming, existingByName); + } + + /// + /// Gives a service whose runsOn names nothing the host it is plainly + /// running on: the system at its own address. + /// + /// A collector that cannot see the machine it is talking to has to guess the + /// host's name — docker over TCP sends whatever the engine calls itself, which + /// need not match any resource — and the link then dangles. The address is + /// evidence the guess is not: a service answering on 192.0.2.57 is running on + /// whatever owns 192.0.2.57. + /// + /// + /// Deliberately conservative. It only fills a link that resolves to nothing, + /// only when exactly one system claims that address, and never across a + /// resource that already has a working parent — an ambiguous address is no + /// evidence at all, and a wrong parent is worse than a missing one. + /// + /// + private static void AnchorRunsOnByIp( + IReadOnlyList existing, + IReadOnlyList incoming, + Dictionary existingByName) { + var services = incoming.OfType().ToList(); + + if (services.Count == 0) + return; + + var incomingNames = new HashSet( + incoming.Select(r => r.Name), + StringComparer.OrdinalIgnoreCase); + + // Both sides count: the host may have arrived in this very payload (discover + // docker emits it alongside its services) or be sitting in the inventory already. + var systemsByIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (SystemResource system in existing.OfType().Concat(incoming.OfType())) { + if (string.IsNullOrWhiteSpace(system.Ip)) + continue; + + if (!systemsByIp.TryGetValue(system.Ip, out List? names)) + systemsByIp[system.Ip] = names = []; + + if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase)) + names.Add(system.Name); + } + + foreach (Service service in services) { + var ip = service.Network?.Ip; + + if (string.IsNullOrWhiteSpace(ip)) + continue; + + var anchored = service.RunsOn.Any(name => + existingByName.ContainsKey(name) || incomingNames.Contains(name)); + + if (anchored) + continue; + + if (!systemsByIp.TryGetValue(ip, out List? candidates) || candidates.Count != 1) + continue; + + service.RunsOn = [candidates[0]]; + } } /// diff --git a/RackPeek.Domain/Discovery/INetworkProbe.cs b/RackPeek.Domain/Discovery/INetworkProbe.cs index 2332b2dc..b39500b5 100644 --- a/RackPeek.Domain/Discovery/INetworkProbe.cs +++ b/RackPeek.Domain/Discovery/INetworkProbe.cs @@ -32,6 +32,27 @@ public interface INetworkProbe { /// The host's reverse-DNS name, or null when it has none worth keeping. Task ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default); + /// + /// The subject line of the certificate a TLS port presents, raw, or null when the + /// port is closed or speaks no TLS. Self-signed certificates are the norm on a + /// homelab — Proxmox and OPNsense both ship one naming the host — so the + /// certificate is read without being trusted, and nothing is ever sent over the + /// connection. + /// + Task ReadTlsSubjectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// The first line a port volunteers on connect, before anything is sent to it — + /// what SSH greets with. Null when the port is closed or stays silent. + /// + Task ReadTcpBannerAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// The head of an HTTP response to GET /: status line, headers, and enough + /// body to reach a <title>. Null when the port serves no HTTP. + /// + Task ReadHttpHeadAsync(string ip, int port, bool tls, TimeSpan timeout, CancellationToken cancellationToken = default); + /// /// The subnet of the first up, non-loopback IPv4 interface with a gateway — what /// `--cidr` defaults to. Null when the machine has no such interface. diff --git a/RackPeek.Domain/Discovery/MacVendorLookup.cs b/RackPeek.Domain/Discovery/MacVendorLookup.cs new file mode 100644 index 00000000..79411f3e --- /dev/null +++ b/RackPeek.Domain/Discovery/MacVendorLookup.cs @@ -0,0 +1,106 @@ +namespace RackPeek.Domain.Discovery; + +/// +/// Turns a MAC address into the organisation IEEE assigned its OUI to — the only +/// identity a silent device on the wire ever volunteers. A camera that answers no +/// port and has no PTR record is still recognisably an Espressif or a Ubiquiti. +/// Pure: the table is generated (see ), never fetched. +/// +public static class MacVendorLookup { + /// + /// True when the address was made up by the device rather than assigned by a + /// manufacturer — the locally-administered bit is set. Modern phones and laptops + /// randomise per network for privacy, so these carry no vendor at all, and the + /// OUI half is meaningless rather than merely unknown. Saying so is more useful + /// than reporting whichever company happens to own the matching block. + /// + public static bool IsLocallyAdministered(string? mac) { + var octet = FirstOctet(mac); + + return octet >= 0 && (octet & 0x02) != 0; + } + + /// + /// The assigned vendor, "Randomised (locally administered)" for a self-assigned + /// address, or null when the OUI is not in the curated table. Null means "we do + /// not know", never "no vendor". + /// + public static string? Lookup(string? mac) { + var prefix = NormalisePrefix(mac); + + if (prefix == null) + return null; + + var index = IndexOf(prefix); + + // The table is consulted before the locally-administered check on purpose: + // hypervisors mint guest addresses out of that range (KVM's 52:54:00), so the + // bit alone would report a VM as anonymous when its prefix names the emulator. + if (index < 0) + return IsLocallyAdministered(mac) + ? "Randomised (locally administered)" + : null; + + var vendorIndex = MacVendorTable.Records[index + 6] - MacVendorTable.FirstIndexChar; + + return vendorIndex >= 0 && vendorIndex < MacVendorTable.Vendors.Length + ? MacVendorTable.Vendors[vendorIndex] + : null; + } + + /// The first six hex digits, upper-cased, or null when that cannot be read. + private static string? NormalisePrefix(string? mac) { + if (string.IsNullOrWhiteSpace(mac)) + return null; + + Span digits = stackalloc char[6]; + var count = 0; + + foreach (var c in mac) { + if (!Uri.IsHexDigit(c)) + continue; + + digits[count++] = char.ToUpperInvariant(c); + + if (count == 6) + return new string(digits); + } + + return null; + } + + private static int FirstOctet(string? mac) { + var prefix = NormalisePrefix(mac); + + return prefix == null + ? -1 + : Convert.ToInt32(prefix[..2], 16); + } + + /// + /// Binary search over the packed records. Returns the index of the matching + /// record's first char, or -1. + /// + private static int IndexOf(string prefix) { + var records = MacVendorTable.Records; + var low = 0; + var high = records.Length / MacVendorTable.RecordLength - 1; + + while (low <= high) { + var mid = (low + high) / 2; + var at = mid * MacVendorTable.RecordLength; + + var comparison = string.CompareOrdinal(records, at, prefix, 0, 6); + + if (comparison == 0) + return at; + + if (comparison < 0) + low = mid + 1; + else + high = mid - 1; + } + + return -1; + } +} diff --git a/RackPeek.Domain/Discovery/MacVendorTable.g.cs b/RackPeek.Domain/Discovery/MacVendorTable.g.cs new file mode 100644 index 00000000..7b34c699 --- /dev/null +++ b/RackPeek.Domain/Discovery/MacVendorTable.g.cs @@ -0,0 +1,653 @@ +// +// Generated by generate-oui-table.py from the IEEE OUI registry +// (https://standards-oui.ieee.org/oui/oui.csv). Do not edit by hand — add a +// vendor pattern to the generator and re-run it instead. +// +// 10868 assignments across 87 vendors, packed as fixed-width +// "PPPPPPv" records (6 hex prefix chars + one vendor index char), sorted so +// MacVendorLookup can binary-search them without building a dictionary. +// + +namespace RackPeek.Domain.Discovery; + +internal static class MacVendorTable { + internal const int RecordLength = 7; + + internal const char FirstIndexChar = '!'; + + internal static readonly string[] Vendors = [ + "AMD", + "ASRock", + "ASUS", + "AVM (Fritz!Box)", + "Amazon", + "Apple", + "Aqara", + "Aquantia", + "Arlo", + "Arris/CommScope", + "Aruba", + "Asustor", + "Axis", + "Beelink", + "Broadcom", + "Buffalo", + "Chelsio", + "Cisco", + "D-Link", + "Dahua", + "Dell", + "Edimax", + "Espressif", + "Fortinet", + "FriendlyELEC", + "Gigabyte", + "Google", + "HPE/HP", + "Hardkernel (ODROID)", + "Hikvision", + "Huawei", + "IKEA", + "Intel", + "Juniper", + "Khadas", + "LG", + "Lenovo", + "MSI", + "Mellanox", + "Microsoft", + "MikroTik", + "Minisforum", + "NVIDIA", + "Netgate", + "Netgear", + "Nintendo", + "Nordic Semiconductor", + "Parallels", + "Philips", + "Pine64", + "Proxmox", + "QEMU/KVM", + "QNAP", + "Radxa", + "Raspberry Pi", + "Realtek", + "Reolink", + "Ring", + "Roku", + "Sagemcom", + "Samsung", + "Seagate", + "Sercomm", + "Shelly", + "Signify (Hue)", + "Silicon Labs", + "Sonoff", + "Sonos", + "Sony", + "Sophos", + "Supermicro", + "Synology", + "TP-Link", + "Technicolor", + "Tenda", + "TerraMaster", + "Texas Instruments", + "Tuya", + "Ubiquiti", + "VMware", + "VirtualBox", + "Western Digital", + "Wyze", + "Xen", + "Xiaomi", + "Zotac", + "Zyxel" + ]; + + internal static readonly string Records = + "00000C200009750000B460000F0]00014220001432000144500014Ae00016320001642000196200019720001C720001C920001E6<0001E7<0002162000217200023D200024A2" + + "00024B200026CQ00027D200027E20002A5<0002B3A0002B920002BA20002C9G0002FC20002FD200033120003322000347A00036B200036C2000393&00039F20003A020003E32" + + "0003E420003FD20003FE20003FFH00040E$00041Fe000423A0004272000428200044BK00044D200044E200046D200046E200049A200049B20004C020004C120004CF^0004DD2" + + "0004DE20004EA<00050020005012000502&0005312000532200054EQ00055D300055E200055F2000569p00057320005742000585B00059A200059B20005B5/0005DC20005DD2" + + "000628200062A20006522000653200065B500067C20006C120006D620006D720006F6200070D200070E20007400000743100074F2000750200077D2000784200078520007AB]" + + "0007B320007B420007E9A0007EB20007EC2000802<0008202000821200082F2000830200083120008322000874500087C200087D2000883<0008A320008A420008C220008C6Q" + + "0008C7<0008E220008E3200090F8000911200091220009432000944200095BM00095CQ00097B200097C20009B620009B720009BFN0009E820009E920009FBQ000A27&000A412" + + "000A422000A57<000A8A2000A8B2000A95&000AB72000AB82000AD9e000AEBi000AF32000AF42000AF7/000B452000B462000B57b000B5F2000B602000B852000B86<000BBE2" + + "000BBF2000BCD<000BDB5000BFC2000BFD2000C29p000C302000C312000C42I000C50^000C6E#000C852000C862000CCAr000CCE2000CCF2000CE68000CF1A000D0B0000D282" + + "000D292000D4B[000D565000D652000D662000D883000D93&000D9D<000DB6/000DBC2000DBD2000DEC2000DED2000E07e000E0CA000E2E6000E35A000E382000E392000E58d" + + "000E59\\000E7F<000E832000E842000E8F_000EA6#000EB3<000ED62000ED72000F1F5000F20<000F232000F242000F342000F352000F3D3000F4Fq000F61<000F8F2000F902" + + "000FB5M000FDEe000FEA:000FF72000FF82001007200100B200100D200101120010142001018/00101F2001029200102F20010542001079200107B2001083<0010A620010DBB" + + "0010E3<0010F620010FA&0010FF2001109F00110A<001111A00112020011212001124&00112F#001132h001143500115C200115D2001175A001185<001192200119320011953" + + "0011BB20011BC20011C6^0011D8#0012002001201200121EB001237m00123F500124320012442001247]001248500124Bm00125AH001279<00127F200128020012D1m0012D2m" + + "0012D920012DA20012EEe0012F0A0012FB]0012FEE001302A001315e001319200131A2001320A001321<0013463001349w00135F200136020013725001377]00137F20013802" + + "0013A9e0013C320013C420013CEA0013D4#0013E8A00141B200141C20014225001438<001451&001469200146A200146CM001478i0014A820014A920014C2<0014C3^0014EEr" + + "0014F120014F220014F6B001500A00150C$001517A00152B200152C20015305001556\\00155DH001560<0015622001563200156Do001599]0015B9]0015C1e0015C550015C62" + + "0015C720015E930015F2#0015F920015FA20016010001620e001632]001635<00163Et00164620016472001656N00166B]00166C]00166FA001676A00169C200169D20016B8e" + + "0016C720016C820016CB&0016DB]0016E6:0016EAA0016EBA001708<00170E200170F2001731#00173B2001759200175A2001783m001788a0017942001795200179A30017A4<" + + "0017ABN0017B6(0017C9]0017CBB0017D5]0017DF20017E020017E3m0017E4m0017E5m0017E6m0017E7m0017E8m0017E9m0017EAm0017EBm0017ECm0017F2&0017FAH001813e" + + "0018182001819200182Fm001830m001831m001832m001833m001834m00184DM001862^001871<00187320018742001882?00188B50018AF]0018B920018BA20018DEA0018F3#" + + "0018FE<0019062001907200191DN00192F2001930200194B\\0019552001956200195B3001963e0019A920019AA20019B950019BB<0019C5e0019CBw0019D1A0019D2A0019E0i" + + "0019E2B0019E3&0019E720019E820019FDN001A11;001A1E<001A2F2001A302001A4B<001A4D:001A4F$001A6C2001A6D2001A75e001A80e001A8A]001A8Cf001A92#001AA05" + + "001AA12001AA22001AB6m001AE22001AE32001AE9N001B0C2001B0D2001B113001B21A001B2A2001B2B2001B2FM001B532001B542001B59e001B63&001B672001B77A001B78<" + + "001B7AN001B8F2001B902001B98]001BBF\\001BC0B001BD42001BD52001BE9/001BEAN001BFC#001C0E2001C0F2001C14p001C235001C42P001C43]001C4A$001C572001C582" + + "001C62D001CA4e001CB02001CB12001CB3&001CBEN001CBFA001CC0A001CC4<001CF03001CF62001CF92001D095001D0De001D0Fi001D25]001D28e001D38^001D452001D462" + + "001D4F&001D60#001D702001D712001D730001D7D:001DA12001DA22001DB5B001DBAe001DBCN001DD8H001DE0A001DE1A001DE52001DE62001DF6]001E0B<001E10?001E132" + + "001E142001E2AM001E35N001E45e001E492001E4A2001E4F5001E52&001E583001E64A001E65A001E67A001E74\\001E75D001E792001E7A2001E7D]001E8C#001EA9N001EBD2" + + "001EBE2001EC2&001EC95001EDCe001EE1]001EE2]001EF62001EF72001F12B001F1F6001F262001F272001F29<001F32N001F33M001F3BA001F3CA001F3F$001F5B&001F6BD" + + "001F6C2001F6D2001F95\\001F9D2001F9E2001FA7e001FC5N001FC6#001FC92001FCA2001FCC]001FCD]001FD0:001FE3D001FE4e001FF3&002037^00207BA0020ED:00211B2" + + "00211C2002127i002147N00214C]00215520021562002159B00215A<00215CA00215DA00216AA00216BA0021705002191300219B500219Ee0021A020021A120021BAm0021BDN" + + "0021D1]0021D2]0021D720021D820021E9&0021FBD00220C200220D2002215#002219500223FM002241&002248H00224CN00225520022562002264<002283B00229020022912" + + "002298e0022A1?0022A5m0022A6e0022A9D0022AAN0022B030022BD20022BE20022D7N0022FAA0022FBA00230420023052002312&002314A002315A002331N002332&0023332" + + "0023342002339]00233A]002345e002348\\002354#00235D200235E200236C&00237D<002399]00239CB0023AB20023AC20023AE50023CCN0023CDi0023D4m0023D6]0023D7]" + + "0023DF&0023EA20023EB20023F1e0023F8w00240130024132002414200241D:00241EN002436&002444N00245020024512002454]00246C<002481<002483D00248C#00248De" + + "002490]002491]002497200249820024A500024B2M0024B6^0024BAm0024BEe0024C320024C420024D6A0024D7A0024DCB0024E850024E9]0024EFe0024F3N0024F720024F92" + + "0024FE$002500&00251BQ002522\"002538]0025452002546200254B&0025645002566]002567]002568?002569\\00258320025842002586i00258BG002590g00259E?0025A0N" + + "0025AEH0025B3<0025B420025B520025BC&0025E5D0025E7e002608&00260A200260B2002618#00264A&00265120026522002655<002659N00265A300265D]00265F]002688B" + + "002691\\002698200269920026B0&0026B950026BB&0026C6A0026C7A0026CA20026CB20026E2D0026F2M002709N00270C200270D200270EA002710A002719i002722o0027902" + + "0027E320028F8A0029C22002A102002A6A2002B70]002BF50002CC82002EC7?002F5C2003019200302420030402003048g003065&00306E<0030712003078200307B20030802" + + "0030852003094200309620030A320030B620030C1<0030F22003146B003192i003217200337An0034DAD0034FE?00351A20035FFm0037B7\\0038DF2003A7D2003A982003A992" + + "003A9A2003A9B2003A9C2003C102003C84b003DE1?003DE8D003EC4?003EE1&00400B2004020*004026000408C-00409620041D22004238A00425A2004268200451D200464B?" + + "004B0D?004B127004E015004E35<004F1A?00500B200500F2005013^005014200502A200503E2005050200505320050542005056p0050732005080200508B<0050A220050A72" + + "0050BA30050BD20050CC^0050D120050E220050E4&0050F020050FC600562B200566D?0056CD&0057C1D0057D22005907E00596C20059DC2005A13?005B94&005D732005F67i" + + "005F862006009200602F200603E20060472006048500604C\\00605C2006070200608320060B0<006151?006171&00620B/0062EC20064402006619?00664B?0066DC&00682B?" + + "0068EB<006B6F?006BF12006CBC2006D52&006F64]0070077007147%007204]007230O00727820072EEA0073E0]007686200778D2007888200789E\\007C2D]007D3B]007D60&" + + "007E95200805F<0080A0<0080C8300812A&0081C420081F9m008320?008621%008701]00873120087642008865&008A55?008A76&008A962008E732008EF2M00900C20090212" + + "009027A00902B200905F2009069B00906D200906F2009086200909220090A620090AB20090B120090BF20090D920090F1^0090F2200919EA009235&009337A0094EC?0097F1&" + + "00991D?009ACD?009AD22009C02<009E1E2009EC8u00A040&00A0C5w00A0C9A00A159D00A289200A2EE200A38E200A3D1200A45F?00A554A00A5BF200A62B700A6CA200A7422" + + "00A91D?00AA00A00AA01A00AA02A00AA6E200AA70D00AAFDm00AD24300ADD5?00AF1F200B04A200B064200B08E200B0C2200B0D0500B0E1200B1E3200B362&00B463Z00B5D0]" + + "00B670200B771200B8B3200BB1C?00BB3A%00BB60A00BC60200BC99>00BE3B?00BE43500BE44b00BE75200BF61]00BF77200C002_00C04F500C0FF^00C164200C1B1200C2C6A" + + "00C30Au00C3F4]00C52CB00C585&00C610&00C84E<00C88B200CAE5200CB51\\00CC05?00CC34B00CCFC200CDFE&00D006200D058200D063200D079200D090200D097200D0B7A" + + "00D0BA200D0BB200D0BC200D0C0200D0D3200D0E4200D0FF200D49EA00D6FE200D76DA00D78F200D861F00D8A2?00D9D1e00DA55200DB70&00DBDFA00DEFB200DF1D200E0142" + + "00E018#00E01E200E034200E04F200E08F200E091D00E0A3200E0B0200E0F7200E0F9200E0FC?00E0FE200E12F?00E16D200E18CA00E3B2]00E406?00E421e00E5F1000EABD2" + + "00EB2De00EBD5200EC0Au00EEAB200F28B200F361%00F39F&00F46F]00F4B9&00F5FD?00F620;00F663200F76F&00F7AD?00F81C?00F82C200F8CC\\00F952?00FA21]00FB4A7" + + "00FC8B%00FCBA200FD22200FD45<00FEC8204006E;0401A1804021F?040312>0403D6N040973<040CCE&040D84b040E3C<040F66i04106Bu04137A&041471?041552&04180F]" + + "041892?0418D6o041B6DD041BBA]041C6CA041E64&042322m042336w0423A3G0425C5?0425E8m042665&042728H042758?04292E]042AE22042EC1&0430FA2043201/04331F?" + + "043389?0433C2A0434CF&043F72G0441A5&04421A#044707m04489A&04495D?044A6C?044BB1?044BED&044F4C?0452F3&045453&0455B8?0456E5A045C6CB045D4Be045FB92" + + "04627320463D0?0464FA5046761u046865&04698FB0469F8&046C59A046C9D2046F00D047179<047295&0472EF&04749E?047503?0476B02047970?0479B7m047A0Bu047AAE?" + + "047C16F04801A?0483087048727b04885F?048C16?048C9A?049226#0495E6k0499B9&0499BB&049D05&049FCA?04A151M04A316m04A6C8A04A741204A81C?04AE47u04B0E7?" + + "04B167u04B1A1]04B247704B429]04B4FE$04B5B2&04B9E3]04BA1C?04BA8D]04BAD6304BC6D&04BD70?04BD88<04BD97204BDBF]04BE58?04BF1B504BF6Dw04BFD5&04C06F?" + + "04C1D8?04C5A4204C5CDG04C807u04C845i04C8B0;04CAED?04CB01]04CCBC?04CD15b04CF4BA04CF8Cu04D13Au04D3B0A04D3B5?04D3CF&04D4C4#04D590804D9F5#04DAD22" + + "04DB56&04E31A\\04E387204E3E5b04E451m04E4B6]04E536&04E598u04E795?04E8B9A04EA56A04EB40204ECD8A04ED33A04EE03m04EECD>04EF61504F03E?04F0EEA04F13E&" + + "04F169?04F352?04F41CI04F778e04F7E4&04F938?04F9F8i04FE31]04FE7F204FE8D?04FF08?080007&080009<08001B5080027q080028m080046e080205?08023C]08028EM" + + "0804B4m080581[0805E2B0808C2]080FE52081093]081196A0812A5%08152F]0816E3?0817352081814<0819A6?081AFD?081C6Eu081F71i081FF320820E7G0821EF]0823C6?" + + "08240B&082525u082573&082697w08276B?082CB6&082E36?082E5F<082FE9?08318B?0831A4?08357DH0836C9M08373D]083A8D7083AF27083BC1>083D88]083E5D\\0840F3k" + + "0845D12084F0A?084FA92084FF92085104?0851F2\\085411>085531I085700i0857FB%085A113085B0E8085BD6A085C1B?085D53&08606E#086202&086266#086361?08638A2" + + "086518&086698&086AC5A086AE5%086BD7b086D41&086E9C?087045&087073?087190A087402&087671B087808]08798C?087A4C?087B0F%087B12\\087B872087C39%087C43?" + + "0881F4B08849D%0887C7&088BC8;088C2C]088E90A088EDC&089115%0891A3%08920450892727089356?0894EC?089542&0896AD20896D7$0897072089734<089DF4A089E08;" + + "089E84?08A189>08A5DF]08A6BC%08A6F7708A842?08AD0A708AED6]08B258B08B339u08B3D6?08B4B1;08B4D2A08B61F708B657$08B95Fb08BD43M08BEAC608BFA0]08BFB8#" + + "08C021?08C06C?08C0EBG08C224%08C729&08C7B5&08CC68208CC81>08CCA7208D01EB08D09F208D1F9708D23EA08D40CA08D42B]08D46AD08D593m08D59D\\08D945?08DD82?" + + "08DDEBb08E64B&08E689&08E7E5?08E84F?08EB21A08EBF6?08ECA9]08ECF5208EDED408EE8B]08F1EA<08F3FB208F458?08F4AB&08F4F0208F69C&08F8BC&08F9E0708FA28?" + + "08FC88]08FD0E]08FD52b08FD58?08FF44&0C02BD]0C0535B0C07DFu0C07F3?0C0ADFm0C0E7630C0ECB?0C116720C12AD<0C1420]0C1539&0C1563&0C1773?0C184E?0C19F8&" + + "0C1B7BH0C1C57m0C1DAFu0C238D?0C264320C272420C29EF50C2A6Fb0C2C54?0C2D71&0C2E57?0C2FB0]0C3021&0C31DC?0C323A]0C3526H0C37DC?0C3B50&0C3E9F&0C413EH" + + "0C41E9?0C42A1G0C4314b0C43F9%0C45BA?0C47C9%0C4885D0C4B54i0C4BEEm0C4DE9&0C4EA070C4F9B?0C5101&0C517E&0C53B7&0C5415A0C599CB0C61CFm0C6743?0C68032" + + "0C6AC4&0C6F8B&0C7043e0C704A?0C715D]0C722Ci0C7274$0C7329_0C74C2&0C75BD20C75D2>0C771A&0C7A15A0C8063i0C8126B0C8268i0C8306?0C839A?0C8408?0C85252" + + "0C85E1&0C8610B0C8910]0C8B9570C8BA2?0C8BFDA0C8DCA]0C8FFF?0C9192A0C96BF?0C975F<0C9838u0C9A3CA0C9D92#0CA3B2&0CA8A7]0CAC8A\\0CAE39A0CAE5Fb0CAE7Dm" + + "0CAF3120CB2B7m0CB319]0CB527?0CB5B3?0CB6D230CB787?0CB78E?0CB7EC?0CB81570CBC9F&0CBEF1?0CC413;0CC47Ag0CC56C&0CC6CC?0CC6FDu0CC98AA0CCC5D&0CD0F82" + + "0CD292A0CD5D320CD6BD?0CD746&0CD99620CDBEA&0CDC7E70CDC91%0CDD24A0CDFA4]0CE0DC]0CE441&0CE4A0?0CE5A1&0CE5B5?0CE623?0CE725H0CEA14o0CEABFG0CEC80m" + + "0CEDC8u0CEE99%0CEF15i0CEFF6b0CF346u0CF5A420CFC18?0CFE45e0CFEE5m100020&10003B7100177?1002B5A1005CA210061C7100645\\1006ED21007B6]10082Cm1009F9%" + + "100BA9A100C6BM100D7FM100D8C?100E7EB1012FB>101B54?101C0C&101D6E<101DC0]101F74<1020BA7102407?1027F5i102959&1029AB]102AB3u102B41]102BAA\\102E00A" + + "102EAFm102F6BH102FCA&103025&103047]10321D?10327E?103917]1039E9B103B54?103B59]103D1CA103F44u1040F3&10417F&104210&104400?104780?10490E?104A7DA" + + "104F58<104FA8e105072_105107A105172?1051DB710521C71052BD?1057252105932[105A17n105A95i105DDC?105FADA10604B<1062E5<1062EB310653051067A3?10683FD" + + "106F3F01070FDG107100?1071B3w1077B1]107A2AH107B44#107BEFw107C61#107D1A5107DC8&1086F4?1088D3?1089FB]108CCF2108EE0]108FFE?1091A871091D1A109266]" + + "109327:1093E9&1094BB&1094EF?109693%1096C621097BD710981951098365109ABAA109ADD&109D7A?109E6B&109F41&10A1DA&10A2D3&10A30F?10A4C9&10A4DA?10A51DA" + + "10A829210A85B710A879A10ABC9]10AE60%10B1F8?10B3C6210B3D5210B3D6210B41D710B588&10B676<10B9C4&10BC36?10BD18210BD3A&10BDA3710BEF5310BF48#10BF67%" + + "10C172?10C197u10C37B#10C3AB?10C595E10C5FAw10C61F?10C735H10CABFm10CD54?10CE02%10CEA9m10CEE9&10CF0F&10D38A]10D542]10D561n10D7B0\\10D9A2;10DA43M" + + "10DA49?10DA63&10DBA2m10DDB1&10E2C9&10E376210E4C2]10E676210E7C6<10E953?10EC81]10F005A10F1F2D10F311210F60AA10F920210F96FD10FC33?10FEEDi10FFE0:" + + "140152]1402EC<140338?140498&14080871409DC?140AC5%140B9E]14109F&1413FB?14147D&14169D214187751418C3A1419232141A97&141BA0&141F78]14205E&14223B;" + + "14230A?1423F2/1423F3/142876&142B2F7142D41b142D4D&142E5E_143004?1432D1]14335C7143375w1435B7&14360Ew1436C6E143B51?143CC3?143EC2A143FA6e1442FCm" + + "144658?144920?1449C5?1449D4u144F8AA145120?145594?14563A?14568E]14579F?1458D0<1459C0M145A05&145EBC?145F94?1460CB&146393714656A?14755BA147590i" + + "147649<147740?147830K147AE4&147DDA&147E19<147F0Fm147FCE&1484732148509&14857FA148692i14876A&1488E6&1489CB?1489FD]148C4A?148F79&148FC6&14907Au" + + "149138%14946C&1495CE&1496E5]149877&14993Eu1499E2&149A10H149AA3?149CEFm149D09?149D99&149ECF5149F3C]149FE8E14A0F8?14A2A0214A32F?14A364]14A3B4?" + + "14A51A?14A6B9]14A78B414AB02?14ABC5A14ABEC<14B31F514B3A1B14B457b14B484]14B653&14B903?14B968?14BB6E]14BBCCm14BC68214BD61&14C14E;14C19F714C213&" + + "14C7C4w14C88B&14C913D14CB19<14CB65H14CC20i14CF92i14D00D&14D11F?14D169?14D19E&14D1D4?14D55C&14D64D314D864i14D881u14DAB9?14DAE9#14DDA9#14DE39?" + + "14E01D]14E1C9b14E22A214E6E4i14EB08?14EBB6i14F287&14F42A]14F65Au14F6D8A14FB70?14FEB5518002De1801F1u18022D?18037351804EDm180B1B%180BD0?180C7A\\" + + "180DF9b180F7631814F4m1816C9]1819D6]181DEAA181E78\\181EB0]182032&182195]18227E]182649A182654]182666]182A57?182A7BN182AD3B182C65m1831BF#1832DE&" + + "183386?18339D2183451&183A2D]183CB7?183D5E?183DA2A183EEF&183F47]183F70&184516m184617]1848BE%184A53&184E16]184ECB]1854CF]1855E3&185644?185680A" + + "1856C3&185936u1859F52185A585185BB3]185E0FA186024<18622C\\1862E4m186472<186590&1866DA51867B0]1868CB>18690Ab186945i1869D4]1869D8n186A81\\18703B?" + + "18742E%187A3B<187A3Eb187EB9&187F88Z188025>188090218810E&188331]188740u18895B]188B0E7188B452188B9D21890D8\\189341A1893D7m189C5D2189E2C?189EFC&" + + "18A084&18A6F7i18A905<18A99B518AA0F?18AB1D]18AF61&18AF8F&18B657?18B83D]18B842&18BB1C?18BB41?18BFB3]18C007?18C04D:18C086/18C23C'18C2BF018C58A?" + + "18CC18A18CE94]18CF24?18D0E1G18D276?18D3CF<18D6C7i18D6DD?18D98F?18DBF2518DC12b18DE50n18DED7?18E2C2]18E671&18E728218E7B0&18E7F4&18E829o18E91D?" + + "18ECE7018EE69&18EF63218EFC0_18F0E4u18F1D8&18F22Ci18F643&18F935218FAB7&18FB7B518FD74I18FE34718FF0FA1C0B8Bo1C0D7D&1C0EAF?1C0EC2&1C12B0%1C1386?" + + "1C13FA?1C151F?1C17D321C1AC0&1C1ADFH1C1B0D:1C1BB5A1C1D67?1C1D8621C1DD3&1C1FF1?1C20DB?1C222621C232C]1C2575u1C28AF<1C290471C2AB0u1C3003<1C32AC?" + + "1C34DAG1C34F1b1C3576]1C36BB&1C3ADE]1C3BF3i1C3C78&1C3CD4?1C3D2F?1C402451C42C2?1C4363?1C4419i1C4586N1C4593m1C472F?1C4D66%1C4D70A1C53F9;1C57DC&" + + "1C599B?1C5A3E]1C5A6BQ1C5CF2&1C5F2B31C61B4i1C61BF&1C627E?1C62B8]1C6349m1C66AA]1C6758?1C692071C6A1Bo1C6A76&1C6A7A21C6F65:1C7055?1C7125&1C721D5" + + "1C73E2?1C740Dw1C76F2]1C7754&1C7B21e1C7EE531C7F2C?1C84A621C8682&1C869A]1C872C#1C8B8471C8BEFu1C8E2A&1C8E5C?1C8F5771C90FFn1C9148&1C9180&1C93C4%" + + "1C98EC<1C9957A1C99DB?1C9C8CB1C9DC271C9E46&1CA681?1CAA0721CABA7&1CAECB?1CAF05]1CAF4A]1CAFF731CB3C9&1CB46C?1CB72C#1CB796?1CBA8Cm1CBDB931CC089b" + + "1CC10CA1CC1DE<1CC21281CC3AB71CCCD6u1CD11A81CD1E021CD21EB1CDBD471CDEA721CDF0F21CDF52m1CE209&1CE2CCm1CE4CB71CE4DDj1CE504?1CE57F]1CE61D]1CE62B&" + + "1CE639?1CE6AD?1CE6C721CE85D21CEAACu1CED6F$1CF29A;1CF42B?1CF64C&1CF8D0]1CF9D5&1CFA68i1CFC1721CFC2A?1CFE2B%1CFFAD?20040F5200484&2008ED?200B16m" + + "200BC52200BC7?200BCFN200CC8M200E2B&2010B1%2013E0]2014C4?201582&2015DE]201642H2016B9A201742D201A94&201BC9B201C3AN201E1D?201E88A201F3B;2021A5D" + + "202351i20256572025CCu202680]20283E?202BC1?202D07]202DF6&20326C]2032C6&203389;203462u2034FBu203543\\203626i20370622037A5&203A072203A43A203B34u" + + "203B67]203CAE&203DB2?2043A8720463A&20474752047B5\\2047DAu204C03<204C9E2204D52G204E71B204E7FM20500D720521D&205383?205476e2054FA?205531]2059D1G" + + "205E64?205EF7]206274H20658E?20677C<206980&206BE7i206BF4?206E9C]206EF172072A9u20768F&207693E2078CD&2078F0&207918A207D74&2082C0u20845F&2087EC?" + + "2088105208C0A&208C86?209148m2091DF&209339B209952u209A7D\\209BA97209BCD&209BDD?209CB4<20A171%20A200?20A2E4&20A5CB&20A60Cu20A680?20A6CD<20A716b" + + "20A766?20A8BF?20A99BH20AB37&20AB48?20AEB6?20B82B\\20BBC0220BD1DA20BEB8%20C19BA20C2B0?20C38Fm20C9D0&20CC27220CC73]20CD39m20CF30#20CFAE220D390]" + + "20D476%20D5BF]20D5C2720D778m20D80BB20DA22?20DBAB]20DBEA220DCE6i20DCFD?20DF73?20DFB9;20E15Di20E2A8&20E525?20E52AM20E7C8720E874&20ED47B20EE28&" + + "20EFBD[20F094;20F120220F17C?20F1B2n20F3A3?20F478u20F4D4&20FA85&20FE00%20FF0C?2400BA?24016F?2401C72240588;240935]240995?240A3F]240AC47240F9B>" + + "241145u241153]241551?24161B224166D?24169D2241AE6?241B7A&241EEB&241F3Ab241FA0?2420C7\\2421ABe24240E&2424B7]2426D6?2427E5?2428FD>242934;2429B0?" + + "242A042242AEA&242BD6Z242E02?242FD0i2430F8?243154?2432AE>2436DA2243FAA?24418CA2442E37244427?24456B?2446E4?244750\\244845>244885?244AF8b244B03]" + + "244B81]244BF1?244BFE#244C07?244CAB7244CE3%244ECD\\24526A42453ED524559A&24587C7245A4Co245A5Fi245AB5]245BA7&245CC5?245D92B245E48&245EBEU245F9F?" + + "246078?2460B3]2462AB72462C6?2462CE<246477u24649F?246511$246800]2468B0]246968i2469A5?246A0E<246C60?246C842246D10&246E965246F287246F8C?2471212" + + "2471525247189m247625m247645?247703A247755?247D4Dm247E122247F20\\247F3C?24813B22481C7?248A07G2491BB?24920E]24952F;249745?249EAB?249F89m24A074&" + + "24A160724A2E1&24A43Co24A452]24A487?24A52C?24A799?24AB81&24B105>24B2DE724B339&24B657224B6FD524BA23224BCF8?24BE05<24C42FQ24C613]24C696]24CE33%" + + "24CF24u24D0DF&24D337u24D5E4224D660b24D79C224D7EB724DA33?24DB94B24DBAC?24DBED]24DCC3724DEC6<24DEEB?24DF6A?24E29D?24E314&24E50F;24E5AAQ24E9B32" + + "24E9CA?24EA9B?24EB16A24EBED?24EC4A724EE9AA24F094&24F0D3]24F27F<24F40A]24F5AA]24F603?24F677&24FB65?24FBE3<24FC4EB24FCE5]2800AF52801CDG28022E&" + + "280244&2802D8]2805A57280708]280B5C&280C50A280DFCe28107B32811A8A2811EC?28167Fu2816A8H2816ADA281709?281878H281DFB?28221E?2824C9%2827BF]28285Dw" + + "282B96?282CB2i282CC4?282D7F&283152?2831F8?283334?2834A222834FF&28353A?2836F0?28372F7283737&28395E]283B823283C90m283CE4?283DC2]283F69e2840DDe" + + "2841C6?2841EC?2845AC?2848E7?2849E9&284B54&284E44?285261228534E?285471?28562F728575D&2857BE>285923u285AEB&285FDB?2864B0?286847b2868D2?286AB8&" + + "286ABA&286B35A286B5C2286C07u286ED4?286F7F228704Eo2872C6]2873F6%287681b2877F1&287AB4?287FCFA288023<288088M28808A?288335]2883C9&28848572887BAi" + + "288A1CB288EEC&288FF6&289104i289200A28924A<2893FE2289401M28940F2289529A2896B0?28987B]289C6E7289E1Em289E97?289EFC\\289F04]28A02B&28A06BA28A24BB" + + "28A44AA28A6DB?28A9AE?28AC9E228AF42]28AFFD228B2BDA28B448?28B591228B5E8m28B829B28BAB5]28BD89;28C039?28C0DAB28C1A0&28C538&28C5C8<28C5D2A28C63FA" + + "28C68EM28C709&28C7CE228CC01]28CDC1W28CF51N28CFDA&28CFE9&28D0EAA28D127u28D3EA?28D5B1&28D6EC?28DBA7b28DCC3?28DE1C]28DE65<28DEE5?28DFEBA28E02C&" + + "28E14C&28E31Fu28E34E?28E5B0?28E6A9]28E7CF&28EA0BH28EA2D&28EC95&28EC9Am28ED6A&28EE52i28EF01%28F033&28F076&28F10E528FBAE?28FF3C&2C01B522C0786?" + + "2C0823_2C08B4?2C0B97u2C0BAB?2C0BE922C0D27?2C0DA7A2C0DCFu2C10C1N2C1165b2C15BF]2C15D9?2C1809&2C195Cu2C1A01?2C1A0522C1CF7&2C1F23&2C200B&2C2080?" + + "2C2131B2C2172B2C233A<2C2768?2C27D7<2C2997H2C2B86\\2C301Aj2C3033M2C312422C326A&2C331122C3358A2C3361&2C36F2?2C36F822C3996\\2C3A91?2C3AB1?2C3AE87" + + "2C3AFD$2C3ECF22C3F3822C4053]2C4138<2C4401]2C44FD<2C4C15B2C4D54#2C4F5222C52AF?2C542D22C5491H2C54CFD2C55D3?2C56DC#2C574122C57CE&2C58B9<2C58E8?" + + "2C598AD2C59E5<2C5A0F22C5EABG2C61F6&2C63A1?2C658D22C693E?2C6B7Dm2C6BF5B2C6DC1A2C6E85A2C71FF%2C73A022C7600&2C768A<2C780E?2C79BEi2C79D7\\2C7BA0A" + + "2C7CF2&2C81BF&2C8217&2C86D222C8DB1A2C91AB$2C93FB_2C9452?2C9520&2C97B1?2C97EDe2C9975]2C9D1E?2C9D90G2C9E00e2CA042?2CA2E522CA59C>2CA774m2CA797?" + + "2CA79E?2CAA8Es2CAB00?2CAB33m2CABEB22CAE2B]2CB05DM2CB1B7G2CB43A&2CB471n2CB68F?2CB7A1?2CBABA]2CBC87&2CBCBB72CBE08&2CC253&2CC546?2CC81BI2CC8F5?" + + "2CCA16&2CCC44e2CCF58?2CCF67W2CD02D22CD066u2CD3ADm2CDA46]2CDB07A2CDF68&2CE2D9?2CE38E22CE412\\2CE5BDo2CEA7F52CEAFCA2CECA6?2CED89?2CEDB0?2CF05DF" + + "2CF0A2&2CF0EE&2CF295?2CF2A5\\2CF43272CF81422CF89B22CFB0F\\2CFDA1#2CFE4Fu3001AF2300505A300916&300E43&300EB8D300EE3(3010E4&30138B<301577w3017C8e" + + "301966]301984?301C22<302432A302478\\3024A9<30294B?3030D0m3030F973035AD&3035C5?3037A623037B3?303926e303A64A303B7C&303EA7A303FBB<304511m304596?" + + "30469AM30487Dn30499E?304D1F%304E1B?3050CEu30560F:305714&305A3A#3061A2?306222]30636B&3063EAB3066D0?3067A1\\306893i306A4F[306A85]307467]307496?" + + "307512e30766FD3076F57307A05?307AD2&307C4A?307C5EB308216&30839873085A9#308730?30894AA3089A6?3089ECN308AF7?308BB22308D99<308DD4?308ECF?309048&" + + "3090AB&3093BC\\309610?30963B?3096FB]309C23F309E62?30A033&30A1FA?30A2C2?30A30F?30A7F5&30A8DBe30A998?30AAE4?30AEA4730AF7Em30B49Ei30B4B8D30B5C2i" + + "30B64FB30BD13w30C0AE&30C50F?30C599#30C6F7730C7AE]30C922730C9CC]30CBF8]30CDA7]30D042530D17E?30D4E2?30D53E&30D587]30D6C9]30D7A1&30D875&30D9D9&" + + "30DE4Bi30DE52]30E044;30E04F&30E171<30E226&30E283m30E37AA30E396?30E3A4A30E4D8?30E4DB230E98E?30EB15?30EDA0730F335?30F600\\30F65D<30F6EFA30F70D2" + + "30F7C5&30F9EDe30FB10b30FBB8?30FC68i30FCEBD30FD38;30FD65?30FE6C&30FEFA230FFFD?3400A3?340286A34029C334033D<3403DEm34080433408BC&3408E1m340962>" + + "340A333340A98?340E22&34105Dm3410BE&3410D0?3410F4b341298&3412F9?3413E8A34145F]3414B5m341513m34159E&3417DD_3417EB5341B2D2341CF0u341E6B?3425B4b" + + "3425BE%342601?342840&342865B342912?342AF1m342B6E&342D0D]342EB6?342EB7A342FBDN343111]34318F&3431C4$343638?34363B&343916;343A20<343DA9?343DC40" + + "343EA4Z34415DA344262&3446EC?3448ED534495B\\344DF7D344EE2?345184?3451C9&3453D2\\3454EF2345840?34588A2345A60F345D9E\\345DA82345E08[345F4573460F9i" + + "34628823464A9<346679?346691&3468B5m346AC2?346B46\\346BD3?346E68?346F9023470692347146?34732D234735A5347916?347C25&347DF6A347E00?347E5Cd3480B3u" + + "3481C4$348296&3482C5]3483D5?3484E4m348518734865D73488182348A12<348A3B?348A7B]348AAE\\348C5E&348D13b34936FB3494547349671?349672i3497F6#34987A7" + + "3498B5M34A137?34A2A2?34A395&34A84E234A8A0?34A8EB&34AA8B]34AB37&34AB95734AF2CN34AFB3%34B1EB&34B1F7m34B20A?34B354?34B472734B7DA734B883234B98Du" + + "34BDC8234BE00]34C059&34C232]34C386&34C3AC]34C3FD234C459m34C515<34C7E9;34C93DA34CDB0734CDBE?34CE00u34CFF6A34D270%34D693?34D868/34DAA1&34DB9C\\" + + "34DBFD234DDCC;34DE1AA34E12DA34E1A9$34E2FD&34E3FB]34E6ADA34E6D7534E894i34EAE7734ED1B234EE16&34EFD7?34F015u34F043]34F084]34F39AA34F5D7?34F64BA" + + "34F68D&34F716i34F8DD&34F8E7234FA1Cu34FCB9<34FCEFD34FD6A&34FD70A34FE77&34FFF3?380025A380195]3802DE_380484&3809FB&380A94]380B3Cm380B40]380E4D2" + + "380F4A&380FAD?3810D5$3810F0<38142853816B3<3816D1]3817B1\\3817C3<38182B738184Ce381868A381C1A2381F8Dn382028?38205623821C7<3822E2<3822F4?3825F3G" + + "382C4A#382CE5n382DD1]382DE8]3830F9D38327AI3833C5H3835FB\\38378B?38396C?38398Fb383E517383FE8?38420Bd3844BE73847BC?38484C&384A80]384C4F?384DD2?" + + "384E56m384F49B385247?38539C&38563DH385B44b385CFBb386233&3863BB<3865B2&3866F0&386893A3868A4]386A77]386DEDB386EB2?386FF4%387035N3870F2?3871DE&" + + "38736E?387862e387A0EA387F8B&3881D7m388345i3886F7;3887D5A38881E?3888A4&38892C&388A06]388B59;388C50D388CEF]388F30]389052?3890A523891B72389496]" + + "3894EDM3898E9?389AF6]389CB2&389E4C<38A44B?38A4EDu38A5C9n38A659\\38AA09238AB41m38AF29438B3F7?38B54D&38BAB0/38BAF8A38BC01?38BD7A<38C0EA838C1EDu" + + "38C22DG38C43A&38C6BDu38C6CEN38C986&38CA84<38CADA&38D09C?38D269m38D40B]38D547#38DEADA38E13D&38E1F4\\38E2C4m38E60Au38EAA7<38EADDB38EB47?38EC0D&" + + "38ECE4]38ED18238F18Fj38F195?38F20DB38F73D%38F7F1?38F889?38F9D3&38FB14?38FC34?38FC98A38FDF8238FF5953C01EFe3C0518]3C058E?3C0630&3C06A7i3C0754&" + + "3C0771e3C07D7&3C08CDB3C08F623C0A7A]3C0B59n3C0D0D73C0E2323C0F0273C135Au3C13BB?3C13CC23C15C2&3C15FB?3C1710\\3C195E]3C1BF8>3C1E0433C1EB5&3C20F6]" + + "3C219CA3C22FB&3C240A?3C25F853C26E423C286D;3C2983]3C2C3053C2CA6u3C2CCD&3C2DB7m3C2EF5b3C2EF9&3C2EFF&3C306F?3C3174;3C318A]3C333233C3464&3C366A?" + + "3C3712$3C3786M3C381F?3C3824u3C38F4e3C39C8&3C3B77&3C410E23C46D8i3C4711?3C4A92<3C4AC9?3C4DBE&3C5002&3C510E23C5282<3C52A1i3C5447?3C573123C576C]" + + "3C5836\\3C585D\\3C58C2A3C59C0?3C5A37]3C5AB4;3C5CC4%3C5EC323C6104B3C610573C6200]3C62F0_3C64CFi3C65D1?3C678C?3C6A48i3C6AA7A3C6AD2i3C6D66K3C6D89&" + + "3C71BF73C7787?3C7843?3C7895i3C7C3F#3C7D0A&3C7DB1m3C7F6Eu3C81D8\\3C8375H3C842773C846Ai3C869A?3C8A1F73C8AB0B3C8B6EG3C8B7F23C8BFE]3C8C93B3C8D20;" + + "3C90E0?3C93F4?3C94D5B3C9872_3C9BC6?3C9C0FA3C9D56?3CA10D]3CA161?3CA308m3CA37E?3CA62F$3CA6F6&3CA82A<3CA916?3CA9ABN3CA9F4A3CAB8E&3CAFB7u3CB233?" + + "3CB922?3CBBFD]3CBD3Eu3CBF60&3CBFD7&3CC03E?3CC5C7?3CCD36&3CCD40&3CCD57u3CCD5D?3CCE7323CD0F8&3CD92B<3CDC7573CDCBC]3CDD57&3CDF1E23CDFBD?3CE002m" + + "3CE064m3CE072&3CE36B43CE441%3CE4B0m3CE824?3CE86E<3CE90E73CE9F7A3CECEFg3CEF8C43CF011A3CF692?3CF75Dw3CF7A4]3CF808?3CF862A3CFA06H3CFA43?3CFA80?" + + "3CFB02&3CFDFEA3CFEAC23CFFD8?40017A2400634?4006A0m4006D52400877u400EB9?4011C3]401277H40148224014AD?40163B]40167E#40169Fi401C83A401CD4?4022D87" + + "4024D2?4025C2A402619&402641?402A8F7402BA1e402BD67402E71m403004&403059b40313Cu40331A&4035E6]4036B7B403802b403B7B?403CFC&403F8Ci4040A7e40410D?" + + "40424424044CE?4044F7N4045C4?404A03w404CCA7404D7F&404D8E?404F42?40538CD4055392405B7F/405CFD5405D82M405EF6]405FC2m4065A3\\406768?4067922406C8F&" + + "406F27?4070F5&407183B4074E0A4076A9?407911&407912m407D0F?407F5FB40831D&4086CB34089C2&4089C6%408D5C:408E2CH408EDF?408F9DB409151740921A&409595i" + + "40984Em4098AD&409BCD3409C28&409EA4B40A2DB%40A3CCA40A44A;40A654?40A677B40A6B7A40A6D9&40A6E8240A746/40A8F0<40A9CF%40ACBF>40AE30i40B034<40B076#" + + "40B0FAD40B15C?40B395&40B3FA&40B4CD%40B4F0B40B570>40B5C1240B6E7?40B70E?40B837e40B93C<40BA09540BC60&40BD32m40C3BC?40C711&40C729\\40C73CA40CBA8?" + + "40CBC0&40CE24240D133A40D160&40D28AN40D32D&40D3AE]40DA5C&40DCA5?40DE24]40DEADB40E3D6<40E64B&40EB21?40EC99A40ECBDA40ED00i40EDCF&40EE6D?40EEDD?" + + "40F078240F201\\40F3B0m40F407N40F49F240F4EC240F520740F6BC%40F946&440010&440049%44004D?44032CA4403A7244053F\\4405B8?44070B;4409C6?4409DA&440C4B?" + + "441030;441244<441524\\441622H4416FA]44179374418FD&4419B6>441A5C2441B88&441BF67441D647441EA1<44227C?44237Cu4425F4&44272E?442A60&442B03244303F?" + + "443192<44321D?443583&4438E8A443D54%443E8Am444201%4447CC>4448C1<444988A4449C0K444A37u444ADB&444C0C&444E1A]444E6D$44552B]4455B1?4455C4?44579Fu" + + "4459E3?445BED<445CE9]445E82w4463B6&4463C2w44643C244650D%446690i446747?446A2E?446B1Fm446D6C]446D7F%446EE5?447147u44746Ce447654?447831?44783E]" + + "447B307447B45%4482E5?448346m448500A44881624488BEm448A5BF448DD52448F17]4490BB&4494FCM449BC1?449E8B&449F46?449FDAb44A038?44A10E&44A191?44A3BBA" + + "44A56EM44A642>44A7F4&44A842544A8FC&44AA50B44ADB1\\44ADD9244AE25244AE44?44AF28A44B176744B32Di44B3C5?44B4A0G44B4B2%44B6BE244BB3B;44BD8D744BDC8u" + + "44BE0B?44C15Cm44C20C244C346?44C3B6?44C532?44C63C]44C65D&44C7FC?44CBADu44D3CA244D453\\44D454\\44D4E0e44D5CC%44D791?44D884&44D9E7o44DA30&44DBBE?" + + "44DF65u44E213u44E2F8b44E4D9244E517A44E59B?44E66E&44E853&44E968?44E9DD\\44EA30]44EAD8m44ECCEB44EE14m44F09E&44F21B&44F459]44F477B44F770u44FB42&" + + "480020<480031?4800B32480234?4805E2?480A28&480EECi480FCF<481258?48128F?48137E]481389G481BA42482254i4825F3?48262C&4827C5?4827E274827EA]482952\\" + + "482CA0u482CD0?482E722482F6B<482FD7?483106K483177N4831B774831DB?48352B&483584?4835AB&483871?483A028483B38&483C0C?483F72]483FDA7483FE9?4840D5A" + + "48435A?48437C&4843DD%4844F7]484520A4846FB?48474B?484982?484996?4849C7]484AE9<484BAA&484C29?484C86?484D7E5485073H485169]4851B7A4851C5A4855197" + + "485702?4857D2/485929D485A0DB485B39#485D35$485F08i485F2D%48605FD4860BC&4861EE]486264)486276?486345?48684AA48701Em48706F?487310B487410248746E&" + + "48785B>48785E%48794D]487B2FH487B6B?487D2Ei48800224882DF?4883C7\\48849Dm4886E8H488759u4889E7A488B0A2488C63?488EEF?488F5AI48902FD4890F054891D52" + + "489A58]489D317489DD1]489EBD<489ECB<48A170248A195&48A3BDm48A472A48A516?48A5E7N48A6B8d48A91C&48A98AI48AABB\\48AD08?48AD9AA48AFF3748B02DK48B25D?" + + "48B423%48B4C3<48B8A3&48BA4E<48BCE1]48BD4A?48BF6B&48C381i48C796]48CA43748CA68&48CDD3?48CFA9?48D24F\\48D539?48D6D5;48D705&48DB50?48DC2D?48DF37<" + + "48E150A48E15C&48E1CA&48E27E\\48E729748E9F1&48EA62<48EDE6w48EE0C348EF1C]48EF61?48F17FA48F1EBN48F6EE748F7BC?48F8DB?48FC07?48FD8E?48FDA3u4C00822" + + "4C01F724C0220u4C034FA4C0F3EA4C10D5i4C11AE74C11BF44C16FCB4C1744%4C17EB\\4C195D\\4C1D96A4C1F86>4C1FCC?4C20B8&4C21D0e4C2498m4C2B3B?4C2E5E]4C2EB4&" + + "4C2FD7?4C306AN4C3275&4C3488A4C3946]4C3BDFH4C3C16]4C3CE2?4C3FD3m4C421E24C445BA4C4553b4C496CA4C49E3u4C4AB4B4C4E3524C5077?4C53FD%4C5499?4C55B2u" + + "4C569D&4C5739]4C57CA&4C5BB3b4C5D3C24C5D6A&4C5E0CI4C5F70A4C60AD%4C60DEM4C617E?4C62DF>4C631B?4C6371u4C63AD?4C66A6]4C6BE8&4C6D58B4C710C24C710D2" + + "4C734FB4C74BF&4C752574C762554C776D24C77CBA4C796EA4C7975&4C79BAA4C7A8824C7C5F&4C7CD9&4C8093A4C80FB;4C820C&4C842174C858A04C889E?4C8BEF?4C8D53?" + + "4C8D79&4C8E19u4C9614B4C97A1b4C97CC&4C9EFFw4C9FF1&4CA56D]4CA64D24CA919n4CA954A4CAB4F&4CAD35&4CAE13?4CAEA3<4CB04AA4CB087?4CB16C?4CB199&4CB910&" + + "4CBB47K4CBC4824CBCA5]4CBD8F>4CC38274CC53Ew4CC5D954CC64Cu4CC95E]4CCA95?4CCBEA?4CCC6AF4CCDB6&4CCF7C<4CD012&4CD0CB?4CD0DD?4CD0F924CD1A1?4CD546<" + + "4CD587<4CD629?4CD71754CD98F54CDA38m4CDD31]4CDE48?4CE0DBu4CE17524CE17624CE20Fu4CE650&4CE65E?4CE67604CE6C0&4CEB42A4CEBB0]4CEBD674CEC0F24CECEE]" + + "4CEDFB#4CEFC0%4CF202u4CF475?4CF55B?4CF5DC>4CF95D?4CFB45?4CFBFE_5000E025000E6G50016B?5001BB]5001D9?50029175004B8?50060425006AB25006F5[5007C3%" + + "500B23?500B26?500F802500FF5k50125Ce5014C1?5017FF2501CB02501CBF2501D93?501FC6&5021EC?50236DN5023A2&50284AA502873?502B73k502DA2A502F9BA502FA82" + + "503237&50325Fb503275]50338Bm503B70&503CC4E503DA1]503DC6u503DD1i503DE52503EAAi503F50?504172?50464A?50465D#50492125049B0]504A6EM504B9E?504F3Bu" + + "5050A4]5051A9m505527D505663m5056BF]50578A&5057A8250586F?505C882505DAC?5061BF2506382?506391?50642Bu506583m5065F3<5066E5?5067AE25067F0w50680A?" + + "5068AC?506A03M506B4BG506F0C\\506F77?5071642507224m5076AFA507705]50787D75078B0?507A55&507AC5&507C6FA508114&508140<5082D5&508492A508569]5087892" + + "508811u5089D1?508A06n508A7F?508BB9n508CB1m508D62?508D9E?508E49u508F4Cu5091E3i50926Au5092B9]5093CE?509546?509839u509893m50995A%509A4C5509A88?" + + "509EA7]509F27?50A009u50A1F3?50A4C8]50A67F&50A6D8&50A72B?50ACB9?50B03Be50B127&50B7C3]50BA84250BC96&50BD5Fi50C4DD050C58DB50C709B50C7BFi50C8E5]" + + "50D2F5u50D45C%50D4F7i50DAD6u50DCE7%50DDAB]50DE06&50E039w50E085A50E467Z50E4E0<50E538>50E549:50E636$50EAD6&50EB71A50EBF6#50EC50u50ED3C&50EEB5m" + + "50F0D3]50F14Am50F265&50F351&50F4EB&50F520]50F5DA%50F722250F7ED?50F958?50FA84i50FC9F]50FE39u525400T540295?5404A6#540764?54077DM540910&540DF9?" + + "540F57b54102E?54104F]5412CB?541310?5414F3A5416A57541E56B54211D?54219D]542259?542369?5425EA?542618?54263De542696&542906&542A1Bd542A43&542B1C%" + + "542B8D&542F2B?54320475432C7&5433CB&5434EF?543631A5439DF?543AD6]543CED\\5440AD]544249e5443B2754443B?5444A3]544538m5447CC\\54481055448E6u544A002" + + "544A16m544B8CB544C8AH544E90&544EF0[54511B?5451DE2545284?5453EDe5455D5?545618?545925?545AA67546009;54606D?5462E2&5464D9\\546749;546990?546C0Em" + + "546CEBA5471DD?54724F&54735A?547595i5475D0254778A<54781A2547C692547DCDm547FEE2548028<54833Aw5486BC25488DE2548998?548ABA2548C81>548D5AA549209?" + + "5492BE]549963&549B12]549B24G549DEA7549F13&549F355549FC6254A050#54A274254A51B?54A637?54A6DB?54A703i54AE27&54AF97i54B121?54B27E\\54B802]54B80A3" + + "54B8DB&54BAD6?54BD79]54BF55B54BF64554C415>54C480?54C80Fi54CF8D?54D17D]54D299i54D7E3<54D9C6?54DCE9b54DD21?54DD4F]54E019Z54E032B54E15B?54E43A&" + + "54E4EDA54E61B&54E6FCi54E6FDe54EAA8&54EBE9&54ECB0_54EF43?54EF44'54F0B1<54F201]54F283b54F294?54F607?54F6E2?54FA3E]54FB66\"54FCF0]54FEEBm5800BBB" + + "5803FB>58044Fi580987%580A202580AD4&580E85\\580EE6G580FA5&581122#58170Ce581862e581CF8A581DD8\\581F28?581FAA&582059u582071]5820B1<582429;582575?" + + "58263Ab58278C0582A93&582ABD7582AF7?582B0Am582BD37582F40N582FF7\\58355D?5835D92583653&583BC2b583F54D58404E&584120i584498u584822e5850ED>5851A3&" + + "585595&5855CA&58569F25856AA&5856C2?58605F?5864C4&58666D&58687A\\586B14&586C25A586D0C<586D67A5873D1?5873D8&587961H5879E0]587A62m587F57&587F66?" + + "588336?588670B58879F?588A5A5588B1C2588BF3w588C817588CCFb588D092588E81b589043\\5891CFA589351?5893D8m5893E8&58946BA5894AE?58957E?58960AD58961DA" + + "58971E25897BD2589A3E%58A023A58A15Fm58A2B5D58A2E1G58A639]58A839A58A8E8%58ABFB?58AC78258AD12&58AE2B?58AEA8?58B035&58B03EN58B10F]58B18F?58B623u" + + "58B965&58BAD4?58BC27258BDA3N58BE72?58BF25758BFEA258C38B]58C5CB]58CB52;58CE2AA58CF79758D061?58D15Am58D349&58D56E358D61Fo58D759?58D812i58D9D5k" + + "58DF59258E28F&58E434B58E488%58E6BA&58E6C5758EA1Fu58F2FC?58F39C258F8D7?58F987?58FB3E?58FB84A58FDB1D5C013B75C0214u5C0272b5C0339?5C07A6?5C0947&" + + "5C0979?5C0B3B?5C0CE6N5C10C5]5C13AC&5C13CC&5C167D?5C1720?5C1BF4&5C1DD9&5C23C2&5C2573G5C260A55C2E59]5C313Em5C319225C337B;5C345B>5C3977B5C3AA2b" + + "5C3C27]5C3E0625C3E1B&5C4071u5C4527B5C475EZ5C4879?5C4979$5C497D]5C4CA9?5C501525C50D9&5C5136]5C514FA5C5181]5C521EN5C5230&5C5284&5C546D?5C5948&" + + "5C5AC725C5E0A]5C5EABB5C5EBB?5C5F67A5C60BA<5C6117?5C628Bi5C63B085C63BFi5C647A?5C648Ew5C64F125C6783A5C6A80w5C6B32m5C6F69/5C7017&5C7075?5C70A3D" + + "5C710D25C78F8?5C7D5E?5C80B6A5C838F25C843Ce5C8505?5C865C]5C8730&5C879CA5C899Ai5C89BC&5C8A38<5C8B6B%5C8D4E&5C9157?5C9175&5C95AE&5C9666e5C969D&" + + "5C97F3&5C9960]5C9977&5C9AA1?5C9BA6&5CA2A2m5CA47D<5CA48A25CA62D25CA64Fi5CA6E6i5CA86A?5CAAFDd5CAC3D]5CADBA&5CADCF&5CAF06D5CB00A?5CB12E25CB13E\\" + + "5CB26DA5CB395?5CB43E?5CB47EA5CB524e5CB8B7&5CB901<5CBA2C<5CBA37H5CBD9A?5CC0A0?5CC1D7]5CC1F2?5CC307?5CC5D4A5CC787?5CC7C1b5CCB99]5CCD5BA5CCF7F7" + + "5CD06Eu5CD2E4A5CD33D]5CD89E?5CD99835CDC49]5CE0C5A5CE17625CE28Cw5CE42AA5CE50Cu5CE747?5CE883?5CE8EB]5CE91E&5CE931i5CED8C<5CEDF4]5CF4ABw5CF51A4" + + "5CF5DA&5CF6DC]5CF7E6&5CF821m5CF938&5CF96A?5CF9DD55CFA25\\5CFC66260019476001B1?600308&6006E3&600810?600F6B&60109E?60123C?60156Fi60183A?6018955" + + "601AC7N601F56?602232o6025ED<602602m6026AA26026EF<60292Bi602E20?602ED5&6030B3?6030D4&603197w6032B1i60334B&6036DDA603A7Ci603AAF]603CEED603D29?" + + "603E5F&60452EA6045CB#6045CD\\6046D4m604DE1?604F5B?605355?605375?6055F976056B1?605718A6057C8&605B305605E4F?605E65G605FAA?60629AB60634C3606405m" + + "606525&606720A60684E]606944&606BBD]606BFFN606C66A606EE8u60706C;6070C0&60735C260756CD607771m6077E2]6079C9?607EC9&607ECD?607FCB]608110&608246&" + + "608306?608334?608373&6083DAb6083E7i6084BD0608B0E&608C4A&608E08]608F5C]609217&6092C8[609316&609578]6095BD&6096A4?609866m609AC1&609BB4?60A10A]" + + "60A2C6?60A37D&60A3E3i60A423b60A44C#60A4B7i60A4D0]60A5E2A60A6C5?60A751?60A954260AAEF?60AB67u60AF6D]60B0E8?60B4A2]60B58D$60B647b60B6E1m60B763b" + + "60B76E;60B9C0260BBEB?60BD83?60BEC4&60C547&60C5AD]60C78DB60CA3A&60CE41?60CE86_60CF84#60D039&60D0A9]60D178260D1D8b60D755?60D9A0E60D9C7&60DD70&" + + "60DD8EA60DE18&60DE44?60DE94?60DEF3?60E327i60E32BA60E3ACD60E701?60E85Bm60EFABb60F18A?60F262A60F445&60F549&60F620d60F677A60F723u60F81D&60FA9D?" + + "60FACD&60FB42&60FDA6&60FEC5&60FF12]64006A56400F1264028Fb64037F]64078C?6407F6]6408642640980u640BD7&640C91&640D22D64122526413AB?64168D26416F0?" + + "6417CD]6418DF\\641B2F]641C10m641CAE]641CB0]64200C&642315?642753?64294336429FF?642CAC?642E41?642F1C?643135&643136G643150<6432A8A6433AAG6433DBm" + + "643AEA2643E0A?643E8C?6441E6&6442C2G644842&64497DA644A7DA644C36A644ED7<645106<6451F4?6453E0?645601i6456B5]6457BAA645A36&645AED&645D86A645DF4]" + + "645E10?646140?646306u64644Au64649BB646624\\6466B3i6466D8]6467CD?64694Em646CB2]646D2F&646D4E?646D6C?646E97i646EE0A647002i647033&647060m6476BA&" + + "647791]647924?647999\\6479F0A647B1E\\647BCE]647BD4m648099A64876C?648788B648914764899AD6489F1]648CBBm648F3E26490C1u64956CD649A63Z649ABE&649B8Fm" + + "649C8Em649D38;649E31u649EF1m649EF3264A0E7264A198?64A200u64A28A?64A3CB&64A5C3&64A651?64A963?64AC2BB64ACE0]64AE0C264B0A6&64B0E8?64B310]64B473u" + + "64B5C6N64B5F2]64B708764B853]64B9E8&64BC0CD64BC43?64BC58A64BD6D&64BF6B?64C045&64C2DED64C394?64C3D6B64C753&64C905&64C9F1%64CC2Eu64CDC2%64CFD9m" + + "64D0D6]64D154I64D2C4&64D4DAA64D562?64D69AA64D7C0?64D814264D989264DB8B>64DD68w64DDE9u64DE6DA64E4A5D64E682&64E7D8]64E833764E881<64E950264F69D2" + + "64F705?64F81C?64FA2B\\64FD29464FD96\\680125%6804892680571]6805CAA680715A680927&6809477680AE2b681324?6813F3%681590\\681729A681A47&681BEF?681C522" + + "68228EB6822E5?6823B0m6825DD7682737]68286Ce6828CF<682C7B2682E3Co682F67&683036&683045?683421A6837E9%683B09&683B782683E26A683EC0&684406m684465&" + + "684571?6845CC&684749m6847C5A684898]684983?684A5F&684AAE?684AE9]684C25?684DB6u684F645685134<68545AA6854FD%68572Dn685ACF]685B35&685D43A685E1Cm" + + "685EDD&686372?68644B&68672576867C7\\686CE6H686DBC>68709Eb6871612687251o6872C3]68764Fe687724i6879092687A64A687BDCB687D6B]687DAC]687DB42687FF0i" + + "6881E0?6883CB&6886A726887C626889C1?688F84?68951B?68962E?68967B&6899CD2689A87%689B43?689C70&689CE22689DD27689E0B2689E19m689E6A?689FD4%68A03E?" + + "68A0F6?68A46A?68A593&68A729&68A828?68A86D&68AB1E&68ABA9\\68ABBCu68AE20&68B599<68B5E3?68B691%68B6B3768B8BBu68BC0C268BDAB268BFC4]68C44Cu68C63A7" + + "68C6ACA68C90Bm68CAC4&68CAE4268CC6E?68CCAE868D79Ao68D927?68D93C&68D972268DBCA&68DBF5%68DDB7i68DFDDu68DFE4]68E1DC068E209?68E580&68E59E268E74Am" + + "68E7C2]68EBAE]68EC8A@68ECC5A68ED57B68EE8F768EF43&68EFBD268EFDC&68F38EB68F543?68F63B%68F7D8H68F90FA68FB7E&68FCCA]68FE71768FEF7&68FF7Bi6C006B]" + + "6C028C?6C02E0<6C030926C03B526C047A?6C0699u6C06D6?6C0B5E<6C0C9A%6C0DC4u6C0E0De6C1270&6C13D526C146E?6C1544H6C1632?6C198F36C19C0&6C1A75?6C1AEAm" + + "6C1C7146C1D2C?6C1F8A&6C205626C23B9e6C2636?6C2995A6C29D226C2B5956C2E85\\6C2F2C]6C2F80A6C2F8A]6C302Am6C310E26C3491?6C3AFF&6C3B6BI6C3BE5<6C3C8C5" + + "6C3DD876C3E6D&6C4008&6C410E26C416A26C41DE?6C442A?6C483Fu6C4A85&6C4CBCi6C4CE2A6C4D73&6C4EF626C4F89w6C4FA126C504D26C51BF?6C51E4?6C5563]6C558D?" + + "6C55B1%6C5697%6C5AB0i6C5CB1b6C5D3AH6C5E3B26C5F1CE6C60D0?6C626DF6C62FEB6C63F8o6C67EF?6C688A%6C6A77A6C6C0F?6C6CD326C709F&6C70CB]6C710D26C71D2?" + + "6C722036C72E7&6C7637?6C77F0?6C78C1B6C79B8m6C7E67&6C7F49?6C8243?6C8336]6C8375/6C8814A6C8BD326C8D7726C8DC1&6C92CF/6C9313G6C9466A6C94F8&6C96CF&" + + "6C9961\\6C998926C999D%6C9CED26CA042b6CA100A6CAB0526CAB31&6CACC2]6CB0CEM6CB133&6CB158i6CB227e6CB2AE26CB2FDm6CB45676CB4FD?6CB749?6CB7E2?6CB7F4]" + + "6CBAB8\\6CC217<6CC26B&6CC374m6CC49F<6CC84076CCDD6M6CD032D6CD1E5?6CD63F?6CD68AD6CD6E326CD704?6CDD3026CDDBC]6CE21076CE4A4b6CE5C9&6CE85C&6CE873i" + + "6CE874?6CEBB6?6CECEBm6CF049:6CF373]6CF37F<6CF6DAA6CF784u6CFA8926CFD22b6CFE54A6CFFCE\\7001B5270039F770041D7700514D700810A700971]700B01\\700B4F2" + + "700F6A270105C270106F<701124&701384&7014A6&7015FBA7018A7270192F?701AB8A701CE7A701F3C]701F53270217Fu7022FE&70287D;70288B]702AD5]702C09N702F35?" + + "70317F&703217A7035092703A0E<703A51u703ACB;703C69&703EAC&7040FF?704698?70480F&7048F7N7049A2w704BCA7704CA2&704CA58704D7B#704E6B?704EE0]704F57i" + + "705464b7054F5?705681&705A0F<705AAC]705FA3u70617B27062B837062CB&70662Ae7066B9?70695A2706BB92706D152706E10?706E6D270700D&707013?70708B27070AA%" + + "7070D5?70720DE70723C?7072FE&707362?7073CB&70792DG707990?7079B32707BE8?707CE3?707DA1\\707DB9270810527081EB&7085C2\"7086C1m708976n708A09?708BCD#" + + "708CB6?708CF2&7090B7?709684&709751u709AC4?709C45?709CD1A709E29e70A04BA70A2B3&70A6CCA70A741o70A8A5H70A8D3A70A8E3?70A983270AC08b70AE2A&70AED5&" + + "70AF09770B13D]70B306&70B317270B51A?70B5E8570B7E4/70B8F6770B950m70BB5B&70BBE9u70BC10H70BC48270BD96270C288A70C59Cb70C7F2?70C9C6270CA9B270CD0DA" + + "70CD60&70CE8C]70CF49A70D07Eb70D313?70D379270D823A70D8C2A70DA48270DB98270DDEF?70DEE2&70DF2F270E422270E56Em70E72C&70E997?70EA1A270EA5A&70EBA5?" + + "70ECE4&70EF00&70F087&70F088N70F096270F35A270F8AEH70F927]70F94A&70FD45?70FD46]70FF76m7400E8?7402E1m7403BD074042BE7404F1A7405A5i740AE1?740B12b" + + "740C2E2740CEE?740EA4&7410E0/7411B227413EAA7414D0&741575u7415F5&74190A]741BB2&741EB1]7422BB?742344u742435?742554K7426AC274272C!742869?7428AEG" + + "742917&742959&742972B742981m743174&74342B?743675B743822u743989i743AF4A743C24?743CDE<743F8E&743FC2>744218&74427F$74428B&744401M74452D?74458A]" + + "7446A0<7446B3m744D28I744D6D?744DBD77450CD?7451BAu74563C:7458F3%745909?745AAA?7460FA?7463C2?74650C&746A84m746DFA]747069?7470FDA74718B&7473B4&" + + "747446;747548%74761FH747786&74782757478A68748114&7483C2o748469N74860B274867A57486E2574872E?74882A?7488BB27489277748B23]748D08&748DAA?748F3C&" + + "748FC227498F40749B89?749D79_749D8F?749E75<749EAF&749EF5]74A02F274A063?74A2E6274A528?74A58Cm74A6CD&74A722D74A7EA%74A981?74ACB9o74AD98274B587&" + + "74B725?74B839m74B8A8?74BC6BA74C14F?74C17ED74C246%74C412H74C929474CA60d74CC40&74D02B#74D21D?74D285m74D423%74D435:74D5E8&74D637%74D6E5?74D6EAm" + + "74D809H74D83EA74DA38674DA78<74DA88i74DADA374DAEAm74E182m74E1B6&74E20C%74E28CH74E2E7274E2F5&74E50BA74E5F9A74E6B8D74E6E2574E798B74E987&74E9BF?" + + "74E9D8774EA3Ai74EB80]74ECB2%74F2FAu74F441]74F67A]74F90F?74F92Co74F9CAN74FA29o74FC77?74FECEi78009E]78028B&7802B127802F8u780473m7804E3?7806C9?" + + "78078F?78084D?780CB8A780CF02780E3E?78119D27811DCu781699?7817BE?7818A8?7818EC87819F7B781A32?781C3C7781C9Db781DBA?781FDB]782051i7820A5N7821847" + + "782327]7824AF#7824BE2782599?7825AD]7828CAd782B46A782B60?782BCB5782DAD?7831C1&78321B37833C6]783409?7834B4?783716]783A84&783F4D&7840E4]78421C7" + + "7844FDi784558o7845B3?7845C457845DC?7846D4]78471D]784859<7849D7]784F43&784F9BB78507CB78521A]785333u78542E3785536.785773?785860?78595E]785B64?" + + "785C5E?785DC8D785ECC&78605Bi786089]786256?7864A027864C0&786559\\7867D7&786A89?786C1C&786C84%78725D2787462A78753E&787826?787871m787984&787B8A&" + + "787E61&788102_78818CN788371?78843Ce78851727885F4?78862EH78886D&788A20o788CB5i788DAF\\78929CA7894B4_78960D&78966E?7898E83789987u789A18I789ED0]" + + "789F70&789FAA?78A03F%78A106i78A2A0N78A3E4&78A504m78A7C7&78A873]78ABBB]78AC44578ACC0<78AF08A78B46A?78B554?78B5F2?78B6FE]78BAF9278BC1A278BDBC]" + + "78C05Ai78C11D]78C213\\78C3E9]78C57Dw78C5E5m78C5F8?78C881e78C884?78CA39&78CD55m78CF2F?78CFF9?78D162&78D294M78D752?78D75F&78D840u78DA6E278DAAF?" + + "78DB2Fm78DC87?78DD33?78DEE4m78E0C5]78E103%78E22C?78E36D778E3B5<78E3DE&78E7D1<78EB46?78EE4C778F09B?78F1C6278F238]78F557?78F5FD?78F7BE]78F882D" + + "78FBD8&78FD94&78FE3DB78FF57A7C004D?7C010Am7C0191&7C034C\\7C035Eu7C03ABu7C03D8\\7C04D0&7C0A3F]7C0BC6]7C0C5F77C0CF1u7C0CFA?7C0ECE27C10C9#7C11BE&" + + "7C11CB?7C162A?7C1689\\7C1960?7C19E3;7C1AC0?7C1B93?7C1C68]7C1CF1?7C1DD9u7C210D27C210E27C214AA7C2302]7C2499&7C2586B7C25A387C2664\\7C296F&7C2A31A" + + "7C2ACA&7C2ADBu7C2C6777C2EBD;7C2EDD]7C310E27C31FAb7C33F9?7C3626?7C3866m7C38AD]7C3985?7C3B2D&7C3D2B?7C3E74?7C49EBu7C4B26&7C4D8F<7C4FAD77C4FCD&" + + "7C5049&7C5079A7C573C<7C5758<7C59B1&7C5A1Cf7C5CF8A7C6097?7C6130&7C6166%7C62E727C6305%7C6456]7C646CD7C669A?7C669Dm7C67A2A7C67AB[7C68B9?7C692B?" + + "7C69F627C6D12H7C6D62&7C6DF8&7C70DBA7C72E7m7C739877C73EB?7C752D]7C7635A7C7668?7C7716w7C787E]7C78B2s7C7A91A7C7BBF]7C7D3D?7C852F?7C876727C87CE7" + + "7C8931?7C8956]7C8BB5]7C8BCAi7C8C09G7C8EE4m7C9122]7C942A?7C94B2Q7C95F327C97E1?7C9824\\7C9A1D&7C9EBD77CA177?7CA1AE&7CA23E?7CA449u7CA62A<7CA8EC<" + + "7CAB60&7CAD4F27CAD7427CB0C2A7CB15D?7CB27DA7CB35327CB566A7CB59Bi7CB59F?7CBB8AN7CC06F&7CC0AAH7CC180&7CC225]7CC255g7CC294u7CC2C6i7CC385?7CC3A1&" + + "7CC537&7CC6B6b7CC882?7CC8DF&7CC95A57CCCB8A7CD1AD&7CD1C3&7CD2DA&7CD3E5?7CD4A8\\7CD54477CD566%7CD62C&7CD661u7CD95C;7CD9A0?7CDC73?7CDFA177CE269m" + + "7CE2CAB7CE53F?7CE87F\\7CE8B177CEC79m7CECB1&7CEDC6%7CF05F&7CF17Ei7CF31BD7CF34D&7CF666n7CF854]7CF88027CF90E]7CFADF&7CFC16&7CFD6Bu7CFE90G7CFF4D$" + + "80000BA80006E&80045F&800518?800794]800C67&800CF9%800D3F]801242&801316A801382?8013BEB80184458018A7]801934A801970]801D39&801F0268020DA\\8020FD]" + + "802395$80248F2802689380276C2802AA8o802DBF2802EC3?802EDE?8030DCm8030E0<8031F0]803253A8035C1u803773M8038BC?8038FBA80398C]803C04i803C20?803FD4&" + + "804126?80433FB80456B78045DDA804715&804786]80482Cs80489F>804971&804A14&804AF2d804B50b804DCB?804E70]804E81]8053E0780542D]80549C]8054D9?8054E3&" + + "805719]805A04D805A708805FC5&806036?806132280646F780647Cn80656D]80657C&8065997806933?806A002806D71%806F1C?806FB0m80711FB80717A?807264?8075BF]" + + "807B3E]807C62>807D14?807D3A7807DF9G807FF8B80802C8808223&8083F6&808489A8086D9]8086F2A808917i808943]808ABD]808DB7<808F1Di808F97u80929F&80953A&" + + "809621E809698&8099CFm8099E7e809B20A809FF5]80A63C%80A997&80ACACB80AD16u80AE54i80AF19&80B03D&80B54E780B575?80B655A80B686?80B989&80BE05&80BEAF>" + + "80C01EA80C16E<80C41Bm80C5E6H80CC12?80CC9CM80CE62<80CEB9]80CF41E80CFA2?80D09B?80D1CE&80D2E5N80D4A5?80D605&80DACE?80DB17B80E01D280E1BF?80E4BAA" + + "80E63Cu80E650&80E82C<80E86F280EA07i80EA0Bw80EA96&80ED2C&80F1A4?80F1B2780F3DA780F5AE>80F5B5m80FB06?8400D2e8400EC`840328B840511&84083AA840C6D2" + + "840D8E7840F4C&84119E]84144DA8415D3?84160C/8416F9i841888B841985]841B5EM841B77A841EA3\\841FE878421F1?842289]842519]8425DB]842712b842789&842859%" + + "842999&842AFD<842B2B5842E14b842E27]842F57&843497<8437D5]843835&84398F8843A4BA843CFC?843DC62843E03\\843E92?844167&844693u8446FE?844765?844880%" + + "845075?845181]845234B8454DF?8455A5]845733H845A3E2845B12?845C315845CF3A845F04]8463D6H8464DD?84683EA846878&846993<847127b84716A?847293m847637?" + + "847848o84788B&8478AC2847B57A847BEB5847CEE2847D7E2847E40m84802D2848506&8488E1&8489AD&848A8D2848C8D&848E0C&848EDFe848F695849265A8492E5?8493A0?" + + "849437&849459>849866]8498A7m849A40>849FB5?84A06E\\84A134&84A1D1\\84A423\\84A466]84A6C8A84A824;84A8E4?84A93E<84A9C4?84AB1A&84AC16&84AD58?84AD8D&" + + "84AEDEu84AFEC084B153&84B1E2H84B1E4&84B261284B4DBb84B517284B541]84B59CB84B802284B890i84BA20b84BB26m84BE52?84C065N84C0EF]84C1C1B84C5A6A84C692m" + + "84C7BB784C7EAe84C9B2384CC63?84CCA8784D1C1A84D328&84D3D5?84D47E<84D6D0%84D7DE?84D81Bi84DBA4?84DBAC?84DD20m84DD84284E342n84E657e84E8CB084E986?" + + "84EAED[84EB0CG84EB18m84EBEF284EE7F?84EEE4]84EF18A84F147284F3EB784F703784FCAC&84FCE6784FCFE&84FD27b84FDD1A84FE40?84FFC228801F9m88074BD880AA3B" + + "880CE0m880F62b880FA2\\881032&88108F?881196?8813BF7881566?8815C5?8817A8&881908&881A14b881DFC2881E5A&881FA1&88200D&88225B<882510<882593i8828B3?" + + "8828FBB88299C]8829BF%882C31&882F92u883037B883314m88365FD8836CF?883A30<883D24;883F27?883F4Am883FD3?884033?88403B?8843E12884477?884604u884AEAm" + + "884D7C&884F5928851F2&8851FB<8852EBu88532EA885395&8853D4?88541F;88546Bm8856A6788572178857EE0885A922885E54]8863C5?8863DF&886440&886639?88665A&" + + "8866A5&8867DC?88693D?886B6E&886BDB&886C60u886D2D?886EEB?886FD45887015&88708CE8871E5%887477?8875562887598]8876B93887873A887ABC2887E9B&8881B9?" + + "888322]888603?88892F?888E68?888FA4?889009B88908D2889986i889B39]889CAD2889F6F]88A0BE?88A25EB88A29EW88A2D7?88A303]88A479&88A6C6\\88A9B7&88ACC0w" + + "88ADD2]88AE07&88B111A88B291&88B4BE?88B7EB&88B945&88B951u88BA74b88BCC1?88BD45]88BFE4?88C08B&88C227?88C22D088C255m88C344;88C397u88C663&88C6E8?" + + "88C9D0D88C9E8e88CB87&88CE3F?88CEFA?88CF98?88CFCDm88D546&88D7F6#88D82EA88D98FB88DA04?88DDB8?88DE39>88DEA9[88E056?88E0F3B88E3AB?88E64BB88E87F&" + + "88E9A4<88E9FE&88F031288F077288F155788F2CE&88F3D5w88F4DAA88F56E?88F6DC?88F872?88FC5D28C006D&8C04BA58C0572?8C0879m8C08AA&8C0D76?8C0FC9?8C10D4\\" + + "8C142A28C15C7?8C1759A8C17B6?8C1952%8C1ABF]8C1D96A8C1E8028C210Ai8C2114?8C22D2>8C2505?8C26AA&8C2937&8C2A85%8C2DAA&8C2E72]8C3066o8C3396&8C3446?" + + "8C34FD?8C3AE3D8C3BADM8C426D?8C44A528C459878C47BE58C4962[8C4B1478C4EBB%8C4F0078C53C3u8C554AA8C5646D8C56C5N8C5877&8C5973w8C5AC1?8C5AF8u8C5EBD?" + + "8C604F28C6422e8C65A3b8C683A?8C6A3B]8C6BDB?8C6D77?8C6FB9b8C705AA8C71F8]8C73DAb8C7712]8C7909<8C79F5]8C7A3Du8C7AAA&8C7B9D&8C7C92&8C8283&8C83E1]" + + "8C83E8?8C844228C8474/8C8590&8C85C1<8C861E&8C862A?8C86DDi8C89A5F8C8ACD?8C8B48b8C8B83m8C8C2978C8D28A8C8EF2&8C8FE9&8C902Di8C913AG8C91A4&8C941F2" + + "8C946128C94DF78C986B&8C9885]8C9A8F\\8CA2F5?8CA3EC]8CA5CF?8CA6DFi8CA96D?8CA982A8CAAB578CAACEu8CB0E9]8CB50E28CB64F28CB87EA8CBEBEu8CBFA6]8CBFEA7" + + "8CC246&8CC5B4\\8CC5D0]8CC681A8CC8CD]8CC9E9?8CCDE8N8CCE4E78CCF0958CD066m8CD0B2u8CD9D6u8CDCD4<8CDEE6]8CDEF9u8CE5C0]8CE5EF?8CE748>8CE9B448CE9EEA" + + "8CE9FF58CEA48]8CEBC6?8CEC4B58CEC7B&8CEDE1o8CF67DM8CF681b8CF8C5A8CFABA&8CFADD?8CFD18?8CFD4978CFDDE\\8CFE57&9000DB]900117?90013B\\9002A94900325?" + + "900628]9006DB?9006F2m9009D0h9009DFA900A48]900A84G900CC8;901057A901195%90150679016BA?90173F?9017AC?9017C8?901F09b901F94i9020C2<9020D7H90235B%" + + "9025F2?9027E4&902AEEu902B34:902BD2?902C09&902E1CA9035A2&9035EAb90380C790395Eb90395F%903C92&903FC3?903FEA?9041B2o904528N904748e9047C2A904846m" + + "90486CZ9049FAA904C81<904CC5&904D4A\\904DE2&904E2B?9051F8<90567129059AFm905A08g905E44?905F7A&9060F1&9061AEA90623F&90633B]90649B79064AD?906584A" + + "90671C?906989]906AEBH906CAC8907065m9070697907240&907282\\9074F2?9077EE2907841A9078B2u907BC6m90808F?90812A&908158&908175]90840D&9088552908A802" + + "908C43&908D6C&908D6E5908D783909497?9094E43909507?9096F309097D579097F3]909838?909A4Ai909A77m909B6F&909C4A&909F22w90A25B&90A57D?90A5AF?90A822%" + + "90AB96b90AC6D&90AE1Bi90B021A90B0ED&90B11C590B144]90B176A90B21F&90B339790B622]90B790&90B931&90BA09?90C115e90C1C6&90C97E?90CAFA;90CC7A?90CCDFA" + + "90CDE8&90CEB8m90D733?90D7EBm90DA72790DD5D&90E17B&90E202m90E2BAA90E317G90E5B1790E6BA#90E95E290EB50290ECEA&90EEC7]90EF68w90F1AA]90F644?90F652i" + + "90F80C]90F82E%90F970?90F9B7?90FB5Du90FD61&90FD9Fb9400B0?9401C2]94049C?9408C7?940B19?940BCD&940C6Di940C98&940D4B2940E5F&940E6B?940EE7?94105A5" + + "9415B2?941625&941700u941865M941882<941DD4;942157&942453?942533?94261D?94270EA942A6Fo942AD6m942B68&942DDC]9433D82943469b94350A]943589?9437F7?" + + "94390EA943A91%943B22M943C96\\943CC67943FC2<943FD6&9440C9<9440F3?944560;9446672944788?9449F6?944A0C_945044m945103]9451DC7945244]9453FFA9454C57" + + "9457A5<9458CBN945915%945AEA?945AFC%945C9A&946010?9460D5<94626Du9463D1]946424<94659CA946DAEG9476B7]94772B?947AF4?947BAEu947BE7]947D77?9487E0u" + + "948854m948978&948B93u948BC1]948E6DN949010?949426&9495A0;94988F\\949AA9H949CBE?949F3Ed94A07D?94A081b94A25D?94A4F9?94A67EM94A990794A9A8m94AD05?" + + "94AD23&94AEF0294B01F&94B10A]94B216b94B271?94B40F<94B43A]94B555794B609A94B86DA94B97E794BF2D&94BF94B94CE0F?94CE2Ce94CFB0?94D00D?94D2BC?94D331u" + + "94D469294D54D?94D771]94D9B3i94DB56e94DBDA?94DE80:94DEB8b94DF34?94E129]94E1AC>94E23CA94E300?94E36Dm94E4BA?94E686794E6BA]94E6F7A94E70BA94E7EA?" + + "94E7F3?94E96A&94E9EE?94EA32&94EB2C;94EC32b94EF50i94F128<94F392894F6A3&94F6D6&94F7ADB94FE22?94FEF4\\94FF06<94FF3C89800C6&9801A7&98038Am98038Ei" + + "98039BG9803D8&98045F?98063C]980709?98072Dm98076A&980C33b980D51?980D67w980D6F]980DAF&9810E8&9812E0u98171Au981A35?981CA2&981DFA]981E19\\98226E%" + + "98247B?98254Ai982AC3b982AFD?982CBCA982D68]982FF8?983268b9835ED?98398E]983A1F;983B8FA983C8C&983DAE7983F60?983FE8]9840BB598415CN984265\\9842F5&" + + "9843FAA9844CE?98460A&984827i984874?984925B984B06?984BE1<984E8A]984FEEA98502E&9851FC?985207?9852B1]98535F?98541BA985945m98597AA985A98?985AEB&" + + "985DADm985F41A985FD3H9860CA&986110?98698A&98751A?987552u987A14H987BF3m987EB5?9880EE]98818A?988389]9884E3m98868BB98876C?98886C?9888E07988924m" + + "988B0A>988B5D\\988D46A988F00<98909659897CCi9898FB;989BCB$989C57?989DE5>989E63&989F1E?98A2C0298A316798A375?98A5F9&98A965$98AD1D?98AF65A98B08B]" + + "98B379&98B3EF?98B6E9N98B8BAD98B8BC]98B8E3&98BA5Fi98BD80A98C08A?98C377798CA33&98CAB5&98CCF3%98CDAC798CF7D&98D293;98D3D7?98D6BB&98D6F7D98D742]" + + "98D7E1298D863798DAC4i98DD60&98DED0i98DF82>98E081u98E0D9&98E255N98E743598E7F4<98E7F5?98E859&98E8FAN98EE94u98F04C298F07Bm98F083?98F0AB&98F112>" + + "98F2B3<98F3F6?98F487m98F4AB798F621u98F9CC498FA2Ee98FAE3u98FB27]98FE3EA98FE54W98FE94&98FEE1&98FFD0E9C0298]9C0351?9C04EB&9C0591G9C05D6o9C098B2" + + "9C09CA?9C139E79C146349C1A25&9C1C12<9C1D36?9C1D58m9C207B&9C216Ai9C2183/9C2472\\9C2595]9C28B3&9C28EF?9C28F7u9C293F&9C2976A9C2A83]9C2E7A]9C2EA1u" + + "9C35EB&9C3708<9C37CBe9C37F4?9C381829C3928]9C3AAF]9C3DCFM9C3E53&9C4782i9C4929?9C4DC229C4E2029C4E36A9C4F5F;9C4FDA&9C52F8?9C5322i9C541629C5636?" + + "9C5766?9C57AD29C583C&9C5884&9C5A80B9C5A81u9C5C8E#9C5CF9e9C5FB0]9C6076&9C61D7?9C63C0G9C648B&9C64A1b9C65B0]9C65EBA9C669729C67D6A9C69D1?9C6B00\"" + + "9C6C15H9C713A?9C7370?9C73B1]9C741A?9C746F?9C760E&9C7613Z9C79E3&9C7BEF<9C7DA3?9C823F?9C8306]9C84BF&9C8ACBB9C8BA0&9C8C6E]9C8CD8<9C8E99<9C8E9C?" + + "9C924F&9C9567?9C9613E9C96D579C971BA9C9774?9C9793?9C99A0u9C9C1F79C9D7Eu9C9E6E79C9E71?9C9ED5u9CA118?9CA2F4i9CA513]9CA615i9CA9B829CA9C5&9CAA1BH" + + "9CAFCA29CB150A9CB2B2?9CB2E8?9CB654<9CBCA6<9CBCF0u9CBFCD?9CC172?9CC394&9CC7A6$9CC893B9CC8E9%9CC9EBM9CCC0179CCC83B9CD35B]9CD36DM9CD57D29CD6433" + + "9CDA3EA9CDAA8&9CDAB7<9CDBAF?9CDC71<9CDF8A?9CE063]9CE17629CE33F&9CE374?9CE635N9CE65E&9CE6E7]9CEC61?9CF1D4[9CF27E]9CF387&9CF3AC&9CF48E&9CF9A1B" + + "9CFA76&9CFC01&9CFC28&9CFCE8AA002A5AA002DC%A00460MA00798]A0086F?A00A9A?A00E98?A00F372A01081]A01828&A01B29\\A01B9E]A01C8D?A01D48" + + "A4004E2A400E2?A402B7%A402B9AA406E9mA407B6]A40801%A408F5\\A40987&A409B3?A40CC32A40E75A416C0&A416E7?A4178B?" + + "A418752A41A3AiA41F725A42249\\A426CAAA42902>A42A953A42B8CMA42BB0iA4307A]A43135&A434D9AA434F1mA4373E?A43828?A438CCNA439B3uA43B0E?A43FA7A44C112A44CC85A44E31AA45046uA4515EBA4530E2A45590uA456302A459BFbA45C25mA45C27NA45D36A4A46B?A4A490]A4A5842A4A64EGA4A930uA4AAFE?" + + "A4AC0F?A4B0F5mA4B197&A4B1C1AA4B2392A4B4392A4B61E?A4B805&A4BA70uA4BA76?A4BADB5A4BB6D5A4BDC4?A4BE2B?A4BF01AA4C0E1NA4C1E8NA4C337&A4C34EmA4C361&" + + "A4C3BEuA4C3F0AA4C494AA4C54E?A4C64F?A4C69A]A4C6F0&A4C74B?A4C788uA4CAA0?A4CB8F7A4CC37bA4CCB3uA4CF127A4CF99&A4D18C&A4D1D2&A4D23E&A4D578mA4D5C2>" + + "A4D931&A4D990]A4DA32mA4DCBE?A4DCD52A4DD58?A4E11ABA4E287uA4E57C7A4E975&A4EBD3]A4EF0CGA4F00F7A4F1E8&A4F6E6&A4F6E8&A4F841&A4F8FFoA4F921&A4F933A" + + "A4FC14&A4FF9FuA8032A7A80600]A809B1?A80C0D2A80C63?A80DE1?A81087mA8154DiA816B2DA816D0]A81AF1&A81B6AmA82066&A823FEDA82948iA829DCiA82BB9]A82BCD?" + + "A82BD5uA82C89&A830BC]A8346A]A83512?A83759?A83B5C?A83CA55A83ED3?A842A1iA842E37A84616?A846747A848FA7A8494D?A84A28&A84B4D]A84FB12A85081?A8515B]" + + "A851AB&A852D4ACBC32&ACBCB5&ACBCD92ACBD70?ACC1EEuACC33A]ACC5B4?ACC906&ACCB51>ACCC8E-ACCCFC%ACCF5C&ACCF85?" + + "ACD0747ACD4002ACD75B\\ACDCCA?ACDFA1&ACE011?ACE215?ACE2D3B402162B405A1uB40931?B40AD8eB40B1D]B40EDEAB40F3BkB4107A%B4107Bm" + + "B41324;B414892B414E6?B41513?B41584]B41678BB418D1&B41974&B41A1D]B41BB0&B41DC4?B41F4DeB423A2;B42802AB42BB9?B42E99:B43052?B43522bB437D83B43836?" + + "B4394C?B43A28]B43A31bB43A457B43AE2?B43B52\\B440A4&B440DC]B44326?B44389?B445065B44BD2&B44C3B4B44C902B45253^B4527DeB4527EeB452A9mB454F2?B45575&" + + "B456E3&B45976&B45BD1iB45CB5GB45D50B4A4E32B4A5EF_B4A64A7B4A898?" + + "B4A8B92B4AC9DmB4AEC1&B4B024iB4B055?B4B291DB4B2E98B4B52FBC2CE62BC2EF6?BC305B5BC325F4" + + "BC32B2]BC3329eBC33ACbBC33DB\\BC351EnBC36F7?BC37D3&BC3898ABC3BAF&BC3D85?BC3F8F?BC4486]BC455B]BC4699iBC4760]BC4A562BC4C78?BC4CA0?BC4CC4&BC4F2D&" + + "BC5274]BC52B7&BC542FABC5436&BC5451]BC5A562BC5E33>BC5FF4\"BC60A7eBC6193uBC620E?BC671C2BC6778&BC68C3?BC6A29mBC6AD1uBC6C21&BC6E64eBC6EE2ABC72B1]" + + "BC744BNBC74EA&BC7574?BC765E]BC7670?BC76C5?BC7737ABC79AD]BC7ABF]BC7B72?BC7E8B]BC7EC3wBC7F7B?BC7FA4uBC804E&BC8385HBC851F]BC89A6NBC89A7&BC89C1&" + + "BC8D1F2BC8D7EbBC926B&BC9307]BC932AbBC9789?BC97E1/BC9911wBC9930?BC9A53?BC9B5E>BC9C31?BC9EBBNBC9F58&BC9FE4BCAEC5#BCB0E7?BCB1F3]BCB2CC]BCB30E2BCB863&BCBAC2>BCBB58&BCBCCA?BCBF2E#BCC427?BCC4932BCCD7F?BCCD99A" + + "BCCE25NBCCF4FwBCD074&BCD11F]BCD177iBCD206?BCD22CABCD2952BCD5ED\\BCD7A5C05234?C056E3>C0582E2" + + "C05B44uC05BBD?C05D897C06118iC06194?C0626B2C06380mC06394&C064E42C06599]C067AF2C06C0C&C06DED>C07009?C07831?C07AD6]C07BBC2C083C9?C0847A&C084E0?" + + "C087EB]C08997]C08B05?C08B2A2C08C602C08D51%C091B9%C0956D&C095CF%C09AD0&C09B63?C09B9EbC09F42&C0A0BB3C0A36D?C0A4CFNC0A53E&C0A5E8AC0A600&C0A810A" + + "C0A938?C0AB2B?C0AC54\\C0B22F&C0B47D?C0B550/C0B5CD?C0B5DDbC0B658&C0B6F9AC0B883AC0BC9A?C0BDC8]C0BFA7BC0BFAC?C0BFC0?C0C7DB&C0C9E3iC0CCF8&C0CDD67" + + "C0CECD&C0D012&C0D026?C0D044\\C0D193?C0D2DD]C0D3C0]C0D46B?C0D5E2]C0D60AmC0D6D5HC0DA5E?C0DCD7?C0DCDA]C0DFEDBC0E018?C0E1BE?C0E3FB?C0E422mC0E42Di" + + "C0E579?C0E862&C0F2FB&C0F4E6?C0F6C2?C0F6EC?C0F853nC0F87F2C0F9B0?C0FD6F\\C0FFA8?C0FFD4MC403A8AC40415MC40528?C40683?C4072F?C409B7BC40ACB2C40B31&" + + "C40BCBuC40D96?C40F08AC41234&C412EC?C412F53C41411&C4143C2C41688?C4168F&C416C8?C4170E?C418E9]C418FC2C41C07]C42155?C42360AC4278C?C42996aC42AD0&" + + "C42B44?C42C03&C42F90>C4345B?C4346BC8A776?C8A823]C8B1CD&" + + "C8B29BAC8B5ADCC1531ACC167E2CC1E56?CC1E97?CC208C?CC20AC]CC20E8&CC2119]CC2293%CC22FE&CC25EF&CC2746&CC28AA#CC29F5&CC2D21k" + + "CC2DB7&CC2DE0ICC2F71ACC3089GCC3296?CC32E5iCC3331mCC33BB\\CC3429iCC35D9oCC36BBbCC36CF2CC38E1?CC3BFBZCC3D82ACC3DD1?CC3E5FD4E8802D4E8B2]D4E95Em" + + "D4E98AAD4E9F47D4EB682D4F057ND4F0EAuD4F242?D4F32DAD4F46F&D4F513mD4F547;D4F5EFDC080F&DC094C?DC0A697DC0B092DC0B34DDC0C5C&DC1057&" + + "DC121D?DC15C8$DC16B2?DC1B48mDC1BA1ADC1ED57DC2148ADC215CADC21E2?DC2727?DC2B2A&DC2B61&DC2C6EIDC2D3C?DC333D?DC3714&DC38E1BDC396F$DC39792DC3A5E[" + + "DC415F&DC41A9ADC42C8?DC44B6]DC4546ADC45B8&DC4628ADC4A3EDCD2FC?DCD2FD?DCD3A2&DCD444?DCD7A0?DCD83B2DCD916?DCDA0C7DCDB27?DCDCE2]DCDEE3mDCE541&DCE55B;DCEAE73DCEB942DCED83uDCEE06?" + + "DCEF09MDCEF80?DCF31CmDCF4015DCF4CA&DCF7192DCF756]DCFB020DCFB48ADCFE18iE00084?E0036B]E005C5iE00630?E0071BE0BDA0&E0BF0BbE0BFB2&E0C250ME0C264AE0C377]E0C3EA&E0C767&E0C79DmE0C932AE0C97A&E0CA3C>E0CB1D%E0CB4E#E0CBEE]" + + "E0CC7A?E0CCF8uE0CDB8?E0D045AE0D083]E0D1732E0D362iE0D462?E0D464AE0D4912E0D4E8AE0D55DAE0D55E:E0D7BAmE0D8485E0DA90?E0DB10]E0DB555E0DCFFuE0DEF2m" + + "E0DF13>E0E0FC?E0E258AE0E2E67E0E37C?E0E5CFmE0E751NE0EB40&E0EFBFNE0F330?E0F442?E0F5C6&E0F62DBE0F6B5NE0F728%E0F847&E0FFF1mE4013B&E4029BAE406BFb" + + "E406E0?E4072B?E407B4?E40A16?E40A75bE40D36AE40EEE?E41088]E4115BE4DB6DuE4DC43?E4DCCC?E4DE40E8A245BE8A34E?E8A55ABE8A660?E8A6CA?" + + "E8A72FHE8A730&E8AACB]E8ABF3?E8AC23?E8ADA6\\E8B0C5AE8B1FCAE8B2655E8B2AC&E8B4C8]E8B5D05E8B6C2BE8B7482E8BA17uE8BA702E8BCE42E8BDD1?E8BE81\\E8BFB8A" + + "E8BFE1AE8C1D7QE8C386&E8C3C5?E8C829AE8C913]E8CC183E8CD2D?E8CF835E8D2FF\\E8D3222E8D52B;E8D765?E8D775?E8D87E%E8D8D1ECAA25]ECAA8F?ECADB8&ECADE03ECB157\\ECB1D7ECC9FF7ECCB30?ECCE132ECCED7&ECD09FuECD508&ECD61B7ECDA3B7ECDCAA&ECDD242" + + "ECE09B]ECE1A92ECE3347ECE61D?ECE7A7AECE9D2&ECE9F5?ECEBB8F84E17eF84E58]F84E73&F84F572F85329?F854B8%F85548mF85971AF85B1B7F85B6E]F85C24dF85EA0AF860F0FCA0F3?FCA13E]" + + "FCA183%FCA27E?FCA5C8&FCA621]FCA667%FCA89BmFCA9F5uFCAA14:FCAA81&FCAAB6]FCAB90?FCB214&FCB3AAAFCB3BCAFCB4677FCB69D4FCB6D8&FCBCD1?FCC17DuFCC233#" + + "FCC734]FCC766?FCCA40eFCD733iFCD749%FCD848&FCD908uFCDE90]FCDEC5mFCE1A6?FCE26C&FCE33C?FCE5F0]FCE8C07FCE998&FCE9D8%FCEB7B?FCECDAoFCEFD7?FCF136]" + + "FCF152eFCF528wFCF5C47FCF738?FCF77B?FCF8AEAFCFBFB2FCFC48&"; +} diff --git a/RackPeek.Domain/Discovery/NetworkProbe.cs b/RackPeek.Domain/Discovery/NetworkProbe.cs index 77f498c2..4f12f78c 100644 --- a/RackPeek.Domain/Discovery/NetworkProbe.cs +++ b/RackPeek.Domain/Discovery/NetworkProbe.cs @@ -1,6 +1,9 @@ using System.Net; using System.Net.NetworkInformation; +using System.Net.Security; using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; using RackPeek.Domain.Resources.Services.Networking; namespace RackPeek.Domain.Discovery; @@ -83,6 +86,135 @@ public async Task TryConnectAsync( } } + public async Task ReadTlsSubjectAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + // Every certificate is accepted: homelab gear is self-signed by default and + // the certificate is being read for its name, never trusted for security. + // The callback goes in the options only — setting it in the constructor too + // makes AuthenticateAsClientAsync throw. + await using var ssl = new SslStream(client.GetStream(), false); + + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { + TargetHost = ip, + RemoteCertificateValidationCallback = (_, _, _, _) => true + }, + cts.Token); + + return ssl.RemoteCertificate is { } certificate + ? new X509Certificate2(certificate).Subject + : null; + } + catch { + // Closed, plaintext, or a handshake this runtime will not do — all "no name". + return null; + } + } + + public async Task ReadTcpBannerAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + await using NetworkStream stream = client.GetStream(); + + var buffer = new byte[256]; + var read = await stream.ReadAsync(buffer, cts.Token); + + return read > 0 + ? Encoding.ASCII.GetString(buffer, 0, read).Trim() + : null; + } + catch { + return null; + } + } + + public async Task ReadHttpHeadAsync( + string ip, + int port, + bool tls, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + Stream stream = client.GetStream(); + SslStream? ssl = null; + + if (tls) { + ssl = new SslStream(stream, false); + + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { + TargetHost = ip, + RemoteCertificateValidationCallback = (_, _, _, _) => true + }, + cts.Token); + + stream = ssl; + } + + try { + // HTTP/1.0 so the server closes the connection itself rather than leaving + // the read waiting on a keep-alive timeout. + var request = Encoding.ASCII.GetBytes( + $"GET / HTTP/1.0\r\nHost: {ip}\r\nUser-Agent: rackpeek-discover\r\nAccept: */*\r\nConnection: close\r\n\r\n"); + + await stream.WriteAsync(request, cts.Token); + await stream.FlushAsync(cts.Token); + + // Enough for the headers and a near the top of the body. Capped so + // a host streaming megabytes cannot hold the sweep open. + var buffer = new byte[8192]; + var total = 0; + + while (total < buffer.Length) { + var read = await stream.ReadAsync(buffer.AsMemory(total), cts.Token); + + if (read == 0) + break; + + total += read; + } + + return total > 0 + ? Encoding.UTF8.GetString(buffer, 0, total) + : null; + } + finally { + if (ssl != null) + await ssl.DisposeAsync(); + else + await stream.DisposeAsync(); + } + } + catch { + return null; + } + } + public Cidr? LocalSubnet() { try { foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) { diff --git a/RackPeek.Domain/Discovery/NetworkScanFacts.cs b/RackPeek.Domain/Discovery/NetworkScanFacts.cs index 6d963cdb..28a13074 100644 --- a/RackPeek.Domain/Discovery/NetworkScanFacts.cs +++ b/RackPeek.Domain/Discovery/NetworkScanFacts.cs @@ -12,7 +12,27 @@ public sealed record NetworkHostFact( string? Mac, string? Hostname, bool AnsweredPing, - IReadOnlyList<int> OpenPorts); + IReadOnlyList<int> OpenPorts) { + /// <summary> + /// A name a service volunteered — a TLS certificate's CN, an SSH greeting, an + /// HTTP title. Null when nothing answered or nothing said anything useful. Never + /// overrides <see cref="Hostname" />: a PTR record is the network's own answer. + /// </summary> + public ServiceIdentity? Identity { get; init; } + + /// <summary> + /// Applications that named themselves over HTTP, one per port. These describe + /// what the host runs rather than what it is, so they become Service resources + /// rather than deciding the host's name. + /// </summary> + public IReadOnlyList<ServiceIdentity> Services { get; init; } = []; + + /// <summary> + /// The organisation IEEE assigned <see cref="Mac" />'s OUI to, or a note that the + /// address is self-assigned. Null when there is no MAC, or none is known for it. + /// </summary> + public string? Vendor { get; init; } +} /// <summary>How to sweep. The defaults suit a quiet home /24.</summary> public sealed record NetworkScanOptions { @@ -31,4 +51,21 @@ public sealed record NetworkScanOptions { /// <summary>How many hosts are probed at once.</summary> public int Concurrency { get; init; } = 128; + + /// <summary> + /// Whether living hosts are asked what they are — a TLS certificate CN, an SSH + /// greeting, an HTTP title. Costs a handful of short connections per host and is + /// the difference between "host-1a2b3c4d" and "pve-node-01". Off makes the sweep a + /// pure liveness check again. + /// </summary> + public bool IdentifyServices { get; init; } = true; + + /// <summary>Cap on each identity probe; these run per living host, not per address.</summary> + public TimeSpan IdentifyTimeout { get; init; } = TimeSpan.FromMilliseconds(1200); + + /// <summary> + /// Ports a living host is checked against, over and above <see cref="Ports" />. + /// Wider because it is paid per living host rather than per address. + /// </summary> + public IReadOnlyList<int> IdentityPorts { get; init; } = WellKnownPorts.Identity; } diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs index 511f335b..2280631a 100644 --- a/RackPeek.Domain/Discovery/NetworkScanMapper.cs +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -1,4 +1,5 @@ using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.Services.Networking; using RackPeek.Domain.Resources.SystemResources; @@ -18,11 +19,18 @@ public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) { DiscoveryId.NetworkScheme, host.Mac ?? $"ip:{host.Ip}"); + // A PTR record is the network's own answer and wins. Failing that, whatever a + // service volunteered beats a hash of the MAC — "pve-node-01" over "host-1a2b3c4d". + var label = DiscoveryNaming.HostLabel(host.Hostname); + + if (string.IsNullOrEmpty(label) && NamesAHost(host.Identity)) + label = DiscoveryNaming.HostLabel(host.Identity!.Name); + var system = new SystemResource { Kind = SystemResource.KindLabel, Name = DiscoveryNaming.Unique( DiscoveryNaming.Suggest( - DiscoveryNaming.HostLabel(host.Hostname), + label, "host", discoveryId), discoveryId, @@ -37,15 +45,87 @@ public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) { if (host.Mac != null) system.Labels["mac"] = host.Mac; + if (host.Vendor != null) + system.Labels["vendor"] = host.Vendor; + + // The ports are observations, not conclusions: "554 is open" is a fact, while + // "this is a camera" is an inference the reader is far better placed to make + // than the scanner. Recording them keeps the evidence without inventing a + // service that may not be what the port number conventionally implies. + if (host.OpenPorts.Count > 0) + system.Labels["open-ports"] = string.Join(",", host.OpenPorts); + + // Kept even when the name came from somewhere else: it records what the host + // actually said, which is how someone judges whether the name is trustworthy. + if (host.Identity != null) + system.Labels["identified-by"] = + $"{Describe(host.Identity.Source)}:{host.Identity.Port} {host.Identity.Name}"; + if (allIps.Count > 1) system.Labels["ips"] = string.Join(",", allIps); resources.Add(system); + + // An application that named itself over HTTP is a fact about what the host + // runs, not about what the host is, so it becomes a Service hanging off the + // card rather than renaming it. + foreach (ServiceIdentity found in host.Services) { + // An appliance's own management UI is not a service running on it: a + // firewall whose page says "OPNsense" on a card already called opnsense + // would otherwise get a second card named opnsense-<hash>, which says + // nothing the first one did not. + if (DiscoveryNaming.Slug(DiscoveryNaming.HostLabel(found.Name)) + .Equals(system.Name, StringComparison.OrdinalIgnoreCase)) + continue; + + var serviceId = DiscoveryId.Create( + DiscoveryId.NetworkScheme, + $"{host.Mac ?? $"ip:{host.Ip}"}:{found.Port}"); + + resources.Add(new Service { + Kind = Service.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest( + DiscoveryNaming.HostLabel(found.Name), + "service", + serviceId), + serviceId, + taken), + DiscoveryId = serviceId, + Network = new Network { + Ip = host.Ip, + Port = found.Port, + Protocol = "TCP" + }, + RunsOn = [system.Name] + }); + } } return resources; } + /// <summary> + /// Whether an identity is a claim about the machine rather than about something + /// running on it. Only a certificate is: an X.509 common name is a host name by + /// construction, which is why a Proxmox node's certificate says "pve-node-01". + /// <para> + /// A page title names an application — and a host may run several, so naming + /// the machine after whichever answered first is arbitrary. Those become + /// Services instead. An SSH greeting names only the daemon; naming from it + /// produced five cards called "openssh" on a real sweep. + /// </para> + /// </summary> + private static bool NamesAHost(ServiceIdentity? identity) => + identity is { Source: IdentitySource.TlsCertificate }; + + private static string Describe(IdentitySource source) => source switch { + IdentitySource.TlsCertificate => "tls", + IdentitySource.SshBanner => "ssh", + IdentitySource.Http => "http", + _ => "dns" + }; + /// <summary> /// One MAC answering on several addresses — a gateway's VIPs and aliases — is /// still one machine, so it becomes one card: the lowest address as the card's diff --git a/RackPeek.Domain/Discovery/NetworkScanner.cs b/RackPeek.Domain/Discovery/NetworkScanner.cs index 0785b3f9..ac975f7c 100644 --- a/RackPeek.Domain/Discovery/NetworkScanner.cs +++ b/RackPeek.Domain/Discovery/NetworkScanner.cs @@ -73,11 +73,29 @@ public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync( } })); + // Interrogate the living: finish the port sweep the liveness check cut short, + // then ask what they are. Only living hosts are asked, so the cost is a few + // short connections per host rather than per address. + (IReadOnlyList<int> Open, ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)[] interrogated = + options.IdentifyServices + ? await Task.WhenAll(alive.Select(h => + InterrogateAsync(probe, options, h.Ip, h.Open, gate, cancellationToken))) + : [ + .. alive.Select(h => + ((IReadOnlyList<int>)h.Open, (ServiceIdentity?)null, (IReadOnlyList<ServiceIdentity>)[])) + ]; + var facts = new List<NetworkHostFact>(alive.Count); for (var i = 0; i < alive.Count; i++) { - (var ip, var ping, List<int> open) = alive[i]; - facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open)); + (var ip, var ping, _) = alive[i]; + var mac = macByIp.GetValueOrDefault(ip); + + facts.Add(new NetworkHostFact(ip, mac, names[i], ping, interrogated[i].Open) { + Identity = interrogated[i].Identity, + Services = interrogated[i].Services, + Vendor = MacVendorLookup.Lookup(mac) + }); } return facts @@ -85,6 +103,157 @@ public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync( .ToList(); } + /// <summary>Ports that speak TLS, where a certificate may name the machine.</summary> + private static readonly HashSet<int> _tlsPorts = [443, 8443, 8006, 9443]; + + /// <summary>Ports that speak plain HTTP, where a page title may name an application.</summary> + private static readonly HashSet<int> _httpPorts = [80, 8080, 8000, 3000, 8123, 9000, 9090, 8096, 7860, 11434, 32400]; + + /// <summary> + /// What to ask each open port, best evidence first. Only open ports are asked — + /// the sweep has just established which those are, and a handshake with a closed + /// port buys nothing but a timeout. + /// <para> + /// A certificate goes first because an X.509 common name is a host name by + /// construction. A page title is an application's name, which is a different + /// claim. An SSH greeting is last and names only the daemon. + /// </para> + /// </summary> + private static IEnumerable<(int Port, IdentitySource Source, bool Tls)> Candidates( + IReadOnlyList<int> openPorts) { + foreach (var port in openPorts.Where(_tlsPorts.Contains)) + yield return (port, IdentitySource.TlsCertificate, true); + + foreach (var port in openPorts.Where(_httpPorts.Contains)) + yield return (port, IdentitySource.Http, false); + + // Something is listening but nobody curated the port: it could be either, so + // try both rather than assume. + foreach (var port in openPorts.Where(p => + p != 22 && !_tlsPorts.Contains(p) && !_httpPorts.Contains(p))) { + yield return (port, IdentitySource.TlsCertificate, true); + yield return (port, IdentitySource.Http, false); + } + + if (openPorts.Contains(22)) + yield return (22, IdentitySource.SshBanner, false); + } + + /// <summary> + /// Everything worth asking a host that has already proven it is alive: which of + /// the probed ports are open, and what its services say they are. + /// <para> + /// The liveness sweep stops at the first answer on purpose — it only needs to + /// know the host exists. That leaves the port list incomplete for a host that + /// answered, and empty for one that answered ping, so it is finished here. The + /// ports are worth having for their own sake (554 says camera, 445 says file + /// server) and they are the best possible targets for the banner probes below: + /// a service on a port nobody curated is exactly the one worth asking. + /// </para> + /// </summary> + private static async Task<(IReadOnlyList<int> Open, ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)> InterrogateAsync( + INetworkProbe probe, + NetworkScanOptions options, + string ip, + IReadOnlyList<int> knownOpen, + SemaphoreSlim gate, + CancellationToken cancellationToken) { + await gate.WaitAsync(cancellationToken); + + try { + var open = new List<int>(knownOpen); + + // The liveness list plus the wider identity list: the host is alive, so the + // extra connections are paid once for it rather than once per address. + foreach (var port in options.Ports.Concat(options.IdentityPorts).Distinct()) { + if (open.Contains(port)) + continue; + + if (await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken)) + open.Add(port); + } + + open.Sort(); + + (ServiceIdentity? identity, IReadOnlyList<ServiceIdentity> services) = + await IdentifyAsync(probe, options, ip, open, cancellationToken); + + return (open, identity, services); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return (knownOpen, null, []); + } + finally { + gate.Release(); + } + } + + /// <summary> + /// Asks every open port what it is and sorts the answers into the two different + /// claims they make. + /// <para> + /// A certificate common name names the machine — that is what an X.509 CN is + /// for — so the first one found becomes the host's identity. A page title + /// names an application, which is a fact about what the host runs rather than + /// about the host, so each one becomes a Service instead. An SSH greeting + /// names only the daemon and is kept as a last-resort annotation. + /// </para> + /// Never throws: an unidentified host is still a found host. + /// </summary> + private static async Task<(ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)> IdentifyAsync( + INetworkProbe probe, + NetworkScanOptions options, + string ip, + IReadOnlyList<int> openPorts, + CancellationToken cancellationToken) { + ServiceIdentity? host = null; + ServiceIdentity? fallback = null; + var services = new List<ServiceIdentity>(); + + try { + foreach ((var port, IdentitySource source, var tls) in Candidates(openPorts)) { + // One application per port: a port that already answered has nothing + // further to say, and asking again only costs time. + if (services.Exists(s => s.Port == port)) + continue; + + var name = source switch { + IdentitySource.TlsCertificate => ServiceIdentityParser.ParseTlsSubject( + await probe.ReadTlsSubjectAsync(ip, port, options.IdentifyTimeout, cancellationToken)), + IdentitySource.SshBanner => ServiceIdentityParser.ParseSshBanner( + await probe.ReadTcpBannerAsync(ip, port, options.IdentifyTimeout, cancellationToken)), + _ => ServiceIdentityParser.ParseHttpIdentity( + await probe.ReadHttpHeadAsync(ip, port, tls, options.IdentifyTimeout, cancellationToken)) + }; + + if (name == null) + continue; + + var identity = new ServiceIdentity(name, source, port); + + switch (source) { + case IdentitySource.TlsCertificate: + host ??= identity; + break; + + case IdentitySource.Http: + services.Add(identity); + break; + + default: + fallback ??= identity; + break; + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + // One host's probe timing out must not abandon the sweep, nor lose whatever + // the earlier ports already said. + } + + return (host ?? services.FirstOrDefault() ?? fallback, services); + } + /// <summary>One host's liveness check; null when nothing answered.</summary> private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync( INetworkProbe probe, diff --git a/RackPeek.Domain/Discovery/ServiceIdentityParser.cs b/RackPeek.Domain/Discovery/ServiceIdentityParser.cs new file mode 100644 index 00000000..36d1e325 --- /dev/null +++ b/RackPeek.Domain/Discovery/ServiceIdentityParser.cs @@ -0,0 +1,211 @@ +using System.Text.RegularExpressions; + +namespace RackPeek.Domain.Discovery; + +/// <summary>Where a host's name came from, best evidence first.</summary> +public enum IdentitySource { + /// <summary>A PTR record — the network's own answer, so it wins.</summary> + ReverseDns, + + /// <summary>The Common Name on the certificate a TLS port presented.</summary> + TlsCertificate, + + /// <summary>The greeting an SSH server sends before anything is asked of it.</summary> + SshBanner, + + /// <summary>An HTTP <c>Server</c> header or page title.</summary> + Http +} + +/// <summary>A name a service volunteered, and what volunteered it.</summary> +public sealed record ServiceIdentity(string Name, IdentitySource Source, int Port); + +/// <summary> +/// Reads a host's name out of what its services say when you connect to them. This +/// is the identification half of <c>nmap -sV</c>, reduced to the three banners that +/// actually name homelab gear: a TLS certificate's CN, an SSH greeting, and an HTTP +/// <c>Server</c> header or page title. Pure — <see cref="INetworkProbe" /> does the +/// talking, everything here just reads what came back. +/// </summary> +public static class ServiceIdentityParser { + /// <summary> + /// Names that identify software rather than a machine, or are placeholders the + /// installer never changed. Keeping them would label every appliance of a kind + /// with the same name, which is worse than no name at all. + /// </summary> + private static readonly HashSet<string> _uselessNames = new(StringComparer.OrdinalIgnoreCase) { + "localhost", + "localhost.localdomain", + "example.com", + "www.example.com", + "default", + "changeme", + "server", + "ubuntu", + "debian", + "raspberrypi", + "openwrt", + "*" + }; + + /// <summary> + /// The Common Name from a certificate's subject. Handles both the OpenSSL-style + /// "CN=host, O=org" and the .NET "CN=host, O=org" orderings, and tolerates the + /// slash-separated form some tools print. + /// </summary> + public static string? ParseTlsSubject(string? subject) { + if (string.IsNullOrWhiteSpace(subject)) + return null; + + Match match = Regex.Match( + subject, + @"CN\s*=\s*(?<cn>[^,/]+)", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + if (!match.Success) + return null; + + var cn = match.Groups["cn"].Value.Trim().Trim('"'); + + // A wildcard certificate names a domain, not this machine. + if (cn.StartsWith("*.", StringComparison.Ordinal)) + return null; + + return Clean(cn); + } + + /// <summary> + /// The software an SSH server announces, e.g. "SSH-2.0-OpenSSH_9.6" -> "OpenSSH". + /// This names what the host runs rather than the host itself, which is still worth + /// having: "dropbear" says embedded appliance, "OpenSSH" says general-purpose box. + /// </summary> + public static string? ParseSshBanner(string? banner) { + if (string.IsNullOrWhiteSpace(banner)) + return null; + + Match match = Regex.Match( + banner, + @"^SSH-\d+\.\d+-(?<software>[^\s\r\n]+)", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + if (!match.Success) + return null; + + var software = match.Groups["software"].Value; + + // Trim the version: "OpenSSH_9.6p1" and "OpenSSH_8.4p1" are the same answer to + // "what is this", and a version in a resource name goes stale on the next patch. + var cut = software.IndexOfAny(['_', '-']); + + if (cut > 0) + software = software[..cut]; + + return Clean(software); + } + + /// <summary> + /// A name from an HTTP response head: the page title if it says something, else + /// the <c>Server</c> header. Titles win because "Home Assistant" identifies a box + /// far better than "nginx" does. + /// </summary> + public static string? ParseHttpIdentity(string? responseHead) { + if (string.IsNullOrWhiteSpace(responseHead)) + return null; + + // An error page's title describes the error, not the host — "HTTP Status 400 – + // Bad Request" is a name no one would recognise. The Server header below is + // still trustworthy on an error response, so only the title is gated. + if (IsSuccessful(responseHead)) { + Match title = Regex.Match( + responseHead, + @"<title[^>]*>(?<title>[^<]{1,120})", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + if (title.Success) { + var cleaned = Clean(LeadingPhrase(title.Groups["title"].Value)); + + if (cleaned != null) + return cleaned; + } + } + + Match server = Regex.Match( + responseHead, + @"^Server:\s*(?[^\r\n]{1,80})", + RegexOptions.IgnoreCase | RegexOptions.Multiline, + TimeSpan.FromSeconds(1)); + + if (!server.Success) + return null; + + var value = server.Groups["server"].Value; + + // "nginx/1.24.0 (Ubuntu)" -> "nginx": the version is noise in a name. + var slash = value.IndexOf('/'); + + if (slash > 0) + value = value[..slash]; + + return Clean(value); + } + + /// + /// The part of a page title before its first separator. Titles are written for + /// people and routinely carry a tagline or a page name after the product — + /// "Forgejo: Beyond coding. We Forge." names a machine far worse than "Forgejo" + /// does. The remainder is dropped only when what precedes it can stand alone. + /// + private static string LeadingPhrase(string title) { + var cut = title.IndexOfAny([':', '|', '–', '—', '·', '»']); + + if (cut <= 0) + return title; + + var lead = title[..cut].Trim(); + + // "RackPeek" splits usefully; ": the homelab tool" does not, and a two-character + // lead is more likely a stray colon than a product name. + return lead.Length >= 3 ? lead : title; + } + + /// + /// Whether the response's status line is a 2xx or 3xx. A head with no recognisable + /// status line is treated as unsuccessful: the title of something that is not + /// plainly a working page is not worth naming a machine after. + /// + private static bool IsSuccessful(string responseHead) { + Match status = Regex.Match( + responseHead, + @"^HTTP/\d(?:\.\d)?\s+(?\d{3})", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + return status.Success + && int.TryParse(status.Groups["code"].Value, out var code) + && code is >= 200 and < 400; + } + + /// + /// Collapses whitespace, drops anything that is only punctuation or digits, and + /// rejects the placeholder names that would label half a rack identically. + /// + private static string? Clean(string? raw) { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var value = Regex.Replace(raw.Trim(), @"\s+", " ", RegexOptions.None, TimeSpan.FromSeconds(1)); + + if (value.Length is 0 or > 120) + return null; + + // A CN that is a bare number (some appliances ship a serial as the CN) or pure + // punctuation names nothing a person would recognise. + if (!value.Any(char.IsLetter)) + return null; + + return _uselessNames.Contains(value) ? null : value; + } +} diff --git a/RackPeek.Domain/Discovery/WellKnownPorts.cs b/RackPeek.Domain/Discovery/WellKnownPorts.cs index 6468ee18..35d36f30 100644 --- a/RackPeek.Domain/Discovery/WellKnownPorts.cs +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -20,4 +20,31 @@ public static class WellKnownPorts { 8443, // alt https 9100 // node-exporter / jetdirect ]; + + /// + /// The wider list a host is checked against once it has already proven it is + /// alive. Liveness is paid per address, so stays short; + /// this runs only on hosts that answered, where a dozen more connections cost + /// nothing and buy two things — a service on one of these ports is a strong + /// statement about what the machine is, and it is a target for the banner probes + /// that actually name it. + /// + public static readonly IReadOnlyList Identity = [ + .. Defaults, + 554, // rtsp — cameras + 1883, // mqtt — home automation brokers + 3000, // grafana / forgejo / many node apps + 3306, // mysql + 5432, // postgres + 5900, // vnc + 6379, // redis + 7860, // gradio / stable-diffusion + 8000, // alt http + 8096, // jellyfin + 8123, // home assistant + 9000, // portainer / minio + 9090, // prometheus / cockpit + 11434, // ollama + 32400 // plex + ]; } diff --git a/RackPeek.Domain/RackPeek.Domain.csproj b/RackPeek.Domain/RackPeek.Domain.csproj index 4b798b85..fa36412c 100644 --- a/RackPeek.Domain/RackPeek.Domain.csproj +++ b/RackPeek.Domain/RackPeek.Domain.csproj @@ -6,6 +6,13 @@ enable + + + + + diff --git a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs index 374a069d..ec164089 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs @@ -28,6 +28,10 @@ public sealed class DiscoverNetworkSettings : DiscoverSettings { [Description("How many hosts to probe at once.")] public int Parallel { get; init; } = 128; + [CommandOption("--no-identify")] + [Description("Skip asking living hosts what they are — sweep for liveness only.")] + public bool NoIdentify { get; init; } + /// The parsed --cidr, or null when it was omitted or does not parse. public NetworkCidr? ParsedCidr => NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null; @@ -128,7 +132,8 @@ protected override async Task ExecuteAsync( Cidr = cidr, Ports = settings.ResolvedPorts, PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout), - Concurrency = settings.Parallel + Concurrency = settings.Parallel, + IdentifyServices = !settings.NoIdentify }; var targets = NetworkScanner.EnumerateTargets(cidr).Count(); diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 39622303..86db5915 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -4090,6 +4090,8 @@ OPTIONS: e.g. 22,80,443. Defaults to a curated homelab list --timeout Milliseconds to wait on each port probe --parallel How many hosts to probe at once + --no-identify Skip asking living hosts what they are — sweep for + liveness only ``` ## `rpk ansible` diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index a4f20723..84ac0c26 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -8,7 +8,7 @@ don't have to type in what the machine already knows about itself. | `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 | -| `rpk discover network` | a subnet, from outside | one **System** per host that answers ping or a well-known TCP port | +| `rpk discover network` | a subnet, from outside | one **System** per host that answers, plus a **Service** for each web application it recognises | 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. @@ -227,10 +227,10 @@ and most installations keep it. **A node becomes two resources**, because it is two things: -* a **Server** named after the node (`kepler`) — the machine, carrying its processor +* a **Server** named after the node (`pve-node-01`) — 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 +* a **System** of type `hypervisor` (`pve-node-01-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 @@ -238,7 +238,7 @@ the graph views are built around. ```yaml - kind: Server - name: kepler + name: pve-node-01 cpus: - model: AMD Ryzen 5 5600G cores: 6 @@ -252,14 +252,14 @@ the graph views are built around. - model: GeForce RTX 3090 - model: GeForce RTX 3090 - kind: System - name: kepler-pve + name: pve-node-01-pve type: hypervisor os: Proxmox VE 8.2.2 - runsOn: [kepler] + runsOn: [pve-node-01] - kind: System name: docker-01 type: vm - runsOn: [kepler-pve] + runsOn: [pve-node-01-pve] ``` Each QEMU guest becomes a `vm` and each LXC guest a `container`, with its allocated @@ -291,7 +291,7 @@ visible from either end: type: vm labels: gpu: GeForce RTX 3090, GeForce RTX 3090 - runsOn: [kepler-pve] + runsOn: [pve-node-01-pve] ``` A label rather than a field, because RackPeek has no first-class way to say "this device @@ -322,8 +322,9 @@ with no cluster uses its node name as the scope instead. ## `rpk discover network` The collector for machines nothing else can describe: no agent, no API — just an -address that answers. It sweeps a subnet and emits one **System** per responding host, -with its IP, its reverse-DNS name, and its MAC address as a label. +address that answers. It sweeps a subnet and emits one **System** per responding host — +with its IP, a name, its MAC address, and the vendor that MAC belongs to — plus a +**Service** for each web application that names itself. ```bash # Sweep this machine's own subnet and look at the result @@ -342,6 +343,95 @@ proxmox, and friends); `--ports 22,80,443` narrows or widens it. The ports are o liveness check: the sweep records that the host exists, not what it serves — pair it with `rpk discover docker` or hand-written Service cards for that. +### Where a scanned host's name comes from + +A homelab rarely has PTR records for everything, and a page of `host-1a2b3c4d` cards is +not documentation. So once a host is known to be alive, the sweep asks it what it is, +taking the first answer from: + +1. **A reverse-DNS (PTR) record** — the network's own answer, so it always wins. +2. **A TLS certificate's Common Name**, read from 443, 8006 or 8443. The strongest + remaining evidence, because appliances ship a certificate naming themselves: a + Proxmox node presents `CN=pve-node-01.example.com`, OPNsense presents its hostname. + The certificate is read, never trusted — self-signed is the norm here. +3. **An SSH greeting** on 22, reduced to the software (`SSH-2.0-dropbear` → `dropbear`). + That names what the host runs rather than the host, which still separates an + embedded appliance from a general-purpose box. +4. **An HTTP page title or `Server` header**, on the usual web ports and on anything else + found open — how a Home Assistant or a Forgejo announces itself. Titles are trimmed to + the phrase before their first separator, so "Forgejo: Beyond coding. We Forge." names a + machine `forgejo`, and the titles of error pages are ignored entirely. + +Whatever answered is recorded in an `identified-by` label (`tls:8006 pve-node-01.example.com`) +so you can see where a name came from and judge it. Nothing is sent to the host beyond a +bare `GET /`, and a host that stays silent simply keeps its generated name. + +Identification costs a handful of short connections per *living* host — never per +address — and `--no-identify` turns it off for a pure liveness sweep. + +### Open ports + +The liveness sweep stops at the first answer, because it only needs to know the host +exists. Once a host has answered, it is checked against a wider list — cameras (554), +MQTT brokers (1883), Home Assistant (8123), Portainer (9000), Plex, Postgres and so on — +and whatever is open lands in an `open-ports` label. + +These are recorded as observations, not conclusions. "554 is open" is a fact; "this is a +camera" is an inference, and the person reading the card is far better placed to draw it +than the scanner is. A port number is a convention rather than a guarantee, so RackPeek +will not name a service from one — but a port **is** the best possible target for the +banner probes above, which is how `9000` became "Portainer" and `8123` became +"Home Assistant". + +The list is what answered out of the ports probed, not a full port scan. `--ports` +widens the liveness set if you want more. + +### Applications become Services + +When a port answers HTTP with something that names itself, that is a fact about what the +host **runs**, not about what the host **is** — so it becomes a Service hanging off the +host's card rather than renaming it: + +```yaml +- kind: System + ip: 192.0.2.204 + name: host-1a2b3c4d + labels: + open-ports: 1883,8123 + identified-by: http:8123 Home Assistant +- kind: Service + name: home-assistant + network: { ip: 192.0.2.204, port: 8123, protocol: TCP } + runsOn: [host-1a2b3c4d] +``` + +A host may run several, so each identified port gets its own Service with its own stable +id — a rescan updates them rather than duplicating them. This is why a page title does +not name the machine: picking whichever application answered first would be arbitrary, +and a certificate is the only answer that is a claim about the machine itself. + +An appliance's own management page is not a service running on it, so a title matching +the host's own name is skipped — a firewall already called `opnsense` does not also need +a service called `opnsense`. + +### Vendor from the MAC + +Where the sweep has a MAC, the card also gets a `vendor` label naming the organisation +that OUI belongs to — `Espressif`, `Ubiquiti`, `Raspberry Pi`, `Proxmox`. For a silent +device with no PTR record and no web UI, this is often the only thing that distinguishes +it from an address. + +Two details worth knowing. A hypervisor's own prefix wins over the +locally-administered bit, so a KVM guest reads as `QEMU/KVM` rather than anonymous. And +an address a device made up for itself — modern phones and laptops randomise per network +for privacy — is reported as `Randomised (locally administered)`, because the OUI half of +such an address names nobody and a lookup would otherwise attribute it to whichever +company happens to own the matching block. + +The table is a curated subset of the IEEE registry covering the gear that turns up on a +homelab, not all 40,000 assignments; an unknown prefix yields no label rather than a +guess. Run `./generate-oui-table.py` against the registry to extend it. + Sweeps are capped at a /16 (65,534 addresses). `--timeout` and `--parallel` tune how patient and how aggressive the sweep is; the defaults finish a quiet /24 in seconds. @@ -349,8 +439,8 @@ A network of several VLANs is several sweeps — each merges into the same inven and the ids keep re-runs honest: ```bash -rpk discover network --cidr 10.0.20.0/24 --push # the LAN -rpk discover network --cidr 10.0.50.0/24 --push # the server VLAN +rpk discover network --cidr 192.168.10.0/24 --push # the LAN +rpk discover network --cidr 192.168.50.0/24 --push # the server VLAN ``` ### Identity diff --git a/Tests.Discovery/MacVendorLookupTests.cs b/Tests.Discovery/MacVendorLookupTests.cs new file mode 100644 index 00000000..768f2709 --- /dev/null +++ b/Tests.Discovery/MacVendorLookupTests.cs @@ -0,0 +1,96 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// The vendor a MAC implies. Every expectation here is a real IEEE assignment, and +/// several are addresses observed on a live homelab — a lookup that silently drifted +/// would be worse than no lookup, because a confident wrong vendor is unfalsifiable +/// to whoever reads the card. +/// +public class MacVendorLookupTests { + [Theory] + [InlineData("bc:24:11:00:1a:01", "Proxmox")] + [InlineData("a0:36:9f:00:1a:02", "Intel")] + [InlineData("1c:6a:1b:00:1a:03", "Ubiquiti")] + [InlineData("48:b0:2d:00:1a:04", "NVIDIA")] + [InlineData("80:f3:da:00:1a:06", "Espressif")] + [InlineData("b8:27:eb:11:22:33", "Raspberry Pi")] + [InlineData("00:0c:29:aa:bb:cc", "VMware")] + public void Assigned_prefixes_resolve_to_their_owner(string mac, string expected) => + Assert.Equal(expected, MacVendorLookup.Lookup(mac)); + + [Theory] + [InlineData("BC:24:11:14:59:DF")] + [InlineData("bc-24-11-14-59-df")] + [InlineData("bc2411145 9df")] + [InlineData("bc24.1114.59df")] + public void Separators_and_case_do_not_matter(string mac) => + Assert.Equal("Proxmox", MacVendorLookup.Lookup(mac)); + + [Fact] + // KVM mints guest addresses from the locally-administered range rather than buying + // an OUI. Reporting "randomised" here would hide the most useful fact about the + // host — that it is a virtual machine. + public void A_hypervisors_own_prefix_beats_the_locally_administered_bit() => + Assert.Equal("QEMU/KVM", MacVendorLookup.Lookup("52:54:00:12:34:56")); + + [Theory] + [InlineData("92:16:01:00:1a:07")] + [InlineData("6a:34:ce:00:1a:08")] + public void Self_assigned_addresses_are_reported_as_randomised(string mac) { + // A phone or laptop randomising per network. The OUI half names no one, so + // saying so beats reporting whichever company owns the matching block. + Assert.Equal("Randomised (locally administered)", MacVendorLookup.Lookup(mac)); + Assert.True(MacVendorLookup.IsLocallyAdministered(mac)); + } + + [Fact] + public void A_universally_administered_address_is_not_flagged_as_randomised() => + Assert.False(MacVendorLookup.IsLocallyAdministered("bc:24:11:00:1a:01")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-mac")] + [InlineData("bc:24")] + public void Unreadable_input_yields_no_vendor(string? mac) { + Assert.Null(MacVendorLookup.Lookup(mac)); + Assert.False(MacVendorLookup.IsLocallyAdministered(mac)); + } + + [Fact] + // 00:00:00 is assigned to Xerox and deliberately not in the curated table. Null + // means "not known", which is honest; a nearest-match would not be. + public void An_unlisted_but_assigned_prefix_is_null_rather_than_a_guess() => + Assert.Null(MacVendorLookup.Lookup("00:00:00:11:22:33")); + + [Fact] + public void Every_packed_record_resolves_to_a_real_vendor_name() { + // Guards the generated table's index encoding: an off-by-one in the index char + // would map prefixes to the wrong vendor, or off the end of the array. Also + // proves the records are sorted, which the binary search depends on. + var records = MacVendorTable.Records; + var count = records.Length / MacVendorTable.RecordLength; + + Assert.True(count > 1000, $"The table holds only {count} records — did generation fail?"); + + string? previous = null; + + for (var i = 0; i < count; i++) { + var prefix = records.Substring(i * MacVendorTable.RecordLength, 6); + + if (previous != null) + Assert.True( + string.CompareOrdinal(previous, prefix) < 0, + $"Records are not sorted ascending: {previous} precedes {prefix}."); + + previous = prefix; + + Assert.False( + string.IsNullOrWhiteSpace(MacVendorLookup.Lookup(prefix + "000000")), + $"{prefix} resolved to nothing."); + } + } +} diff --git a/Tests.Discovery/NetworkIdentityTests.cs b/Tests.Discovery/NetworkIdentityTests.cs new file mode 100644 index 00000000..623fd966 --- /dev/null +++ b/Tests.Discovery/NetworkIdentityTests.cs @@ -0,0 +1,216 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// How a swept host gets a name and a vendor. Sweeping a homelab that has no PTR +/// records used to produce a page of "host-<hash>" cards; these tests own the +/// rules that turn those into recognisable machines. +/// +public class NetworkIdentityTests { + private static NetworkHostFact Host( + string ip, + string? mac = null, + string? hostname = null, + ServiceIdentity? identity = null, + string? vendor = null, + IReadOnlyList? services = null) => + new(ip, mac, hostname, true, []) { + Identity = identity, + Vendor = vendor, + Services = services ?? [] + }; + + private static SystemResource Single(params NetworkHostFact[] hosts) => + Assert.IsType(Assert.Single(NetworkScanMapper.ToResources(hosts))); + + [Fact] + public void A_certificate_name_becomes_the_card_name() { + SystemResource card = Single(Host( + "192.0.2.13", + identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006))); + + // The label is the host part, as it is for a PTR name. + Assert.Equal("pve-node-01", card.Name); + } + + [Fact] + public void A_ptr_record_still_wins_over_a_service_banner() { + // The network's own answer beats whatever a certificate happens to say — a + // stale or copied certificate must never rename a host DNS already knows. + SystemResource card = Single(Host( + "192.0.2.13", + hostname: "pve-01.lan", + identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006))); + + Assert.Equal("pve-01", card.Name); + } + + [Fact] + public void Without_any_name_the_card_still_falls_back_to_the_hash() { + SystemResource card = Single(Host("192.0.2.111")); + + Assert.StartsWith("host-", card.Name); + } + + [Fact] + public void What_the_host_said_is_recorded_even_when_it_named_the_card() { + SystemResource card = Single(Host( + "192.0.2.204", + identity: new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123))); + + // Whoever reads the card can see the name came from an HTTP title on 8123 and + // judge it accordingly, rather than trusting a name of unknown provenance. + Assert.Equal("http:8123 Home Assistant", card.Labels["identified-by"]); + } + + [Fact] + public void An_ssh_greeting_annotates_but_never_names() { + // Every Linux box on a subnet runs the same daemon. Naming from the greeting + // produced five cards called "openssh" on a real sweep — no more use than five + // called "host-", and misleading about what was actually identified. + SystemResource card = Single(Host( + "192.0.2.209", + identity: new ServiceIdentity("OpenSSH", IdentitySource.SshBanner, 22))); + + Assert.StartsWith("host-", card.Name); + Assert.Equal("ssh:22 OpenSSH", card.Labels["identified-by"]); + } + + [Fact] + public void An_application_that_named_itself_over_http_becomes_a_service_on_its_host() { + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)]) + ]); + + SystemResource host = Assert.Single(cards.OfType()); + Service service = Assert.Single(cards.OfType()); + + Assert.Equal("home-assistant", service.Name); + Assert.Equal([host.Name], service.RunsOn); + Assert.Equal("192.0.2.204", service.Network?.Ip); + Assert.Equal(8123, service.Network?.Port); + } + + [Fact] + public void A_page_title_names_the_service_and_leaves_the_host_a_hash() { + // A host may run several applications, so naming the machine after whichever + // answered first is arbitrary. Only a certificate names the machine itself. + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)]) + ]); + + Assert.StartsWith("host-", Assert.Single(cards.OfType()).Name); + } + + [Fact] + public void Several_applications_on_one_host_each_get_their_own_service() { + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.105", services: [ + new ServiceIdentity("Portainer", IdentitySource.Http, 9000), + new ServiceIdentity("Grafana", IdentitySource.Http, 3000) + ]) + ]); + + var services = cards.OfType().ToList(); + + Assert.Equal(2, services.Count); + Assert.Equal([9000, 3000], services.Select(s => s.Network!.Port)); + // Each port is its own thing, so each keeps its own stable id. + Assert.Equal(2, services.Select(s => s.DiscoveryId).Distinct().Count()); + } + + [Fact] + public void An_appliances_own_management_page_is_not_a_service_on_itself() { + // A firewall whose page says "OPNsense" on a card already called opnsense would + // otherwise gain a second card named opnsense- saying nothing new. + List cards = NetworkScanMapper.ToResources([ + Host( + "192.0.2.101", + identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443), + services: [new ServiceIdentity("OPNsense", IdentitySource.Http, 80)]) + ]); + + Assert.Equal("opnsense", Assert.Single(cards.OfType()).Name); + Assert.Empty(cards.OfType()); + } + + [Fact] + public void A_services_id_survives_a_rescan_and_differs_per_port() { + NetworkHostFact host = Host( + "192.0.2.105", + "bc:24:11:00:1a:01", + services: [ + new ServiceIdentity("Portainer", IdentitySource.Http, 9000), + new ServiceIdentity("Grafana", IdentitySource.Http, 3000) + ]); + + var first = NetworkScanMapper.ToResources([host]).OfType().Select(s => s.DiscoveryId).ToList(); + var second = NetworkScanMapper.ToResources([host]).OfType().Select(s => s.DiscoveryId).ToList(); + + Assert.Equal(first, second); + Assert.Equal(2, first.Distinct().Count()); + } + + [Fact] + public void A_vendor_is_labelled_when_the_mac_is_known() { + SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif")); + + Assert.Equal("Espressif", card.Labels["vendor"]); + Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]); + } + + [Fact] + public void No_vendor_label_is_invented_when_none_is_known() { + SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33")); + + Assert.False(card.Labels.ContainsKey("vendor")); + } + + [Fact] + public void The_card_stays_sparse_whatever_identification_found() { + // The sparseness contract: a scan sees an address, never an OS or a core count. + // Anything written here would overwrite the real values on the next rescan of a + // card an agent collector has since filled in. + SystemResource card = Single(Host( + "192.0.2.13", + "bc:24:11:00:1a:01", + identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006), + vendor: "Proxmox")); + + Assert.Null(card.Type); + Assert.Null(card.Os); + Assert.Null(card.Cores); + Assert.Null(card.Ram); + Assert.Equal("192.0.2.13", card.Ip); + } + + [Fact] + public void Identity_does_not_move_a_hosts_discovery_id() { + // Identity is seeded on the MAC; a name learned from a service must not change + // it, or every card would be recreated the day someone fixes a certificate. + NetworkHostFact withoutName = Host("192.0.2.13", "bc:24:11:00:1a:01"); + NetworkHostFact withName = Host( + "192.0.2.13", + "bc:24:11:00:1a:01", + identity: new ServiceIdentity("pve-node-01", IdentitySource.TlsCertificate, 8006)); + + Assert.Equal( + Single(withoutName).DiscoveryId, + Single(withName).DiscoveryId); + } + + [Fact] + public void Two_hosts_naming_themselves_the_same_still_get_distinct_names() { + // Every OPNsense VLAN gateway presents the same certificate CN. + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.101", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)), + Host("192.0.2.141", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)) + ]); + + Assert.Equal(2, cards.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } +} diff --git a/Tests.Discovery/NetworkScannerTests.cs b/Tests.Discovery/NetworkScannerTests.cs index b34b2946..c576cf7d 100644 --- a/Tests.Discovery/NetworkScannerTests.cs +++ b/Tests.Discovery/NetworkScannerTests.cs @@ -9,14 +9,175 @@ namespace Tests.Discovery; /// The probe is the IO seam — everything above it is what these tests own. /// public class NetworkScannerTests { + // Identification is off unless a test asks for it, so the liveness tests keep + // measuring only liveness. private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) => new() { Cidr = Cidr.Parse(cidr), Ports = ports.Length > 0 ? ports : [22, 80], PingTimeout = TimeSpan.FromMilliseconds(5), - PortTimeout = TimeSpan.FromMilliseconds(5) + PortTimeout = TimeSpan.FromMilliseconds(5), + IdentifyServices = false }; + private static NetworkScanOptions IdentifyingOptions(string cidr = "10.0.0.0/30") => + Options(cidr) with { + IdentifyServices = true, + IdentifyTimeout = TimeSpan.FromMilliseconds(5) + }; + + [Fact] + public async Task The_port_list_the_liveness_check_cut_short_is_finished_for_the_living() { + var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + probe, + IdentifyingOptions() with { Ports = [22, 80] }); + + // Liveness stopped at 22; the interrogation pass goes back for the rest, so the + // card records what the host actually serves rather than the first thing tried. + Assert.Equal([22, 80], Assert.Single(hosts).OpenPorts); + } + + [Fact] + public async Task A_host_that_answered_ping_still_gets_its_ports_inventoried() { + // Liveness skips port probing entirely once ping answers, which used to leave + // every pingable host with no port evidence at all. + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 80)] + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + probe, + IdentifyingOptions() with { Ports = [22, 80] }); + + Assert.Equal([80], Assert.Single(hosts).OpenPorts); + } + + [Fact] + public async Task A_dead_host_is_never_interrogated() { + var probe = new ScriptedProbe(); + + await NetworkScanner.ScanAsync(probe, IdentifyingOptions() with { Ports = [22, 80] }); + + Assert.Empty(probe.IdentityProbes); + } + + [Fact] + public async Task Only_living_hosts_are_asked_what_they_are() { + // 443 has to be open for it to be asked: only ports the sweep found listening + // are interrogated, so a closed port costs no handshake. + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 443)], + TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + Assert.Equal("router.lan", Assert.Single(hosts).Identity?.Name); + // 10.0.0.2 answered nothing, so it was never asked. + Assert.DoesNotContain(probe.IdentityProbes, p => p.Ip == "10.0.0.2"); + } + + [Fact] + public async Task A_certificate_names_the_host_even_when_other_ports_also_answer() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 443), ("10.0.0.1", 22)], + TlsSubjects = { [("10.0.0.1", 443)] = "CN=pve-node-01.example.com" }, + Banners = { [("10.0.0.1", 22)] = "SSH-2.0-OpenSSH_9.6" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + NetworkHostFact host = Assert.Single(hosts); + // Every open port is asked — an SSH greeting is still worth recording — but a + // certificate is the only answer that is a claim about the machine itself. + Assert.Equal("pve-node-01.example.com", host.Identity?.Name); + Assert.Equal(IdentitySource.TlsCertificate, host.Identity?.Source); + } + + [Fact] + public async Task A_page_title_outranks_an_ssh_greeting() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 22), ("10.0.0.1", 80)], + Banners = { [("10.0.0.1", 22)] = "SSH-2.0-dropbear" }, + HttpHeads = { [("10.0.0.1", 80)] = "HTTP/1.0 200 OK\r\n\r\nHome Assistant" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + // Every Linux box answers SSH with the same daemon name; the title says which + // box this actually is, so SSH is asked last. + Assert.Equal("Home Assistant", Assert.Single(hosts).Identity?.Name); + } + + [Fact] + public async Task An_open_port_the_sweep_found_is_also_asked() { + // The liveness sweep already paid to learn 8123 was open. A service on a port + // nobody curated is exactly the one worth asking. + var probe = new ScriptedProbe { + OpenPorts = [("10.0.0.2", 8123)], + HttpHeads = { [("10.0.0.2", 8123)] = "HTTP/1.0 200 OK\r\n\r\nHome Assistant" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + probe, + IdentifyingOptions() with { Ports = [8123] }); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Equal("Home Assistant", host.Identity?.Name); + Assert.Equal(8123, host.Identity?.Port); + } + + [Fact] + public async Task A_host_that_says_nothing_is_still_reported() { + var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Null(host.Identity); + Assert.Equal("10.0.0.1", host.Ip); + } + + [Fact] + public async Task Turning_identification_off_asks_nothing() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Null(Assert.Single(hosts).Identity); + Assert.Empty(probe.IdentityProbes); + } + + [Fact] + public async Task A_hosts_vendor_comes_from_its_arp_mac() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + Arp = "? (10.0.0.1) at bc:24:11:00:1a:01 on en0 ifscope [ethernet]" + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Equal("Proxmox", Assert.Single(hosts).Vendor); + } + + [Fact] + public async Task A_host_with_no_arp_entry_has_no_vendor() { + var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Null(Assert.Single(hosts).Vendor); + } + [Fact] public async Task A_host_that_answers_nothing_is_not_reported() { var probe = new ScriptedProbe(); @@ -118,8 +279,13 @@ private sealed class ScriptedProbe : INetworkProbe { public Dictionary Names { get; } = []; public TimeSpan PingDelay { get; init; } = TimeSpan.Zero; + public Dictionary<(string Ip, int Port), string> TlsSubjects { get; } = []; + public Dictionary<(string Ip, int Port), string> Banners { get; } = []; + public Dictionary<(string Ip, int Port), string> HttpHeads { get; } = []; + public List<(string Ip, int Port)> PortProbes { get; } = []; public List DnsLookups { get; } = []; + public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = []; public bool IsSupported => true; public int MaxInFlight { get; private set; } public bool ArpReadAfterSweep { get; private set; } @@ -178,6 +344,43 @@ public Task TryConnectAsync( return Task.FromResult(Names.GetValueOrDefault(ip)); } + public Task ReadTlsSubjectAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + IdentityProbes.Add((ip, port, "tls")); + } + + return Task.FromResult(TlsSubjects.GetValueOrDefault((ip, port))); + } + + public Task ReadTcpBannerAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + IdentityProbes.Add((ip, port, "banner")); + } + + return Task.FromResult(Banners.GetValueOrDefault((ip, port))); + } + + public Task ReadHttpHeadAsync( + string ip, + int port, + bool tls, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + IdentityProbes.Add((ip, port, "http")); + } + + return Task.FromResult(HttpHeads.GetValueOrDefault((ip, port))); + } + public Cidr? LocalSubnet() => null; } } diff --git a/Tests.Discovery/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs new file mode 100644 index 00000000..2f757e57 --- /dev/null +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -0,0 +1,128 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Giving a service the host it is plainly running on, when the collector could not. +/// A docker collector talking to a remote engine has to guess its host's name and the +/// link then dangles; the service's own address is evidence the guess is not. The +/// rules that keep this from doing harm are what these tests own. +/// +public class RunsOnByIpTests { + private static SystemResource System(string name, string? ip, string? discoveryId = null) => + new() { + Kind = SystemResource.KindLabel, + Name = name, + Ip = ip, + DiscoveryId = discoveryId + }; + + private static Service Service(string name, string? ip, string? runsOn = null, string? discoveryId = "rpk1:docker:1") => + new() { + Kind = RackPeek.Domain.Resources.Services.Service.KindLabel, + Name = name, + DiscoveryId = discoveryId, + Network = ip == null ? null : new Network { Ip = ip }, + RunsOn = runsOn == null ? [] : [runsOn] + }; + + [Fact] + public void A_service_whose_host_name_matches_nothing_is_anchored_to_the_system_at_its_address() { + // The remote-docker case: the engine called itself NAS01.lan, the inventory + // calls that machine nas01, and nothing linked the two. + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", "192.0.2.50", "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["nas01"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_service_with_no_host_at_all_is_anchored_too() { + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", "192.0.2.50")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["nas01"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_host_arriving_in_the_same_payload_counts() { + // discover docker emits the host alongside its services. + List incoming = [ + System("nas01", "192.0.2.50", "rpk1:sys:a"), + Service("immich", "192.0.2.50", "somewhere-else") + ]; + + DiscoveryIdResolver.ResolveNames([], incoming); + + Assert.Equal(["nas01"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_working_host_link_is_never_overwritten() { + // The service says it runs on a resource that exists. That is the collector's + // own answer and it beats an inference drawn from an address. + List existing = [ + System("nas01", "192.0.2.50"), + System("some-vm", "192.0.2.99") + ]; + List incoming = [Service("immich", "192.0.2.50", "some-vm")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["some-vm"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void An_address_two_systems_claim_anchors_nothing() { + // Overlapping subnets across sites, or a stale card nobody cleaned up. An + // ambiguous address is no evidence, and a wrong parent is worse than none. + List existing = [ + System("site-a-host", "192.0.2.50"), + System("site-b-host", "192.0.2.50") + ]; + List incoming = [Service("immich", "192.0.2.50", "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["NAS01.lan"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_service_with_no_address_is_left_alone() { + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", null, "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["NAS01.lan"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void An_address_no_system_claims_anchors_nothing() { + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", "198.51.100.99", "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["NAS01.lan"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void Systems_are_not_anchored_by_address() { + // Two systems sharing an address are the same machine or a conflict — never a + // parent and a child. Only services are hosted. + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [System("scanned-host", "192.0.2.50", "rpk1:net:b")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Empty(incoming.OfType().Single().RunsOn); + } +} diff --git a/Tests.Discovery/ServiceIdentityParserTests.cs b/Tests.Discovery/ServiceIdentityParserTests.cs new file mode 100644 index 00000000..860fd70b --- /dev/null +++ b/Tests.Discovery/ServiceIdentityParserTests.cs @@ -0,0 +1,138 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// Reading a host's name out of what its services volunteer. The fixtures follow the +/// shape of what real homelab gear emits — a Proxmox node, an OPNsense firewall, a +/// network printer — because the value of this parser is entirely in what it makes of +/// awkward real-world output rather than well-formed examples. The names and +/// addresses throughout are invented. +/// +public class ServiceIdentityParserTests { + [Theory] + // Proxmox ships a per-node certificate naming the node. This single line is what + // turns "host-1a2b3c4d" into "pve-node-01". + [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-01.example.com", "pve-node-01.example.com")] + [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-02.example", "pve-node-02.example")] + [InlineData("CN=OPNsense.localdomain, O=OPNsense self-signed", "OPNsense.localdomain")] + [InlineData("CN=printer-lobby.local", "printer-lobby.local")] + [InlineData("CN = spaced.example.lan, O = Org", "spaced.example.lan")] + public void A_certificate_common_name_is_read_from_its_subject(string subject, string expected) => + Assert.Equal(expected, ServiceIdentityParser.ParseTlsSubject(subject)); + + [Theory] + [InlineData("CN=*.example.com, O=Org")] // names a domain, not this machine + [InlineData("CN=localhost")] // the installer's placeholder + [InlineData("CN=-6268337161588699610")] // an appliance serial: no letters, names nothing + [InlineData("O=Org Only, OU=No Common Name")] + [InlineData("")] + [InlineData(null)] + public void A_subject_that_names_nothing_useful_is_rejected(string? subject) => + Assert.Null(ServiceIdentityParser.ParseTlsSubject(subject)); + + [Theory] + [InlineData("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5", "OpenSSH")] + [InlineData("SSH-2.0-dropbear", "dropbear")] + [InlineData("SSH-2.0-dropbear_2022.83", "dropbear")] + [InlineData("SSH-1.99-Cisco-1.25", "Cisco")] + // The version is deliberately dropped: it goes stale on the next patch, and a + // resource named after a point release is worse than one named after the daemon. + public void An_ssh_greeting_yields_the_software_without_its_version(string banner, string expected) => + Assert.Equal(expected, ServiceIdentityParser.ParseSshBanner(banner)); + + [Theory] + [InlineData("220 mail.example.com ESMTP Postfix")] // not SSH + [InlineData("HTTP/1.1 200 OK")] + [InlineData("")] + [InlineData(null)] + public void Something_that_is_not_an_ssh_greeting_yields_nothing(string? banner) => + Assert.Null(ServiceIdentityParser.ParseSshBanner(banner)); + + [Fact] + public void A_page_title_is_preferred_over_the_server_header() { + // "Home Assistant" identifies the box; "nginx" identifies half the internet. + var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0\r\n\r\nHome Assistant"; + + Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void The_server_header_is_used_when_there_is_no_title() { + var head = "HTTP/1.0 200 OK\r\nServer: llama.cpp\r\nContent-Type: text/html\r\n\r\n"; + + Assert.Equal("llama.cpp", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void A_server_headers_version_is_trimmed() { + var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0 (Ubuntu)\r\n\r\n"; + + Assert.Equal("nginx", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Theory] + // Observed on a live sweep: the tagline became the card's name. + [InlineData("Forgejo: Beyond coding. We Forge.", "Forgejo")] + [InlineData("Grafana | Dashboards", "Grafana")] + [InlineData("Jellyfin – Media", "Jellyfin")] + [InlineData("Home Assistant", "Home Assistant")] + public void A_title_is_trimmed_to_the_phrase_before_its_separator(string body, string expected) => + Assert.Equal(expected, ServiceIdentityParser.ParseHttpIdentity("HTTP/1.0 200 OK\r\n\r\n" + body)); + + [Fact] + public void A_title_whose_lead_is_too_short_to_stand_alone_is_kept_whole() { + // A stray separator near the start is not a product name. + var head = "HTTP/1.0 200 OK\r\n\r\nmy: little server"; + + Assert.Equal("my: little server", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void Whitespace_in_a_title_is_collapsed() { + var head = "HTTP/1.0 200 OK\r\n\r\n\n Home Assistant\n"; + + Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Theory] + [InlineData("HTTP/1.0 401 Unauthorized\r\nWWW-Authenticate: Basic\r\n\r\n")] + [InlineData("HTTP/1.0 200 OK\r\n\r\n ")] + [InlineData("HTTP/1.0 200 OK\r\n\r\n404")] // digits name nothing + [InlineData("")] + [InlineData(null)] + public void An_http_response_that_names_nothing_yields_nothing(string? head) => + Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head)); + + [Fact] + public void An_error_pages_title_is_not_a_name() { + // Observed on a live sweep: a host answering 400 produced a card called + // "http-status-400-bad-request". + var head = "HTTP/1.1 400 Bad Request\r\nServer: Apache\r\n\r\n" + + "HTTP Status 400 – Bad Request"; + + // The Server header is still accurate on an error response, so it is used. + Assert.Equal("Apache", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void An_error_response_with_no_server_header_names_nothing() { + var head = "HTTP/1.1 500 Internal Server Error\r\n\r\nSomething broke"; + + Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void A_redirect_still_counts_as_a_working_page() { + var head = "HTTP/1.1 302 Found\r\nLocation: /ui\r\n\r\nHome Assistant"; + + Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void An_absurdly_long_title_is_rejected_rather_than_becoming_a_name() { + var head = $"HTTP/1.0 200 OK\r\n\r\n{new string('x', 300)}"; + + Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head)); + } +} diff --git a/generate-oui-table.py b/generate-oui-table.py new file mode 100755 index 00000000..7658605c --- /dev/null +++ b/generate-oui-table.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Regenerates RackPeek.Domain/Discovery/MacVendorTable.g.cs from the IEEE OUI registry. + + curl -sL -o /tmp/oui.csv https://standards-oui.ieee.org/oui/oui.csv + ./generate-oui-table.py /tmp/oui.csv + +The registry holds ~40,000 assignments; shipping all of them would bloat the +single-file binaries for little gain, so this keeps a curated subset: the vendors +whose hardware actually turns up on a homelab network. Add a pattern to VENDORS +below and re-run to extend it. + +Records are packed as fixed-width "PPPPPPv" (6 hex prefix chars + one vendor index +char) in a sorted string so the lookup is a binary search with no allocation and no +dictionary to build at startup. +""" +import csv +import re +import sys + +# Display name -> pattern matched against the registry's Organization Name. +# Anchored where a loose substring would drag in unrelated companies. +VENDORS = { + "Apple": r"^Apple, Inc\.?$|^Apple Inc", + "Intel": r"^Intel Corporate$|^Intel Corporation$", + "Ubiquiti": r"Ubiquiti", + "Raspberry Pi": r"Raspberry Pi", + "Espressif": r"Espressif|^Shanghai High-Flying|Ai-Thinker", + "Proxmox": r"Proxmox", + "VMware": r"VMware", + "QEMU/KVM": r"^QEMU", + "VirtualBox": r"PCS Systemtechnik|VirtualBox", + "Microsoft": r"^Microsoft Corporation$", + "Parallels": r"^Parallels", + "Synology": r"Synology", + "QNAP": r"QNAP", + "Netgear": r"NETGEAR|Netgear", + "TP-Link": r"TP-LINK|TP-Link|Tp-Link", + "D-Link": r"D-Link|D\-LINK", + "MikroTik": r"MikroTik|Mikrotik|Routerboard", + "Cisco": r"^Cisco Systems|^Cisco$", + "Aruba": r"Aruba", + "Zyxel": r"ZyXEL|Zyxel", + "Realtek": r"Realtek", + "Broadcom": r"^Broadcom", + "NVIDIA": r"NVIDIA|Nvidia", + "AMD": r"^Advanced Micro Devices", + "Dell": r"^Dell Inc|^Dell Computer|^Dell EMC", + "HPE/HP": r"Hewlett Packard|^HP Inc", + "Supermicro": r"Super Micro|Supermicro", + "ASUS": r"^ASUSTek|^ASUS", + "ASRock": r"ASRock", + "Gigabyte": r"GIGA-BYTE|Gigabyte", + "MSI": r"Micro-Star", + "Lenovo": r"^Lenovo", + "Samsung": r"^Samsung Electro|^Samsung Electronics", + "LG": r"^LG Electronics", + "Sony": r"^Sony ", + "Google": r"^Google, Inc|^Google LLC", + "Amazon": r"^Amazon Technologies", + "Sonos": r"^Sonos", + "Signify (Hue)": r"Signify|Philips Lighting", + "Philips": r"^Philips", + "Xiaomi": r"Xiaomi|XIAOMI", + "IKEA": r"IKEA|Inter IKEA", + "Aqara": r"Lumi United|Aqara", + "Reolink": r"Reolink", + "Hikvision": r"Hikvision|HIKVISION", + "Dahua": r"Dahua", + "Axis": r"^Axis Communication", + "AVM (Fritz!Box)": r"^AVM ", + "Juniper": r"^Juniper", + "Fortinet": r"^Fortinet", + "Netgate": r"Netgate", + "Seagate": r"^Seagate", + "Western Digital": r"Western Digital", + "Buffalo": r"^BUFFALO|^Buffalo", + "Asustor": r"ASUSTOR|Asustor", + "TerraMaster": r"TerraMaster|Terra-?Master", + "Sophos": r"^Sophos", + "Arris/CommScope": r"^ARRIS|CommScope", + "Technicolor": r"Technicolor", + "Sagemcom": r"Sagemcom", + "Roku": r"^Roku", + "Nintendo": r"^Nintendo", + "Texas Instruments": r"^Texas Instruments", + "Tuya": r"^Tuya|Hangzhou Tuya", + "Shelly": r"Allterco|Shelly", + "Zotac": r"ZOTAC", + "Beelink": r"Beelink|AZW", + "Minisforum": r"Minisforum|MINIX", + "Ring": r"^Ring LLC|Ring Inc", + "Arlo": r"^Arlo", + "Wyze": r"^Wyze", + "Sonoff": r"ITEAD|SONOFF", + "Mellanox": r"Mellanox", + "Chelsio": r"Chelsio", + "Aquantia": r"Aquantia", + "Edimax": r"Edimax|EDIMAX", + "Tenda": r"^Tenda|Shenzhen Tenda", + "Huawei": r"^HUAWEI|^Huawei", + "Sercomm": r"^Sercomm", + "Silicon Labs": r"Silicon Laborator", + "Nordic Semiconductor": r"Nordic Semiconductor", + "Pine64": r"^Pine ?64|PINE64", + "Hardkernel (ODROID)": r"Hardkernel", + "Radxa": r"^Radxa|Rockchip", + "FriendlyELEC": r"FriendlyARM|FriendlyELEC", + "Khadas": r"Khadas|Shenzhen Wesion", +} + +# Prefixes that identify software but are not IEEE assignments, because hypervisors +# mint addresses out of the locally-administered range rather than buying an OUI. +# Without these a KVM guest would be reported as merely "randomised". +EXTRAS = { + "525400": "QEMU/KVM", + "00163E": "Xen", +} + +# Vendor index is stored as one printable char, so the table cannot exceed this. +_FIRST_INDEX_CHAR = 33 # '!' +_MAX_VENDORS = 126 - _FIRST_INDEX_CHAR + + +def main(csv_path: str) -> int: + names = sorted(set(VENDORS) | set(EXTRAS.values())) + + if len(names) > _MAX_VENDORS: + sys.exit(f"{len(names)} vendors exceeds the {_MAX_VENDORS} a single index char can address.") + + index_of = {name: i for i, name in enumerate(names)} + compiled = [(re.compile(pat), name) for name, pat in VENDORS.items()] + + seen: dict[str, str] = {} + + with open(csv_path, encoding="utf-8", errors="replace") as handle: + for row in csv.DictReader(handle): + prefix = (row.get("Assignment") or "").strip().upper() + org = (row.get("Organization Name") or "").strip() + + if len(prefix) != 6 or prefix in seen: + continue + + for pattern, name in compiled: + if pattern.search(org): + seen[prefix] = name + break + + seen.update(EXTRAS) + + records = "".join( + prefix + chr(_FIRST_INDEX_CHAR + index_of[name]) + for prefix, name in sorted(seen.items()) + ) + + # 140 is a whole number of records, so each source line holds exactly 20 of them. + chunks = [records[i:i + 140] for i in range(0, len(records), 140)] + + # The index alphabet starts at '!' and runs past '"' and '\', both of which have to + # be escaped in the C# literal. Escaping changes only the source text; the string + # the runtime builds still holds the original characters, so offsets stay correct. + def escape(chunk: str) -> str: + return chunk.replace("\\", "\\\\").replace('"', '\\"') + + literal = "\n".join(f' "{escape(chunk)}" +' for chunk in chunks) + literal = literal.rstrip(" +") + ";" + + vendor_literal = ",\n".join(f' "{name}"' for name in names) + + out = f'''// +// Generated by generate-oui-table.py from the IEEE OUI registry +// (https://standards-oui.ieee.org/oui/oui.csv). Do not edit by hand — add a +// vendor pattern to the generator and re-run it instead. +// +// {len(seen)} assignments across {len(names)} vendors, packed as fixed-width +// "PPPPPPv" records (6 hex prefix chars + one vendor index char), sorted so +// {'MacVendorLookup'} can binary-search them without building a dictionary. +// + +namespace RackPeek.Domain.Discovery; + +internal static class MacVendorTable {{ + internal const int RecordLength = 7; + + internal const char FirstIndexChar = '{chr(_FIRST_INDEX_CHAR)}'; + + internal static readonly string[] Vendors = [ +{vendor_literal} + ]; + + internal static readonly string Records = +{literal} +}} +''' + + target = "RackPeek.Domain/Discovery/MacVendorTable.g.cs" + + with open(target, "w", encoding="utf-8") as handle: + handle.write(out) + + print(f"Wrote {target}: {len(seen)} assignments, {len(names)} vendors, {len(records)} chars.") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + + raise SystemExit(main(sys.argv[1])) From fca60524e2968eba3a1c991dfcd992d3c7b02c6c Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 22:47:36 +0100 Subject: [PATCH 20/29] Learn where Proxmox guests are, and stop a sweep duplicating them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox only records a guest's address when someone set one statically, so on a DHCP estate every guest arrived with no address at all. That cost more than the field: a network sweep of another subnet has no ARP entry to work from, so it identifies a host by address alone — and with the guests carrying no address there was nothing to match, leaving a second, emptier card beside every guest the hypervisor had already described in full. Guests are now asked where they are. A VM answers through its qemu-guest-agent, a container through its running interfaces; a stopped guest is never asked, since it has nothing to report and the call is one round trip per guest. Choosing among the answers is the hard part. A guest that runs containers reports several interfaces — docker0 and its per-network bridges, Home Assistant's hassio, any VPN tunnel — and recording 172.17.0.1 as the machine's address would be worse than recording nothing, because every container host on the estate reports the same one. The NIC MACs Proxmox assigned are the discriminator: an interface carrying one is a NIC the hypervisor gave the guest, anything else the guest invented. Where the MACs cannot be read, nothing is claimed. A statically configured address still wins, being what the administrator asked for. With addresses in hand a sweep's find can be matched to the guest it actually is, so the resolver gains an address bridge beside the MAC one. Narrow on purpose: only a scan-grade card that produced no MAC of its own, only against an agent-grade card of the same kind, and only when exactly one stored system claims that address. Services anchor to the stored card in preference to a sweep's stand-in for the same reason. On a live two-node estate this turned nine scanned cards on the guest subnet into six guests updated in place and two genuinely unknown hosts, with the discovered web services landing on the real VMs rather than on stand-ins. The read orchestration moves into the domain on the way past. The CLI and the MCP tool each had a copy, and the copies had already drifted — the MCP one dropped the guests' MACs, silently costing every guest its chance of unifying with a scan. Now there is one, and it is tested. Co-Authored-By: Claude Fable 5 --- .../Discovery/DiscoveryIdResolver.cs | 124 +++++++++-- RackPeek.Domain/Discovery/IProxmoxClient.cs | 12 + RackPeek.Domain/Discovery/ProxmoxApiClient.cs | 30 +++ RackPeek.Domain/Discovery/ProxmoxDiscovery.cs | 107 +++++++++ RackPeek.Domain/Discovery/ProxmoxModels.cs | 103 +++++++++ RackPeek.Mcp/Tools/DiscoveryTools.cs | 36 +-- .../Discovery/DiscoverProxmoxCommand.cs | 43 +--- .../wwwroot/raw_docs/discovery-guide.md | 27 +++ .../Fixtures/pve-agent-interfaces.json | 36 +++ .../Fixtures/pve-lxc-interfaces.json | 16 ++ Tests.Discovery/ProxmoxGuestAddressTests.cs | 207 ++++++++++++++++++ Tests.Discovery/RunsOnByIpTests.cs | 99 +++++++++ 12 files changed, 747 insertions(+), 93 deletions(-) create mode 100644 RackPeek.Domain/Discovery/ProxmoxDiscovery.cs create mode 100644 Tests.Discovery/Fixtures/pve-agent-interfaces.json create mode 100644 Tests.Discovery/Fixtures/pve-lxc-interfaces.json create mode 100644 Tests.Discovery/ProxmoxGuestAddressTests.cs diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index dcb4eeec..805f86c8 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -41,6 +41,7 @@ public static void ResolveNames( .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase); Dictionary existingByMac = BuildMacMap(existing); + Dictionary existingByIp = BuildIpMap(existing); // Tolerant of a hand-edited file that managed to get two resources of the // same name: the first wins, rather than crashing the import. @@ -52,7 +53,7 @@ public static void ResolveNames( var renames = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (Resource resource in incomingWithId) { - var resolved = ResolveName(resource, existingById, existingByName, existingByMac); + var resolved = ResolveName(resource, existingById, existingByName, existingByMac, existingByIp); if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)) continue; @@ -102,18 +103,9 @@ private static void AnchorRunsOnByIp( // Both sides count: the host may have arrived in this very payload (discover // docker emits it alongside its services) or be sitting in the inventory already. - var systemsByIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (SystemResource system in existing.OfType().Concat(incoming.OfType())) { - if (string.IsNullOrWhiteSpace(system.Ip)) - continue; - - if (!systemsByIp.TryGetValue(system.Ip, out List? names)) - systemsByIp[system.Ip] = names = []; - - if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase)) - names.Add(system.Name); - } + // Stored systems are gathered separately because they win — see below. + Dictionary> storedByIp = IndexByIp(existing); + Dictionary> arrivingByIp = IndexByIp(incoming); foreach (Service service in services) { var ip = service.Network?.Ip; @@ -127,11 +119,37 @@ private static void AnchorRunsOnByIp( if (anchored) continue; - if (!systemsByIp.TryGetValue(ip, out List? candidates) || candidates.Count != 1) + // A stored system beats one arriving in this payload when both claim the + // address. They are usually the same machine seen twice — a hypervisor knows + // its guest by name and specification, a sweep only found something + // answering — and the stored card is the one a person recognises. + List? candidates = + storedByIp.TryGetValue(ip, out List? stored) ? stored + : arrivingByIp.TryGetValue(ip, out List? arriving) ? arriving + : null; + + if (candidates is not [var host]) + continue; + + service.RunsOn = [host]; + } + } + + private static Dictionary> IndexByIp(IReadOnlyList resources) { + var byIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (SystemResource system in resources.OfType()) { + if (string.IsNullOrWhiteSpace(system.Ip)) continue; - service.RunsOn = [candidates[0]]; + if (!byIp.TryGetValue(system.Ip, out List? names)) + byIp[system.Ip] = names = []; + + if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase)) + names.Add(system.Name); } + + return byIp; } /// @@ -170,7 +188,8 @@ private static string ResolveName( Resource resource, Dictionary existingById, Dictionary existingByName, - Dictionary existingByMac) { + Dictionary existingByMac, + Dictionary existingByIp) { // 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; @@ -180,6 +199,9 @@ private static string ResolveName( if (TryUnifyByMac(resource, existingByMac, out var unifiedName)) return unifiedName; + if (TryUnifyByIp(resource, existingByIp, out unifiedName)) + return unifiedName; + // Unknown id and the name is free: nothing to reconcile. if (!existingByName.TryGetValue(resource.Name, out Resource? sameName)) return resource.Name; @@ -258,6 +280,76 @@ private static bool TryUnifyByMac( return false; } + /// + /// The bridge for machines a sweep cannot identify by MAC at all: ARP is + /// link-local, so a host on any subnet but the scanner's own yields no MAC and + /// its identity falls back to its address. Once a hypervisor reports its guests' + /// addresses, that same address is the only thing tying the sweep's find to the + /// guest the inventory already describes in full. + /// + /// Narrow on purpose. It applies only to a scan-grade card that produced no + /// MAC of its own — one that has a MAC was either already unified above or + /// genuinely disagrees, and a MAC is better evidence than an address. The + /// stored card must be agent-grade and of the same kind, and must be the only + /// one claiming that address: two cards on one address is a conflict or an + /// overlapping subnet, neither of which is evidence of anything. + /// + /// + private static bool TryUnifyByIp( + Resource resource, + Dictionary existingByIp, + out string unifiedName) { + unifiedName = string.Empty; + + if (DiscoveryId.Scheme(resource.DiscoveryId) != DiscoveryId.NetworkScheme) + return false; + + // A scan that saw a MAC has better evidence than an address, and the MAC rule + // above has already had its say. + if (MacsOf(resource).Any()) + return false; + + if (resource is not SystemResource { Ip: { } ip } || string.IsNullOrWhiteSpace(ip)) + return false; + + if (!existingByIp.TryGetValue(ip, out Resource? stored) + || stored.GetType() != resource.GetType()) + return false; + + // Another scan card at the same address says nothing: both are stand-ins. + if (DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme) + return false; + + // Same as the MAC bridge: the scan's weaker identity is dropped so the merge + // cannot downgrade the stored one. + resource.DiscoveryId = null; + unifiedName = stored.Name; + + return true; + } + + /// + /// Systems by address, excluding any address more than one of them claims — an + /// ambiguous address is not evidence. + /// + private static Dictionary BuildIpMap(IReadOnlyList existing) { + var byIp = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (SystemResource system in existing.OfType()) { + if (string.IsNullOrWhiteSpace(system.Ip)) + continue; + + if (!byIp.TryAdd(system.Ip, system)) + ambiguous.Add(system.Ip); + } + + foreach (var ip in ambiguous) + byIp.Remove(ip); + + return byIp; + } + /// The MACs a resource claims, from its "mac" and "macs" labels, normalised. private static IEnumerable MacsOf(Resource resource) { IEnumerable raw = [ diff --git a/RackPeek.Domain/Discovery/IProxmoxClient.cs b/RackPeek.Domain/Discovery/IProxmoxClient.cs index 20531c49..5ecf130f 100644 --- a/RackPeek.Domain/Discovery/IProxmoxClient.cs +++ b/RackPeek.Domain/Discovery/IProxmoxClient.cs @@ -41,4 +41,16 @@ Task GetGuestConfigAsync( string endpoint, int vmId, CancellationToken cancellationToken = default); + + /// + /// Addresses the guest reports for its own interfaces — the only way to learn a + /// DHCP guest's address, since the config only carries one when it was set + /// statically. Needs the guest agent for a VM and a running container for LXC, + /// so an empty list is the normal answer for anything that has neither. + /// + Task> GetGuestAddressesAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default); } diff --git a/RackPeek.Domain/Discovery/ProxmoxApiClient.cs b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs index 24ceda0f..2618d128 100644 --- a/RackPeek.Domain/Discovery/ProxmoxApiClient.cs +++ b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Net.Security; namespace RackPeek.Domain.Discovery; @@ -143,6 +144,35 @@ public async Task GetGuestConfigAsync( } } + public async Task> GetGuestAddressesAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) { + // Every failure here means the same thing: the guest cannot say where it is. + // No agent installed, agent not running, container stopped, guest deleted since + // it was listed, or a token without VM.Monitor — all leave the address unknown, + // which is what the guest config already told us. + try { + var path = endpoint == LxcEndpoint + ? $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/interfaces" + : $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/agent/network-get-interfaces"; + + var json = await GetAsync(path, cancellationToken); + + return endpoint == LxcEndpoint + ? ProxmoxResponseParser.ParseContainerInterfaces(json) + : ProxmoxResponseParser.ParseAgentInterfaces(json); + } + catch (HttpRequestException) { + return []; + } + catch (JsonException) { + // A node that answers the agent call with an error body rather than a status. + return []; + } + } + private async Task FirstNodeAsync(CancellationToken cancellationToken) { IReadOnlyList nodes = await GetNodesAsync(cancellationToken); diff --git a/RackPeek.Domain/Discovery/ProxmoxDiscovery.cs b/RackPeek.Domain/Discovery/ProxmoxDiscovery.cs new file mode 100644 index 00000000..763e631b --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxDiscovery.cs @@ -0,0 +1,107 @@ +using RackPeek.Domain.Resources; + +namespace RackPeek.Domain.Discovery; + +/// +/// Reads a whole Proxmox endpoint and maps it to resources. +/// +/// Lives here rather than in a command so the CLI and the MCP tool run the same +/// code. They used to hold a copy each, and the copies had already drifted — the +/// MCP one dropped the guests' MACs, which silently cost every guest its chance +/// of unifying with a network scan. +/// +/// +public static class ProxmoxDiscovery { + public static async Task> ReadAsync( + IProxmoxClient client, + CancellationToken cancellationToken = default) { + var scope = await client.GetIdentityScopeAsync(cancellationToken); + IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); + + var nodes = new List(); + var guests = new List(); + + 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 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))); + + // Ask the running guests where they are. The config only carries an + // address when someone set one statically, so on a DHCP estate this is + // the difference between every guest having an address and none of them + // having one — and an address is what lets a guest line up with the host + // a network sweep found at that address. + IReadOnlyList[] addresses = await Task.WhenAll( + listedGuests.Select(g => IsRunning(g) + ? client.GetGuestAddressesAsync(nodeName, endpoint, g.VmId, cancellationToken) + : Task.FromResult>([]))); + + for (var i = 0; i < listedGuests.Count; i++) { + IReadOnlyList macs = configs[i].Macs ?? []; + + guests.Add(listedGuests[i] with { + Os = configs[i].Os, + Ip = configs[i].Ip ?? SelectGuestIp(addresses[i], macs), + Disks = configs[i].DiskBytes, + PassthroughAddresses = configs[i].PassthroughAddresses, + Macs = macs + }); + } + } + } + + return ProxmoxResourceMapper.ToResources(scope, nodes, guests); + } + + /// + /// The address that belongs to the guest itself. + /// + /// A guest agent reports every interface inside the machine, and a guest that + /// runs containers has several: Docker's docker0 and its per-network + /// bridges, Home Assistant's hassio, any VPN tunnel. Recording + /// 172.17.0.1 as the machine's address would be worse than recording nothing, + /// because every Docker host on the estate reports the same one. + /// + /// + /// The NIC MACs Proxmox assigned are the discriminator: they are already read + /// from the guest's config, and an interface carrying one is a NIC the + /// hypervisor gave the guest rather than something the guest invented. When + /// the MACs are unknown — a container, or a config the token cannot read — + /// nothing is claimed, since a guess here is indistinguishable from a fact. + /// + /// + public static string? SelectGuestIp( + IReadOnlyList addresses, + IReadOnlyList configuredMacs) { + if (addresses.Count == 0 || configuredMacs.Count == 0) + return null; + + var allowed = new HashSet( + configuredMacs.Select(ArpTableParser.NormaliseMac).Where(m => m != null)!, + StringComparer.OrdinalIgnoreCase); + + if (allowed.Count == 0) + return null; + + return addresses + .Where(a => ArpTableParser.NormaliseMac(a.Mac) is { } mac && allowed.Contains(mac)) + .Select(a => a.Ip) + .FirstOrDefault(); + } + + private static bool IsRunning(ProxmoxGuest guest) => + string.Equals(guest.Status, "running", StringComparison.OrdinalIgnoreCase); +} diff --git a/RackPeek.Domain/Discovery/ProxmoxModels.cs b/RackPeek.Domain/Discovery/ProxmoxModels.cs index d23264c1..11909199 100644 --- a/RackPeek.Domain/Discovery/ProxmoxModels.cs +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -62,6 +62,12 @@ public sealed record ProxmoxGuest { public IReadOnlyList Tags { get; init; } = []; + /// + /// running, stopped and friends, from the guest list. Only a running + /// guest can be asked where it is, and a stopped one has no address to report. + /// + public string? Status { get; init; } + /// Filled in from the guest's config, which is the only place it is known. public string? Os { get; init; } @@ -424,6 +430,7 @@ public static long ParseSize(string volume) { Cores = GetInt(element, "cpus") ?? 0, MemoryBytes = GetLong(element, "maxmem") ?? 0, DiskBytes = GetLong(element, "maxdisk") ?? 0, + Status = GetString(element, "status"), Tags = ParseTags(GetString(element, "tags")) }; } @@ -464,9 +471,105 @@ private static IEnumerable Data(string json) { element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result) ? result : null; + + /// + /// Addresses a QEMU guest reports through its guest agent + /// (agent/network-get-interfaces). The agent sees every interface inside + /// the guest, including the bridges Docker and Home Assistant create, so the + /// caller filters by the NIC MACs Proxmox actually assigned — see + /// . + /// + public static List ParseAgentInterfaces(string json) { + var addresses = new List(); + + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data) + || data.ValueKind != JsonValueKind.Object + || !data.TryGetProperty("result", out JsonElement result) + || result.ValueKind != JsonValueKind.Array) + return addresses; + + foreach (JsonElement iface in result.EnumerateArray()) { + if (iface.ValueKind != JsonValueKind.Object) + continue; + + var name = GetString(iface, "name"); + var mac = GetString(iface, "hardware-address"); + + if (!iface.TryGetProperty("ip-addresses", out JsonElement ips) + || ips.ValueKind != JsonValueKind.Array) + continue; + + foreach (JsonElement entry in ips.EnumerateArray()) { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (!string.Equals(GetString(entry, "ip-address-type"), "ipv4", StringComparison.OrdinalIgnoreCase)) + continue; + + var ip = GetString(entry, "ip-address"); + + if (IsUsableAddress(ip)) + addresses.Add(new ProxmoxGuestAddress(name, mac, ip!)); + } + } + + return addresses; + } + + /// + /// Addresses a container reports through lxc/{vmid}/interfaces, which is a + /// flat list rather than the agent's nested shape and spells the address with its + /// prefix (10.0.0.5/24). + /// + public static List ParseContainerInterfaces(string json) { + var addresses = new List(); + + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data) + || data.ValueKind != JsonValueKind.Array) + return addresses; + + foreach (JsonElement iface in data.EnumerateArray()) { + if (iface.ValueKind != JsonValueKind.Object) + continue; + + var ip = GetString(iface, "inet"); + + // "10.0.0.5/24" — the prefix belongs to the interface, not to the address + // the inventory records. + var slash = ip?.IndexOf('/') ?? -1; + + if (slash > 0) + ip = ip![..slash]; + + if (IsUsableAddress(ip)) + addresses.Add(new ProxmoxGuestAddress( + GetString(iface, "name"), + GetString(iface, "hwaddr"), + ip!)); + } + + return addresses; + } + + /// + /// Whether an address is worth recording: a real IPv4 that is neither loopback + /// nor the 169.254 a guest assigns itself when DHCP fails. + /// + private static bool IsUsableAddress(string? ip) => + !string.IsNullOrWhiteSpace(ip) + && !ip.StartsWith("127.", StringComparison.Ordinal) + && !ip.StartsWith("169.254.", StringComparison.Ordinal) + && ip.Count(c => c == '.') == 3; } /// The parts of a guest's config worth recording. Everything is optional. +/// One address a guest reports for one of its own interfaces. +public sealed record ProxmoxGuestAddress(string? Interface, string? Mac, string Ip); + public sealed record ProxmoxGuestConfig( string? Os, string? Ip, diff --git a/RackPeek.Mcp/Tools/DiscoveryTools.cs b/RackPeek.Mcp/Tools/DiscoveryTools.cs index 4db8bbf7..ea0501d8 100644 --- a/RackPeek.Mcp/Tools/DiscoveryTools.cs +++ b/RackPeek.Mcp/Tools/DiscoveryTools.cs @@ -125,7 +125,7 @@ public Task DiscoverProxmox( List resources; try { - resources = await ReadProxmoxAsync(client, cancellationToken); + resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken); } catch (HttpRequestException ex) { var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException @@ -156,40 +156,6 @@ private async Task ReadHostAsync(CancellationToken cancellationToke }); } - /// Same read orchestration as `rpk discover proxmox`. - private static async Task> ReadProxmoxAsync( - IProxmoxClient client, - CancellationToken cancellationToken) { - var scope = await client.GetIdentityScopeAsync(cancellationToken); - IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); - - var nodes = new List(); - var guests = new List(); - - foreach (ProxmoxNode listedNode in listed) { - ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken); - nodes.Add(node); - - foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) { - IReadOnlyList listedGuests = - await client.GetGuestsAsync(node.Name, endpoint, cancellationToken); - - ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g => - client.GetGuestConfigAsync(node.Name, 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); - } - private async Task EmitAsync(List resources, int skipped, bool apply) { var yaml = DiscoveryDocument.ToYaml(resources); diff --git a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs index 5103835e..e5c29a48 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs @@ -73,7 +73,7 @@ protected override async Task ExecuteAsync( List resources; try { - resources = await ReadAsync(client, cancellationToken); + resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken); } catch (HttpRequestException ex) { AnsiConsole.MarkupLine( @@ -98,45 +98,4 @@ protected override async Task ExecuteAsync( return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken); } - - private static async Task> ReadAsync( - IProxmoxClient client, - CancellationToken cancellationToken) { - var scope = await client.GetIdentityScopeAsync(cancellationToken); - IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); - - var nodes = new List(); - var guests = new List(); - - 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 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, - Macs = configs[i].Macs ?? [] - }); - } - } - - return ProxmoxResourceMapper.ToResources(scope, nodes, guests); - } } diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 84ac0c26..01a5b5ad 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -319,6 +319,33 @@ with no cluster uses its node name as the scope instead. --- +### Where a guest's address comes from + +Proxmox only records an address in a guest's config when someone set one statically, so +on a DHCP estate the config knows nothing. The guest itself does, and will say so: a VM +through its **qemu-guest-agent**, a container through its running interfaces. Discovery +asks every *running* guest, which is one extra call per guest and nothing at all for one +that is switched off. + +A guest that runs containers has several interfaces — Docker's `docker0` and its +per-network bridges, Home Assistant's `hassio`, any VPN tunnel — and recording +`172.17.0.1` as the machine's address would be worse than recording nothing, because +every container host on the estate reports the same one. So the NIC MACs Proxmox assigned +are used as the discriminator: an interface carrying one is a NIC the hypervisor gave the +guest, anything else is something the guest invented. Where the MACs cannot be read, +nothing is claimed. + +No agent, a stopped guest, or a token without `VM.Monitor` all mean the same thing — +the address stays unknown, exactly as before. + +**Why it matters beyond the address itself.** A guest's address is what lets +`rpk discover network` recognise it. ARP is link-local, so a sweep of any subnet but its +own gets no MAC and can only identify a host by address; once the hypervisor has reported +that same address, the sweep's find is matched to the guest the hypervisor already +described in full rather than becoming a second, emptier card beside it. + +--- + ## `rpk discover network` The collector for machines nothing else can describe: no agent, no API — just an diff --git a/Tests.Discovery/Fixtures/pve-agent-interfaces.json b/Tests.Discovery/Fixtures/pve-agent-interfaces.json new file mode 100644 index 00000000..d3a69277 --- /dev/null +++ b/Tests.Discovery/Fixtures/pve-agent-interfaces.json @@ -0,0 +1,36 @@ +{ + "data": { + "result": [ + { + "name": "lo", + "hardware-address": "00:00:00:00:00:00", + "ip-addresses": [ + { "ip-address": "127.0.0.1", "ip-address-type": "ipv4", "prefix": 8 }, + { "ip-address": "::1", "ip-address-type": "ipv6", "prefix": 128 } + ] + }, + { + "name": "ens18", + "hardware-address": "bc:24:11:00:1a:01", + "ip-addresses": [ + { "ip-address": "192.0.2.105", "ip-address-type": "ipv4", "prefix": 24 }, + { "ip-address": "fe80::be24:11ff:fe00:1a01", "ip-address-type": "ipv6", "prefix": 64 } + ] + }, + { + "name": "docker0", + "hardware-address": "02:42:9a:11:22:33", + "ip-addresses": [ + { "ip-address": "172.17.0.1", "ip-address-type": "ipv4", "prefix": 16 } + ] + }, + { + "name": "br-0d764d475870", + "hardware-address": "02:42:7e:44:55:66", + "ip-addresses": [ + { "ip-address": "172.18.0.1", "ip-address-type": "ipv4", "prefix": 16 } + ] + } + ] + } +} diff --git a/Tests.Discovery/Fixtures/pve-lxc-interfaces.json b/Tests.Discovery/Fixtures/pve-lxc-interfaces.json new file mode 100644 index 00000000..d0458057 --- /dev/null +++ b/Tests.Discovery/Fixtures/pve-lxc-interfaces.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "name": "lo", + "hwaddr": "00:00:00:00:00:00", + "inet": "127.0.0.1/8", + "inet6": "::1/128" + }, + { + "name": "eth0", + "hwaddr": "bc:24:11:00:1a:09", + "inet": "192.0.2.150/24", + "inet6": "fe80::be24:11ff:fe00:1a09/64" + } + ] +} diff --git a/Tests.Discovery/ProxmoxGuestAddressTests.cs b/Tests.Discovery/ProxmoxGuestAddressTests.cs new file mode 100644 index 00000000..78e7bcdb --- /dev/null +++ b/Tests.Discovery/ProxmoxGuestAddressTests.cs @@ -0,0 +1,207 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Learning where a guest actually is. Proxmox only records an address in a guest's +/// config when someone set one statically, so on a DHCP estate every guest arrived +/// without one — and an address is what lets a guest line up with the host a network +/// sweep found. The guest itself knows, and will say so through its agent. +/// +/// The hard part is that the agent reports every interface inside the machine, +/// including the bridges Docker and Home Assistant create. Picking the wrong one +/// would record 172.17.0.1 as the machine's address, which every container host +/// on the estate would also report. +/// +/// +public class ProxmoxGuestAddressTests { + private const string _nicMac = "bc:24:11:00:1a:01"; + + private static List AgentAddresses() => + ProxmoxResponseParser.ParseAgentInterfaces(Fixture.Read("pve-agent-interfaces.json")); + + [Fact] + public void An_agent_reports_every_routable_ipv4_it_can_see() { + List addresses = AgentAddresses(); + + // Loopback and the IPv6 entries are dropped; the NIC and both docker bridges stay, + // because deciding between them is the caller's job, not the parser's. + Assert.Equal(["192.0.2.105", "172.17.0.1", "172.18.0.1"], addresses.Select(a => a.Ip)); + } + + [Fact] + public void The_guests_own_nic_wins_over_the_bridges_it_created() => + Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [_nicMac])); + + [Fact] + // Proxmox configs upper-case them; agents vary. Both go through the same normaliser + // the ARP reader uses, so a scan and this collector always agree. + public void The_mac_is_matched_however_either_side_spells_it() => + Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["BC-24-11-00-1A-01"])); + + [Fact] + // Every address on offer belongs to something the guest invented. Recording one + // would be worse than recording nothing. + public void Nothing_is_claimed_when_no_interface_carries_a_configured_mac() => + Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["bc:24:11:ff:ff:ff"])); + + [Fact] + // A token that cannot read the guest config leaves no discriminator, so there is no + // way to tell a NIC from a bridge. + public void Nothing_is_claimed_when_the_configured_macs_are_unknown() => + Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [])); + + [Fact] + public void An_agent_that_answers_nothing_yields_nothing() { + Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":null}""")); + Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":{"result":[]}}""")); + } + + [Fact] + public void A_container_reports_its_address_with_the_prefix_stripped() { + List addresses = + ProxmoxResponseParser.ParseContainerInterfaces(Fixture.Read("pve-lxc-interfaces.json")); + + ProxmoxGuestAddress only = Assert.Single(addresses); + Assert.Equal("192.0.2.150", only.Ip); + Assert.Equal("eth0", only.Interface); + } + + [Fact] + public void A_guest_that_failed_dhcp_is_not_recorded_at_its_self_assigned_address() { + // 169.254 means "I could not get an address", which is not an address worth + // writing into an inventory. + var json = """ + {"data":{"result":[{"name":"ens18","hardware-address":"bc:24:11:00:1a:01", + "ip-addresses":[{"ip-address":"169.254.12.7","ip-address-type":"ipv4","prefix":16}]}]}} + """; + + Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces(json)); + } + + [Fact] + public async Task A_statically_configured_address_still_wins_over_the_agent() { + // The config is what the administrator asked for; the agent is what the guest + // happens to report. Where both exist they agree, and where they do not the + // configured one is the intent. + var client = new ScriptedProxmoxClient { + Guests = [Guest(100, "static-guest")], + Configs = { [100] = new ProxmoxGuestConfig("Linux", "192.0.2.9", [], [], [_nicMac]) }, + Addresses = { [100] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] } + }; + + List resources = await ProxmoxDiscovery.ReadAsync(client); + + Assert.Equal("192.0.2.9", GuestCard(resources, "static-guest").Ip); + } + + [Fact] + public async Task A_dhcp_guest_takes_the_address_its_agent_reports() { + var client = new ScriptedProxmoxClient { + Guests = [Guest(101, "dhcp-guest")], + Configs = { [101] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }, + Addresses = { [101] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] } + }; + + List resources = await ProxmoxDiscovery.ReadAsync(client); + + Assert.Equal("192.0.2.105", GuestCard(resources, "dhcp-guest").Ip); + } + + [Fact] + public async Task A_stopped_guest_is_never_asked_where_it_is() { + // It has no address to report, and asking costs a round trip per guest on an + // estate where most guests may be off. + var client = new ScriptedProxmoxClient { + Guests = [Guest(102, "stopped-guest", "stopped")], + Configs = { [102] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) } + }; + + await ProxmoxDiscovery.ReadAsync(client); + + Assert.Empty(client.AddressCalls); + } + + [Fact] + public async Task The_guests_macs_reach_the_card() { + // The MCP tool used to run its own copy of this orchestration which dropped the + // MACs, silently costing every guest its chance of unifying with a scan. + var client = new ScriptedProxmoxClient { + Guests = [Guest(103, "mac-guest")], + Configs = { [103] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) } + }; + + List resources = await ProxmoxDiscovery.ReadAsync(client); + + Assert.Equal(_nicMac, GuestCard(resources, "mac-guest").Labels["macs"]); + } + + private static SystemResource GuestCard(List resources, string name) => + resources.OfType().Single(r => r.Name == name); + + private static ProxmoxGuest Guest(int vmId, string name, string status = "running") => + new() { + VmId = vmId, + Node = "pve01", + Name = name, + Type = "vm", + Status = status + }; + + private sealed class ScriptedProxmoxClient : IProxmoxClient { + public List Guests { get; init; } = []; + public Dictionary Configs { get; } = []; + public Dictionary> Addresses { get; } = []; + public List AddressCalls { get; } = []; + + public string Endpoint => "https://pve.example.com:8006"; + + public Task GetIdentityScopeAsync(CancellationToken cancellationToken = default) => + Task.FromResult("example-cluster"); + + public Task> GetNodesAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([new ProxmoxNode { Name = "pve01" }]); + + public Task EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default) => + Task.FromResult(node); + + public Task> GetGuestsAsync( + string node, + string endpoint, + CancellationToken cancellationToken = default) => + Task.FromResult>( + endpoint == ProxmoxApiClient.QemuEndpoint ? Guests : []); + + public Task> GetDisksAsync( + string node, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task> GetGpusAsync( + string node, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task GetGuestConfigAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) => + Task.FromResult(Configs.TryGetValue(vmId, out ProxmoxGuestConfig? config) + ? config + : new ProxmoxGuestConfig(null, null, [], [])); + + public Task> GetGuestAddressesAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) { + AddressCalls.Add(vmId); + + return Task.FromResult>( + Addresses.TryGetValue(vmId, out List? found) ? found : []); + } + } +} diff --git a/Tests.Discovery/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs index 2f757e57..d799b050 100644 --- a/Tests.Discovery/RunsOnByIpTests.cs +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -79,6 +79,22 @@ public void A_working_host_link_is_never_overwritten() { Assert.Equal(["some-vm"], incoming.OfType().Single().RunsOn); } + [Fact] + public void A_stored_host_beats_a_scanned_stand_in_at_the_same_address() { + // Once a hypervisor reports its guests' addresses, a sweep of that subnet finds + // the same machines again and contributes a sparse card per address. The service + // belongs on the guest the hypervisor described, not on the sweep's stand-in. + List existing = [System("app-vm", "192.0.2.50")]; + List incoming = [ + System("host-1a2b3c4d", "192.0.2.50", "rpk1:net:c"), + Service("immich", "192.0.2.50", "SOMEWHERE.lan") + ]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["app-vm"], incoming.OfType().Single().RunsOn); + } + [Fact] public void An_address_two_systems_claim_anchors_nothing() { // Overlapping subnets across sites, or a stale card nobody cleaned up. An @@ -125,4 +141,87 @@ public void Systems_are_not_anchored_by_address() { Assert.Empty(incoming.OfType().Single().RunsOn); } + + // --------------------------------------------------------------- + // Unifying a sweep's find with the guest a hypervisor already described + // --------------------------------------------------------------- + + private static SystemResource Scanned(string name, string ip, string? mac = null) { + var card = new SystemResource { + Kind = SystemResource.KindLabel, + Name = name, + Ip = ip, + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, mac ?? $"ip:{ip}") + }; + + if (mac != null) + card.Labels["mac"] = mac; + + return card; + } + + private static SystemResource Guest(string name, string ip, string id = "abc123") => + new() { + Kind = SystemResource.KindLabel, + Name = name, + Ip = ip, + DiscoveryId = $"rpk1:pve:{id}" + }; + + [Fact] + public void A_sweep_find_becomes_the_guest_the_hypervisor_already_described() { + // ARP is link-local, so a guest on another subnet gives the sweep no MAC and its + // identity falls back to the address. Now that the hypervisor reports that same + // address, it is the only thing tying the two records together. + List existing = [Guest("app-vm", "192.0.2.105")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + SystemResource card = incoming.OfType().Single(); + Assert.Equal("app-vm", card.Name); + // The sweep's weaker identity is dropped so the merge cannot downgrade the + // hypervisor's. + Assert.Null(card.DiscoveryId); + } + + [Fact] + public void A_sweep_find_that_saw_a_mac_is_left_to_the_mac_rule() { + // A MAC is better evidence than an address. If it did not unify above, the two + // records disagree, and an address must not override that. + List existing = [Guest("app-vm", "192.0.2.105")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105", "bc:24:11:00:1a:01")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } + + [Fact] + public void Two_stored_systems_at_one_address_unify_nothing() { + // Overlapping subnets across sites, or a stale card. Ambiguity is not evidence. + List existing = [ + Guest("site-a-vm", "192.0.2.105", "aaa111"), + Guest("site-b-vm", "192.0.2.105", "bbb222") + ]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } + + [Fact] + public void One_sweep_find_never_unifies_with_another() { + // A card the sweep itself produced is a stand-in for something nobody has + // described. Two stand-ins at one address say nothing about each other, so the + // address rule stays out of it — here the stored one was identified by MAC on + // its own segment, and the incoming one only by address. + List existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } } From d51415ae81ecf5c52eec1d01c7caf989603b980a Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 28 Sep 2026 08:24:22 +0100 Subject: [PATCH 21/29] Fix three discovery defects found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Proxmox number written as a string took the whole run down. Its perl backend quotes numeric fields inconsistently — "vmid":"100" and "maxdisk":"512110190592" both turn up across versions — and the TryGetInt32 family does not return false for a string, it throws. The kind is now checked first and a quoted number parsed, which is what the caller meant either way. Before this, one such field produced "Unexpected error occurred" plus a stack trace on the CLI, and MCP forwarded the BCL's "requires an element of type 'Number'" as though the user had done something wrong. An ssh:// or npipe:// DOCKER_HOST crashed. `docker context` sets the first for a remote host and the second is the Windows default, so both are ordinary values to find in the environment. HttpClient accepts either URI and only throws NotSupportedException on the first request, which was past the command's catch list. They are now refused where the client is built, through the UriFormatException both front ends already turn into "not a usable Docker endpoint" — one phrasing for a bad endpoint rather than two — and the message says how to forward the socket instead. A remote engine with no IPv4 address had its services recorded at this machine's address. Services are recorded at their host's address and the inventory holds IPv4 only, so when the endpoint resolves to IPv6 alone the address is genuinely unknown; substituting whatever machine ran the command gave every service a confident, wrong address that then flowed into the ansible, ssh and hosts exports. There is no fallback now, and the run stops with an explanation. Each fix has tests that fail without it: quoted numbers across the guest list, both unreachable endpoint schemes, and an engine answering on an IPv6-only socket. Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/DockerApiClient.cs | 19 ++++++ RackPeek.Domain/Discovery/ProxmoxModels.cs | 40 +++++++++--- RackPeek.Mcp/Tools/DiscoveryTools.cs | 11 +++- .../Discovery/DiscoverDockerCommand.cs | 16 ++++- Tests.Discovery/ProxmoxQuotedNumberTests.cs | 63 +++++++++++++++++++ Tests.Discovery/RemoteDockerDiscoveryTests.cs | 29 +++++++++ Tests.Mcp/DiscoveryToolTests.cs | 24 +++++++ Tests.Mcp/FakeHttpServer.cs | 11 ++-- .../DiscoverDockerEndpointTests.cs | 33 ++++++++++ 9 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 Tests.Discovery/ProxmoxQuotedNumberTests.cs create mode 100644 Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs diff --git a/RackPeek.Domain/Discovery/DockerApiClient.cs b/RackPeek.Domain/Discovery/DockerApiClient.cs index a204770e..f096d0f5 100644 --- a/RackPeek.Domain/Discovery/DockerApiClient.cs +++ b/RackPeek.Domain/Discovery/DockerApiClient.cs @@ -98,12 +98,31 @@ private static (HttpClient Client, string Endpoint) Create(string? dockerHost) { return (UnixSocketClient(path), dockerHost); } + // Anything else is rejected here rather than at send time. HttpClient accepts an + // ssh:// or npipe:// URI happily and only throws NotSupportedException on the + // first request — which is not in the caller's catch list, so a DOCKER_HOST that + // `docker context` set up quite normally crashed with a stack trace instead of + // saying what was wrong. + if (!StartsWithScheme(dockerHost, "tcp://") + && !StartsWithScheme(dockerHost, "http://") + && !StartsWithScheme(dockerHost, "https://")) + // A UriFormatException on purpose: both front ends already turn that into + // "not a usable Docker endpoint", so there is one phrasing for a bad endpoint + // rather than two. + throw new UriFormatException( + "Use a unix socket (unix:///var/run/docker.sock) or a TCP endpoint " + + "(tcp://host:2375). For an ssh:// context, forward the socket first — " + + "ssh -L 2375:/var/run/docker.sock user@host — and point --docker-host at that."); + // 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 bool StartsWithScheme(string value, string scheme) => + value.StartsWith(scheme, StringComparison.OrdinalIgnoreCase); + private static HttpClient UnixSocketClient(string socketPath) { var handler = new SocketsHttpHandler { ConnectCallback = async (_, cancellationToken) => { diff --git a/RackPeek.Domain/Discovery/ProxmoxModels.cs b/RackPeek.Domain/Discovery/ProxmoxModels.cs index 11909199..52573225 100644 --- a/RackPeek.Domain/Discovery/ProxmoxModels.cs +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -462,15 +462,39 @@ private static IEnumerable Data(string json) { ? value.GetString() : null; - private static int? GetInt(JsonElement element, string name) => - element.TryGetProperty(name, out JsonElement value) && value.TryGetInt32(out var result) - ? result - : null; + /// + /// A number Proxmox may have written as a string. + /// + /// Its perl backend quotes numeric fields inconsistently — "vmid":"100" + /// and "maxdisk":"512110190592" both turn up across versions. The + /// TryGetInt32 family does not return false for a string, it throws, so + /// reading one unguarded took the whole run down with a stack trace. The kind + /// is checked first and a quoted number parsed, which is what the caller meant + /// either way. + /// + /// + private static int? GetInt(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return null; - private static long? GetLong(JsonElement element, string name) => - element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result) - ? result - : null; + return value.ValueKind switch { + JsonValueKind.Number when value.TryGetInt32(out var number) => number, + JsonValueKind.String when int.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } + + /// + private static long? GetLong(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return null; + + return value.ValueKind switch { + JsonValueKind.Number when value.TryGetInt64(out var number) => number, + JsonValueKind.String when long.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } /// /// Addresses a QEMU guest reports through its guest agent diff --git a/RackPeek.Mcp/Tools/DiscoveryTools.cs b/RackPeek.Mcp/Tools/DiscoveryTools.cs index ea0501d8..83556517 100644 --- a/RackPeek.Mcp/Tools/DiscoveryTools.cs +++ b/RackPeek.Mcp/Tools/DiscoveryTools.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -79,9 +80,17 @@ ex is HttpRequestException or IOException or TimeoutException ? host.MachineId ?? host.Hostname : engine?.Id ?? client.Endpoint; + // No fallback to this machine's address: it is not the remote engine's, and + // stamping it on would give every service a confidently wrong one. var serviceIp = client.IsLocal ? host.Ip - : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip; + : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken); + + if (string.IsNullOrWhiteSpace(serviceIp)) + throw new ValidationException( + $"Could not determine an IPv4 address for {client.Endpoint}. Services are " + + "recorded at their host's address and the inventory holds IPv4 only; " + + "dial the engine by address instead, e.g. tcp://192.0.2.10:2375."); List found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp); diff --git a/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs index 0005537d..e3587ca1 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs @@ -86,10 +86,22 @@ ex is HttpRequestException or IOException or TimeoutException : 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. + // endpoint the user dialled. There is deliberately no fallback: this machine's + // own address is not the remote engine's, and stamping it on would put a + // confidently wrong address on every service — one that then flows into the + // ansible, ssh and hosts exports. var serviceIp = client.IsLocal ? host.Ip - : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip; + : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken); + + if (string.IsNullOrWhiteSpace(serviceIp)) { + AnsiConsole.MarkupLine( + $"[red]Could not determine an IPv4 address for {Markup.Escape(client.Endpoint)}.[/] " + + "Services are recorded at their host's address, and the inventory holds IPv4 only. " + + "Dial the engine by address instead, e.g. --docker-host tcp://192.0.2.10:2375"); + + return 1; + } List services = DockerServiceMapper.ToResources(containers, seed, hostName, serviceIp); diff --git a/Tests.Discovery/ProxmoxQuotedNumberTests.cs b/Tests.Discovery/ProxmoxQuotedNumberTests.cs new file mode 100644 index 00000000..0d0c66ae --- /dev/null +++ b/Tests.Discovery/ProxmoxQuotedNumberTests.cs @@ -0,0 +1,63 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// Proxmox's perl backend quotes numeric fields inconsistently — "vmid":"100" +/// and "maxdisk":"512110190592" both turn up across versions. The +/// TryGetInt32 family does not return false for a string, it throws, so a +/// single quoted number used to take the whole run down with a stack trace: the CLI +/// printed "Unexpected error occurred" and MCP forwarded the BCL's +/// "requires an element of type 'Number'" as if it were the user's fault. +/// +public class ProxmoxQuotedNumberTests { + [Fact] + public void A_guest_whose_numbers_are_quoted_is_read_rather_than_throwing() { + var json = """ + {"data":[{"vmid":"100","name":"quoted-guest","cpus":"4", + "maxmem":"4294967296","maxdisk":"512110190592","status":"running"}]} + """; + + ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")); + + Assert.Equal(100, guest.VmId); + Assert.Equal("quoted-guest", guest.Name); + Assert.Equal(4, guest.Cores); + Assert.Equal(4294967296, guest.MemoryBytes); + Assert.Equal(512110190592, guest.DiskBytes); + } + + [Fact] + public void Plain_numbers_still_read_the_same_way() { + var json = """ + {"data":[{"vmid":101,"name":"plain-guest","cpus":2, + "maxmem":2147483648,"maxdisk":34359738368,"status":"running"}]} + """; + + ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")); + + Assert.Equal(101, guest.VmId); + Assert.Equal(2, guest.Cores); + Assert.Equal(2147483648, guest.MemoryBytes); + } + + [Fact] + public void A_field_that_is_neither_a_number_nor_a_numeric_string_is_simply_absent() { + // Guessing at "N/A" would be worse than leaving the field empty, and it must + // still not throw. + var json = """{"data":[{"vmid":102,"name":"odd-guest","cpus":"N/A","maxmem":null}]}"""; + + ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")); + + Assert.Equal(0, guest.Cores); + Assert.Equal(0, guest.MemoryBytes); + } + + [Fact] + public void A_guest_whose_vmid_is_quoted_is_still_identified() { + // vmid is the guest's identity; dropping it would silently lose the guest. + var json = """{"data":[{"vmid":"103","name":"id-guest"}]}"""; + + Assert.Equal(103, Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")).VmId); + } +} diff --git a/Tests.Discovery/RemoteDockerDiscoveryTests.cs b/Tests.Discovery/RemoteDockerDiscoveryTests.cs index bd4857b4..177b0924 100644 --- a/Tests.Discovery/RemoteDockerDiscoveryTests.cs +++ b/Tests.Discovery/RemoteDockerDiscoveryTests.cs @@ -179,4 +179,33 @@ private static SystemResource StoredHost2(string name) { return host; } + + // -- endpoints this cannot reach --------------------------------------------------- + + [Theory] + // `docker context` sets this for a remote host, so it is a perfectly normal value to + // find in DOCKER_HOST. + [InlineData("ssh://user@nas")] + // The Windows default. + [InlineData("npipe:////./pipe/docker_engine")] + [InlineData("gibberish://nowhere")] + public void An_endpoint_scheme_that_cannot_be_dialled_is_refused_with_advice(string endpoint) { + // HttpClient accepts these URIs happily and only throws NotSupportedException on + // the first request — which the caller does not catch, so discovery used to die + // with a stack trace instead of saying what was wrong. + UriFormatException error = Assert.Throws(() => new DockerApiClient(endpoint)); + + Assert.Contains("ssh -L", error.Message); + } + + [Theory] + [InlineData("tcp://192.0.2.10:2375")] + [InlineData("http://192.0.2.10:2375")] + [InlineData("unix:///var/run/docker.sock")] + [InlineData("unix:///run/user/1000/podman/podman.sock")] + public void The_endpoints_that_do_work_are_untouched(string endpoint) { + using var client = new DockerApiClient(endpoint); + + Assert.Equal(endpoint, client.Endpoint); + } } diff --git a/Tests.Mcp/DiscoveryToolTests.cs b/Tests.Mcp/DiscoveryToolTests.cs index f368566c..caf8ad02 100644 --- a/Tests.Mcp/DiscoveryToolTests.cs +++ b/Tests.Mcp/DiscoveryToolTests.cs @@ -193,4 +193,28 @@ public async Task An_unreachable_proxmox_host_is_a_clean_error_naming_the_endpoi Assert.Contains("Could not read", error); Assert.Contains("http://127.0.0.1:1", error); } + + [Fact] + public async Task An_engine_with_no_ipv4_address_is_refused_rather_than_given_this_machines() { + // Services are recorded at their host's address. When the engine answers but has + // no IPv4 — the inventory holds IPv4 only — the address is genuinely unknown, and + // the code used to substitute the address of whatever machine ran the command. + // Every service then carried a confident, wrong address that flowed on into the + // ansible, ssh and hosts exports. + if (!System.Net.Sockets.Socket.OSSupportsIPv6) + return; // no loopback to bind; nothing to prove here on this host + + await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync(true); + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + Exception error = await Assert.ThrowsAnyAsync(() => + client.CallOkAsync("discover_docker", new Dictionary { + ["dockerHost"] = $"tcp://{engine.Host}" + })); + + Assert.Contains("IPv4", error.Message); + // Nothing was recorded at a borrowed address. + Assert.DoesNotContain("jellyfin", api.StoredYaml); + } } diff --git a/Tests.Mcp/FakeHttpServer.cs b/Tests.Mcp/FakeHttpServer.cs index 5d28bfb2..8608a58e 100644 --- a/Tests.Mcp/FakeHttpServer.cs +++ b/Tests.Mcp/FakeHttpServer.cs @@ -21,10 +21,13 @@ internal sealed class FakeHttpServer : IAsyncDisposable { public string Host => new Uri(BaseUrl).Authority; - public static async Task StartAsync(Action map) { + public static async Task StartAsync(Action map, bool ipv6 = false) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); - builder.WebHost.UseUrls("http://127.0.0.1:0"); + + // An IPv6-only endpoint is the one case where an engine answers but has no IPv4 + // address to record its services at. + builder.WebHost.UseUrls(ipv6 ? "http://[::1]:0" : "http://127.0.0.1:0"); WebApplication app = builder.Build(); map(app); @@ -34,13 +37,13 @@ public static async Task StartAsync(Action map) } /// A fake Docker Engine API with the shared captured fixtures. - public static Task StartDockerEngineAsync() => + public static Task StartDockerEngineAsync(bool ipv6 = false) => StartAsync(app => { app.MapGet("/containers/json", () => Results.Content( TestData.Fixture("docker-containers.json"), "application/json")); app.MapGet("/info", () => Results.Content( TestData.Fixture("docker-info.json"), "application/json")); - }); + }, ipv6); /// /// A fake Proxmox VE API. Both fixture nodes answer with the same guest lists, diff --git a/Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs b/Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs new file mode 100644 index 00000000..87aa2f32 --- /dev/null +++ b/Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs @@ -0,0 +1,33 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.DiscoveryTests; + +/// +/// `rpk discover docker` against endpoints it cannot reach. Every case here fails +/// before any container is listed, so these tests never talk to a daemon. +/// +[Collection("Yaml CLI tests")] +public class DiscoverDockerEndpointTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + private async Task ExecuteAsync(params string[] args) => + await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml"); + + [Theory] + [InlineData("ssh://user@nas")] + [InlineData("npipe:////./pipe/docker_engine")] + public async Task an_endpoint_that_cannot_be_dialled_says_so_instead_of_crashing(string endpoint) { + // `docker context` sets ssh:// for a remote host and npipe:// is the Windows + // default, so both are ordinary values to find in DOCKER_HOST. They used to + // reach HttpClient, which accepts the URI and throws NotSupportedException on + // the first request — past the command's catch list, so the user got + // "Unexpected error occurred" and a stack trace. + var output = await ExecuteAsync("discover", "docker", "--docker-host", endpoint); + + Assert.DoesNotContain("Unexpected error", output); + Assert.DoesNotContain("at RackPeek.", output); + + // And the message says what to do about it. + Assert.Contains("ssh -L", output); + } +} From 6cd8f9847ad8d5404fb67a632c4195d50b276af1 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 28 Sep 2026 08:36:23 +0100 Subject: [PATCH 22/29] Add rpk discover opnsense: read the firewall's neighbour table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collector for everything a sweep can see but not identify. ARP is link-local. Sweeping from one machine yields a MAC for that machine's own segment and nothing but an address for every other subnet — and an address alone cannot survive a DHCP re-lease or be matched against anything already documented. The firewall routes every subnet, so its neighbour table carries the MAC for all of them, the name it handed out, and which leg each machine answered on. Cards are seeded exactly as the network sweep seeds its own, keyed on the MAC, because both describe the same thing by the same evidence: a machine observed on the network rather than asked about itself. So a host the firewall knows and a host a sweep found are one card, whichever ran first, with no special case anywhere to say so. On a live estate this turned 21 resources into 13 updated in place and 8 machines no sweep had ever seen — devices that answer no port and no ping but sit in the firewall's table. What it leaves out, on purpose: the firewall's own addresses (every routed subnet contributes one and they are all the same box, which is a Firewall rather than the handful of Systems this would invent), entries that have aged out, broadcast and multicast, and neighbours on public addresses. That last one is the ISP's equipment on the WAN leg — not the user's infrastructure, and recording it would put a public address into a file people commit. --include-public asks for them. Tolerant of the endpoint rename in OPNsense 25.7, which moved these actions from camelCase to snake_case and broke integrations pinned to either spelling; both are tried. Both response shapes are read too, since the search wrapper wraps the same rows in an object. A key that lacks the Diagnostics: ARP Table privilege gets a redirect to the login page rather than a 401, so that is reported as the permission problem it is. Verified against a live two-firewall estate; the fixtures and tests use invented addresses and MAC suffixes throughout. Co-Authored-By: Claude Fable 5 --- .../Discovery/OpnsenseApiClient.cs | 128 +++++++++++++ .../Discovery/OpnsenseDiscovery.cs | 108 +++++++++++ RackPeek.Domain/Discovery/OpnsenseModels.cs | 120 +++++++++++++ Shared.Rcl/CliBootstrap.cs | 5 + .../Discovery/DiscoverOpnsenseCommand.cs | 100 +++++++++++ .../wwwroot/raw_docs/cli-commands-index.md | 1 + Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 45 ++++- .../wwwroot/raw_docs/discovery-guide.md | 69 +++++++ Tests.Discovery/Fixtures/opnsense-arp.json | 86 +++++++++ Tests.Discovery/OpnsenseDiscoveryTests.cs | 169 ++++++++++++++++++ 10 files changed, 826 insertions(+), 5 deletions(-) create mode 100644 RackPeek.Domain/Discovery/OpnsenseApiClient.cs create mode 100644 RackPeek.Domain/Discovery/OpnsenseDiscovery.cs create mode 100644 RackPeek.Domain/Discovery/OpnsenseModels.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs create mode 100644 Tests.Discovery/Fixtures/opnsense-arp.json create mode 100644 Tests.Discovery/OpnsenseDiscoveryTests.cs diff --git a/RackPeek.Domain/Discovery/OpnsenseApiClient.cs b/RackPeek.Domain/Discovery/OpnsenseApiClient.cs new file mode 100644 index 00000000..028b1c15 --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseApiClient.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Headers; +using System.Net.Security; +using System.Text; + +namespace RackPeek.Domain.Discovery; + +/// Reads an OPNsense firewall's API. The IO half of firewall discovery. +public interface IOpnsenseClient { + /// Where this client is pointed, for error messages. + string Endpoint { get; } + + /// + /// Every neighbour the firewall currently has an ARP entry for, across every + /// subnet it routes. + /// + Task> GetNeighboursAsync(CancellationToken cancellationToken = default); +} + +/// +/// Talks to the OPNsense API with a key and secret, which OPNsense issues per user +/// and sends as HTTP basic credentials. A key can be given a read-only role and +/// revoked on its own, so it is used rather than a login. +/// +public sealed class OpnsenseApiClient : IOpnsenseClient, IDisposable { + public const string KeyEnvironmentVariable = "RPK_OPN_KEY"; + public const string SecretEnvironmentVariable = "RPK_OPN_SECRET"; + + /// + /// The ARP endpoint, newest spelling first. OPNsense 25.7 renamed its API actions + /// from camelCase to snake_case and kept the old names only for a while — an + /// integration pinned to either one breaks on half the installations out there. + /// Asking for each in turn costs one extra request against older firmware and + /// nothing against new. + /// + private static readonly string[] _arpPaths = [ + "api/diagnostics/interface/get_arp", + "api/diagnostics/interface/getArp" + ]; + + private readonly HttpClient _httpClient; + + /// + /// OPNsense ships with a self-signed certificate and most installations keep it. + /// Opt-in all the same. + /// + public OpnsenseApiClient( + string host, + string key, + string secret, + bool allowUntrustedCertificate = false, + HttpClient? httpClient = null) { + Endpoint = Normalise(host); + + _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate)); + _httpClient.BaseAddress = new Uri(Endpoint + "/"); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{key}:{secret}"))); + } + + public string Endpoint { get; } + + public void Dispose() => _httpClient.Dispose(); + + public async Task> GetNeighboursAsync( + CancellationToken cancellationToken = default) { + HttpRequestException? last = null; + + foreach (var path in _arpPaths) { + try { + return OpnsenseResponseParser.ParseArp(await GetAsync(path, cancellationToken)); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + // Wrong spelling for this firmware; try the other. + last = ex; + } + } + + throw last ?? new HttpRequestException("The firewall has no ARP endpoint this understands."); + } + + /// + /// A bare name gets https, matching how the web UI is reached. The path is + /// trimmed so callers may paste a URL straight out of the browser. + /// + public static string Normalise(string host) { + var trimmed = host.Trim().TrimEnd('/'); + + if (!trimmed.Contains("://", StringComparison.Ordinal)) + trimmed = "https://" + trimmed; + + var uri = new Uri(trimmed); + + return uri.GetLeftPart(UriPartial.Authority); + } + + private async Task GetAsync(string path, CancellationToken cancellationToken) { + using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken); + + // A key without the right privilege is the common setup mistake, and OPNsense + // answers it with a redirect to the login page rather than a 401. + if (response.StatusCode is System.Net.HttpStatusCode.Found + or System.Net.HttpStatusCode.MovedPermanently + or System.Net.HttpStatusCode.Unauthorized + or System.Net.HttpStatusCode.Forbidden) + throw new HttpRequestException( + $"The firewall refused the API key ({(int)response.StatusCode}). Check the key and secret, " + + "and that its user holds the Diagnostics: ARP Table privilege.", + null, + response.StatusCode); + + 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; + } +} diff --git a/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs new file mode 100644 index 00000000..eea457e2 --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs @@ -0,0 +1,108 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// +/// Turns a firewall's neighbour table into System resources. +/// +/// The cards are seeded exactly as seeds its +/// own — the network scheme, keyed on the MAC — because they describe the same +/// thing by the same evidence: a machine observed on the network rather than +/// asked about itself. That makes the two collectors interchangeable. A host the +/// firewall knows and a host a sweep found are one card, whichever ran first, +/// with no special case anywhere to say so. +/// +/// +public static class OpnsenseDiscovery { + public static async Task> ReadAsync( + IOpnsenseClient client, + bool includePublic = false, + CancellationToken cancellationToken = default) => + ToResources(await client.GetNeighboursAsync(cancellationToken), includePublic); + + /// + /// Whether an address belongs to a network someone runs themselves: the RFC 1918 + /// ranges plus the carrier-grade block an ISP may hand out. + /// + /// A firewall's WAN leg has neighbours too, and they are the ISP's equipment + /// rather than anything the user owns. Recording them would also put a public + /// address into a file people commit to git, which is a surprising thing for + /// an inventory of a home lab to do on its own. Anyone documenting a fleet on + /// public addresses can ask for them. + /// + /// + public static bool IsPrivate(string ip) { + var parts = ip.Split('.'); + + if (parts.Length != 4 || !int.TryParse(parts[0], out var a) || !int.TryParse(parts[1], out var b)) + return false; + + return a switch { + 10 => true, + 172 => b is >= 16 and <= 31, + 192 => b == 168, + 100 => b is >= 64 and <= 127, // carrier-grade NAT + _ => false + }; + } + + public static List ToResources( + IReadOnlyList neighbours, + bool includePublic = false) { + var taken = new HashSet(StringComparer.OrdinalIgnoreCase); + var resources = new List(); + + foreach (OpnsenseNeighbour neighbour in neighbours + .Where(n => includePublic || IsPrivate(n.Ip)) + .OrderBy(n => Order(n.Ip))) { + var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, neighbour.Mac); + + var system = new SystemResource { + Kind = SystemResource.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest( + DiscoveryNaming.HostLabel(neighbour.Hostname), + "host", + discoveryId), + discoveryId, + taken), + DiscoveryId = discoveryId, + // Sparse for the same reason a scan's cards are: the firewall knows where + // a machine is and what its NIC is, never what runs on it. Writing a guess + // here would overwrite the real values on the next run of a collector that + // does know. + Ip = neighbour.Ip + }; + + system.Labels["mac"] = neighbour.Mac; + + // Ours first so the vocabulary matches every other card, the firewall's own + // lookup second — it carries the whole IEEE registry, so it answers for the + // prefixes the curated table leaves out. + var vendor = MacVendorLookup.Lookup(neighbour.Mac) ?? neighbour.Manufacturer; + + if (vendor != null) + system.Labels["vendor"] = vendor; + + // Which leg of the firewall saw it — the closest thing to a physical location + // the firewall can offer, and the thing that says which VLAN a host is on. + if (neighbour.Interface != null) + system.Labels["segment"] = neighbour.Interface; + + resources.Add(system); + } + + return resources; + } + + private static uint Order(string ip) { + try { + return Resources.Services.Networking.IpHelper.ToUInt32(ip); + } + catch (ArgumentException) { + // A malformed address still deserves a card; it just sorts last. + return uint.MaxValue; + } + } +} diff --git a/RackPeek.Domain/Discovery/OpnsenseModels.cs b/RackPeek.Domain/Discovery/OpnsenseModels.cs new file mode 100644 index 00000000..c40eac6e --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseModels.cs @@ -0,0 +1,120 @@ +using System.Text.Json; + +namespace RackPeek.Domain.Discovery; + +/// +/// One neighbour the firewall has seen, from its ARP table. +/// +/// This is the record a sweep cannot produce for anything off its own segment: +/// ARP is link-local, so a host on another subnet gives a scanner an address and +/// nothing else. The firewall routes every subnet, so its table carries the MAC +/// for all of them — and a MAC is the identity that survives a DHCP re-lease. +/// +/// +public sealed record OpnsenseNeighbour( + string Ip, + string Mac, + string? Hostname, + string? Manufacturer, + string? Interface); + +public static class OpnsenseResponseParser { + /// + /// Reads the ARP table. OPNsense answers either a flat array or, through the + /// search wrapper, an object with a rows array; both shapes are accepted so + /// the caller need not care which endpoint answered. + /// + public static List ParseArp(string json) { + var neighbours = new List(); + + using var document = JsonDocument.Parse(json); + + JsonElement root = document.RootElement; + + JsonElement rows = root.ValueKind switch { + JsonValueKind.Array => root, + JsonValueKind.Object when root.TryGetProperty("rows", out JsonElement r) => r, + _ => default + }; + + if (rows.ValueKind != JsonValueKind.Array) + return neighbours; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (JsonElement entry in rows.EnumerateArray()) { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + // An address the firewall holds itself. Every routed subnet contributes one, + // and they are all the same box — which is a Firewall, not the handful of + // Systems this would otherwise invent. + if (IsTrue(entry, "permanent")) + continue; + + // The entry is still listed after it ages out; it says where something used + // to be, which is not evidence that it is there now. + if (IsTrue(entry, "expired")) + continue; + + var mac = ArpTableParser.NormaliseMac(Text(entry, "mac")); + var ip = Text(entry, "ip"); + + if (mac == null || string.IsNullOrWhiteSpace(ip)) + continue; + + // Broadcast and multicast are not machines. + if (mac is "ff:ff:ff:ff:ff:ff" || IsMulticast(mac)) + continue; + + // One row per machine: a host answering on several of the firewall's + // interfaces is still one machine, and the first row carries its address. + if (!seen.Add(mac)) + continue; + + neighbours.Add(new OpnsenseNeighbour( + ip, + mac, + Clean(Text(entry, "hostname")), + Clean(Text(entry, "manufacturer")), + Clean(Text(entry, "intf_description")) ?? Clean(Text(entry, "intf")))); + } + + return neighbours; + } + + /// + /// A locally administered group address — the low bit of the first octet marks + /// multicast, which no host owns. + /// + private static bool IsMulticast(string mac) => + Convert.ToInt32(mac[..2], 16) % 2 == 1; + + private static string? Text(JsonElement element, string name) => + element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + /// + /// OPNsense writes these as real booleans in some versions and as the strings + /// "1"/"true" in others. + /// + private static bool IsTrue(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return false; + + return value.ValueKind switch { + JsonValueKind.True => true, + JsonValueKind.String => value.GetString() is "1" or "true" or "yes", + JsonValueKind.Number => value.TryGetInt32(out var number) && number != 0, + _ => false + }; + } + + /// Blank and placeholder values arrive as empty strings or dashes. + private static string? Clean(string? value) { + var trimmed = value?.Trim(); + + return string.IsNullOrEmpty(trimmed) || trimmed is "-" or "(none)" ? null : trimmed; + } +} diff --git a/Shared.Rcl/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index fa120baf..cb8de342 100644 --- a/Shared.Rcl/CliBootstrap.cs +++ b/Shared.Rcl/CliBootstrap.cs @@ -806,6 +806,11 @@ public static void BuildApp(CommandApp app) { .WithExample("discover", "proxmox", "--host", "https://pve.lan:8006", "--insecure") .WithExample("discover", "proxmox", "--host", "pve.lan", "--push"); + discover.AddCommand("opnsense") + .WithDescription("Read an OPNsense firewall's neighbour table and emit every machine on it.") + .WithExample("discover", "opnsense", "--host", "https://firewall.lan", "--insecure") + .WithExample("discover", "opnsense", "--host", "firewall.lan", "--push"); + discover.AddCommand("network") .WithDescription("Sweep a subnet and emit every answering host as a System resource.") .WithExample("discover", "network") diff --git a/Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs new file mode 100644 index 00000000..f81ed7b6 --- /dev/null +++ b/Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs @@ -0,0 +1,100 @@ +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 DiscoverOpnsenseSettings : DiscoverSettings { + [CommandOption("--host ")] + [Description("OPNsense host, e.g. https://firewall.lan. A bare host name gets https.")] + public string? Host { get; init; } + + [CommandOption("--key ")] + [Description("API key. Defaults to RPK_OPN_KEY.")] + public string? Key { get; init; } + + [CommandOption("--secret ")] + [Description("API secret. Defaults to RPK_OPN_SECRET.")] + public string? Secret { get; init; } + + [CommandOption("--insecure")] + [Description("Accept a self-signed certificate, which OPNsense ships with by default.")] + public bool Insecure { get; init; } + + [CommandOption("--include-public")] + [Description("Also record neighbours on public addresses, such as the ISP equipment on the WAN leg.")] + public bool IncludePublic { get; init; } + + public string? ResolvedKey => + DiscoveryPublisher.Resolve(Key, OpnsenseApiClient.KeyEnvironmentVariable); + + public string? ResolvedSecret => + DiscoveryPublisher.Resolve(Secret, OpnsenseApiClient.SecretEnvironmentVariable); + + public override ValidationResult Validate() { + if (string.IsNullOrWhiteSpace(Host)) + return ValidationResult.Error("Pass --host, e.g. --host https://firewall.lan"); + + if (string.IsNullOrWhiteSpace(ResolvedKey)) + return ValidationResult.Error( + $"No API key. Pass --key or set {OpnsenseApiClient.KeyEnvironmentVariable}."); + + if (string.IsNullOrWhiteSpace(ResolvedSecret)) + return ValidationResult.Error( + $"No API secret. Pass --secret or set {OpnsenseApiClient.SecretEnvironmentVariable}."); + + return base.Validate(); + } +} + +/// +/// Reads an OPNsense firewall's neighbour table and emits every machine on it. +/// +/// The collector for everything a sweep can see but not identify. ARP is +/// link-local, so sweeping from one host yields a MAC only for that host's own +/// segment and an address for everything else — and an address alone cannot +/// survive a DHCP re-lease or be matched to anything. The firewall routes every +/// subnet, so its table has the MAC for all of them. +/// +/// +public sealed class DiscoverOpnsenseCommand : AsyncCommand { + protected override async Task ExecuteAsync( + CommandContext context, + DiscoverOpnsenseSettings settings, + CancellationToken cancellationToken) { + using var client = new OpnsenseApiClient( + settings.Host!, + settings.ResolvedKey!, + settings.ResolvedSecret!, + settings.Insecure); + + List resources; + + try { + resources = await OpnsenseDiscovery.ReadAsync(client, settings.IncludePublic, cancellationToken); + } + catch (Exception ex) when ( + ex is HttpRequestException or IOException or TimeoutException + || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) { + AnsiConsole.MarkupLine( + $"[red]Could not read {Markup.Escape(client.Endpoint)}.[/] {Markup.Escape(ex.Message)}"); + + // A self-signed certificate is the other common cause, and its message is + // opaque enough to be worth naming. + if (!settings.Insecure && ex is HttpRequestException { StatusCode: null }) + AnsiConsole.MarkupLine( + "[grey]If the firewall uses its own certificate, add --insecure.[/]"); + + return 1; + } + + if (resources.Count == 0) + AnsiConsole.MarkupLine( + "[grey]The firewall's ARP table is empty. It only holds neighbours it has " + + "spoken to recently, so this is normal on a quiet network.[/]"); + + return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken); + } +} diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index 915ff824..8aaf5b66 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md @@ -242,6 +242,7 @@ - [system](/docs/cli-commands#rpk-discover-system) - Inspect this machine and emit it as a System resource - [docker](/docs/cli-commands#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this - [proxmox](/docs/cli-commands#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems + - [opnsense](/docs/cli-commands#rpk-discover-opnsense) - Read an OPNsense firewall's neighbour table and emit every machine on it - [network](/docs/cli-commands#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage Ansible inventory - [inventory](/docs/cli-commands#rpk-ansible-inventory) - Generate an Ansible inventory diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 86db5915..ed1a7f27 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -3966,11 +3966,14 @@ 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 - network Sweep a subnet and emit every answering host as a System resource + 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 + opnsense Read an OPNsense firewall's neighbour table and emit every + machine on it + network Sweep a subnet and emit every answering host as a System + resource ``` ## `rpk discover system` @@ -4061,6 +4064,38 @@ OPTIONS: Proxmox ships with by default ``` +## `rpk discover opnsense` +``` +DESCRIPTION: +Read an OPNsense firewall's neighbour table and emit every machine on it + +USAGE: + rpk discover opnsense [OPTIONS] + +EXAMPLES: + rpk discover opnsense --host https://firewall.lan --insecure + rpk discover opnsense --host firewall.lan --push + +OPTIONS: + -h, --help Prints help information + --push Upload the result to a RackPeek server instead of + printing it + --server RackPeek server to upload to. Defaults to the + RPK_SERVER environment variable + --api-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 OPNsense host, e.g. https://firewall.lan. A bare + host name gets https + --key API key. Defaults to RPK_OPN_KEY + --secret API secret. Defaults to RPK_OPN_SECRET + --insecure Accept a self-signed certificate, which OPNsense + ships with by default + --include-public Also record neighbours on public addresses, such as + the ISP equipment on the WAN leg +``` + ## `rpk discover network` ``` DESCRIPTION: diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 01a5b5ad..12ef579d 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -8,6 +8,7 @@ don't have to type in what the machine already knows about itself. | `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 | +| `rpk discover opnsense` | an OPNsense firewall's neighbour table | one **System** per machine it has seen, on every subnet it routes | | `rpk discover network` | a subnet, from outside | one **System** per host that answers, plus a **Service** for each web application it recognises | Both print YAML to standard output by default and change nothing, so it is always safe @@ -346,6 +347,74 @@ described in full rather than becoming a second, emptier card beside it. --- +## `rpk discover opnsense` + +The collector for everything a sweep can see but not identify. + +ARP is link-local. Sweeping from one machine gets you a MAC for that machine's own +segment and nothing but an address for every other subnet — and an address alone cannot +survive a DHCP re-lease or be matched against anything else you have documented. The +firewall routes every subnet, so its neighbour table has the MAC for all of them, plus +the name it handed out and which leg it was seen on. + +```bash +# Look at what the firewall knows +rpk discover opnsense --host https://firewall.lan --insecure + +# Merge it into your server +rpk discover opnsense --host firewall.lan --push +``` + +| Option | Meaning | +|---|---| +| `--host ` | The firewall. A bare name gets `https`. | +| `--key ` | API key. Defaults to `RPK_OPN_KEY`. | +| `--secret ` | API secret. Defaults to `RPK_OPN_SECRET`. | +| `--insecure` | Accept the self-signed certificate OPNsense ships with. | +| `--include-public` | Also record neighbours on public addresses (see below). | + +Plus the same `--push` / `--server` / `--api-key` / `--dry-run` options as every other +collector. + +Create the credentials in the firewall under **System → Access → Users**, on a user that +holds the **Diagnostics: ARP Table** privilege. Read-only is enough; nothing here writes. + +### What it records, and what it leaves out + +One **System** per machine, carrying its address, its MAC, the vendor that MAC belongs +to, and a `segment` label naming the firewall leg it answered on — which is the closest +thing to "which VLAN is this on" that the firewall can tell you. + +Left out on purpose: + +* **The firewall's own addresses.** Every routed subnet contributes one and they are all + the same box — which is a Firewall, not the handful of Systems this would invent. +* **Entries that have aged out.** They say where something used to be. +* **Broadcast and multicast addresses**, which no machine owns. +* **Neighbours on public addresses**, such as the ISP equipment on the WAN leg. They are + not your infrastructure, and recording one would put a public address into a file you + may well commit. Pass `--include-public` if you are documenting a fleet that lives on + them. + +A machine answering on two of the firewall's legs is one card, not two: its identity is +its MAC. + +### Why it lines up with everything else + +A card from the firewall is seeded exactly as `rpk discover network` seeds its own — +keyed on the MAC — because both describe the same thing by the same evidence: a machine +observed on the network rather than asked about itself. So a host the firewall knows and +a host a sweep found are **one card**, whichever collector ran first, with no special +case anywhere to say so. Run both and the firewall fills in the identity a sweep of a +routed subnet could never get. + +One wrinkle worth knowing: names are yours, so discovery never renames a resource that +already exists — including one a sweep named `host-` before the firewall could +offer something better. Running the firewall collector first, or on a fresh inventory, +gets you the good names. + +--- + ## `rpk discover network` The collector for machines nothing else can describe: no agent, no API — just an diff --git a/Tests.Discovery/Fixtures/opnsense-arp.json b/Tests.Discovery/Fixtures/opnsense-arp.json new file mode 100644 index 00000000..139d6f10 --- /dev/null +++ b/Tests.Discovery/Fixtures/opnsense-arp.json @@ -0,0 +1,86 @@ +[ + { + "mac": "00:50:c2:00:1a:01", + "ip": "198.51.100.7", + "intf": "igb0", + "expired": false, + "expires": -1, + "permanent": false, + "type": "ethernet", + "manufacturer": "Cisco Systems", + "hostname": "", + "intf_description": "WAN" + }, + { + "mac": "00:1b:21:00:1a:02", + "ip": "192.0.2.1", + "intf": "igb1", + "expired": false, + "expires": -1, + "permanent": true, + "type": "ethernet", + "manufacturer": "Intel Corporate", + "hostname": "", + "intf_description": "LAB" + }, + { + "mac": "1c:6a:1b:00:1a:03", + "ip": "192.168.10.150", + "intf": "igb1_vlan20", + "expired": false, + "expires": 1132, + "permanent": false, + "type": "ethernet", + "manufacturer": "Ubiquiti Inc", + "hostname": "ap-outdoor", + "intf_description": "home" + }, + { + "mac": "bc:24:11:00:1a:04", + "ip": "192.168.50.105", + "intf": "igb1_vlan50", + "expired": false, + "expires": 980, + "permanent": false, + "type": "ethernet", + "manufacturer": "Proxmox Server Solutions GmbH", + "hostname": "forgejo", + "intf_description": "HomeServices" + }, + { + "mac": "aa:bb:cc:00:1a:05", + "ip": "192.168.10.99", + "intf": "igb1_vlan20", + "expired": true, + "expires": -1, + "permanent": false, + "type": "ethernet", + "manufacturer": "", + "hostname": "gone-away", + "intf_description": "home" + }, + { + "mac": "ff:ff:ff:ff:ff:ff", + "ip": "192.168.10.255", + "intf": "igb1_vlan20", + "expired": false, + "expires": -1, + "permanent": false, + "type": "ethernet", + "manufacturer": "", + "hostname": "", + "intf_description": "home" + }, + { + "mac": "1c:6a:1b:00:1a:03", + "ip": "192.168.30.150", + "intf": "igb1_vlan30", + "expired": false, + "expires": 900, + "permanent": false, + "type": "ethernet", + "manufacturer": "Ubiquiti Inc", + "hostname": "ap-outdoor", + "intf_description": "IOT" + } +] diff --git a/Tests.Discovery/OpnsenseDiscoveryTests.cs b/Tests.Discovery/OpnsenseDiscoveryTests.cs new file mode 100644 index 00000000..fe4baf77 --- /dev/null +++ b/Tests.Discovery/OpnsenseDiscoveryTests.cs @@ -0,0 +1,169 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// A firewall's neighbour table in, System resources out. +/// +/// This is the collector for everything a sweep can see but not identify. ARP is +/// link-local, so sweeping from one host yields a MAC for that host's own segment +/// and an address for everything else — and an address alone cannot survive a +/// DHCP re-lease or be matched to anything. The firewall routes every subnet, so +/// its table has the MAC, the name it handed out, and which leg it was seen on. +/// +/// +public class OpnsenseDiscoveryTests { + private static List Neighbours() => + OpnsenseResponseParser.ParseArp(Fixture.Read("opnsense-arp.json")); + + private static List Discover(bool includePublic = false) => + OpnsenseDiscovery.ToResources(Neighbours(), includePublic); + + [Fact] + public void The_table_yields_one_neighbour_per_machine() { + List neighbours = Neighbours(); + + // Of seven rows: one is the firewall's own address, one has aged out, one is the + // broadcast address, and one is a second sighting of a host that answers on two + // of the firewall's legs. + Assert.Equal(3, neighbours.Count); + } + + [Fact] + // Every routed subnet contributes one, and they are all the same box — which is a + // Firewall, not the handful of Systems this would otherwise invent. + public void An_address_the_firewall_holds_itself_is_not_a_neighbour() => + Assert.DoesNotContain(Neighbours(), n => n.Mac == "00:1b:21:00:1a:02"); + + [Fact] + // It says where something used to be, which is not evidence that it is there now. + public void An_entry_that_has_aged_out_is_not_a_neighbour() => + Assert.DoesNotContain(Neighbours(), n => n.Hostname == "gone-away"); + + [Fact] + public void The_broadcast_address_is_not_a_machine() => + Assert.DoesNotContain(Neighbours(), n => n.Mac.StartsWith("ff:", StringComparison.Ordinal)); + + [Fact] + // Its identity is the MAC, so two sightings must not become two machines. + public void A_host_seen_on_two_legs_is_still_one_card() => + Assert.Single(Discover().OfType(), s => s.Labels["mac"] == "1c:6a:1b:00:1a:03"); + + [Fact] + // It is not the user's infrastructure, and recording it would put a public address + // into a file people commit. + public void The_isps_equipment_on_the_wan_leg_is_left_out_by_default() => + Assert.DoesNotContain(Discover().OfType(), s => s.Ip == "198.51.100.7"); + + [Fact] + // A fleet documented on public addresses is a real case; it just is not the default. + public void Public_neighbours_can_be_asked_for() => + Assert.Contains(Discover(true).OfType(), s => s.Ip == "198.51.100.7"); + + [Theory] + [InlineData("10.0.0.5")] + [InlineData("172.16.4.9")] + [InlineData("172.31.255.254")] + [InlineData("192.168.1.1")] + [InlineData("100.64.0.1")] // carrier-grade NAT + public void Private_and_carrier_ranges_count_as_ones_own_network(string ip) => + Assert.True(OpnsenseDiscovery.IsPrivate(ip)); + + [Theory] + [InlineData("198.51.100.7")] + [InlineData("8.8.8.8")] + [InlineData("172.15.0.1")] // just below the private block + [InlineData("172.32.0.1")] // just above it + [InlineData("100.63.0.1")] // just below the carrier block + [InlineData("not-an-address")] + public void Everything_else_does_not(string ip) => + Assert.False(OpnsenseDiscovery.IsPrivate(ip)); + + [Fact] + // The whole point: a sweep of another subnet can only call this host-. + public void The_name_the_firewall_handed_out_becomes_the_card_name() => + Assert.Contains(Discover().OfType(), s => s.Name == "forgejo"); + + [Fact] + public void A_neighbour_with_no_name_still_gets_a_stable_one() { + List cards = Discover(true); + + SystemResource wan = cards.OfType().Single(s => s.Ip == "198.51.100.7"); + Assert.StartsWith("host-", wan.Name); + } + + [Fact] + public void A_card_carries_the_mac_the_vendor_and_the_leg_it_was_seen_on() { + SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); + + Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]); + Assert.Equal("Proxmox", card.Labels["vendor"]); + Assert.Equal("HomeServices", card.Labels["segment"]); + Assert.Equal("192.168.50.105", card.Ip); + } + + [Fact] + public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() { + // Ours is a curated subset, so it answers "Proxmox" where the firewall says + // "Proxmox Server Solutions GmbH" — but the firewall carries the whole registry + // and answers for prefixes ours has never heard of. + List cards = Discover(true); + + SystemResource wan = cards.OfType().Single(s => s.Ip == "198.51.100.7"); + Assert.Equal("Cisco Systems", wan.Labels["vendor"]); + } + + [Fact] + public void A_card_is_seeded_exactly_as_a_sweep_would_seed_it() { + // The contract that makes the two collectors interchangeable: same evidence, same + // identity, so a host the firewall knows and a host a sweep found are one card + // whichever ran first — with no special case anywhere to say so. + SystemResource fromFirewall = Discover().OfType().Single(s => s.Name == "forgejo"); + + SystemResource fromSweep = NetworkScanMapper.ToResources([ + new NetworkHostFact("192.168.50.105", "bc:24:11:00:1a:04", null, true, []) + ]).OfType().Single(); + + Assert.Equal(fromSweep.DiscoveryId, fromFirewall.DiscoveryId); + } + + [Fact] + public void The_cards_stay_sparse() { + // The firewall knows where a machine is and what its NIC is, never what runs on + // it. A guess here would overwrite the real values on the next run of a collector + // that does know. + SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); + + Assert.Null(card.Type); + Assert.Null(card.Os); + Assert.Null(card.Cores); + Assert.Null(card.Ram); + } + + [Fact] + public void The_paginated_shape_reads_the_same_as_the_flat_one() { + // The search wrapper returns {rows: [...]} while the direct call returns a bare + // array; a caller should not have to know which endpoint answered. + var rows = $$"""{"rows": {{Fixture.Read("opnsense-arp.json")}}, "total": 7}"""; + + Assert.Equal( + Neighbours().Select(n => n.Mac), + OpnsenseResponseParser.ParseArp(rows).Select(n => n.Mac)); + } + + [Theory] + [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":"1"}]""")] + [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":1}]""")] + [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":true}]""")] + public void A_flag_is_read_however_the_firmware_spells_it(string json) => + // OPNsense writes these as real booleans in some versions and as "1" in others. + Assert.Empty(OpnsenseResponseParser.ParseArp(json)); + + [Fact] + public void An_empty_table_is_not_an_error() { + Assert.Empty(OpnsenseResponseParser.ParseArp("[]")); + Assert.Empty(OpnsenseResponseParser.ParseArp("""{"rows":[],"total":0}""")); + } +} From a5143e366231467ef621a5c0f6af3aa7b4f2c76e Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:31 +0100 Subject: [PATCH 23/29] Make every open port a Service, and name labels for what they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open port was landing in an `open-ports` label: a comma-separated string nothing could link to, filter on, or hang a note from. RackPeek already has a resource for "a thing listening on an address and a port", so each open port now becomes a Service with its own stable id, running on the host that serves it — nebula-ssh, nebula-https, nebula-proxmox. What a service said about itself still beats what its port number implies, because a port is a convention and an answer is evidence. Where nothing answered the name falls back to the host plus the port's usual service, and an unrecognised port keeps its number as nebula-tcp-9987 rather than guessing. An appliance whose management page only says its own name back is named for the port instead, so a firewall called opnsense gains an opnsense-https rather than a second card called opnsense. Two labels change with it. `vendor` becomes `nic-vendor`, because the OUI identifies whoever owns the network interface, which is not the same claim as who made the machine — a Proxmox guest's virtual NIC reads Proxmox while the box underneath it is a Dell. And `segment` goes: it described the firewall's wiring rather than the machine, and changed whenever anything was re-cabled. Co-Authored-By: Claude Opus 5 (1M context) --- .../Discovery/NetworkScanMapper.cs | 59 +++++---- .../Discovery/OpnsenseDiscovery.cs | 9 +- RackPeek.Domain/Discovery/WellKnownPorts.cs | 47 +++++++ Tests.Discovery/NetworkIdentityTests.cs | 11 +- Tests.Discovery/OpenPortServiceTests.cs | 115 ++++++++++++++++++ Tests.Discovery/OpnsenseDiscoveryTests.cs | 16 ++- 6 files changed, 217 insertions(+), 40 deletions(-) create mode 100644 Tests.Discovery/OpenPortServiceTests.cs diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs index 2280631a..096afe6f 100644 --- a/RackPeek.Domain/Discovery/NetworkScanMapper.cs +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -45,15 +45,11 @@ public static List ToResources(IReadOnlyList hosts) { if (host.Mac != null) system.Labels["mac"] = host.Mac; + // Named for what it is: the organisation that owns the NIC's OUI, which is + // not the same claim as who made the machine — a Proxmox guest's NIC says + // Proxmox while the box underneath it is a Dell. if (host.Vendor != null) - system.Labels["vendor"] = host.Vendor; - - // The ports are observations, not conclusions: "554 is open" is a fact, while - // "this is a camera" is an inference the reader is far better placed to make - // than the scanner. Recording them keeps the evidence without inventing a - // service that may not be what the port number conventionally implies. - if (host.OpenPorts.Count > 0) - system.Labels["open-ports"] = string.Join(",", host.OpenPorts); + system.Labels["nic-vendor"] = host.Vendor; // Kept even when the name came from somewhere else: it records what the host // actually said, which is how someone judges whether the name is trustworthy. @@ -66,35 +62,48 @@ public static List ToResources(IReadOnlyList hosts) { resources.Add(system); - // An application that named itself over HTTP is a fact about what the host - // runs, not about what the host is, so it becomes a Service hanging off the - // card rather than renaming it. - foreach (ServiceIdentity found in host.Services) { - // An appliance's own management UI is not a service running on it: a - // firewall whose page says "OPNsense" on a card already called opnsense - // would otherwise get a second card named opnsense-, which says - // nothing the first one did not. - if (DiscoveryNaming.Slug(DiscoveryNaming.HostLabel(found.Name)) - .Equals(system.Name, StringComparison.OrdinalIgnoreCase)) - continue; + // Something listening on a port is a service, and a service is a resource in + // its own right rather than a note on the host. The host says where it is; + // each port says what it serves. + IReadOnlyDictionary identified = + host.Services.ToDictionary(s => s.Port); + + // A port that answered a banner probe is open by definition, so the two lists + // agree in practice — but an identified service must not go missing if they + // ever disagree. + IEnumerable ports = host.OpenPorts + .Concat(identified.Keys) + .Distinct(); + foreach (var port in ports) { var serviceId = DiscoveryId.Create( DiscoveryId.NetworkScheme, - $"{host.Mac ?? $"ip:{host.Ip}"}:{found.Port}"); + $"{host.Mac ?? $"ip:{host.Ip}"}:{port}"); + + // What the service said about itself beats what its port number implies, + // because a port is a convention and an answer is evidence. The exception + // is an appliance whose management page just says its own name back: a + // second card called opnsense tells no one anything, where an opnsense-https + // sitting on opnsense says exactly what it is. + var announced = identified.TryGetValue(port, out ServiceIdentity? found) + ? DiscoveryNaming.HostLabel(found.Name) + : string.Empty; + + var serviceLabel = announced.Length > 0 + && !announced.Equals(system.Name, StringComparison.OrdinalIgnoreCase) + ? announced + : $"{system.Name}-{WellKnownPorts.NameFor(port)}"; resources.Add(new Service { Kind = Service.KindLabel, Name = DiscoveryNaming.Unique( - DiscoveryNaming.Suggest( - DiscoveryNaming.HostLabel(found.Name), - "service", - serviceId), + DiscoveryNaming.Suggest(serviceLabel, "service", serviceId), serviceId, taken), DiscoveryId = serviceId, Network = new Network { Ip = host.Ip, - Port = found.Port, + Port = port, Protocol = "TCP" }, RunsOn = [system.Name] diff --git a/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs index eea457e2..15d02d66 100644 --- a/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs +++ b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs @@ -82,13 +82,10 @@ public static List ToResources( // prefixes the curated table leaves out. var vendor = MacVendorLookup.Lookup(neighbour.Mac) ?? neighbour.Manufacturer; + // The organisation that owns the NIC's OUI — not a claim about who made the + // machine, which is a different thing entirely. if (vendor != null) - system.Labels["vendor"] = vendor; - - // Which leg of the firewall saw it — the closest thing to a physical location - // the firewall can offer, and the thing that says which VLAN a host is on. - if (neighbour.Interface != null) - system.Labels["segment"] = neighbour.Interface; + system.Labels["nic-vendor"] = vendor; resources.Add(system); } diff --git a/RackPeek.Domain/Discovery/WellKnownPorts.cs b/RackPeek.Domain/Discovery/WellKnownPorts.cs index 35d36f30..84579d3b 100644 --- a/RackPeek.Domain/Discovery/WellKnownPorts.cs +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -47,4 +47,51 @@ public static class WellKnownPorts { 11434, // ollama 32400 // plex ]; + + /// + /// What a port conventionally carries, for naming the service found on it. A port + /// number is a convention rather than a guarantee, so this only ever supplies a + /// name — anything a service actually said about itself wins over it. + /// + private static readonly Dictionary _names = new() { + [21] = "ftp", + [22] = "ssh", + [23] = "telnet", + [25] = "smtp", + [53] = "dns", + [80] = "http", + [443] = "https", + [445] = "smb", + [554] = "rtsp", + [631] = "ipp", + [1883] = "mqtt", + [2375] = "docker", + [2376] = "docker", + [3000] = "http", + [3306] = "mysql", + [3389] = "rdp", + [5000] = "http", + [5432] = "postgres", + [5900] = "vnc", + [6379] = "redis", + [7860] = "http", + [8000] = "http", + [8006] = "proxmox", + [8080] = "http", + [8096] = "jellyfin", + [8123] = "home-assistant", + [8443] = "https", + [9000] = "http", + [9090] = "http", + [9100] = "jetdirect", + [11434] = "ollama", + [32400] = "plex" + }; + + /// + /// The conventional name for a port, or tcp-1234 when nobody curated one — + /// which still says more than the bare number, and stays stable across runs. + /// + public static string NameFor(int port) => + _names.TryGetValue(port, out var name) ? name : $"tcp-{port}"; } diff --git a/Tests.Discovery/NetworkIdentityTests.cs b/Tests.Discovery/NetworkIdentityTests.cs index 623fd966..2e137db0 100644 --- a/Tests.Discovery/NetworkIdentityTests.cs +++ b/Tests.Discovery/NetworkIdentityTests.cs @@ -124,9 +124,10 @@ public void Several_applications_on_one_host_each_get_their_own_service() { } [Fact] - public void An_appliances_own_management_page_is_not_a_service_on_itself() { + public void An_appliances_own_management_page_is_named_for_the_port_it_serves() { // A firewall whose page says "OPNsense" on a card already called opnsense would - // otherwise gain a second card named opnsense- saying nothing new. + // otherwise gain a second card named opnsense-, saying nothing new. The + // port is what distinguishes the service from the box it runs on. List cards = NetworkScanMapper.ToResources([ Host( "192.0.2.101", @@ -135,7 +136,7 @@ public void An_appliances_own_management_page_is_not_a_service_on_itself() { ]); Assert.Equal("opnsense", Assert.Single(cards.OfType()).Name); - Assert.Empty(cards.OfType()); + Assert.Equal("opnsense-http", Assert.Single(cards.OfType()).Name); } [Fact] @@ -159,7 +160,7 @@ public void A_services_id_survives_a_rescan_and_differs_per_port() { public void A_vendor_is_labelled_when_the_mac_is_known() { SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif")); - Assert.Equal("Espressif", card.Labels["vendor"]); + Assert.Equal("Espressif", card.Labels["nic-vendor"]); Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]); } @@ -167,7 +168,7 @@ public void A_vendor_is_labelled_when_the_mac_is_known() { public void No_vendor_label_is_invented_when_none_is_known() { SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33")); - Assert.False(card.Labels.ContainsKey("vendor")); + Assert.False(card.Labels.ContainsKey("nic-vendor")); } [Fact] diff --git a/Tests.Discovery/OpenPortServiceTests.cs b/Tests.Discovery/OpenPortServiceTests.cs new file mode 100644 index 00000000..0f6b4013 --- /dev/null +++ b/Tests.Discovery/OpenPortServiceTests.cs @@ -0,0 +1,115 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// An open port is a service, not a note on the host. +/// +/// It used to land in an "open-ports" label — a comma-separated string that +/// nothing could link to, filter on, or hang a note from. RackPeek already has a +/// resource for "a thing listening on an address and a port", so a sweep that +/// finds 22 open on nebula should produce a Service called nebula-ssh running on +/// nebula, exactly as if someone had written it in by hand. +/// +/// +public class OpenPortServiceTests { + private static List Scan(string ip, string? mac, params int[] ports) => + NetworkScanMapper.ToResources([ + new NetworkHostFact(ip, mac, "nebula", true, ports) + ]); + + private static List Services(params int[] ports) => + Scan("192.0.2.20", "bc:24:11:00:2a:01", ports).OfType().ToList(); + + [Fact] + public void An_open_port_is_no_longer_a_label() { + SystemResource host = Scan("192.0.2.20", "bc:24:11:00:2a:01", 22, 80) + .OfType() + .Single(); + + Assert.False(host.Labels.ContainsKey("open-ports")); + } + + [Fact] + public void Each_open_port_becomes_a_service_on_the_host() { + List services = Services(22, 80); + + Assert.Equal(2, services.Count); + Assert.All(services, s => Assert.Equal(["nebula"], s.RunsOn)); + } + + [Fact] + public void A_service_is_named_for_the_host_and_what_the_port_serves() { + Service ssh = Assert.Single(Services(22)); + + Assert.Equal("nebula-ssh", ssh.Name); + } + + [Theory] + [InlineData(443, "nebula-https")] + [InlineData(445, "nebula-smb")] + [InlineData(1883, "nebula-mqtt")] + [InlineData(8006, "nebula-proxmox")] + [InlineData(32400, "nebula-plex")] + public void Well_known_ports_are_named_by_what_they_serve(int port, string expected) => + Assert.Equal(expected, Assert.Single(Services(port)).Name); + + [Fact] + // Better an honest tcp-9987 than a guess: the number is the only fact available. + public void An_unrecognised_port_keeps_its_number() => + Assert.Equal("nebula-tcp-9987", Assert.Single(Services(9987)).Name); + + [Fact] + public void A_service_that_named_itself_beats_what_its_port_implies() { + // A port is a convention and an answer is evidence. Home Assistant on 8123 is + // the convention; a page that says "Forgejo" is the machine telling you. + List resources = NetworkScanMapper.ToResources([ + new NetworkHostFact("192.0.2.21", "bc:24:11:00:2a:02", "nebula", true, [8123]) { + Services = [new ServiceIdentity("Forgejo", IdentitySource.Http, 8123)] + } + ]); + + Assert.Equal("forgejo", Assert.Single(resources.OfType()).Name); + } + + [Fact] + public void A_service_records_where_it_is_listening() { + Service ssh = Assert.Single(Services(22)); + + Assert.Equal("192.0.2.20", ssh.Network?.Ip); + Assert.Equal(22, ssh.Network?.Port); + Assert.Equal("TCP", ssh.Network?.Protocol); + } + + [Fact] + public void A_services_identity_is_its_hosts_identity_and_the_port() { + // Stable across rescans, and distinct per port, so a rescan updates the same two + // cards rather than inventing a pair every time. + var first = Services(22, 80).Select(s => s.DiscoveryId).ToList(); + var second = Services(22, 80).Select(s => s.DiscoveryId).ToList(); + + Assert.Equal(first, second); + Assert.Equal(2, first.Distinct().Count()); + } + + [Fact] + public void Two_hosts_running_the_same_thing_get_distinct_cards() { + // Naming after the host is what keeps these apart — "ssh" alone would collide on + // every machine in the rack. + List resources = NetworkScanMapper.ToResources([ + new NetworkHostFact("192.0.2.30", "bc:24:11:00:2a:03", "nebula", true, [22]), + new NetworkHostFact("192.0.2.31", "bc:24:11:00:2a:04", "orion", true, [22]) + ]); + + Assert.Equal( + ["nebula-ssh", "orion-ssh"], + resources.OfType().Select(s => s.Name).Order()); + } + + [Fact] + public void A_host_with_nothing_listening_yields_no_services() => + Assert.Empty(Scan("192.0.2.40", "bc:24:11:00:2a:05").OfType()); +} diff --git a/Tests.Discovery/OpnsenseDiscoveryTests.cs b/Tests.Discovery/OpnsenseDiscoveryTests.cs index fe4baf77..2173e5ec 100644 --- a/Tests.Discovery/OpnsenseDiscoveryTests.cs +++ b/Tests.Discovery/OpnsenseDiscoveryTests.cs @@ -95,15 +95,23 @@ public void A_neighbour_with_no_name_still_gets_a_stable_one() { } [Fact] - public void A_card_carries_the_mac_the_vendor_and_the_leg_it_was_seen_on() { + public void A_card_carries_the_mac_and_who_made_the_nic() { SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]); - Assert.Equal("Proxmox", card.Labels["vendor"]); - Assert.Equal("HomeServices", card.Labels["segment"]); + Assert.Equal("Proxmox", card.Labels["nic-vendor"]); Assert.Equal("192.168.50.105", card.Ip); } + [Fact] + public void Which_leg_of_the_firewall_it_answered_on_is_not_recorded() { + // It describes the firewall's wiring, not the machine, and it changes the moment + // anything is re-cabled or a VLAN is renamed. + SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); + + Assert.False(card.Labels.ContainsKey("segment")); + } + [Fact] public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() { // Ours is a curated subset, so it answers "Proxmox" where the firewall says @@ -112,7 +120,7 @@ public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() { List cards = Discover(true); SystemResource wan = cards.OfType().Single(s => s.Ip == "198.51.100.7"); - Assert.Equal("Cisco Systems", wan.Labels["vendor"]); + Assert.Equal("Cisco Systems", wan.Labels["nic-vendor"]); } [Fact] From 8900008c55f3676a2a36f2f04703086e6afedf60 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:47 +0100 Subject: [PATCH 24/29] Let names and identity improve as collectors learn more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collector names a card from whatever it could see, and when it could see nothing the name falls back to a slug of the card's own id — host-1a2b3c4d says only that something is there. A later run, or a collector that can see more, often does know the machine's name: a firewall knows what it handed out over DHCP, a hypervisor knows what its guest is called. Those placeholders now give way to real names, and everything pointing at the old name follows: stored runsOn, stored connections, services named after their host, and the incoming payload's own references. A new `userNamed` flag draws the line discovery must not cross. Renaming sets it, every rename path goes through one use case, and nothing in discovery touches the name again. A resource with no discoveryId is user-named whatever the flag says — nothing but a person could have written it. The upgrade is one-way, placeholder to real, and one real name never replaces another, or two collectors that each knew a different name would rename a box back and forth on every run. The address bridge is now symmetric. It fired only when the incoming card was the scan and the stored one was agent-grade, so sweeping before running the hypervisor left both cards behind where the other order produced one. Which collector ran first is an accident of what someone typed and must not decide what the inventory holds. Two scan cards can bridge as well, but only when exactly one of them saw a MAC: a firewall's neighbour table names the interface answering at an address where a sweep of a subnet it does not sit on only knows something replied, and those are not two stand-ins. Cards of equal standing never bridge. Measured on a nine-subnet lab, sweeping first: fourteen machines were in the inventory twice, and are now in it once. Co-Authored-By: Claude Opus 5 (1M context) --- RackPeek.Domain/Api/UpsertInventoryUseCase.cs | 12 +- .../Discovery/DiscoveryIdResolver.cs | 215 ++++++++++-- .../Yaml/YamlResourceCollection.cs | 4 +- RackPeek.Domain/Resources/Resource.cs | 28 ++ .../UseCases/RenameResourceUseCase.cs | 4 + .../wwwroot/schemas/v4/schema.v4.json | 4 + .../wwwroot/schemas/v4/schema.v4.json | 4 + Tests.Discovery/IpBridgeSymmetryTests.cs | 171 ++++++++++ .../PlaceholderNameUpgradeTests.cs | 321 ++++++++++++++++++ Tests.Discovery/RunsOnByIpTests.cs | 57 +++- schemas/v4/schema.v4.json | 4 + 11 files changed, 793 insertions(+), 31 deletions(-) create mode 100644 Tests.Discovery/IpBridgeSymmetryTests.cs create mode 100644 Tests.Discovery/PlaceholderNameUpgradeTests.cs diff --git a/RackPeek.Domain/Api/UpsertInventoryUseCase.cs b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs index ca0efb60..a5187ac7 100644 --- a/RackPeek.Domain/Api/UpsertInventoryUseCase.cs +++ b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs @@ -63,9 +63,18 @@ public async Task ExecuteAsync(ImportYamlRequest request) { List? incomingResources = incomingRoot.Resources; IReadOnlyList currentResources = await repo.GetAllOfTypeAsync(); + IReadOnlyList currentConnections = await repo.GetConnectionsAsync(); + // 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); + // A dry run gets the same reconciliation but is not allowed to improve stored + // names, because that rewrites the inventory and a dry run must not. + DiscoveryIdResolver.ResolveNames( + currentResources, + incomingResources, + incomingRoot.Connections, + currentConnections, + !request.DryRun); IGrouping? duplicate = incomingResources .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) @@ -123,7 +132,6 @@ public async Task ExecuteAsync(ImportYamlRequest request) { else if (oldYaml != newYaml) response.Updated.Add(incoming.Name); } - IReadOnlyList currentConnections = await repo.GetConnectionsAsync(); List? mergedConnections = ConnectionMerger.Merge( currentConnections, incomingRoot.Connections, diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index 805f86c8..e6a8e77b 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -21,11 +21,21 @@ public static class DiscoveryIdResolver { /// the stored resources the ids point at. Also rewrites runsOn references /// between incoming resources — and the payload's , /// which name resources the same way — so a rename does not break the tree. + /// + /// The one exception is , which lets a + /// stored placeholder nobody chose be replaced by a real name this payload + /// knows — see . That rewrites the stored side, so + /// only a caller that is about to persist should ask for it, and it must hand + /// over for the same reason the incoming + /// side hands over its own. + /// /// public static void ResolveNames( IReadOnlyList existing, IReadOnlyList incoming, - IReadOnlyList? connections = null) { + IReadOnlyList? connections = null, + IReadOnlyList? storedConnections = null, + bool improveStoredNames = false) { var incomingWithId = incoming .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId)) .ToList(); @@ -52,13 +62,37 @@ public static void ResolveNames( var renames = new Dictionary(StringComparer.OrdinalIgnoreCase); + var storedRenames = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Resource resource in incomingWithId) { + // Captured before resolution, which may null the id as part of unifying. + var offeredName = resource.Name; + var offeredId = resource.DiscoveryId; + var resolved = ResolveName(resource, existingById, existingByName, existingByMac, existingByIp); - if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)) + if (resolved.Equals(offeredName, StringComparison.OrdinalIgnoreCase)) continue; - renames[resource.Name] = resolved; + // The stored card is about to lend its name to this one. If that name is a + // placeholder nobody chose and this collector has a real one, the better name + // should win instead — so the card improves as more is learned about it. + if (improveStoredNames + && existingByName.TryGetValue(resolved, out Resource? stored) + && !existingByName.ContainsKey(offeredName) + && CanImproveName(stored, offeredName, offeredId)) { + storedRenames[stored.Name] = offeredName; + + // The name index has to follow, or a later card in this same payload would + // resolve onto a name that no longer exists. + existingByName.Remove(stored.Name); + stored.Name = offeredName; + existingByName[offeredName] = stored; + + continue; + } + + renames[offeredName] = resolved; resource.Name = resolved; } @@ -67,10 +101,111 @@ public static void ResolveNames( RewriteConnections(connections, renames); } + // The stored side has its own references to fix up, and its own connections. The + // incoming side gets the same treatment because a payload may well be a re-push of + // previously exported YAML, which still names the resource the way it was stored. + if (storedRenames.Count > 0) { + RewriteRunsOn(existing, storedRenames); + + // Now that the hosts answer to their new names, the services named after the + // old ones follow. Done here so the renames below travel together. + foreach ((var from, var to) in RenameServicesAfterTheirHost(existing, storedRenames, existingByName)) + storedRenames[from] = to; + + RewriteConnections(storedConnections, storedRenames); + RewriteRunsOn(incoming, storedRenames); + RewriteConnections(connections, storedRenames); + } + PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName); AnchorRunsOnByIp(existing, incoming, existingByName); } + /// + /// Carries a service's name along when the host it runs on stops being a + /// placeholder. + /// + /// A sweep names what it finds on a port after the host it found it on, so a + /// machine it could only call host-1a2b3c4d gets a host-1a2b3c4d-ssh + /// beside it. When the firewall or the hypervisor later supplies the real name + /// the host becomes forgejo and the service is left announcing a machine + /// that no longer exists — the link still resolves, but the name reads as a + /// leftover, which is exactly what it is. + /// + /// + /// Only names this collector's own convention produced are touched: the + /// service must be named for the old host and must actually run on it, must + /// not be a name a person chose, and the name it would take must be free. + /// + /// + private static Dictionary RenameServicesAfterTheirHost( + IReadOnlyList existing, + Dictionary hostRenames, + Dictionary existingByName) { + var renamed = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (Service service in existing.OfType()) { + if (service.IsUserNamed()) + continue; + + foreach ((var oldHost, var newHost) in hostRenames) { + if (!service.Name.StartsWith($"{oldHost}-", StringComparison.OrdinalIgnoreCase)) + continue; + + // runsOn has already been rewritten, so this is the new name by now. A + // service merely named like the host without running on it is a + // coincidence, and coincidences are not renamed. + if (!service.RunsOn.Contains(newHost, StringComparer.OrdinalIgnoreCase)) + continue; + + var candidate = $"{newHost}{service.Name[oldHost.Length..]}"; + + if (existingByName.ContainsKey(candidate)) + break; + + renamed[service.Name] = candidate; + existingByName.Remove(service.Name); + service.Name = candidate; + existingByName[candidate] = service; + + break; + } + } + + return renamed; + } + + /// + /// Whether a stored card's name is a placeholder that this collector can improve + /// on. + /// + /// A discovered card is named from whatever the collector could see, and when + /// that was nothing it falls back to a slug of its own id — host-1a2b3c4d + /// says only that something is there. A later run, or a collector that can see + /// more, often does know the machine's name: a firewall knows what it handed + /// out over DHCP, a hypervisor knows what its guest is called. Keeping the + /// placeholder in that case would mean the inventory never improved. + /// + /// + /// Only ever placeholder to real name, and never over a name a person chose. + /// Real to real is left alone on purpose: two collectors that each know a + /// different name for a machine would otherwise rename it back and forth on + /// every run. + /// + /// + private static bool CanImproveName(Resource stored, string offeredName, string? offeredId) => + !stored.IsUserNamed() + && IsGeneratedName(stored.Name, stored.DiscoveryId) + && !IsGeneratedName(offeredName, offeredId); + + /// + /// Whether a name is the slug-of-its-own-id form + /// falls back to when the collector had nothing better to offer. + /// + private static bool IsGeneratedName(string name, string? discoveryId) => + !string.IsNullOrWhiteSpace(discoveryId) + && name.EndsWith($"-{DiscoveryId.ShortSuffix(discoveryId)}", StringComparison.OrdinalIgnoreCase); + /// /// Gives a service whose runsOn names nothing the host it is plainly /// running on: the system at its own address. @@ -285,14 +420,33 @@ private static bool TryUnifyByMac( /// link-local, so a host on any subnet but the scanner's own yields no MAC and /// its identity falls back to its address. Once a hypervisor reports its guests' /// addresses, that same address is the only thing tying the sweep's find to the - /// guest the inventory already describes in full. + /// guest the inventory describes in full. /// - /// Narrow on purpose. It applies only to a scan-grade card that produced no - /// MAC of its own — one that has a MAC was either already unified above or - /// genuinely disagrees, and a MAC is better evidence than an address. The - /// stored card must be agent-grade and of the same kind, and must be the only - /// one claiming that address: two cards on one address is a conflict or an - /// overlapping subnet, neither of which is evidence of anything. + /// Which of the two arrived first must not matter, so this reads the same in + /// both directions: one scan-grade card that produced no MAC, one agent-grade + /// card, one address, same kind. The agent-grade identity always wins — it is + /// dropped from the incoming card when the incoming card is the scan (so the + /// merge cannot downgrade the stored one) and kept when the incoming card is + /// the agent (so the merge upgrades the stored one). + /// + /// + /// Two scan cards can bridge as well, but only when exactly one of them saw a + /// MAC. A firewall's neighbour table gives an address and the NIC + /// answering at it; a sweep of a subnet it does not sit on gives an address + /// and nothing else. Those are not two stand-ins — one is a direct observation + /// of a specific interface and the other is "something replied" — so the + /// MAC-bearing card wins the identity and the address-only card folds into it. + /// + /// + /// Narrow on purpose. Against an agent-grade card the scan side must have no + /// MAC at all: one that has a MAC either unified through the MAC bridge + /// already or genuinely disagrees, and disagreement is not evidence. Two cards + /// of equal standing never bridge — both agent-grade, both scan-grade with a + /// MAC, or both scan-grade without one — because an address adds nothing when + /// neither side can better it. And the address must be claimed by exactly one + /// stored card, which guarantees: two cards on one + /// address is a conflict or an overlapping subnet, neither of which is + /// evidence of anything. /// /// private static bool TryUnifyByIp( @@ -301,14 +455,6 @@ private static bool TryUnifyByIp( out string unifiedName) { unifiedName = string.Empty; - if (DiscoveryId.Scheme(resource.DiscoveryId) != DiscoveryId.NetworkScheme) - return false; - - // A scan that saw a MAC has better evidence than an address, and the MAC rule - // above has already had its say. - if (MacsOf(resource).Any()) - return false; - if (resource is not SystemResource { Ip: { } ip } || string.IsNullOrWhiteSpace(ip)) return false; @@ -316,13 +462,36 @@ private static bool TryUnifyByIp( || stored.GetType() != resource.GetType()) return false; - // Another scan card at the same address says nothing: both are stand-ins. - if (DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme) + var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme; + var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme; + + var incomingHasMac = MacsOf(resource).Any(); + var storedHasMac = MacsOf(stored).Any(); + + // Which side holds the weaker identity, and so folds into the other. Null means + // the two are of equal standing and the address settles nothing. + bool? incomingIsWeaker = + incomingIsNet != storedIsNet + // Agent grade against scan grade. The scan is the weaker one, but only + // when it saw no MAC of its own — one that did either unified through the + // MAC bridge already or disagrees with the card it would be folded into. + ? (incomingIsNet ? incomingHasMac : storedHasMac) ? null : incomingIsNet + : !incomingIsNet + // Two agent-grade identities. A guest and the machine-id of the OS inside + // it are two cards on purpose; sharing an address does not change that. + ? null + // Two scan-grade cards: a MAC beats an address, and nothing beats nothing. + : incomingHasMac == storedHasMac ? null : !incomingHasMac; + + if (incomingIsWeaker is not { } weaker) return false; - // Same as the MAC bridge: the scan's weaker identity is dropped so the merge - // cannot downgrade the stored one. - resource.DiscoveryId = null; + // Same as the MAC bridge: the weaker identity is dropped so the merge cannot + // downgrade the stronger one. Where the stronger card is the one arriving, its id + // survives and the merge stamps it onto the stored card instead. + if (weaker) + resource.DiscoveryId = null; + unifiedName = stored.Name; return true; diff --git a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs index 747528eb..bb10cbd8 100644 --- a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs +++ b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs @@ -161,7 +161,9 @@ public async Task Merge(string incomingYaml, MergeMode mode) { DiscoveryIdResolver.ResolveNames( resourceCollection.Resources, incomingResources, - incomingRoot.Connections); + incomingRoot.Connections, + resourceCollection.Connections, + true); List merged = ResourceCollectionMerger.Merge( resourceCollection.Resources, diff --git a/RackPeek.Domain/Resources/Resource.cs b/RackPeek.Domain/Resources/Resource.cs index 74b417c3..0d957af8 100644 --- a/RackPeek.Domain/Resources/Resource.cs +++ b/RackPeek.Domain/Resources/Resource.cs @@ -59,6 +59,34 @@ public abstract class Resource { /// public string? DiscoveryId { get; set; } + /// + /// Whether a person chose this name. Set the moment anyone renames the resource, + /// and never unset. + /// + /// A discovered resource starts out named by whatever the collector could see, + /// which is often a placeholder derived from its own id. Later runs — or a + /// better collector — may learn the machine's real name, and should be able to + /// improve on a placeholder. They must never touch a name a person typed. + /// + /// + /// Absent means "not stated". A resource with no was + /// entered by hand and is therefore user-named whatever this says; see + /// . + /// + /// + public bool? UserNamed { get; set; } + + /// + /// Whether this resource's name is a person's choice and so off limits to + /// discovery. True when the flag says so, and true for anything with no + /// discovery id at all — nothing but a person could have written it. + /// + /// + /// A method rather than a property because everything public on a resource is + /// serialised, and this is derived from what is stored rather than part of it. + /// + public bool IsUserNamed() => UserNamed ?? string.IsNullOrWhiteSpace(DiscoveryId); + public string[] Tags { get; set; } = []; public Dictionary Labels { get; set; } = new(); public string? Notes { get; set; } diff --git a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs index 17b636ba..43ab10aa 100644 --- a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs +++ b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs @@ -27,6 +27,10 @@ public async Task ExecuteAsync(string originalName, string newName) { throw new NotFoundException($"Resource '{originalName}' not found."); original.Name = newName; + + // A person has now chosen this name, so discovery must stop improving on it. + original.UserNamed = true; + await repo.UpdateAsync(original); IReadOnlyList allResources = await repo.GetAllOfTypeAsync(); diff --git a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json index 78a1349a..05c463e7 100644 --- a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json @@ -68,6 +68,10 @@ "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk 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}$" }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, "tags": { "type": "array", "items": { diff --git a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json index 78a1349a..05c463e7 100644 --- a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json @@ -68,6 +68,10 @@ "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk 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}$" }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, "tags": { "type": "array", "items": { diff --git a/Tests.Discovery/IpBridgeSymmetryTests.cs b/Tests.Discovery/IpBridgeSymmetryTests.cs new file mode 100644 index 00000000..425f09f8 --- /dev/null +++ b/Tests.Discovery/IpBridgeSymmetryTests.cs @@ -0,0 +1,171 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// The address bridge has to read the same whichever collector ran first. +/// +/// ARP is link-local, so a sweep of a routed subnet gets an address and no MAC, +/// and the card it writes can only be called host-<hash>. A hypervisor +/// knows that guest by name, by specification, and by the address it holds — so +/// the address is the one thing tying the two together. Whether the sweep or the +/// hypervisor got there first is an accident of what the person typed, and must +/// not decide whether the inventory ends up with one card or two. +/// +/// +/// A live run of nine subnets found this the hard way: sweeping before running +/// the Proxmox and firewall collectors produced fourteen machines twice over, +/// where the same commands in the other order produced one card each. +/// +/// +public class IpBridgeSymmetryTests { + private const string _ip = "10.0.50.105"; + private const string _mac = "bc:24:11:00:4a:01"; + + /// What a sweep of a routed subnet can write: an address, and nothing else. + private static SystemResource ScanCard(string? ip = _ip) => new() { + Kind = SystemResource.KindLabel, + Name = "host-2ed3bfd7", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"ip:{ip}"), + Ip = ip + }; + + /// What the hypervisor knows about the same box. + private static SystemResource GuestCard(string name = "forgejo", string? ip = _ip) => new() { + Kind = SystemResource.KindLabel, + Name = name, + DiscoveryId = DiscoveryId.Create(ProxmoxResourceMapper.Scheme, "vmid-105"), + Type = "vm", + Os = "Linux", + Ip = ip, + Labels = { ["macs"] = _mac } + }; + + private static void Resolve(List existing, List incoming) => + DiscoveryIdResolver.ResolveNames(existing, incoming, null, null, true); + + [Fact] + public void The_hypervisor_claims_the_card_a_sweep_left_behind() { + // Sweep ran first. This is the direction that was silently broken. + List existing = [ScanCard()]; + List incoming = [GuestCard()]; + + Resolve(existing, incoming); + + // One card, not two: the guest lands on the stored one. + Assert.Equal(existing[0].Name, incoming[0].Name); + } + + [Fact] + public void The_sweep_lands_on_the_card_the_hypervisor_left_behind() { + // The direction that already worked, kept honest. + List existing = [GuestCard()]; + List incoming = [ScanCard()]; + + Resolve(existing, incoming); + + Assert.Equal("forgejo", incoming[0].Name); + } + + [Fact] + public void The_agent_identity_wins_whichever_way_round_it_arrives() { + // Arriving second, the hypervisor's id survives so the merge can upgrade the + // stored card from a stand-in to a real identity. + List incoming = [GuestCard()]; + Resolve([ScanCard()], incoming); + Assert.StartsWith("rpk1:pve:", incoming[0].DiscoveryId); + + // Arriving second, the sweep's id is dropped so the merge cannot downgrade it. + incoming = [ScanCard()]; + Resolve([GuestCard()], incoming); + Assert.Null(incoming[0].DiscoveryId); + } + + [Fact] + public void The_placeholder_name_gives_way_to_the_real_one() { + // The point of unifying: the machine stops being a hash. This is the upgrade + // that could never fire while the cards stayed separate. + List existing = [ScanCard()]; + + Resolve(existing, [GuestCard()]); + + Assert.Equal("forgejo", existing[0].Name); + } + + [Fact] + public void Two_stand_ins_at_one_address_still_unify_nothing() { + // Neither side knows anything the other does not, so an address proves nothing. + List existing = [ScanCard()]; + SystemResource other = ScanCard(); + other.Name = "host-9999aaaa"; + other.DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "ip:other"); + + Resolve(existing, [other]); + + Assert.Equal("host-9999aaaa", other.Name); + } + + [Fact] + public void Two_agent_grade_cards_at_one_address_still_unify_nothing() { + // A guest and the machine-id of the OS inside it are both agent-grade. They are + // two cards on purpose; an address must not collapse them. + var fromAgent = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "forgejo-inside", + DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"), + Ip = _ip + }; + + Resolve([GuestCard()], [fromAgent]); + + Assert.Equal("forgejo-inside", fromAgent.Name); + } + + [Fact] + public void A_sweep_that_saw_a_mac_is_left_to_the_mac_bridge() { + // A MAC is better evidence than an address, whichever side is holding it. A scan + // card with one either unified on the MAC already or genuinely disagrees. + SystemResource scan = ScanCard(); + scan.Labels["mac"] = "bc:24:11:00:4a:99"; + + Resolve([scan], [GuestCard()]); + + Assert.Equal("host-2ed3bfd7", scan.Name); + } + + [Fact] + public void A_different_kind_of_thing_at_the_same_address_is_not_the_same_thing() { + // The box documented as hardware and the OS a sweep saw on it are separate cards + // on purpose — the merge replaces on a type change, so unifying would delete one. + var server = new Server { + Kind = Server.KindLabel, + Name = "kepler", + DiscoveryId = DiscoveryId.Create(ProxmoxResourceMapper.Scheme, "node-kepler") + }; + + List existing = [ScanCard()]; + + Resolve(existing, [server]); + + Assert.Equal("kepler", server.Name); + Assert.Equal("host-2ed3bfd7", existing[0].Name); + } + + [Fact] + public void An_address_two_stored_cards_claim_unifies_nothing() { + // Overlapping subnets, or a stale card. Ambiguity is not evidence. + SystemResource first = ScanCard(); + SystemResource second = ScanCard(); + second.Name = "host-bbbbcccc"; + second.DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "ip:dup"); + + List incoming = [GuestCard()]; + + Resolve([first, second], incoming); + + Assert.Equal("forgejo", incoming[0].Name); + } +} diff --git a/Tests.Discovery/PlaceholderNameUpgradeTests.cs b/Tests.Discovery/PlaceholderNameUpgradeTests.cs new file mode 100644 index 00000000..423257f9 --- /dev/null +++ b/Tests.Discovery/PlaceholderNameUpgradeTests.cs @@ -0,0 +1,321 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// A placeholder name is a stand-in, and a later collector that knows the machine's +/// real name should be allowed to replace it. +/// +/// A sweep of a routed subnet can see that something answers and nothing else, so +/// it writes host-1a2b3c4d. Run the firewall collector and that same machine has +/// a DHCP name; ask the hypervisor and it has a guest name. Without this the +/// inventory would keep the hash for ever and the better name would be discarded +/// on every run. +/// +/// +/// The line it must not cross is a name a person typed. That is what +/// records, and once set it is never unset. +/// +/// +public class PlaceholderNameUpgradeTests { + private const string _mac = "bc:24:11:00:3a:01"; + + private static string PlaceholderFor(string discoveryId) => + $"host-{DiscoveryId.ShortSuffix(discoveryId)}"; + + /// A sweep's card: an address, a MAC, and a name that is just its own hash. + private static SystemResource Stored(string? name = null, bool? userNamed = null) { + var id = DiscoveryId.Create(DiscoveryId.NetworkScheme, _mac); + + return new SystemResource { + Kind = SystemResource.KindLabel, + Name = name ?? PlaceholderFor(id), + DiscoveryId = id, + UserNamed = userNamed, + Ip = "192.0.2.50", + Labels = { ["mac"] = _mac } + }; + } + + /// The firewall's view of the same box: same identity, but it knows the name. + private static SystemResource Incoming(string name = "forgejo") => new() { + Kind = SystemResource.KindLabel, + Name = name, + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, _mac), + Ip = "192.0.2.50", + Labels = { ["mac"] = _mac } + }; + + private static List Resolve( + Resource stored, + Resource incoming, + IReadOnlyList? storedConnections = null, + params Resource[] alsoStored) { + List existing = [stored, .. alsoStored]; + + DiscoveryIdResolver.ResolveNames( + existing, + [incoming], + null, + storedConnections, + true); + + return existing; + } + + [Fact] + public void A_real_name_replaces_a_placeholder_nobody_chose() { + SystemResource stored = Stored(); + + Resolve(stored, Incoming()); + + Assert.Equal("forgejo", stored.Name); + } + + [Fact] + public void A_name_a_person_typed_is_never_touched() { + SystemResource stored = Stored("the-blue-one", true); + + Resolve(stored, Incoming()); + + Assert.Equal("the-blue-one", stored.Name); + } + + [Fact] + public void A_placeholder_a_person_chose_to_keep_is_still_theirs() { + // Renaming a card back to its hash is a strange thing to do, but it is a choice, + // and the flag is what records that rather than the shape of the name. + SystemResource stored = Stored(userNamed: true); + var before = stored.Name; + + Resolve(stored, Incoming()); + + Assert.Equal(before, stored.Name); + } + + [Fact] + public void A_placeholder_never_replaces_a_real_name() { + // The reverse direction: the sweep runs after the firewall and knows less. The + // upgrade is one-way or the card would flip names on alternate runs. + SystemResource stored = Stored("forgejo"); + var placeholder = PlaceholderFor(stored.DiscoveryId!); + + Resolve(stored, Incoming(placeholder)); + + Assert.Equal("forgejo", stored.Name); + } + + [Fact] + public void One_real_name_does_not_replace_another() { + // Two collectors that each know a different name for one box would otherwise + // rename it back and forth every run. First real name wins and stays. + SystemResource stored = Stored("forgejo"); + + Resolve(stored, Incoming("git-server")); + + Assert.Equal("forgejo", stored.Name); + } + + [Fact] + public void A_hand_written_resource_counts_as_user_named_without_the_flag() { + // Nothing but a person could have written a resource with no discovery id, so + // the absent flag must not be read as permission. + var stored = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "forgejo", + Ip = "192.0.2.50" + }; + + Assert.True(stored.IsUserNamed()); + Assert.False(Stored().IsUserNamed()); + } + + [Fact] + public void Everything_pointing_at_the_old_name_follows_it() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var service = new Service { + Kind = Service.KindLabel, + Name = "forgejo-https", + RunsOn = [placeholder] + }; + + Resolve(stored, Incoming(), null, service); + + Assert.Equal(["forgejo"], service.RunsOn); + } + + [Fact] + public void Stored_connections_follow_it_too() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var connection = new Connection { + A = new PortReference { Resource = placeholder }, + B = new PortReference { Resource = "core-switch", PortIndex = 12 } + }; + + Resolve(stored, Incoming(), [connection]); + + Assert.Equal("forgejo", connection.A.Resource); + } + + [Fact] + public void A_re_push_of_exported_yaml_follows_the_rename_too() { + // The payload still calls the machine by the name it was stored under, because + // that is what was exported. Its links have to land on the upgraded card. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var arriving = new Service { + Kind = Service.KindLabel, + Name = "forgejo-https", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:443"), + RunsOn = [placeholder] + }; + + DiscoveryIdResolver.ResolveNames([stored], [Incoming(), arriving], null, null, true); + + Assert.Equal("forgejo", stored.Name); + Assert.Equal(["forgejo"], arriving.RunsOn); + } + + [Fact] + public void A_service_named_after_the_host_follows_it() { + // The sweep names what it finds on a port after the host it found it on, so the + // leftover reads host-1a2b3c4d-ssh running on forgejo until this carries it over. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + RunsOn = [placeholder] + }; + + Resolve(stored, Incoming(), null, ssh); + + Assert.Equal("forgejo-ssh", ssh.Name); + Assert.Equal(["forgejo"], ssh.RunsOn); + } + + [Fact] + public void A_service_a_person_named_keeps_its_name() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + UserNamed = true, + RunsOn = [placeholder] + }; + + Resolve(stored, Incoming(), null, ssh); + + Assert.Equal($"{placeholder}-ssh", ssh.Name); + } + + [Fact] + public void A_service_that_only_looks_like_the_host_is_not_renamed() { + // Named for the old host but running somewhere else entirely: a coincidence, + // and coincidences are not evidence of anything. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var stray = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "elsewhere:22"), + RunsOn = ["some-other-box"] + }; + + Resolve(stored, Incoming(), null, stray); + + Assert.Equal($"{placeholder}-ssh", stray.Name); + } + + [Fact] + public void A_service_whose_new_name_is_taken_keeps_the_old_one() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + RunsOn = [placeholder] + }; + + var occupier = new Service { + Kind = Service.KindLabel, + Name = "forgejo-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "other:22"), + RunsOn = ["some-other-box"] + }; + + Resolve(stored, Incoming(), null, ssh, occupier); + + Assert.Equal($"{placeholder}-ssh", ssh.Name); + } + + [Fact] + public void A_connection_to_a_renamed_service_follows_it() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + RunsOn = [placeholder] + }; + + var connection = new Connection { + A = new PortReference { Resource = $"{placeholder}-ssh" }, + B = new PortReference { Resource = "core-switch", PortIndex = 3 } + }; + + DiscoveryIdResolver.ResolveNames( + [stored, ssh], [Incoming()], null, [connection], true); + + Assert.Equal("forgejo-ssh", connection.A.Resource); + } + + [Fact] + public void A_name_already_in_use_is_left_alone() { + // Renaming onto an occupied name would collide two unrelated resources in the + // merge, which keys on name. Keeping the placeholder is the safe outcome. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var other = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "forgejo", + Ip = "192.0.2.99" + }; + + Resolve(stored, Incoming(), null, other); + + Assert.Equal(placeholder, stored.Name); + } + + [Fact] + public void A_dry_run_leaves_the_stored_name_where_it_was() { + // The upgrade rewrites the inventory, so a caller that is only reporting what + // would happen must not ask for it — the default is off for exactly this reason. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + DiscoveryIdResolver.ResolveNames([stored], [Incoming()]); + + Assert.Equal(placeholder, stored.Name); + } +} diff --git a/Tests.Discovery/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs index d799b050..5008c51d 100644 --- a/Tests.Discovery/RunsOnByIpTests.cs +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -212,16 +212,63 @@ public void Two_stored_systems_at_one_address_unify_nothing() { } [Fact] - public void One_sweep_find_never_unifies_with_another() { - // A card the sweep itself produced is a stand-in for something nobody has - // described. Two stand-ins at one address say nothing about each other, so the - // address rule stays out of it — here the stored one was identified by MAC on - // its own segment, and the incoming one only by address. + public void Two_sweep_finds_with_nothing_between_them_are_the_same_card_already() { + // Neither card is more than "something replied at this address", and an address + // is exactly what seeds their identity when no MAC was seen — so they carry the + // same id and the address rule never gets a say. The stored name wins, as it + // does for any re-run. + List existing = [Scanned("host-99999999", "192.0.2.105")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + Assert.Equal(existing[0].DiscoveryId, incoming[0].DiscoveryId); + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-99999999", incoming.OfType().Single().Name); + } + + [Fact] + public void A_sweep_find_folds_into_one_that_saw_the_mac() { + // Not two stand-ins: the stored card names the NIC answering at that address — + // a firewall's neighbour table does this for every subnet it routes — where the + // incoming one only knows something replied. The MAC is the better identity, so + // the address-only card folds into it rather than becoming a second machine. List existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; DiscoveryIdResolver.ResolveNames(existing, incoming); + SystemResource card = incoming.OfType().Single(); + Assert.Equal("host-99999999", card.Name); + + // The weaker address-seeded identity is dropped so the merge cannot downgrade + // the MAC-seeded one it is landing on. + Assert.Null(card.DiscoveryId); + } + + [Fact] + public void The_mac_wins_arriving_second_too() { + // Sweep the routed subnet first, ask the firewall after: same two facts, same + // one card. Here the MAC-seeded identity is the one that survives. + List existing = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + List incoming = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + SystemResource card = incoming.OfType().Single(); + Assert.Equal("host-1a2b3c4d", card.Name); + Assert.NotNull(card.DiscoveryId); + } + + [Fact] + public void Two_sweep_finds_that_each_saw_a_mac_never_unify() { + // Both name a NIC, and they name different ones. The MAC bridge has already had + // its say; sharing an address now is a conflict or an overlapping subnet. + List existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105", "bc:24:11:00:1a:0a")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); } } diff --git a/schemas/v4/schema.v4.json b/schemas/v4/schema.v4.json index 78a1349a..05c463e7 100644 --- a/schemas/v4/schema.v4.json +++ b/schemas/v4/schema.v4.json @@ -68,6 +68,10 @@ "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk 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}$" }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, "tags": { "type": "array", "items": { From bed8c3ab95b04df96388bfbd7dad095411e74428 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:59 +0100 Subject: [PATCH 25/29] Only offer a service link a browser can actually follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, one cause: three places each decided for themselves what a service's URL was, and all three were wrong. The card derived every link as http:// regardless of the port, so SSH on 22 rendered as http://10.0.0.5:22/ — a link that looks real, invites a click and cannot load. It was reading Network.Protocol as though it named a scheme, but discovery writes the transport there (TCP), which says nothing about what rides on top of it. The dependency trees had it worse: they fed NetworkString() into an href, and that was display text — "Ip: 10.0.0.5:3000", prefix and trailing space included — so the link was dead on arrival. Both rules now live in one place. A scheme is only inferred where the convention is genuinely a web one; ssh, smb, mqtt, dns and anything uncurated get no link at all, because a dead link is worse than none. A URL someone typed by hand still always wins, and an explicit http or https in the protocol field is still believed, which is the only thing that can answer on an uncurated port. Co-Authored-By: Claude Opus 5 (1M context) --- RackPeek.Domain/Resources/Services/Service.cs | 36 +++---- .../Resources/Services/ServiceEndpoint.cs | 78 +++++++++++++++ .../HardwareDependencyTreeComponent.razor | 41 +++++--- .../Services/ServiceCardComponent.razor | 25 +---- .../SystemDependencyTreeComponent.razor | 41 +++++--- Tests.Discovery/ServiceEndpointTests.cs | 96 +++++++++++++++++++ 6 files changed, 253 insertions(+), 64 deletions(-) create mode 100644 RackPeek.Domain/Resources/Services/ServiceEndpoint.cs create mode 100644 Tests.Discovery/ServiceEndpointTests.cs diff --git a/RackPeek.Domain/Resources/Services/Service.cs b/RackPeek.Domain/Resources/Services/Service.cs index ce3401cd..fac7b5bf 100644 --- a/RackPeek.Domain/Resources/Services/Service.cs +++ b/RackPeek.Domain/Resources/Services/Service.cs @@ -1,30 +1,24 @@ -using System.Text; - namespace RackPeek.Domain.Resources.Services; public class Service : Resource { public const string KindLabel = "Service"; public Network? Network { get; set; } - public string NetworkString() { - if (Network == null) return string.Empty; - - if (!string.IsNullOrEmpty(Network.Url)) return Network.Url; - - var stringBuilder = new StringBuilder(); - if (!string.IsNullOrEmpty(Network.Ip)) { - stringBuilder.Append("Ip: "); - stringBuilder.Append(Network.Ip); - if (Network.Port.HasValue) { - stringBuilder.Append(':'); - stringBuilder.Append(Network.Port.Value); - } - - stringBuilder.Append(' '); - } - - return stringBuilder.ToString(); - } + /// + /// Where this service answers, for showing to a person. Display text, never a + /// link — is the link. + /// + public string NetworkString() => + !string.IsNullOrEmpty(Network?.Url) + ? Network.Url + : ServiceEndpoint.Describe(Network); + + /// + /// A link a browser can follow, or null when this port serves something a browser + /// cannot open. + /// + public string? BrowsableUrl(string? fallbackIp = null) => + ServiceEndpoint.BrowsableUrl(Network, fallbackIp); } public class Network { diff --git a/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs new file mode 100644 index 00000000..a59140ce --- /dev/null +++ b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs @@ -0,0 +1,78 @@ +namespace RackPeek.Domain.Resources.Services; + +/// +/// Where a service answers, and whether a browser can do anything with it. +/// +public static class ServiceEndpoint { + /// + /// Ports a browser opens over TLS. Proxmox serves its management UI on 8006 and + /// redirects plain HTTP, so guessing http there costs the user a round trip. + /// + private static readonly HashSet _https = [443, 8006, 8443, 9443]; + + /// Ports a browser opens in the clear. + private static readonly HashSet _http = [ + 80, 631, 3000, 5000, 7860, 8000, 8080, 8096, 8123, 9000, 9090, 11434, 32400 + ]; + + /// + /// The address a service answers on — 10.0.50.105:3000 — for showing to a + /// person. Empty when there is no address to show. This is display text and never + /// a link: see for that. + /// + public static string Describe(Network? network) { + if (string.IsNullOrWhiteSpace(network?.Ip)) + return string.Empty; + + return network.Port.HasValue + ? $"{network.Ip}:{network.Port.Value}" + : network.Ip; + } + + /// + /// A link a browser can actually follow, or null when it cannot. + /// + /// A URL somebody typed always wins. Failing that the scheme has to be + /// inferred, and a port number is a convention rather than a promise — so this + /// answers only where the convention is a web one. SSH, SMB, MQTT, DNS and + /// anything uncurated get no link at all, which is more useful than an + /// http:// that cannot load: a dead link invites a click and wastes it. + /// + /// + /// protocol is only consulted when it names a scheme. Discovery writes + /// the transport there — TCP — which says nothing about what rides on + /// top of it. + /// + /// + public static string? BrowsableUrl(Network? network, string? fallbackIp = null) { + if (network == null) + return null; + + if (!string.IsNullOrWhiteSpace(network.Url)) + return network.Url; + + var ip = !string.IsNullOrWhiteSpace(network.Ip) ? network.Ip : fallbackIp; + + if (string.IsNullOrWhiteSpace(ip) || network.Port is not { } port) + return null; + + var scheme = SchemeFor(port, network.Protocol); + + if (scheme == null) + return null; + + return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString(); + } + + private static string? SchemeFor(int port, string? protocol) { + var stated = protocol?.Trim().ToLowerInvariant(); + + if (stated is "http" or "https") + return stated; + + if (_https.Contains(port)) + return "https"; + + return _http.Contains(port) ? "http" : null; + } +} diff --git a/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor b/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor index fc84725f..34476716 100644 --- a/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor +++ b/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor @@ -55,6 +55,7 @@ else case Service service: var endpoint = service.NetworkString(); + var browsable = service.BrowsableUrl(); @@ -69,13 +70,34 @@ else @if (!string.IsNullOrWhiteSpace(endpoint)) { - - - @endpoint - + + @if (!string.IsNullOrWhiteSpace(browsable)) + + { + + + + @endpoint + + + + } + + else + + { + + @endpoint + + } }
@@ -107,10 +129,7 @@ else { var endpoint = service.NetworkString(); - if (string.IsNullOrWhiteSpace(endpoint)) - return null; - - return endpoint; + return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint; } } diff --git a/Shared.Rcl/Services/ServiceCardComponent.razor b/Shared.Rcl/Services/ServiceCardComponent.razor index 502fa60e..871abd0b 100644 --- a/Shared.Rcl/Services/ServiceCardComponent.razor +++ b/Shared.Rcl/Services/ServiceCardComponent.razor @@ -461,27 +461,10 @@ Nav.NavigateTo($"resources/services/{Uri.EscapeDataString(newName)}"); } - private string? GetBrowsableHref() - { - var ip = Service.Network?.Ip ?? EffectiveIp; - var port = Service.Network?.Port; - - if (string.IsNullOrWhiteSpace(ip) || port is null) - return null; - - var proto = Service.Network?.Protocol?.Trim().ToLowerInvariant(); - - var scheme = proto switch - { - "https" => "https", - "http" => "http", - _ => "http" - }; - - // Build a correct absolute URL - var ub = new UriBuilder(scheme, ip) { Port = port.Value }; - return ub.Uri.ToString(); - } + // A port is a convention, not a promise: ssh on 22 is not browsable and an http:// + // link to it only wastes a click. The domain owns that judgement so this page, the + // dependency trees and anything else agree on it. + private string? GetBrowsableHref() => Service.BrowsableUrl(EffectiveIp); } @code diff --git a/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor b/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor index c1144dc2..d70e72cc 100644 --- a/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor +++ b/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor @@ -18,6 +18,7 @@ else { case Service service: var endpoint = service.NetworkString(); + var browsable = service.BrowsableUrl(); @@ -32,13 +33,34 @@ else @if (!string.IsNullOrWhiteSpace(endpoint)) { - - - @endpoint - + + @if (!string.IsNullOrWhiteSpace(browsable)) + + { + + + + @endpoint + + + + } + + else + + { + + @endpoint + + } } @@ -83,10 +105,7 @@ else { var endpoint = service.NetworkString(); - if (string.IsNullOrWhiteSpace(endpoint)) - return null; - - return endpoint; + return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint; } } \ No newline at end of file diff --git a/Tests.Discovery/ServiceEndpointTests.cs b/Tests.Discovery/ServiceEndpointTests.cs new file mode 100644 index 00000000..f0c08457 --- /dev/null +++ b/Tests.Discovery/ServiceEndpointTests.cs @@ -0,0 +1,96 @@ +using RackPeek.Domain.Resources.Services; + +namespace Tests.Discovery; + +/// +/// Where a service answers, and whether a browser can do anything with it. +/// +/// Both of these were wrong in the UI. The endpoint was rendered as +/// Ip: 10.0.50.105:3000 and then used as a link target, so clicking it did +/// nothing. And the scheme fell back to http for every port, so an SSH +/// service showed http://10.0.50.105:22/ — a link that looks real, invites +/// a click and cannot load. +/// +/// +public class ServiceEndpointTests { + private static Network Net(int? port, string? protocol = "TCP", string? ip = "10.0.50.105", string? url = null) => + new() { Ip = ip, Port = port, Protocol = protocol, Url = url }; + + private static Service Svc(Network? network) => + new() { Kind = Service.KindLabel, Name = "svc", Network = network }; + + [Fact] + public void An_endpoint_reads_as_an_address_and_nothing_else() => + // It goes on screen next to the service name; a label belongs in the markup. + Assert.Equal("10.0.50.105:3000", Svc(Net(3000)).NetworkString()); + + [Fact] + public void An_endpoint_with_no_port_is_just_the_address() => + Assert.Equal("10.0.50.105", Svc(Net(null)).NetworkString()); + + [Fact] + public void A_service_with_no_network_has_no_endpoint() => + Assert.Equal(string.Empty, Svc(null).NetworkString()); + + [Theory] + [InlineData(22)] // ssh + [InlineData(445)] // smb + [InlineData(1883)] // mqtt + [InlineData(53)] // dns + [InlineData(3306)] // mysql + [InlineData(9987)] // nothing curated + public void A_port_a_browser_cannot_open_gets_no_link(int port) => + Assert.Null(Svc(Net(port)).BrowsableUrl()); + + [Theory] + [InlineData(80, "http://10.0.50.105/")] + [InlineData(3000, "http://10.0.50.105:3000/")] + [InlineData(8123, "http://10.0.50.105:8123/")] + [InlineData(9000, "http://10.0.50.105:9000/")] + public void A_web_port_gets_a_link(int port, string expected) => + Assert.Equal(expected, Svc(Net(port)).BrowsableUrl()); + + [Theory] + [InlineData(443, "https://10.0.50.105/")] + [InlineData(8006, "https://10.0.50.105:8006/")] + [InlineData(8443, "https://10.0.50.105:8443/")] + public void A_tls_port_gets_an_https_link(int port, string expected) => + // Proxmox on 8006 redirects plain HTTP, so guessing http costs a round trip. + Assert.Equal(expected, Svc(Net(port)).BrowsableUrl()); + + [Fact] + public void The_transport_is_not_mistaken_for_a_scheme() { + // Discovery writes "TCP" into protocol, which says nothing about what rides on + // top of it. Reading that as a scheme is what produced http:// on port 22. + Assert.Null(Svc(Net(22, "TCP")).BrowsableUrl()); + Assert.Equal("http://10.0.50.105:3000/", Svc(Net(3000, "TCP")).BrowsableUrl()); + } + + [Theory] + [InlineData("https", "https://10.0.50.105:9999/")] + [InlineData("HTTP", "http://10.0.50.105:9999/")] + public void A_protocol_that_names_a_scheme_is_believed(string protocol, string expected) => + // On an uncurated port this is the only thing that can answer. + Assert.Equal(expected, Svc(Net(9999, protocol)).BrowsableUrl()); + + [Fact] + public void A_url_someone_typed_always_wins() { + Service service = Svc(Net(22, "TCP", url: "https://git.example.com/")); + + Assert.Equal("https://git.example.com/", service.BrowsableUrl()); + Assert.Equal("https://git.example.com/", service.NetworkString()); + } + + [Fact] + public void A_service_with_no_address_of_its_own_can_borrow_its_hosts() { + // The card resolves the host's address when the service carries none. + Service service = Svc(Net(8080, ip: null)); + + Assert.Null(service.BrowsableUrl()); + Assert.Equal("http://10.0.20.7:8080/", service.BrowsableUrl("10.0.20.7")); + } + + [Fact] + public void A_service_with_no_port_is_not_guessed_at() => + Assert.Null(Svc(Net(null)).BrowsableUrl()); +} From fbd0b2b511b38841441990f612409125c80f0b77 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:59 +0100 Subject: [PATCH 26/29] Document the discovery changes, and ignore the local triage scratch file Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + .../wwwroot/raw_docs/discovery-guide.md | 121 ++++++++++++------ 2 files changed, 83 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index d5a68784..0356049b 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,4 @@ FodyWeavers.xsd # macOS .DS_Store +ISSUE_TRIAGE.md diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 12ef579d..c0fb12fc 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -61,13 +61,41 @@ 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. + updates `storage-01`. It will never rename a resource you named — see + [Names get better until you choose one](#names-get-better-until-you-choose-one). * **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. +### Names get better until you choose one + +A collector names a card from whatever it could see, and when it could see nothing the +name falls back to a slug of the card's own id — `host-1a2b3c4d` says only that something +is there. A later run, or a collector that can see more, often *does* know the machine's +name: a firewall knows what it handed out over DHCP, a hypervisor knows what its guest is +called. So a placeholder gets replaced by a real name when one turns up, and the +inventory improves as you point more collectors at it. + +The moment you rename a resource yourself, that stops. The rename records a `userNamed` +flag, and nothing in discovery touches the name again: + +```yaml +- kind: System + name: the-blue-one + userNamed: true + discoveryId: rpk1:net:9f2c1a7e40b3d582 +``` + +Two rules keep this from turning into churn. The upgrade only ever goes from a +placeholder to a real name, never the other way: a sweep that runs after the firewall +knows *less*, and must not undo it. And one real name never replaces another — two +collectors that each knew a different name for one box would otherwise rename it back and +forth on every run, so the first real name wins and stays. A resource with no +`discoveryId` at all is user-named whatever the flag says; nothing but a person could +have written it. + > **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 @@ -381,9 +409,13 @@ holds the **Diagnostics: ARP Table** privilege. Read-only is enough; nothing her ### What it records, and what it leaves out -One **System** per machine, carrying its address, its MAC, the vendor that MAC belongs -to, and a `segment` label naming the firewall leg it answered on — which is the closest -thing to "which VLAN is this on" that the firewall can tell you. +One **System** per machine, carrying its address, its MAC, and the vendor that MAC +belongs to. + +What it does *not* record is which of the firewall's legs the machine answered on. That +describes the firewall's wiring rather than the machine, and it changes the moment +anything is re-cabled or a VLAN is renamed — where the address and the MAC are facts +about the machine itself. Left out on purpose: @@ -408,10 +440,10 @@ a host a sweep found are **one card**, whichever collector ran first, with no sp case anywhere to say so. Run both and the firewall fills in the identity a sweep of a routed subnet could never get. -One wrinkle worth knowing: names are yours, so discovery never renames a resource that -already exists — including one a sweep named `host-` before the firewall could -offer something better. Running the firewall collector first, or on a fresh inventory, -gets you the good names. +Order does not matter for names either. A card a sweep could only call `host-` +takes the name the firewall handed out over DHCP as soon as you run this collector — see +[Names get better until you choose one](#names-get-better-until-you-choose-one). A name +you chose yourself is never touched. --- @@ -469,55 +501,65 @@ address — and `--no-identify` turns it off for a pure liveness sweep. The liveness sweep stops at the first answer, because it only needs to know the host exists. Once a host has answered, it is checked against a wider list — cameras (554), -MQTT brokers (1883), Home Assistant (8123), Portainer (9000), Plex, Postgres and so on — -and whatever is open lands in an `open-ports` label. - -These are recorded as observations, not conclusions. "554 is open" is a fact; "this is a -camera" is an inference, and the person reading the card is far better placed to draw it -than the scanner is. A port number is a convention rather than a guarantee, so RackPeek -will not name a service from one — but a port **is** the best possible target for the -banner probes above, which is how `9000` became "Portainer" and `8123` became -"Home Assistant". +MQTT brokers (1883), Home Assistant (8123), Portainer (9000), Plex, Postgres and so on. -The list is what answered out of the ports probed, not a full port scan. `--ports` -widens the liveness set if you want more. +### Every open port becomes a Service -### Applications become Services - -When a port answers HTTP with something that names itself, that is a fact about what the -host **runs**, not about what the host **is** — so it becomes a Service hanging off the -host's card rather than renaming it: +Something listening on a port is a service, and a service is a resource in its own right +rather than a note on the host. Each open port therefore gets its own **Service** card, +named for the host and what that port serves: ```yaml - kind: System ip: 192.0.2.204 - name: host-1a2b3c4d + name: nebula labels: - open-ports: 1883,8123 identified-by: http:8123 Home Assistant +- kind: Service + name: nebula-ssh + network: { ip: 192.0.2.204, port: 22, protocol: TCP } + runsOn: [nebula] - kind: Service name: home-assistant network: { ip: 192.0.2.204, port: 8123, protocol: TCP } - runsOn: [host-1a2b3c4d] + runsOn: [nebula] ``` -A host may run several, so each identified port gets its own Service with its own stable -id — a rescan updates them rather than duplicating them. This is why a page title does -not name the machine: picking whichever application answered first would be arbitrary, -and a certificate is the only answer that is a claim about the machine itself. +This replaces the old `open-ports` label, which was a comma-separated string nothing +could link to, filter on, or hang a note from. -An appliance's own management page is not a service running on it, so a title matching -the host's own name is skipped — a firewall already called `opnsense` does not also need -a service called `opnsense`. +What the service said about itself wins over what its port number implies, because a +port is a convention and an answer is evidence: `8123` is Home Assistant by convention, +but a page that says "Forgejo" is the machine telling you. Where nothing answered, the +name falls back to the host plus the port's usual service — `nebula-ssh`, `nebula-https`, +`nebula-smb` — and an unrecognised port keeps its number as `nebula-tcp-9987`, which is +an honest record rather than a guess. -### Vendor from the MAC +One case is deliberately special: an appliance whose management page just says its own +name back. A firewall already called `opnsense` gains an `opnsense-https`, not a second +card called `opnsense`. -Where the sweep has a MAC, the card also gets a `vendor` label naming the organisation +Each port keeps its own stable id, so a rescan updates these cards rather than +duplicating them. A port is also the best possible target for the banner probes above, +which is how `9000` became "Portainer". Note that this is what answered out of the ports +probed, not a full port scan; `--ports` widens the set. + +This is also why a page title does not name the *machine*: picking whichever application +answered first would be arbitrary, and a certificate is the only answer that is a claim +about the machine itself. + +### NIC vendor from the MAC + +Where the sweep has a MAC, the card also gets a `nic-vendor` label naming the organisation that OUI belongs to — `Espressif`, `Ubiquiti`, `Raspberry Pi`, `Proxmox`. For a silent device with no PTR record and no web UI, this is often the only thing that distinguishes it from an address. -Two details worth knowing. A hypervisor's own prefix wins over the +It is named for what it actually is. The OUI identifies whoever owns the network +interface, which is not the same claim as who made the machine: a Proxmox guest's virtual +NIC reads `Proxmox` while the box underneath it is a Dell. + +Two further details worth knowing. A hypervisor's own prefix wins over the locally-administered bit, so a KVM guest reads as `QEMU/KVM` rather than anonymous. And an address a device made up for itself — modern phones and laptops randomise per network for privacy — is reported as `Randomised (locally administered)`, because the OUI half of @@ -569,8 +611,9 @@ the same card**, whichever ran first: - Agent first: a later sweep recognises the box and just refreshes its address — never touching the identity or anything you or the agent wrote. -The card keeps whatever name it already had (names are always user-owned), so a -scan-first card keeps its generated `host-…` name until you rename it once. A MAC that +The card keeps whatever name it already had, except that a generated `host-…` name gives +way to a real one when a collector turns up knowing it, and a name you chose never does. +A MAC that two stored cards both claim unifies nothing — ambiguity always falls back to separate cards — and agent-grade identities never unify with each other on a MAC alone (cloned VMs can share one; that is what machine-ids are for). The machine running the sweep From 1adc1685ba9735390e4e2125daa46af3d5911a91 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:15:15 +0100 Subject: [PATCH 27/29] Stop the Git service test racing every other test that registers services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering RackPeek's services writes process-wide statics as a side effect, RpkConstants.HasGitServices among them. That is harmless in production, where a process registers once, and a race in a test run, where dozens of classes register with different configuration. Three groups were mutating it from three different xUnit collections — the Git tests, the YAML CLI host, and the API tests — and collections run in parallel with each other, so whichever ran last won. Git_Token_Set_Registers_... asserted the flag was true and read whatever an API test had just set, failing roughly one full-suite run in three while passing in isolation every time. They now share one collection with parallelisation off, named for the shared state rather than for the YAML CLI, because membership is decided by "does this touch the statics" rather than by what the test nominally exercises. Six consecutive full runs green, against a baseline that failed one run in two. Costs about three seconds on a fifteen-second suite. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + Tests/Api/InventoryEndpointStartupTests.cs | 1 + Tests/Api/InventoryEndpointTests.cs | 1 + .../Api/InventoryEndpointUnconfiguredTests.cs | 1 + .../AccessPointCommandTests.cs | 2 +- .../AccessPointTests/AccessPointErrorTests.cs | 2 +- .../AccessPointWorkflowTests.cs | 2 +- Tests/EndToEnd/CliCommandsWorkflowTests.cs | 2 +- .../ConnectionRemoveWorkflowTests.cs | 2 +- .../ConnectionWorkflowTests.cs | 2 +- .../PortConnectionWorkflowTests.cs | 2 +- .../ConnectionTests/RenameResourceTests.cs | 2 +- Tests/EndToEnd/CorruptConfigTests.cs | 2 +- Tests/EndToEnd/DeleteResourceTests.cs | 2 +- .../DesktopTests/DesktopCommandTests.cs | 2 +- .../DesktopTests/DesktopErrorTests.cs | 2 +- .../DesktopTests/DesktopWorkflowTests.cs | 2 +- .../DiscoverNetworkValidationTests.cs | 2 +- .../AnsibleInventoryWorkflowTests.cs | 2 +- .../ExporterTests/HostsExportWorkflowTests.cs | 2 +- .../ExporterTests/SshExportWorkflowTests.cs | 2 +- .../FirewallTests/FirewallCommandTests.cs | 2 +- .../FirewallTests/FirewallErrorTests.cs | 2 +- .../FirewallTests/FirewallWorkflowTests.cs | 2 +- .../EndToEnd/GenerateAnsibleInventoryTests.cs | 2 +- Tests/EndToEnd/Graph/GraphTopologyCliTests.cs | 2 +- Tests/EndToEnd/Infra/YamlCliTestCollection.cs | 19 ++++++++++++++++++- Tests/EndToEnd/Labels/LabelsWorkflowTests.cs | 2 +- .../LaptopTests/LaptopCommandTests.cs | 2 +- .../EndToEnd/LaptopTests/LaptopErrorTests.cs | 2 +- .../LaptopTests/LaptopNicWorkflowTests.cs | 2 +- .../LaptopTests/LaptopWorkflowTests.cs | 2 +- .../EndToEnd/OtherTests/OtherCommandTests.cs | 2 +- Tests/EndToEnd/OtherTests/OtherErrorTests.cs | 2 +- .../OtherTests/OtherPortWorkflowTests.cs | 2 +- .../EndToEnd/OtherTests/OtherWorkflowTests.cs | 2 +- .../RouterTests/RouterCommandTests.cs | 2 +- .../EndToEnd/RouterTests/RouterErrorTests.cs | 2 +- .../RouterTests/RouterWorkflowTests.cs | 2 +- .../ServerTests/ServerCommandTests.cs | 2 +- .../EndToEnd/ServerTests/ServerErrorTests.cs | 2 +- .../ServerTests/ServerWorkflowTests.cs | 2 +- .../ServiceTests/ServiceCommandTests.cs | 2 +- .../ServiceTests/ServiceErrorTests.cs | 2 +- .../ServiceTests/ServiceWorkflowTests.cs | 2 +- .../SwitchTests/SwitchCommandTests.cs | 2 +- .../EndToEnd/SwitchTests/SwitchErrorTests.cs | 2 +- .../SwitchTests/SwitchWorkflowTests.cs | 2 +- .../SystemTests/SystemCommandTests.cs | 2 +- .../EndToEnd/SystemTests/SystemErrorTests.cs | 2 +- .../SystemTests/SystemWorkflowTests.cs | 2 +- Tests/EndToEnd/Tags/TagsWorkflowTests.cs | 2 +- Tests/EndToEnd/UpsTests/UpsCommandTests.cs | 2 +- Tests/EndToEnd/UpsTests/UpsErrorTest.cs | 2 +- .../EndToEnd/UpsTests/UpsPortWorkflowTests.cs | 2 +- Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs | 2 +- Tests/Git/GitConfigurationTests.cs | 2 +- 57 files changed, 74 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index d5a68784..0356049b 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,4 @@ FodyWeavers.xsd # macOS .DS_Store +ISSUE_TRIAGE.md diff --git a/Tests/Api/InventoryEndpointStartupTests.cs b/Tests/Api/InventoryEndpointStartupTests.cs index 16a1db93..ae8c8626 100644 --- a/Tests/Api/InventoryEndpointStartupTests.cs +++ b/Tests/Api/InventoryEndpointStartupTests.cs @@ -9,6 +9,7 @@ namespace Tests.Api; /// rpk discover --push on a timer. It therefore has to work on a server that /// nobody has opened a page on yet. ///
+[Collection("Process-wide static state")] public class InventoryEndpointStartupTests(ITestOutputHelper output) : ApiTestBase(output) { private const string _existingConfig = """ version: 3 diff --git a/Tests/Api/InventoryEndpointTests.cs b/Tests/Api/InventoryEndpointTests.cs index 851a2d17..bf975f91 100644 --- a/Tests/Api/InventoryEndpointTests.cs +++ b/Tests/Api/InventoryEndpointTests.cs @@ -5,6 +5,7 @@ namespace Tests.Api; +[Collection("Process-wide static state")] public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(output) { [Fact] public async Task DryRun_Add_New_Resource_Does_Not_Persist() { diff --git a/Tests/Api/InventoryEndpointUnconfiguredTests.cs b/Tests/Api/InventoryEndpointUnconfiguredTests.cs index fd8d2b4a..787829f2 100644 --- a/Tests/Api/InventoryEndpointUnconfiguredTests.cs +++ b/Tests/Api/InventoryEndpointUnconfiguredTests.cs @@ -4,6 +4,7 @@ namespace Tests.Api; +[Collection("Process-wide static state")] public class InventoryEndpointUnconfiguredTests(ITestOutputHelper output) : ApiTestBase(output) { protected override void ConfigureTestConfiguration(IDictionary config) => config.Remove("RPK_API_KEY"); diff --git a/Tests/EndToEnd/AccessPointTests/AccessPointCommandTests.cs b/Tests/EndToEnd/AccessPointTests/AccessPointCommandTests.cs index 1e841c9f..a0d28a1c 100644 --- a/Tests/EndToEnd/AccessPointTests/AccessPointCommandTests.cs +++ b/Tests/EndToEnd/AccessPointTests/AccessPointCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.AccessPointTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class AccessPointCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/AccessPointTests/AccessPointErrorTests.cs b/Tests/EndToEnd/AccessPointTests/AccessPointErrorTests.cs index 6f9fe0e1..8653e053 100644 --- a/Tests/EndToEnd/AccessPointTests/AccessPointErrorTests.cs +++ b/Tests/EndToEnd/AccessPointTests/AccessPointErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.AccessPointTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class AccessPointErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs b/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs index 95a225e6..bbb884d1 100644 --- a/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs +++ b/Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.AccessPointTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/CliCommandsWorkflowTests.cs b/Tests/EndToEnd/CliCommandsWorkflowTests.cs index 5af7ec20..2569eac9 100644 --- a/Tests/EndToEnd/CliCommandsWorkflowTests.cs +++ b/Tests/EndToEnd/CliCommandsWorkflowTests.cs @@ -7,7 +7,7 @@ namespace Tests.EndToEnd; /// Comprehensive E2E test covering all CLI commands with varied input data. /// Tests happy paths for CRUD operations, components, labels, and exporters. ///
-[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class CliCommandsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs b/Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs index 86737866..f2f75337 100644 --- a/Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs +++ b/Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ConnectionTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ConnectionRemoveWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs b/Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs index 654667e4..eb29a778 100644 --- a/Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs +++ b/Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ConnectionTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ConnectionWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs b/Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs index b69fa588..80b63d17 100644 --- a/Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs +++ b/Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ConnectionTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class PortConnectionWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs b/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs index cd38d847..123b3089 100644 --- a/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs +++ b/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ConnectionTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class RenameResourceTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { diff --git a/Tests/EndToEnd/CorruptConfigTests.cs b/Tests/EndToEnd/CorruptConfigTests.cs index bbbf1969..153dee35 100644 --- a/Tests/EndToEnd/CorruptConfigTests.cs +++ b/Tests/EndToEnd/CorruptConfigTests.cs @@ -15,7 +15,7 @@ namespace Tests.EndToEnd; // leaves valid YAML that is indistinguishable from a smaller inventory. Nothing at // load time can detect it, which is precisely why the writer-side fix (atomic, // durable saves — see PhysicalTextFileStoreTests) is the primary remedy. -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class CorruptConfigTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { // Exactly what the serializer writes for three bare servers. diff --git a/Tests/EndToEnd/DeleteResourceTests.cs b/Tests/EndToEnd/DeleteResourceTests.cs index bfd933d6..a8ec435a 100644 --- a/Tests/EndToEnd/DeleteResourceTests.cs +++ b/Tests/EndToEnd/DeleteResourceTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class DeleteResourceTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/DesktopTests/DesktopCommandTests.cs b/Tests/EndToEnd/DesktopTests/DesktopCommandTests.cs index 91109b62..f656c184 100644 --- a/Tests/EndToEnd/DesktopTests/DesktopCommandTests.cs +++ b/Tests/EndToEnd/DesktopTests/DesktopCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.DesktopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class DesktopCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/DesktopTests/DesktopErrorTests.cs b/Tests/EndToEnd/DesktopTests/DesktopErrorTests.cs index bc88ad84..a5b6eb93 100644 --- a/Tests/EndToEnd/DesktopTests/DesktopErrorTests.cs +++ b/Tests/EndToEnd/DesktopTests/DesktopErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.DesktopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class DesktopErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/DesktopTests/DesktopWorkflowTests.cs b/Tests/EndToEnd/DesktopTests/DesktopWorkflowTests.cs index b2cf9d5c..91c8f1e9 100644 --- a/Tests/EndToEnd/DesktopTests/DesktopWorkflowTests.cs +++ b/Tests/EndToEnd/DesktopTests/DesktopWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.DesktopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class DesktopWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs b/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs index ddfe059d..4d666d82 100644 --- a/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs +++ b/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs @@ -7,7 +7,7 @@ namespace Tests.EndToEnd.DiscoveryTests; /// `rpk discover network` argument validation. Every case here fails before any /// probing starts, so these tests never send a packet anywhere. ///
-[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class DiscoverNetworkValidationTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task ExecuteAsync(params string[] args) => diff --git a/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs b/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs index eccce9ae..8aa04765 100644 --- a/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs +++ b/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ExporterTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class AnsibleInventoryWorkflowTests( TempYamlCliFixture fs, ITestOutputHelper outputHelper) diff --git a/Tests/EndToEnd/ExporterTests/HostsExportWorkflowTests.cs b/Tests/EndToEnd/ExporterTests/HostsExportWorkflowTests.cs index 3548074b..909fe52c 100644 --- a/Tests/EndToEnd/ExporterTests/HostsExportWorkflowTests.cs +++ b/Tests/EndToEnd/ExporterTests/HostsExportWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ExporterTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class HostsExportWorkflowTests( TempYamlCliFixture fs, ITestOutputHelper outputHelper) diff --git a/Tests/EndToEnd/ExporterTests/SshExportWorkflowTests.cs b/Tests/EndToEnd/ExporterTests/SshExportWorkflowTests.cs index a57c2c1e..bcd49104 100644 --- a/Tests/EndToEnd/ExporterTests/SshExportWorkflowTests.cs +++ b/Tests/EndToEnd/ExporterTests/SshExportWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ExporterTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SshExportWorkflowTests( TempYamlCliFixture fs, ITestOutputHelper outputHelper) diff --git a/Tests/EndToEnd/FirewallTests/FirewallCommandTests.cs b/Tests/EndToEnd/FirewallTests/FirewallCommandTests.cs index 5acbe9a2..fb5df8fa 100644 --- a/Tests/EndToEnd/FirewallTests/FirewallCommandTests.cs +++ b/Tests/EndToEnd/FirewallTests/FirewallCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.FirewallTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class FirewallCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/FirewallTests/FirewallErrorTests.cs b/Tests/EndToEnd/FirewallTests/FirewallErrorTests.cs index 7a33dbfd..57fdf1b9 100644 --- a/Tests/EndToEnd/FirewallTests/FirewallErrorTests.cs +++ b/Tests/EndToEnd/FirewallTests/FirewallErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.FirewallTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class FirewallErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs b/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs index 7aa8a463..720c1068 100644 --- a/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs +++ b/Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.FirewallTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/GenerateAnsibleInventoryTests.cs b/Tests/EndToEnd/GenerateAnsibleInventoryTests.cs index 324b3f2a..108f4c2f 100644 --- a/Tests/EndToEnd/GenerateAnsibleInventoryTests.cs +++ b/Tests/EndToEnd/GenerateAnsibleInventoryTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class GenerateAnsibleInventoryTests( TempYamlCliFixture fs, ITestOutputHelper outputHelper) diff --git a/Tests/EndToEnd/Graph/GraphTopologyCliTests.cs b/Tests/EndToEnd/Graph/GraphTopologyCliTests.cs index fdd49df4..0265b288 100644 --- a/Tests/EndToEnd/Graph/GraphTopologyCliTests.cs +++ b/Tests/EndToEnd/Graph/GraphTopologyCliTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.Graph; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class GraphTopologyCliTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/Infra/YamlCliTestCollection.cs b/Tests/EndToEnd/Infra/YamlCliTestCollection.cs index 50e97d18..70aae442 100644 --- a/Tests/EndToEnd/Infra/YamlCliTestCollection.cs +++ b/Tests/EndToEnd/Infra/YamlCliTestCollection.cs @@ -1,6 +1,23 @@ namespace Tests.EndToEnd.Infra; -[CollectionDefinition("Yaml CLI tests", DisableParallelization = true)] +/// +/// Every test that registers RackPeek's services, directly or through the CLI host. +/// +/// Registration writes process-wide statics as a side effect — +/// RpkConstants.HasGitServices among them — which is harmless in +/// production, where a process registers once, and a race in a test run, where +/// dozens of classes register with different configuration. Two classes in +/// different collections run in parallel, so a test asserting on one of those +/// statics can read a value another class set microseconds earlier. +/// +/// +/// One collection with parallelisation off is what makes those assertions mean +/// anything. The name is deliberately about the shared state rather than about +/// the YAML CLI, because membership is decided by "does this touch the statics", +/// not by what the test is nominally exercising. +/// +/// +[CollectionDefinition("Process-wide static state", DisableParallelization = true)] public class YamlCliTestCollection : ICollectionFixture { } diff --git a/Tests/EndToEnd/Labels/LabelsWorkflowTests.cs b/Tests/EndToEnd/Labels/LabelsWorkflowTests.cs index a044df33..4b9e46cd 100644 --- a/Tests/EndToEnd/Labels/LabelsWorkflowTests.cs +++ b/Tests/EndToEnd/Labels/LabelsWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.Labels; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class LabelsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs b/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs index e16dc033..a2bd6782 100644 --- a/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs +++ b/Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.LaptopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class LaptopCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs b/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs index 786bbb45..6a89cffe 100644 --- a/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs +++ b/Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.LaptopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class LaptopErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs b/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs index 98bf2a7f..d5a05e79 100644 --- a/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs +++ b/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.LaptopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class LaptopNicWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/LaptopTests/LaptopWorkflowTests.cs b/Tests/EndToEnd/LaptopTests/LaptopWorkflowTests.cs index 1468787d..1ba53a3b 100644 --- a/Tests/EndToEnd/LaptopTests/LaptopWorkflowTests.cs +++ b/Tests/EndToEnd/LaptopTests/LaptopWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.LaptopTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class LaptopWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/OtherTests/OtherCommandTests.cs b/Tests/EndToEnd/OtherTests/OtherCommandTests.cs index 878a33ce..b7fd3c44 100644 --- a/Tests/EndToEnd/OtherTests/OtherCommandTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.OtherTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class OtherCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/OtherTests/OtherErrorTests.cs b/Tests/EndToEnd/OtherTests/OtherErrorTests.cs index 38908f6b..d19fa688 100644 --- a/Tests/EndToEnd/OtherTests/OtherErrorTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.OtherTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class OtherErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs b/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs index d8423672..efa5412e 100644 --- a/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.OtherTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class OtherPortWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs b/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs index fa0f4e0d..7080682a 100644 --- a/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs +++ b/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.OtherTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class OtherWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/RouterTests/RouterCommandTests.cs b/Tests/EndToEnd/RouterTests/RouterCommandTests.cs index 2c0479c2..670a4dd2 100644 --- a/Tests/EndToEnd/RouterTests/RouterCommandTests.cs +++ b/Tests/EndToEnd/RouterTests/RouterCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.RouterTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class RouterCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/RouterTests/RouterErrorTests.cs b/Tests/EndToEnd/RouterTests/RouterErrorTests.cs index 6e1fed42..7b9b15fc 100644 --- a/Tests/EndToEnd/RouterTests/RouterErrorTests.cs +++ b/Tests/EndToEnd/RouterTests/RouterErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.RouterTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class RouterErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs b/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs index 8af3a5cc..80f5efe0 100644 --- a/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs +++ b/Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.RouterTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ServerTests/ServerCommandTests.cs b/Tests/EndToEnd/ServerTests/ServerCommandTests.cs index ff986ba4..6bc42165 100644 --- a/Tests/EndToEnd/ServerTests/ServerCommandTests.cs +++ b/Tests/EndToEnd/ServerTests/ServerCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ServerTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ServerCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ServerTests/ServerErrorTests.cs b/Tests/EndToEnd/ServerTests/ServerErrorTests.cs index 6f6be876..2db1a6f7 100644 --- a/Tests/EndToEnd/ServerTests/ServerErrorTests.cs +++ b/Tests/EndToEnd/ServerTests/ServerErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ServerTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ServerErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs b/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs index d97e3471..a112928b 100644 --- a/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs +++ b/Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ServerTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs b/Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs index e0a25b27..26087c0d 100644 --- a/Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs +++ b/Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ServiceTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ServiceCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ServiceTests/ServiceErrorTests.cs b/Tests/EndToEnd/ServiceTests/ServiceErrorTests.cs index 84535366..553bb01a 100644 --- a/Tests/EndToEnd/ServiceTests/ServiceErrorTests.cs +++ b/Tests/EndToEnd/ServiceTests/ServiceErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ServiceTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ServiceErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs b/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs index 5415f481..62232967 100644 --- a/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs +++ b/Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.ServiceTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/SwitchTests/SwitchCommandTests.cs b/Tests/EndToEnd/SwitchTests/SwitchCommandTests.cs index d75e32aa..c3acc9b7 100644 --- a/Tests/EndToEnd/SwitchTests/SwitchCommandTests.cs +++ b/Tests/EndToEnd/SwitchTests/SwitchCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.SwitchTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SwitchCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/SwitchTests/SwitchErrorTests.cs b/Tests/EndToEnd/SwitchTests/SwitchErrorTests.cs index a0b0a67c..5594e0a5 100644 --- a/Tests/EndToEnd/SwitchTests/SwitchErrorTests.cs +++ b/Tests/EndToEnd/SwitchTests/SwitchErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.SwitchTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SwitchErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs b/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs index 7120bc01..fe86226e 100644 --- a/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs +++ b/Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.SwitchTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/SystemTests/SystemCommandTests.cs b/Tests/EndToEnd/SystemTests/SystemCommandTests.cs index 931479b9..f25419bd 100644 --- a/Tests/EndToEnd/SystemTests/SystemCommandTests.cs +++ b/Tests/EndToEnd/SystemTests/SystemCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.SystemTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SystemCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/SystemTests/SystemErrorTests.cs b/Tests/EndToEnd/SystemTests/SystemErrorTests.cs index 2d876d35..3f5e0682 100644 --- a/Tests/EndToEnd/SystemTests/SystemErrorTests.cs +++ b/Tests/EndToEnd/SystemTests/SystemErrorTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.SystemTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SystemErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs b/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs index d3c432ad..92bafc4c 100644 --- a/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs +++ b/Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.SystemTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/Tags/TagsWorkflowTests.cs b/Tests/EndToEnd/Tags/TagsWorkflowTests.cs index 80e0e7a8..e33e115e 100644 --- a/Tests/EndToEnd/Tags/TagsWorkflowTests.cs +++ b/Tests/EndToEnd/Tags/TagsWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.Tags; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class TagsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/UpsTests/UpsCommandTests.cs b/Tests/EndToEnd/UpsTests/UpsCommandTests.cs index 0d7c2d76..26838925 100644 --- a/Tests/EndToEnd/UpsTests/UpsCommandTests.cs +++ b/Tests/EndToEnd/UpsTests/UpsCommandTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.UpsTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class UpsCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/UpsTests/UpsErrorTest.cs b/Tests/EndToEnd/UpsTests/UpsErrorTest.cs index d15eca48..d30e8b7c 100644 --- a/Tests/EndToEnd/UpsTests/UpsErrorTest.cs +++ b/Tests/EndToEnd/UpsTests/UpsErrorTest.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.UpsTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class UpsErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs b/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs index d34b6242..eeacf690 100644 --- a/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs +++ b/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.UpsTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class UpsPortWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs b/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs index 95c5d233..e762f691 100644 --- a/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs +++ b/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs @@ -3,7 +3,7 @@ namespace Tests.EndToEnd.UpsTests; -[Collection("Yaml CLI tests")] +[Collection("Process-wide static state")] public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture { private async Task<(string, string)> ExecuteAsync(params string[] args) { diff --git a/Tests/Git/GitConfigurationTests.cs b/Tests/Git/GitConfigurationTests.cs index d9e3483f..bf42fd29 100644 --- a/Tests/Git/GitConfigurationTests.cs +++ b/Tests/Git/GitConfigurationTests.cs @@ -6,7 +6,7 @@ namespace Tests.Git; -[Collection("Git static state")] +[Collection("Process-wide static state")] public sealed class GitConfigurationTests : IDisposable { private readonly string _tempDir; From d801aaa685e520e06f4ef65091727e87f05c077b Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:23:25 +0100 Subject: [PATCH 28/29] Never let an unbuildable address take down the page rendering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BrowsableUrl built its result with UriBuilder, which throws UriFormatException on a host it cannot parse — "a/b", a name with spaces, a bare run of dots. The address field holds whatever a person typed or a collector read off a device, and neither is obliged to produce something a URL can be built from. That throw was already latent on the service card, where it would have spoiled one card. It matters more now: the dependency trees call this for every service they render, so a single malformed address would blank the entire hardware or system page instead of costing one link. A missing link costs a click. Letting this escape costs the page. Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/Services/ServiceEndpoint.cs | 11 ++++++++++- Tests.Discovery/ServiceEndpointTests.cs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs index a59140ce..698c46c3 100644 --- a/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs +++ b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs @@ -61,7 +61,16 @@ public static string Describe(Network? network) { if (scheme == null) return null; - return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString(); + try { + return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString(); + } + catch (UriFormatException) { + // Whatever is in the address field, a person put it there by hand or a + // collector read it off a device, and neither is obliged to produce something + // a URL can be built from. A missing link costs a click; letting this escape + // would take down every page that renders the resource. + return null; + } } private static string? SchemeFor(int port, string? protocol) { diff --git a/Tests.Discovery/ServiceEndpointTests.cs b/Tests.Discovery/ServiceEndpointTests.cs index f0c08457..94b78f05 100644 --- a/Tests.Discovery/ServiceEndpointTests.cs +++ b/Tests.Discovery/ServiceEndpointTests.cs @@ -93,4 +93,23 @@ public void A_service_with_no_address_of_its_own_can_borrow_its_hosts() { [Fact] public void A_service_with_no_port_is_not_guessed_at() => Assert.Null(Svc(Net(null)).BrowsableUrl()); + + [Theory] + [InlineData("host name with spaces")] + [InlineData("...")] + [InlineData("a/b")] + [InlineData("under_score.local")] + [InlineData("fe80::1")] + [InlineData("[::1]")] + public void An_address_no_url_can_be_built_from_yields_no_link_rather_than_throwing(string ip) { + // The address field holds whatever a person typed or a device reported, and + // neither is obliged to produce something a URL can be built from. This is + // rendered inside the hardware and system trees, so an exception here would + // blank the whole page rather than spoil one link. + Service service = Svc(Net(8080, ip: ip)); + + Exception? thrown = Record.Exception(() => service.BrowsableUrl()); + + Assert.Null(thrown); + } } From eeb2726c05deb73790c0444c4651e7ab94ceb425 Mon Sep 17 00:00:00 2001 From: VirSanctus <25885060+VirSanctus@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:04:29 +0300 Subject: [PATCH 29/29] Allow renaming a resource to a different case of its own name (#328) The conflict check matched the resource being renamed, so a case-only rename such as Srv01 -> srv01 failed with "already exists". Only a different resource with the new name is now a conflict, so renaming onto another resource's case variant is still refused, including in a config that already holds both variants. Renaming a resource to exactly its current name now succeeds without writing anything, instead of failing with "already exists". --- .../UseCases/RenameResourceUseCase.cs | 10 +- .../ConnectionTests/RenameResourceTests.cs | 106 ++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs index 17b636ba..1cbb4629 100644 --- a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs +++ b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs @@ -18,14 +18,20 @@ public async Task ExecuteAsync(string originalName, string newName) { newName = Normalize.HardwareName(newName); ThrowIfInvalid.ResourceName(newName); - Resource? existingResource = await repo.GetByNameAsync(newName); + Resource? original = await repo.GetByNameAsync(originalName); + + IReadOnlyList resources = await repo.GetAllOfTypeAsync(); + Resource? existingResource = resources.FirstOrDefault(r => + !ReferenceEquals(r, original) && r.Name.Equals(newName, StringComparison.OrdinalIgnoreCase)); if (existingResource != null) throw new ConflictException($"{existingResource.Kind} resource '{newName}' already exists."); - Resource? original = await repo.GetByNameAsync(originalName); if (original == null) throw new NotFoundException($"Resource '{originalName}' not found."); + if (original.Name == newName) + return; + original.Name = newName; await repo.UpdateAsync(original); diff --git a/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs b/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs index 123b3089..d92cca4a 100644 --- a/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs +++ b/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs @@ -208,4 +208,110 @@ await ExecuteAsync("connections", "add", Assert.Contains("srv-prod-app-01", yaml); Assert.Contains("app-backend-link", yaml); } + + [Fact] + public async Task rename_that_only_changes_case_updates_the_name() { + await ExecuteAsync("servers", "add", "CaseOnly21"); + + (var output, var yaml) = await ExecuteAsync("servers", "rename", "CaseOnly21", "caseonly21"); + + Assert.Contains("Server 'CaseOnly21' renamed to 'caseonly21'.", output); + Assert.Contains("name: caseonly21", yaml); + Assert.DoesNotContain("name: CaseOnly21", yaml); + } + + [Fact] + public async Task rename_that_only_changes_case_updates_runs_on_and_connections() { + await ExecuteAsync("servers", "add", "CaseOnly31"); + await ExecuteAsync("servers", "add", "CaseOnly32"); + + await ExecuteAsync("servers", "nic", "add", "CaseOnly31", + "--type", "RJ45", "--speed", "10", "--ports", "2"); + + await ExecuteAsync("servers", "nic", "add", "CaseOnly32", + "--type", "RJ45", "--speed", "10", "--ports", "2"); + + await ExecuteAsync("connections", "add", + "CaseOnly31", "0", "0", + "CaseOnly32", "0", "0", + "--label", "case-only-link"); + + await ExecuteAsync("systems", "add", "sys-case-only-31"); + await ExecuteAsync("systems", "set", "sys-case-only-31", "--runs-on", "CaseOnly31"); + + (var output, var yaml) = await ExecuteAsync("servers", "rename", "CaseOnly31", "CASEONLY31"); + + Assert.Contains("Server 'CaseOnly31' renamed to 'CASEONLY31'.", output); + Assert.Contains("name: CASEONLY31", yaml); + Assert.Contains("resource: CASEONLY31", yaml); + Assert.Contains("- CASEONLY31", yaml); + Assert.DoesNotContain("CaseOnly31", yaml); + } + + [Fact] + public async Task rename_onto_another_resources_case_variant_is_still_refused() { + await ExecuteAsync("servers", "add", "Clash41"); + await ExecuteAsync("servers", "add", "Clash42"); + await ExecuteAsync("systems", "add", "clash43"); + + (var sameKind, _) = await ExecuteAsync("servers", "rename", "Clash41", "CLASH42"); + (var otherKind, var yaml) = await ExecuteAsync("servers", "rename", "Clash41", "CLASH43"); + + Assert.Contains("Conflict: Server resource 'CLASH42' already exists.", sameKind); + Assert.Contains("Conflict: System resource 'CLASH43' already exists.", otherKind); + Assert.Contains("name: Clash41", yaml); + Assert.Contains("name: Clash42", yaml); + Assert.Contains("name: clash43", yaml); + Assert.DoesNotContain("CLASH4", yaml); + } + + [Fact] + public async Task rename_to_the_identical_name_leaves_the_file_unchanged() { + await ExecuteAsync("servers", "add", "Same51"); + await ExecuteAsync("servers", "add", "Same52"); + await ExecuteAsync("servers", "add", "Same53"); + + foreach (var s in new[] { "Same51", "Same52", "Same53" }) { + await ExecuteAsync("servers", "nic", "add", s, + "--type", "RJ45", "--speed", "10", "--ports", "2"); + } + + await ExecuteAsync("connections", "add", + "Same51", "0", "0", + "Same52", "0", "0", + "--label", "same-link"); + + await ExecuteAsync("connections", "add", + "Same52", "0", "1", + "Same53", "0", "0", + "--label", "other-link"); + + await ExecuteAsync("systems", "add", "sys-same-51"); + (_, var before) = await ExecuteAsync("systems", "set", "sys-same-51", "--runs-on", "same51"); + + (var output, var after) = await ExecuteAsync("servers", "rename", "Same51", "Same51"); + + Assert.Contains("Server 'Same51' renamed to 'Same51'.", output); + Assert.Equal(before, after); + } + + [Fact] + public async Task rename_is_refused_when_the_config_already_holds_a_case_variant() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """ + version: 4 + resources: + - kind: Server + name: Dup61 + - kind: Server + name: dup61 + connections: [] + """); + + (var output, var yaml) = await ExecuteAsync("servers", "rename", "Dup61", "DUP61"); + + Assert.Contains("Conflict: Server resource 'DUP61' already exists.", output); + Assert.Contains("name: Dup61", yaml); + Assert.Contains("name: dup61", yaml); + Assert.DoesNotContain("DUP61", yaml); + } }