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/.github/workflows/test.yml b/.github/workflows/test.yml index 472126c4..9e272175 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,59 @@ 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 + + + 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/.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/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 2cccc51b..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! @@ -85,6 +85,12 @@ 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) + +* + [**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/RackPeek.Domain/Api/UpsertInventoryUseCase.cs b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs index dc9629f8..a5187ac7 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,19 @@ 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. + // 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) .FirstOrDefault(g => g.Count() > 1); @@ -118,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/ArpTableParser.cs b/RackPeek.Domain/Discovery/ArpTableParser.cs new file mode 100644 index 00000000..81fac034 --- /dev/null +++ b/RackPeek.Domain/Discovery/ArpTableParser.cs @@ -0,0 +1,107 @@ +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); + } + + var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries); + + if (columns.Length < 2 || !IsIpv4(columns[0])) + 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); + } + + // Windows arp -a: "192.168.1.1 a4-91-b1-4e-3c-20 dynamic" + var windowsMac = NormaliseMac(columns[1]); + + return windowsMac == null ? null : (columns[0], windowsMac); + } + + /// + /// 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(':', '-'); + + 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/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..1af5aed5 --- /dev/null +++ b/RackPeek.Domain/Discovery/DiscoveryId.cs @@ -0,0 +1,49 @@ +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 const string NetworkScheme = "net"; + + 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()}"; + } + + /// 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(':'); + 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..e6a8e77b --- /dev/null +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -0,0 +1,591 @@ +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; + +/// +/// 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. + /// + /// 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? storedConnections = null, + bool improveStoredNames = false) { + 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); + + 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. + var existingByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (Resource resource in existing) + existingByName.TryAdd(resource.Name, resource); + + 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(offeredName, StringComparison.OrdinalIgnoreCase)) + continue; + + // 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; + } + + if (renames.Count > 0) { + RewriteRunsOn(incoming, renames); + 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. + /// + /// 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. + // 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; + + if (string.IsNullOrWhiteSpace(ip)) + continue; + + var anchored = service.RunsOn.Any(name => + existingByName.ContainsKey(name) || incomingNames.Contains(name)); + + if (anchored) + continue; + + // 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; + + 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; + } + + /// + /// 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) { + // 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 + || string.IsNullOrWhiteSpace(resource.DiscoveryId) + || !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, + 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; + + // 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; + + 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; + + // 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; + } + + /// + /// 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 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 describes in full. + /// + /// 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( + Resource resource, + Dictionary existingByIp, + out string unifiedName) { + unifiedName = string.Empty; + + 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; + + 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 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; + } + + /// + /// 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 = [ + 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++) + 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..f096d0f5 --- /dev/null +++ b/RackPeek.Domain/Discovery/DockerApiClient.cs @@ -0,0 +1,146 @@ +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); + } + + // 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) => { + 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/INetworkProbe.cs b/RackPeek.Domain/Discovery/INetworkProbe.cs new file mode 100644 index 00000000..b39500b5 --- /dev/null +++ b/RackPeek.Domain/Discovery/INetworkProbe.cs @@ -0,0 +1,61 @@ +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 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); + + /// 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, 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. + /// + Cidr? LocalSubnet(); +} diff --git a/RackPeek.Domain/Discovery/IProxmoxClient.cs b/RackPeek.Domain/Discovery/IProxmoxClient.cs new file mode 100644 index 00000000..5ecf130f --- /dev/null +++ b/RackPeek.Domain/Discovery/IProxmoxClient.cs @@ -0,0 +1,56 @@ +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); + + /// + /// 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/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/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 new file mode 100644 index 00000000..4f12f78c --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkProbe.cs @@ -0,0 +1,259 @@ +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; + +/// 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(); + 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) { + // Linux reads the kernel's file; BSD/macOS answer `arp -an`; Windows' arp.exe + // 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, + 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(timeout); + + 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 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()) { + 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; + + // 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 + && !IsLinkLocal(a.Address)); + + if (address == null) + continue; + + return Cidr.Parse($"{address.Address}/{address.PrefixLength}"); + } + } + catch { + // Fall through: the caller asks the user for --cidr instead. + } + + 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 new file mode 100644 index 00000000..28a13074 --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkScanFacts.cs @@ -0,0 +1,71 @@ +using RackPeek.Domain.Resources.Services.Networking; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// One responding host, as the sweep saw it. <see cref="OpenPorts" /> 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. +/// </summary> +public sealed record NetworkHostFact( + string Ip, + string? Mac, + string? Hostname, + bool AnsweredPing, + 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 { + public required Cidr Cidr { get; init; } + + /// <summary>TCP ports probed to catch hosts that do not answer ping.</summary> + public IReadOnlyList<int> Ports { get; init; } = WellKnownPorts.Defaults; + + public TimeSpan PingTimeout { get; init; } = TimeSpan.FromMilliseconds(300); + + public TimeSpan PortTimeout { get; init; } = TimeSpan.FromMilliseconds(500); + + /// <summary>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.</summary> + public TimeSpan DnsTimeout { get; init; } = TimeSpan.FromSeconds(2); + + /// <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 new file mode 100644 index 00000000..096afe6f --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -0,0 +1,183 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.Services.Networking; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// <summary>Maps swept hosts onto the System resources RackPeek stores. Pure.</summary> +public static class NetworkScanMapper { + public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) { + var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var resources = new List<Resource>(); + + foreach ((NetworkHostFact host, IReadOnlyList<string> 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 discoveryId = DiscoveryId.Create( + 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( + label, + "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; + + // 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["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. + 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); + + // 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<int, ServiceIdentity> 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<int> ports = host.OpenPorts + .Concat(identified.Keys) + .Distinct(); + + foreach (var port in ports) { + var serviceId = DiscoveryId.Create( + DiscoveryId.NetworkScheme, + $"{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(serviceLabel, "service", serviceId), + serviceId, + taken), + DiscoveryId = serviceId, + Network = new Network { + Ip = host.Ip, + Port = 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 + /// 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. + /// </summary> + private static IEnumerable<(NetworkHostFact Host, IReadOnlyList<string> AllIps)> Collapse( + IReadOnlyList<NetworkHostFact> hosts) { + var byMac = new Dictionary<string, List<NetworkHostFact>>(StringComparer.OrdinalIgnoreCase); + + foreach (NetworkHostFact host in hosts) + if (host.Mac != null) { + if (!byMac.TryGetValue(host.Mac, out List<NetworkHostFact>? group)) + byMac[host.Mac] = group = []; + + group.Add(host); + } + + var emitted = new HashSet<string>(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 new file mode 100644 index 00000000..ac975f7c --- /dev/null +++ b/RackPeek.Domain/Discovery/NetworkScanner.cs @@ -0,0 +1,287 @@ +using RackPeek.Domain.Resources.Services.Networking; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// 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 <see cref="INetworkProbe" />. +/// </summary> +public static class NetworkScanner { + /// <summary> + /// 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. + /// </summary> + public const int MinPrefix = 16; + + /// <summary> + /// 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. + /// </summary> + public static IEnumerable<string> 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<IReadOnlyList<NetworkHostFact>> 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); + + (string Ip, bool Ping, List<int> 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<string, string> 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(); + } + })); + + // 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, _) = 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 + .OrderBy(f => IpHelper.ToUInt32(f.Ip)) + .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, + 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<int>(); + + // 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/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; + +/// <summary>Reads an OPNsense firewall's API. The IO half of firewall discovery.</summary> +public interface IOpnsenseClient { + /// <summary>Where this client is pointed, for error messages.</summary> + string Endpoint { get; } + + /// <summary> + /// Every neighbour the firewall currently has an ARP entry for, across every + /// subnet it routes. + /// </summary> + Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync(CancellationToken cancellationToken = default); +} + +/// <summary> +/// 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. +/// </summary> +public sealed class OpnsenseApiClient : IOpnsenseClient, IDisposable { + public const string KeyEnvironmentVariable = "RPK_OPN_KEY"; + public const string SecretEnvironmentVariable = "RPK_OPN_SECRET"; + + /// <summary> + /// 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. + /// </summary> + private static readonly string[] _arpPaths = [ + "api/diagnostics/interface/get_arp", + "api/diagnostics/interface/getArp" + ]; + + private readonly HttpClient _httpClient; + + /// <param name="allowUntrustedCertificate"> + /// OPNsense ships with a self-signed certificate and most installations keep it. + /// Opt-in all the same. + /// </param> + 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<IReadOnlyList<OpnsenseNeighbour>> 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."); + } + + /// <summary> + /// 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. + /// </summary> + 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<string> 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..15d02d66 --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs @@ -0,0 +1,105 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// Turns a firewall's neighbour table into System resources. +/// <para> +/// The cards are seeded exactly as <see cref="NetworkScanMapper" /> 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. +/// </para> +/// </summary> +public static class OpnsenseDiscovery { + public static async Task<List<Resource>> ReadAsync( + IOpnsenseClient client, + bool includePublic = false, + CancellationToken cancellationToken = default) => + ToResources(await client.GetNeighboursAsync(cancellationToken), includePublic); + + /// <summary> + /// Whether an address belongs to a network someone runs themselves: the RFC 1918 + /// ranges plus the carrier-grade block an ISP may hand out. + /// <para> + /// 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. + /// </para> + /// </summary> + 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<Resource> ToResources( + IReadOnlyList<OpnsenseNeighbour> neighbours, + bool includePublic = false) { + var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var resources = new List<Resource>(); + + 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; + + // 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["nic-vendor"] = vendor; + + 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; + +/// <summary> +/// One neighbour the firewall has seen, from its ARP table. +/// <para> +/// 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. +/// </para> +/// </summary> +public sealed record OpnsenseNeighbour( + string Ip, + string Mac, + string? Hostname, + string? Manufacturer, + string? Interface); + +public static class OpnsenseResponseParser { + /// <summary> + /// Reads the ARP table. OPNsense answers either a flat array or, through the + /// search wrapper, an object with a <c>rows</c> array; both shapes are accepted so + /// the caller need not care which endpoint answered. + /// </summary> + public static List<OpnsenseNeighbour> ParseArp(string json) { + var neighbours = new List<OpnsenseNeighbour>(); + + 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<string>(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; + } + + /// <summary> + /// A locally administered group address — the low bit of the first octet marks + /// multicast, which no host owns. + /// </summary> + 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; + + /// <summary> + /// OPNsense writes these as real booleans in some versions and as the strings + /// "1"/"true" in others. + /// </summary> + 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 + }; + } + + /// <summary>Blank and placeholder values arrive as empty strings or dashes.</summary> + private static string? Clean(string? value) { + var trimmed = value?.Trim(); + + return string.IsNullOrEmpty(trimmed) || trimmed is "-" or "(none)" ? null : trimmed; + } +} diff --git a/RackPeek.Domain/Discovery/ProxmoxApiClient.cs b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs new file mode 100644 index 00000000..2618d128 --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs @@ -0,0 +1,223 @@ +using System.Text.Json; +using System.Net.Security; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// Talks to the Proxmox VE API with an API token. A token is used rather than a +/// password because it can be given a read-only role and revoked on its own. +/// </summary> +public sealed class ProxmoxApiClient : IProxmoxClient, IDisposable { + public const string QemuEndpoint = "qemu"; + public const string LxcEndpoint = "lxc"; + public const string TokenIdEnvironmentVariable = "RPK_PVE_TOKEN_ID"; + public const string TokenSecretEnvironmentVariable = "RPK_PVE_TOKEN_SECRET"; + + private readonly HttpClient _httpClient; + + /// <param name="allowUntrustedCertificate"> + /// Proxmox ships with a self-signed certificate and most installations keep it, + /// so this is needed more often than not. It is opt-in all the same. + /// </param> + public ProxmoxApiClient( + string host, + string tokenId, + string tokenSecret, + bool allowUntrustedCertificate = false, + HttpClient? httpClient = null) { + Endpoint = Normalise(host); + + _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate)); + _httpClient.BaseAddress = new Uri(Endpoint + "/api2/json/"); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + + // Proxmox expects the whole token as one opaque Authorization value. + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation( + "Authorization", + $"PVEAPIToken={tokenId}={tokenSecret}"); + } + + public string Endpoint { get; } + + public void Dispose() => _httpClient.Dispose(); + + public async Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default) { + // A standalone host has no cluster, and Proxmox answers 5xx rather than an empty + // list, so a failure here is expected and means "not clustered". + try { + var json = await GetAsync("cluster/status", cancellationToken); + var clusterName = ProxmoxResponseParser.ParseIdentityScope(json, string.Empty); + + // Only fall back to the node list when there is no cluster name — the + // fallback costs a second call, and on a cluster it would be thrown away. + return clusterName.Length > 0 ? clusterName : await FirstNodeAsync(cancellationToken); + } + catch (HttpRequestException) { + // Either there is no cluster, or the token may not read it. Either way the + // node is a sound scope: a vmid is unique within it. + return await FirstNodeAsync(cancellationToken); + } + } + + public async Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default) => + ProxmoxResponseParser.ParseNodes(await GetAsync("nodes", cancellationToken)); + + public async Task<ProxmoxNode> EnrichAsync( + ProxmoxNode node, + CancellationToken cancellationToken = default) { + try { + // The three endpoints are independent, so the round trips overlap. + Task<string> status = GetAsync($"nodes/{Uri.EscapeDataString(node.Name)}/status", cancellationToken); + Task<IReadOnlyList<ProxmoxDisk>> disks = GetDisksAsync(node.Name, cancellationToken); + Task<IReadOnlyList<ProxmoxGpu>> gpus = GetGpusAsync(node.Name, cancellationToken); + + ProxmoxNode detail = ProxmoxResponseParser.ParseNodeStatus(await status, node.Name); + + return node with { + Cores = detail.Cores > 0 ? detail.Cores : node.Cores, + MemoryBytes = detail.MemoryBytes > 0 ? detail.MemoryBytes : node.MemoryBytes, + Version = detail.Version, + CpuModel = detail.CpuModel, + Sockets = detail.Sockets, + PhysicalCores = detail.PhysicalCores, + Disks = await disks, + Gpus = await gpus + }; + } + catch (HttpRequestException) { + return node; + } + } + + public async Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync( + string node, + string endpoint, + CancellationToken cancellationToken = default) { + var json = await GetAsync($"nodes/{Uri.EscapeDataString(node)}/{endpoint}", cancellationToken); + + return ProxmoxResponseParser.ParseGuests( + json, + node, + endpoint == LxcEndpoint ? ProxmoxResponseParser.ContainerType : ProxmoxResponseParser.VmType); + } + + public async Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync( + string node, + CancellationToken cancellationToken = default) { + try { + return ProxmoxResponseParser.ParseDisks( + await GetAsync($"nodes/{Uri.EscapeDataString(node)}/disks/list", cancellationToken)); + } + catch (HttpRequestException) { + return []; + } + } + + public async Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync( + string node, + CancellationToken cancellationToken = default) { + try { + return ProxmoxResponseParser.ParseGpus( + await GetAsync($"nodes/{Uri.EscapeDataString(node)}/hardware/pci", cancellationToken)); + } + catch (HttpRequestException) { + return []; + } + } + + public async Task<ProxmoxGuestConfig> GetGuestConfigAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) { + // A guest can disappear between listing and reading it; that is not worth failing + // the whole run over, so it simply contributes nothing. + try { + var json = await GetAsync( + $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/config", + cancellationToken); + + return ProxmoxResponseParser.ParseGuestConfig(json); + } + catch (HttpRequestException) { + return new ProxmoxGuestConfig(null, null, [], []); + } + } + + public async Task<IReadOnlyList<ProxmoxGuestAddress>> 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<string> FirstNodeAsync(CancellationToken cancellationToken) { + IReadOnlyList<ProxmoxNode> nodes = await GetNodesAsync(cancellationToken); + + return nodes.FirstOrDefault()?.Name ?? "proxmox"; + } + + private async Task<string> GetAsync(string path, CancellationToken cancellationToken) { + using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + throw new HttpRequestException( + "Proxmox rejected the API token (401). Check the token id is of the form " + + "user@realm!tokenname and that the secret matches."); + + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + throw new HttpRequestException( + $"The API token is not permitted to read {path} (403). In the Proxmox UI: " + + "Datacenter -> Permissions -> Add -> API Token Permission, path '/', " + + "role PVEAuditor, with Propagate ticked."); + + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + + private static HttpClientHandler Handler(bool allowUntrustedCertificate) { + var handler = new HttpClientHandler(); + + if (allowUntrustedCertificate) + handler.ServerCertificateCustomValidationCallback = + (_, _, _, _) => true; + + return handler; + } + + private static string Normalise(string host) { + var trimmed = host.Trim().TrimEnd('/'); + + if (trimmed.Contains("://", StringComparison.Ordinal)) + return trimmed; + + // A bare name gets the default scheme and port, but "pve.lan:8006" already + // carries a port — appending another would make the URL unparseable. + return trimmed.Contains(':', StringComparison.Ordinal) + ? $"https://{trimmed}" + : $"https://{trimmed}:8006"; + } +} 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; + +/// <summary> +/// Reads a whole Proxmox endpoint and maps it to resources. +/// <para> +/// 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. +/// </para> +/// </summary> +public static class ProxmoxDiscovery { + public static async Task<List<Resource>> ReadAsync( + IProxmoxClient client, + CancellationToken cancellationToken = default) { + var scope = await client.GetIdentityScopeAsync(cancellationToken); + IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken); + + var nodes = new List<ProxmoxNode>(); + var guests = new List<ProxmoxGuest>(); + + foreach (ProxmoxNode listedNode in listed) { + // Node detail needs a broader permission than listing guests does, so it is + // enrichment rather than a requirement — a read-only token still gets a tree. + ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken); + nodes.Add(node); + + var nodeName = node.Name; + + foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) { + IReadOnlyList<ProxmoxGuest> listedGuests = + await client.GetGuestsAsync(nodeName, endpoint, cancellationToken); + + // The list call knows nothing about the OS, and for a container it does + // not know the address either. Both live in the guest's own config — one + // call per guest, so they run concurrently rather than one at a time. + ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g => + client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken))); + + // 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<ProxmoxGuestAddress>[] addresses = await Task.WhenAll( + listedGuests.Select(g => IsRunning(g) + ? client.GetGuestAddressesAsync(nodeName, endpoint, g.VmId, cancellationToken) + : Task.FromResult<IReadOnlyList<ProxmoxGuestAddress>>([]))); + + for (var i = 0; i < listedGuests.Count; i++) { + IReadOnlyList<string> 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); + } + + /// <summary> + /// The address that belongs to the guest itself. + /// <para> + /// A guest agent reports every interface inside the machine, and a guest that + /// runs containers has several: Docker's <c>docker0</c> and its per-network + /// bridges, Home Assistant's <c>hassio</c>, 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. + /// </para> + /// <para> + /// 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. + /// </para> + /// </summary> + public static string? SelectGuestIp( + IReadOnlyList<ProxmoxGuestAddress> addresses, + IReadOnlyList<string> configuredMacs) { + if (addresses.Count == 0 || configuredMacs.Count == 0) + return null; + + var allowed = new HashSet<string>( + 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 new file mode 100644 index 00000000..52573225 --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -0,0 +1,602 @@ +using System.Text.Json; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// A Proxmox node: the physical machine and the hypervisor installed on it. RackPeek +/// models those as two resources, so both sets of facts are gathered here. +/// </summary> +public sealed record ProxmoxNode { + public required string Name { get; init; } + + /// <summary>Logical processors, which is what the hypervisor OS sees.</summary> + public int Cores { get; init; } + + public long MemoryBytes { get; init; } + + /// <summary>e.g. <c>pve-manager/8.2.2/9355359cd7afbae4</c>, only from the status call.</summary> + public string? Version { get; init; } + + // Hardware, all from the status call and all optional — a token without Sys.Audit + // still gets a usable node, just without these. + public string? CpuModel { get; init; } + public int Sockets { get; init; } + public int PhysicalCores { get; init; } + public IReadOnlyList<ProxmoxDisk> Disks { get; init; } = []; + + /// <summary> + /// Display adapters in the machine. A GPU passed through to a guest is still + /// physically in the host, so this is where it belongs — the PCI address is kept + /// so a guest holding it can say which card it has. + /// </summary> + public IReadOnlyList<ProxmoxGpu> Gpus { get; init; } = []; +} + +/// <summary>A physical disk as Proxmox reports it, already classified by type.</summary> +public sealed record ProxmoxDisk(string Type, long SizeBytes, string? Model); + +/// <summary>A display adapter and where it sits on the bus.</summary> +public sealed record ProxmoxGpu(string Address, string Model); + +/// <summary>A guest on a node. QEMU and LXC differ only in the kind of system they are.</summary> +public sealed record ProxmoxGuest { + public required int VmId { get; init; } + public required string Node { get; init; } + + /// <summary>Empty for a guest that has never been named; the mapper falls back to the vmid.</summary> + public string Name { get; init; } = string.Empty; + + /// <summary><c>vm</c> or <c>container</c>, matching SystemResource.ValidSystemTypes.</summary> + public required string Type { get; init; } + + public int Cores { get; init; } + public long MemoryBytes { get; init; } + /// <summary>The boot disk, from the guest list. A fallback for when the config is unreadable.</summary> + public long DiskBytes { get; init; } + + /// <summary>Every attached disk, from the guest's config.</summary> + public IReadOnlyList<long> Disks { get; init; } = []; + + /// <summary>PCI addresses handed exclusively to this guest, from its config.</summary> + public IReadOnlyList<string> PassthroughAddresses { get; init; } = []; + + public IReadOnlyList<string> Tags { get; init; } = []; + + /// <summary> + /// <c>running</c>, <c>stopped</c> 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. + /// </summary> + public string? Status { get; init; } + + /// <summary>Filled in from the guest's config, which is the only place it is known.</summary> + public string? Os { get; init; } + + public string? Ip { get; init; } + + /// <summary> + /// 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. + /// </summary> + public IReadOnlyList<string> Macs { get; init; } = []; +} + +/// <summary> +/// Parses the Proxmox API. Every response wraps its payload in a <c>data</c> member. +/// Pure, so it tests from captured responses without a Proxmox to talk to. +/// </summary> +public static class ProxmoxResponseParser { + public const string VmType = "vm"; + public const string ContainerType = "container"; + + /// <summary> + /// Nodes with whatever detail the token is allowed to see. Proxmox strips + /// <c>maxcpu</c> and <c>maxmem</c> from this response for a token without the + /// rights to read them, rather than refusing the call, so both shapes are normal. + /// </summary> + public static List<ProxmoxNode> ParseNodes(string json) { + return Data(json) + .Where(n => !string.IsNullOrWhiteSpace(GetString(n, "node"))) + .Select(n => new ProxmoxNode { + Name = GetString(n, "node")!, + Cores = GetInt(n, "maxcpu") ?? 0, + MemoryBytes = GetLong(n, "maxmem") ?? 0 + }) + .OrderBy(n => n.Name, StringComparer.Ordinal) + .ToList(); + } + + public static ProxmoxNode ParseNodeStatus(string json, string nodeName) { + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data)) + return new ProxmoxNode { Name = nodeName }; + + var cores = data.TryGetProperty("cpuinfo", out JsonElement cpu) + ? GetInt(cpu, "cpus") ?? GetInt(cpu, "cores") ?? 0 + : 0; + + var memory = data.TryGetProperty("memory", out JsonElement mem) + ? GetLong(mem, "total") ?? 0 + : 0; + + return new ProxmoxNode { + Name = nodeName, + Cores = cores, + MemoryBytes = memory, + Version = GetString(data, "pveversion"), + CpuModel = cpu.ValueKind == JsonValueKind.Object ? GetString(cpu, "model") : null, + Sockets = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "sockets") ?? 0 : 0, + PhysicalCores = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "cores") ?? 0 : 0 + }; + } + + /// <summary> + /// Physical disks. Proxmox has already worked out nvme/ssd/hdd, which is better + /// than the guess <c>discover system</c> has to make from a rotational flag. + /// </summary> + public static List<ProxmoxDisk> ParseDisks(string json) { + return Data(json) + .Select(d => new ProxmoxDisk( + NormaliseDiskType(GetString(d, "type")), + GetLong(d, "size") ?? 0, + GetString(d, "model"))) + .Where(d => d.SizeBytes > 0) + .ToList(); + } + + /// <summary>Proxmox says "unknown" for a disk it cannot classify; RackPeek omits the type.</summary> + private static string NormaliseDiskType(string? type) { + return type?.ToLowerInvariant() switch { + "nvme" => "nvme", + "ssd" => "ssd", + "hdd" => "hdd", + _ => string.Empty + }; + } + + /// <summary> + /// Display adapters from the node's PCI device list. PCI class 0x03 is the + /// display-controller class, which is how a GPU is told apart from the other + /// couple of dozen devices on a modern board. + /// </summary> + public static List<ProxmoxGpu> ParseGpus(string json) { + return Data(json) + .Where(d => (GetString(d, "class") ?? string.Empty).StartsWith("0x03", StringComparison.Ordinal)) + .Select(d => new { Address = GetString(d, "id"), Model = MarketingName(GetString(d, "device_name")) }) + .Where(g => !string.IsNullOrWhiteSpace(g.Address) && !string.IsNullOrWhiteSpace(g.Model)) + .Select(g => new ProxmoxGpu(g.Address!, g.Model!)) + .ToList(); + } + + /// <summary> + /// PCI addresses a guest has been given exclusive use of. The config writes them + /// as <c>hostpci0: 0000:01:00</c>, optionally with trailing options and sometimes + /// without the function suffix the device list carries. + /// </summary> + public static List<string> ParsePassthrough(JsonElement config) { + return config.EnumerateObject() + .Where(p => p.Name.StartsWith("hostpci", StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!.Split(',')[0].Trim()) + .Where(v => v.Length > 0) + .ToList(); + } + + /// <summary> + /// PCI names the part and then the product: <c>GA102 [GeForce RTX 3090]</c>. The + /// bracketed half is the one people would have typed, so it wins where it exists. + /// </summary> + public static string? MarketingName(string? deviceName) { + if (string.IsNullOrWhiteSpace(deviceName)) + return null; + + var open = deviceName.IndexOf('['); + var close = deviceName.IndexOf(']'); + + return close > open && open >= 0 + ? deviceName[(open + 1)..close].Trim() + : deviceName.Trim(); + } + + public static List<ProxmoxGuest> ParseGuests(string json, string node, string type) { + return Data(json) + .Select(g => ParseGuest(g, node, type)) + .OfType<ProxmoxGuest>() + .OrderBy(g => g.VmId) + .ToList(); + } + + /// <summary> + /// The scope a vmid is unique within. A clustered guest can migrate between nodes, + /// so the cluster name is what keeps its identity stable; a standalone host has no + /// cluster entry and falls back to the node. + /// </summary> + public static string ParseIdentityScope(string clusterStatusJson, string fallbackNode) { + JsonElement cluster = Data(clusterStatusJson) + .FirstOrDefault(e => GetString(e, "type") == "cluster"); + + var name = cluster.ValueKind == JsonValueKind.Object ? GetString(cluster, "name") : null; + + return string.IsNullOrWhiteSpace(name) ? fallbackNode : name; + } + + /// <summary> + /// Reads the guest's own config. This is the only place the OS is knowable, and + /// for a container it carries the address too — which is why the extra call per + /// guest earns its place. + /// </summary> + public static ProxmoxGuestConfig ParseGuestConfig(string json) { + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data)) + return new ProxmoxGuestConfig(null, null, [], [], []); + + return new ProxmoxGuestConfig( + DescribeOs(GetString(data, "ostype")), + ParseStaticIp(GetString(data, "net0")), + ParseDiskSizes(data), + ParsePassthrough(data), + ParseMacs(data)); + } + + /// <summary> + /// The MACs in a guest's netN lines. QEMU spells them as the NIC model's value + /// (<c>virtio=BC:24:11:…</c>), containers as <c>hwaddr=BC:24:11:…</c> — 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. + /// </summary> + public static List<string> ParseMacs(JsonElement config) { + var macs = new List<string>(); + + 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); + + /// <summary> + /// Every disk attached to a guest. The guest list only carries <c>maxdisk</c>, + /// which is the boot disk alone — a VM with a small root and a large data volume + /// would otherwise be recorded at a fraction of its real size. + /// </summary> + public static List<long> ParseDiskSizes(JsonElement config) { + var sizes = new List<long>(); + + foreach (JsonProperty property in config.EnumerateObject()) { + if (!IsDiskSlot(property.Name)) + continue; + + var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null; + + if (value == null || value.Contains("media=cdrom", StringComparison.OrdinalIgnoreCase)) + continue; + + var size = ParseSize(value); + + if (size > 0) + sizes.Add(size); + } + + return sizes; + } + + /// <summary> + /// Disk-bearing config keys. <c>unusedN</c> is excluded because it is a detached + /// volume with no size, and the EFI and TPM state volumes because they are + /// firmware scratch space of a few megabytes rather than storage anyone inventories. + /// </summary> + private static bool IsDiskSlot(string key) { + string[] prefixes = ["scsi", "virtio", "sata", "ide", "mp"]; + + if (key.Equals("rootfs", StringComparison.OrdinalIgnoreCase)) + return true; + + return prefixes.Any(p => + key.StartsWith(p, StringComparison.OrdinalIgnoreCase) + && key.Length > p.Length + && key[p.Length..].All(char.IsAsciiDigit)); + } + + /// <summary> + /// Reads <c>size=64G</c> out of a volume definition, in bytes. Proxmox permits a + /// fractional number (<c>size=4.5G</c>, after an odd resize) and a bare number, + /// which is bytes. + /// </summary> + public static long ParseSize(string volume) { + foreach (var part in volume.Split(',', StringSplitOptions.TrimEntries)) { + if (!part.StartsWith("size=", StringComparison.OrdinalIgnoreCase)) + continue; + + var raw = part[5..].Trim(); + + if (raw.Length == 0) + return 0; + + var multiplier = char.ToUpperInvariant(raw[^1]) switch { + 'K' => 1024L, + 'M' => 1024L * 1024, + 'G' => 1024L * 1024 * 1024, + 'T' => 1024L * 1024 * 1024 * 1024, + _ => 0L + }; + + if (multiplier == 0) + return long.TryParse(raw, out var bytes) && bytes > 0 ? bytes : 0; + + return double.TryParse( + raw[..^1], + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var value) + && value > 0 + ? (long)Math.Round(value * multiplier) + : 0; + } + + return 0; + } + + /// <summary> + /// Proxmox stores an ostype code. The container ones name a real distribution and + /// are worth having; the QEMU ones are coarse by nature — <c>l26</c> means any + /// Linux since 2.6 — so they stay vague rather than pretending to precision. + /// </summary> + internal static string? DescribeOs(string? ostype) { + if (string.IsNullOrWhiteSpace(ostype)) + return null; + + return ostype.ToLowerInvariant() switch { + "l24" => "Linux", + "l26" => "Linux", + "solaris" => "Solaris", + "wxp" => "Windows XP", + "w2k" => "Windows 2000", + "w2k3" => "Windows Server 2003", + "w2k8" => "Windows Server 2008", + "wvista" => "Windows Vista", + "win7" => "Windows 7", + "win8" => "Windows 8", + "win10" => "Windows 10", + "win11" => "Windows 11", + "other" => null, + "unmanaged" => null, + // Container templates are named after the distribution itself. + var distribution => char.ToUpperInvariant(distribution[0]) + distribution[1..] + }; + } + + /// <summary> + /// Pulls the address out of a net interface line such as + /// <c>name=eth0,bridge=vmbr0,ip=192.168.1.53/24</c>. Returns null for + /// <c>ip=dhcp</c> and <c>ip=manual</c>, where the config knows no more than we do. + /// </summary> + internal static string? ParseStaticIp(string? net) { + if (string.IsNullOrWhiteSpace(net)) + return null; + + foreach (var part in net.Split(',', StringSplitOptions.TrimEntries)) { + if (!part.StartsWith("ip=", StringComparison.OrdinalIgnoreCase)) + continue; + + var value = part[3..].Split('/')[0].Trim(); + + return value.Equals("dhcp", StringComparison.OrdinalIgnoreCase) + || value.Equals("manual", StringComparison.OrdinalIgnoreCase) + || value.Length == 0 + ? null + : value; + } + + return null; + } + + private static ProxmoxGuest? ParseGuest(JsonElement element, string node, string type) { + var vmid = GetInt(element, "vmid"); + + if (vmid == null) + return null; + + return new ProxmoxGuest { + VmId = vmid.Value, + Node = node, + Name = GetString(element, "name") ?? string.Empty, + Type = type, + Cores = GetInt(element, "cpus") ?? 0, + MemoryBytes = GetLong(element, "maxmem") ?? 0, + DiskBytes = GetLong(element, "maxdisk") ?? 0, + Status = GetString(element, "status"), + Tags = ParseTags(GetString(element, "tags")) + }; + } + + /// <summary>Proxmox joins guest tags with semicolons.</summary> + private static List<string> ParseTags(string? tags) { + if (string.IsNullOrWhiteSpace(tags)) + return []; + + return tags.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(DiscoveryNaming.Slug) + .Where(t => t.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static IEnumerable<JsonElement> Data(string json) { + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data) + || data.ValueKind != JsonValueKind.Array) + return []; + + return data.EnumerateArray().Select(e => e.Clone()).ToList(); + } + + private static string? GetString(JsonElement element, string name) => + element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + /// <summary> + /// A number Proxmox may have written as a string. + /// <para> + /// Its perl backend quotes numeric fields inconsistently — <c>"vmid":"100"</c> + /// and <c>"maxdisk":"512110190592"</c> both turn up across versions. The + /// <c>TryGetInt32</c> 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. + /// </para> + /// </summary> + private static int? GetInt(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return 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 + }; + } + + /// <inheritdoc cref="GetInt" /> + 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 + }; + } + + /// <summary> + /// Addresses a QEMU guest reports through its guest agent + /// (<c>agent/network-get-interfaces</c>). 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 + /// <see cref="ProxmoxDiscovery.SelectGuestIp" />. + /// </summary> + public static List<ProxmoxGuestAddress> ParseAgentInterfaces(string json) { + var addresses = new List<ProxmoxGuestAddress>(); + + 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; + } + + /// <summary> + /// Addresses a container reports through <c>lxc/{vmid}/interfaces</c>, which is a + /// flat list rather than the agent's nested shape and spells the address with its + /// prefix (<c>10.0.0.5/24</c>). + /// </summary> + public static List<ProxmoxGuestAddress> ParseContainerInterfaces(string json) { + var addresses = new List<ProxmoxGuestAddress>(); + + 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; + } + + /// <summary> + /// Whether an address is worth recording: a real IPv4 that is neither loopback + /// nor the 169.254 a guest assigns itself when DHCP fails. + /// </summary> + 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; +} + +/// <summary>The parts of a guest's config worth recording. Everything is optional.</summary> +/// <summary>One address a guest reports for one of its own interfaces.</summary> +public sealed record ProxmoxGuestAddress(string? Interface, string? Mac, string Ip); + +public sealed record ProxmoxGuestConfig( + string? Os, + string? Ip, + IReadOnlyList<long> DiskBytes, + IReadOnlyList<string> PassthroughAddresses, + IReadOnlyList<string>? Macs = null); diff --git a/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs b/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs new file mode 100644 index 00000000..1708fba9 --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs @@ -0,0 +1,253 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SubResources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// Maps a Proxmox estate onto RackPeek's three levels. Pure. +/// <para> +/// A node becomes two resources, because it is two things: the machine +/// (<c>kepler</c>, a Server carrying the CPU, memory and disks) and the +/// hypervisor installed on it (<c>kepler-pve</c>, a System). Guests then run on +/// the hypervisor, giving Hardware -> System -> System all the way down. +/// </para> +/// </summary> +public static class ProxmoxResourceMapper { + public const string Scheme = "pve"; + + /// <summary>Label recording which cards a guest has exclusive use of.</summary> + public const string GpuLabel = "gpu"; + + /// <summary>The cap RackPeek's own validation puts on a label value.</summary> + private const int _maxLabelLength = Helpers.ThrowIfInvalid.MaxLabelValueLength; + + public static List<Resource> ToResources( + string scope, + IReadOnlyList<ProxmoxNode> nodes, + IReadOnlyList<ProxmoxGuest> guests) { + var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var resources = new List<Resource>(); + + // Nodes first, so a guest named after its node does not take the node's name. + var hypervisorNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + // PCI addresses repeat on every machine, so a guest can only be matched against + // the cards in the node it actually runs on. + var gpusByNode = nodes.ToDictionary( + n => n.Name, + n => n.Gpus, + StringComparer.OrdinalIgnoreCase); + + foreach (ProxmoxNode node in nodes) { + Server server = ToServer(node, scope, taken); + SystemResource hypervisor = ToHypervisor(node, scope, server.Name, taken); + + hypervisorNames[node.Name] = hypervisor.Name; + resources.Add(server); + resources.Add(hypervisor); + } + + // A vmid is unique within the scope, so two entries carrying the same one are + // the same guest — which is what a migration in flight looks like, reported by + // both the node it is leaving and the node it is joining. Without this they + // would come out as two resources sharing an identity. + resources.AddRange(guests + .DistinctBy(g => g.VmId) + .Select(g => ToResource(g, scope, hypervisorNames, gpusByNode, taken))); + + return resources; + } + + /// <summary>The machine itself. Takes the node's own name, being the thing people point at.</summary> + private static Server ToServer(ProxmoxNode node, string scope, ISet<string> taken) { + var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}"); + + return new Server { + Kind = Server.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest(node.Name, "server", discoveryId), + discoveryId, + taken), + DiscoveryId = discoveryId, + Ram = ToGb(node.MemoryBytes) is { } ram ? new Ram { Size = ram } : null, + Cpus = ToCpus(node), + Drives = ToDrives(node.Disks), + + // A GPU passed through to a guest is still bolted into this machine, so it + // is recorded here rather than on whatever borrows it. VRAM is not something + // the PCI list knows, so it is left off. + Gpus = node.Gpus.Count == 0 ? null : node.Gpus.Select(g => new Gpu { Model = g.Model }).ToList() + }; + } + + /// <summary>The Proxmox install running on the machine, which is what guests run on.</summary> + private static SystemResource ToHypervisor( + ProxmoxNode node, + string scope, + string serverName, + ISet<string> taken) { + var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}/pve"); + + return new SystemResource { + Kind = SystemResource.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest($"{node.Name}-pve", "hypervisor", discoveryId), + discoveryId, + taken), + DiscoveryId = discoveryId, + Type = "hypervisor", + Os = DescribeVersion(node.Version), + Cores = node.Cores > 0 ? node.Cores : null, + Ram = ToGb(node.MemoryBytes), + RunsOn = [serverName] + }; + } + + /// <summary> + /// One entry per socket. Proxmox reports totals across the machine, so they are + /// divided down — two sockets of an 8-core part read as 2 x 8, not 1 x 16. + /// </summary> + private static List<Cpu>? ToCpus(ProxmoxNode node) { + if (string.IsNullOrWhiteSpace(node.CpuModel)) + return null; + + var sockets = Math.Max(1, node.Sockets); + + var cpu = new Cpu { + Model = node.CpuModel, + Cores = node.PhysicalCores > 0 ? node.PhysicalCores / sockets : null, + Threads = node.Cores > 0 ? node.Cores / sockets : null + }; + + return Enumerable.Range(0, sockets).Select(_ => cpu).ToList(); + } + + private static List<Drive>? ToDrives(IReadOnlyList<ProxmoxDisk> disks) { + if (disks.Count == 0) + return null; + + return disks + .Select(d => new Drive { + Type = string.IsNullOrEmpty(d.Type) ? null : d.Type, + Size = (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes) + }) + .ToList(); + } + + private static SystemResource ToResource( + ProxmoxGuest guest, + string scope, + IReadOnlyDictionary<string, string> hypervisorNames, + IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode, + ISet<string> taken) { + // The vmid is unique within the cluster and survives a rename or a migration + // between nodes, which is exactly what an identity needs to do. + var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}"); + + Dictionary<string, string> 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( + 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 = labels, + RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : [] + }; + } + + /// <summary> + /// Every disk the config lists, falling back to the boot disk from the guest list + /// when the config could not be read. The storage backend says nothing about the + /// underlying medium, so the type is left off rather than guessed. + /// </summary> + private static List<Drive>? ToGuestDrives(ProxmoxGuest guest) { + IReadOnlyList<long> sizes = guest.Disks.Count > 0 + ? guest.Disks + : guest.DiskBytes > 0 + ? [guest.DiskBytes] + : []; + + if (sizes.Count == 0) + return null; + + return sizes + .Select(bytes => new Drive { Size = (int)DiscoveryUnits.BytesToWholeGb(bytes) }) + .ToList(); + } + + /// <summary> + /// Records the cards a guest holds. The GPU itself stays on the Server, because + /// that is where it is physically installed; this is the assignment, which + /// RackPeek has no first-class way to express. + /// </summary> + private static Dictionary<string, string> PassthroughLabels( + ProxmoxGuest guest, + IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode) { + var labels = new Dictionary<string, string>(); + + if (guest.PassthroughAddresses.Count == 0 + || !gpusByNode.TryGetValue(guest.Node, out IReadOnlyList<ProxmoxGpu>? gpus)) + return labels; + + var held = guest.PassthroughAddresses + .Select(address => gpus.FirstOrDefault(g => AddressesMatch(g.Address, address))) + .OfType<ProxmoxGpu>() + .Select(g => g.Model) + .ToList(); + + if (held.Count == 0) + return labels; + + var value = string.Join(", ", held); + + labels[GpuLabel] = value.Length <= _maxLabelLength + ? value + : value[.._maxLabelLength].TrimEnd(',', ' '); + + return labels; + } + + /// <summary> + /// The device list gives a function suffix (<c>0000:01:00.0</c>) that a guest + /// config usually leaves off (<c>0000:01:00</c>), so either may be the longer. + /// </summary> + private static bool AddressesMatch(string deviceAddress, string configured) => + deviceAddress.StartsWith(configured, StringComparison.OrdinalIgnoreCase) + || configured.StartsWith(deviceAddress, StringComparison.OrdinalIgnoreCase); + + /// <summary>A guest that was never named still needs one; the vmid is what people call it.</summary> + private static string FallbackName(ProxmoxGuest guest) => + string.IsNullOrWhiteSpace(guest.Name) + ? $"{(guest.Type == ProxmoxResponseParser.ContainerType ? "ct" : "vm")}-{guest.VmId}" + : guest.Name; + + /// <summary><c>pve-manager/8.2.2/9355359c</c> becomes <c>Proxmox VE 8.2.2</c>.</summary> + internal static string? DescribeVersion(string? pveVersion) { + if (string.IsNullOrWhiteSpace(pveVersion)) + return null; + + var parts = pveVersion.Split('/'); + + return parts.Length >= 2 ? $"Proxmox VE {parts[1]}" : pveVersion; + } + + private static double? ToGb(long bytes) => + bytes > 0 ? DiscoveryUnits.BytesToWholeGb(bytes) : null; +} 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/SystemFacts.cs b/RackPeek.Domain/Discovery/SystemFacts.cs new file mode 100644 index 00000000..7ebd6e76 --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemFacts.cs @@ -0,0 +1,81 @@ +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, + string? Mac = null); + +/// +/// 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; } = []; + + /// + /// 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 new file mode 100644 index 00000000..c4302f9f --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemFactsParser.cs @@ -0,0 +1,182 @@ + +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), + 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 + // 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; + } + + /// + /// 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"]; + + 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..01ddfb3d --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemProbeCommon.cs @@ -0,0 +1,133 @@ +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, + 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. + 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..4ae94c00 --- /dev/null +++ b/RackPeek.Domain/Discovery/SystemResourceMapper.cs @@ -0,0 +1,42 @@ +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); + + var resource = 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() + }; + + // 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/RackPeek.Domain/Discovery/WellKnownPorts.cs b/RackPeek.Domain/Discovery/WellKnownPorts.cs new file mode 100644 index 00000000..84579d3b --- /dev/null +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -0,0 +1,97 @@ +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 + ]; + + /// + /// 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 + ]; + + /// + /// 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/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/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/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/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/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..bb10cbd8 100644 --- a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs +++ b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; 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; @@ -24,6 +26,22 @@ 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; } + + /// + /// 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( @@ -36,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])) @@ -56,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)) @@ -66,6 +88,7 @@ public Task> GetLabelsAsync() { } public Task> GetResourceIpsAsync() { + ThrowIfLoadFailed(); var result = new List<(Resource, string)>(); List allResources = resourceCollection.Resources; @@ -99,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)) @@ -108,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(); @@ -125,9 +152,19 @@ 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, + resourceCollection.Connections, + true); + List merged = ResourceCollectionMerger.Merge( resourceCollection.Resources, incomingResources, @@ -160,6 +197,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)) @@ -167,27 +205,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)); } @@ -200,27 +253,88 @@ 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( + private async Task LoadUnderLockAsync() { + var yaml = await fileStore.ReadAllTextAsync(filePath); + + 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; + } - resourceCollection.Resources.Clear(); + // 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; + } - 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.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 + /// (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) { @@ -267,6 +381,7 @@ public Task RemoveConnectionsForPortAsync(PortReference port) { } public Task> GetConnectionsAsync() { + ThrowIfLoadFailed(); IReadOnlyList result = resourceCollection.Connections .ToList() @@ -276,6 +391,7 @@ public Task> GetConnectionsAsync() { } public Task> GetConnectionsForResourceAsync(string resource) { + ThrowIfLoadFailed(); IReadOnlyList result = resourceCollection.Connections .Where(c => @@ -288,6 +404,7 @@ public Task> GetConnectionsForResourceAsync(string res } public Task GetConnectionForPortAsync(PortReference port) { + ThrowIfLoadFailed(); Connection? connection = resourceCollection.Connections .FirstOrDefault(c => @@ -353,6 +470,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 +580,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/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/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/Resource.cs b/RackPeek.Domain/Resources/Resource.cs index 89c948e1..0d957af8 100644 --- a/RackPeek.Domain/Resources/Resource.cs +++ b/RackPeek.Domain/Resources/Resource.cs @@ -52,6 +52,41 @@ 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; } + + /// + /// 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/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/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..698c46c3 --- /dev/null +++ b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs @@ -0,0 +1,87 @@ +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; + + 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) { + 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/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/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.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/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.Domain/ServiceCollectionExtensions.cs b/RackPeek.Domain/ServiceCollectionExtensions.cs index a8fb2690..8bb3fdf4 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,13 @@ 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.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/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/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.Domain/UseCases/RenameResourceUseCase.cs b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs index 17b636ba..29132a50 100644 --- a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs +++ b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs @@ -18,15 +18,25 @@ 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; + + // 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.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..03b08f99 --- /dev/null +++ b/RackPeek.Mcp/ToolErrors.cs @@ -0,0 +1,38 @@ +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 (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); + } + 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..83556517 --- /dev/null +++ b/RackPeek.Mcp/Tools/DiscoveryTools.cs @@ -0,0 +1,181 @@ +using System.ComponentModel.DataAnnotations; +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; + + // 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); + + 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); + + 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 ProxmoxDiscovery.ReadAsync(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 + }); + } + + 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.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.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..05c463e7 --- /dev/null +++ b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json @@ -0,0 +1,718 @@ +{ + "$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}$" + }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, + "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", + "usb" + ] + }, + "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" + } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "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 + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "other": { + "allOf": [ + { + "$ref": "#/$defs/resourceBase" + }, + { + "type": "object", + "properties": { + "kind": { + "const": "Other" + }, + "model": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "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", + "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/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/RackPeek.Web/Program.cs b/RackPeek.Web/Program.cs index fc49cf5e..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,12 +90,42 @@ 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(); 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(); @@ -107,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.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..05c463e7 --- /dev/null +++ b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json @@ -0,0 +1,718 @@ +{ + "$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}$" + }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, + "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", + "usb" + ] + }, + "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" + } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "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 + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "other": { + "allOf": [ + { + "$ref": "#/$defs/resourceBase" + }, + { + "type": "object", + "properties": { + "kind": { + "const": "Other" + }, + "model": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "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", + "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..b8b19a0d 100644 --- a/RackPeek.sln +++ b/RackPeek.sln @@ -14,39 +14,144 @@ 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 +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/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/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index 24bff39a..cb8de342 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; @@ -37,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; @@ -61,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; @@ -90,15 +94,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 +130,27 @@ 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})."); + } + 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 services.AddYamlRepos(); - // Application + // Application (also registers the discovery probes, for every host) services.AddUseCases(); services.AddCommands(); } @@ -491,6 +522,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."); @@ -532,6 +573,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."); @@ -667,6 +719,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."); @@ -728,6 +788,36 @@ 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"); + + 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") + .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 => { ansible.SetDescription("Generate and manage Ansible inventory."); @@ -801,6 +891,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/Commands/Discovery/DiscoverDockerCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs new file mode 100644 index 00000000..e3587ca1 --- /dev/null +++ b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs @@ -0,0 +1,135 @@ +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. 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); + + 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); + + 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/DiscoverNetworkCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs new file mode 100644 index 00000000..ec164089 --- /dev/null +++ b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs @@ -0,0 +1,158 @@ +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 { + private IReadOnlyList? _resolvedPorts; + + [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; + + [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; + + public IReadOnlyList ResolvedPorts => + _resolvedPorts ??= string.IsNullOrWhiteSpace(Ports) + ? WellKnownPorts.Defaults + : ParsePorts(Ports) ?? WellKnownPorts.Defaults; + + public override ValidationResult Validate() { + if (Cidr != null) { + 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 < NetworkScanner.MinPrefix) + return ValidationResult.Error( + $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{NetworkScanner.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) { + 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.ParsedCidr is { } requested) { + cidr = requested; + } + 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; + } + + // 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; + } + + var options = new NetworkScanOptions { + Cidr = cidr, + Ports = settings.ResolvedPorts, + PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout), + Concurrency = settings.Parallel, + IdentifyServices = !settings.NoIdentify + }; + + 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/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/Commands/Discovery/DiscoverProxmoxCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs new file mode 100644 index 00000000..e5c29a48 --- /dev/null +++ b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs @@ -0,0 +1,101 @@ +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 ProxmoxDiscovery.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); + } +} 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/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/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/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/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/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/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 @@ }
+ + + + +
@@ -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/Shared.Rcl/OtherHardware/OtherCardComponent.razor b/Shared.Rcl/OtherHardware/OtherCardComponent.razor index 8c2dad97..7290b81b 100644 --- a/Shared.Rcl/OtherHardware/OtherCardComponent.razor +++ b/Shared.Rcl/OtherHardware/OtherCardComponent.razor @@ -1,4 +1,5 @@ @using RackPeek.Domain.Resources.OtherHardware +@using Shared.Rcl.Hardware @inject UpdateOtherUseCase UpdateUseCase @inject IGetResourceByNameUseCase GetByNameUseCase @inject IDeleteResourceUseCase DeleteUseCase @@ -103,6 +104,12 @@ }
+ + + 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/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/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/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/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index aa325387..8aaf5b66 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md @@ -1,243 +1,261 @@ -- [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 +- [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 + - [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 + - [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/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 319bdb35..ed1a7f27 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 @@ -2014,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 ``` @@ -2135,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: @@ -2252,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 ``` @@ -2373,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: @@ -3030,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 ``` @@ -3371,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: @@ -3725,6 +3947,188 @@ 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 + 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` +``` +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 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: +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 + --no-identify Skip asking living hosts what they are — sweep for + liveness only +``` + ## `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..c0fb12fc --- /dev/null +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -0,0 +1,655 @@ +# 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 | +| `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 +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 — 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 +> `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 (`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` (`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 +the graph views are built around. + +```yaml +- kind: Server + name: pve-node-01 + 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: pve-node-01-pve + type: hypervisor + os: Proxmox VE 8.2.2 + runsOn: [pve-node-01] +- kind: System + name: docker-01 + type: vm + runsOn: [pve-node-01-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: [pve-node-01-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. + +--- + +### 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 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, 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: + +* **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. + +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. + +--- + +## `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, 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 +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. + +### 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. + +### Every open port becomes a Service + +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: nebula + labels: + 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: [nebula] +``` + +This replaces the old `open-ports` label, which was a comma-separated string nothing +could link to, filter on, or hang a note from. + +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. + +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`. + +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. + +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 +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. + +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 192.168.10.0/24 --push # the LAN +rpk discover network --cidr 192.168.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. 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 + 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. + +### 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, 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 +finds itself, and unifies with its own `rpk discover system` card the same way. + +`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 + +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: + +```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..5646cba3 100644 --- a/Shared.Rcl/wwwroot/raw_docs/docs-index.json +++ b/Shared.Rcl/wwwroot/raw_docs/docs-index.json @@ -4,11 +4,13 @@ "install-guide.md", "git-integration.md", "ansible-generator-guide.md", + "discovery-guide.md", "docker-gen-guide.md", "cli-commands.md", "cli-commands-index.md", "ssh-config-export.md", "hosts-file-export.md", "inventory-api.md", + "mcp-guide.md", "versioning.md" -] \ No newline at end of file +] diff --git a/Shared.Rcl/wwwroot/raw_docs/install-guide.md b/Shared.Rcl/wwwroot/raw_docs/install-guide.md index 36031e83..779a8f96 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) @@ -92,16 +98,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: @@ -132,13 +138,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/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/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/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/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/Tests.Discovery/ArpTableParserTests.cs b/Tests.Discovery/ArpTableParserTests.cs new file mode 100644 index 00000000..81f2b2bf --- /dev/null +++ b/Tests.Discovery/ArpTableParserTests.cs @@ -0,0 +1,81 @@ +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 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")); + 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")] + [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)); + + [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/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/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-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/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-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/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/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/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-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-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/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/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/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/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/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/NetworkDiscoveryMergeTests.cs b/Tests.Discovery/NetworkDiscoveryMergeTests.cs new file mode 100644 index 00000000..4b13da37 --- /dev/null +++ b/Tests.Discovery/NetworkDiscoveryMergeTests.cs @@ -0,0 +1,192 @@ +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 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, + 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 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. + // 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/NetworkIdentityTests.cs b/Tests.Discovery/NetworkIdentityTests.cs new file mode 100644 index 00000000..2e137db0 --- /dev/null +++ b/Tests.Discovery/NetworkIdentityTests.cs @@ -0,0 +1,217 @@ +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_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. The + // port is what distinguishes the service from the box it runs on. + 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.Equal("opnsense-http", Assert.Single(cards.OfType()).Name); + } + + [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["nic-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("nic-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/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..f3dcff6e --- /dev/null +++ b/Tests.Discovery/NetworkScanMapperTests.cs @@ -0,0 +1,120 @@ +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 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) + ]); + + Assert.Equal(alone[0].DiscoveryId, Assert.Single(withVip).DiscoveryId); + } + + [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..c576cf7d --- /dev/null +++ b/Tests.Discovery/NetworkScannerTests.cs @@ -0,0 +1,386 @@ +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 { + // 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), + 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(); + + 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."); + } + + [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(); + 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 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; } + + 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, + TimeSpan timeout, + 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 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/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 new file mode 100644 index 00000000..2173e5ec --- /dev/null +++ b/Tests.Discovery/OpnsenseDiscoveryTests.cs @@ -0,0 +1,177 @@ +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_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["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 + // "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["nic-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}""")); + } +} 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/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/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/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); + } +} 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/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..177b0924 --- /dev/null +++ b/Tests.Discovery/RemoteDockerDiscoveryTests.cs @@ -0,0 +1,211 @@ +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; + } + + // -- 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.Discovery/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs new file mode 100644 index 00000000..5008c51d --- /dev/null +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -0,0 +1,274 @@ +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 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 + // 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); + } + + // --------------------------------------------------------------- + // 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 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/Tests.Discovery/ServiceEndpointTests.cs b/Tests.Discovery/ServiceEndpointTests.cs new file mode 100644 index 00000000..94b78f05 --- /dev/null +++ b/Tests.Discovery/ServiceEndpointTests.cs @@ -0,0 +1,115 @@ +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()); + + [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); + } +} 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/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.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/BreadcrumbTypeTests.cs b/Tests.E2e/BreadcrumbTypeTests.cs new file mode 100644 index 00000000..e82525c8 --- /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 "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, + 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(); + } + } +} 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.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/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/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/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(); } 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/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/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.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.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}"); 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..caf8ad02 --- /dev/null +++ b/Tests.Mcp/DiscoveryToolTests.cs @@ -0,0 +1,220 @@ +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); + } + + [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/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..8608a58e --- /dev/null +++ b/Tests.Mcp/FakeHttpServer.cs @@ -0,0 +1,68 @@ +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, bool ipv6 = false) { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + // 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); + await app.StartAsync(); + + return new FakeHttpServer(app); + } + + /// A fake Docker Engine API with the shared captured fixtures. + 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, + /// 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/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..ae8c8626 --- /dev/null +++ b/Tests/Api/InventoryEndpointStartupTests.cs @@ -0,0 +1,88 @@ +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. +/// +[Collection("Process-wide static state")] +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/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 3d1276bf..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) { @@ -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/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..d92cca4a 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 { @@ -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); + } } diff --git a/Tests/EndToEnd/CorruptConfigTests.cs b/Tests/EndToEnd/CorruptConfigTests.cs new file mode 100644 index 00000000..153dee35 --- /dev/null +++ b/Tests/EndToEnd/CorruptConfigTests.cs @@ -0,0 +1,128 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd; + +// Load-side half of https://github.com/Timmoth/RackPeek/issues/337. +// +// 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("Process-wide static state")] +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; + } + + private string ConfigPath => Path.Combine(fs.Root, "config.yaml"); + + [Fact] + 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"); + + // 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_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"); + + 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/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/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); + } +} diff --git a/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs b/Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs new file mode 100644 index 00000000..4d666d82 --- /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("Process-wide static state")] +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/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs b/Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs index 7514f0ae..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) @@ -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"), """ 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 04b6a3a4..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) { @@ -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/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 eb444574..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) { @@ -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..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) { @@ -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..d5a05e79 --- /dev/null +++ b/Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs @@ -0,0 +1,109 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.LaptopTests; + +[Collection("Process-wide static state")] +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/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 3d268a21..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) { @@ -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..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) { @@ -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..efa5412e --- /dev/null +++ b/Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs @@ -0,0 +1,114 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.OtherTests; + +[Collection("Process-wide static state")] +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/OtherTests/OtherWorkflowTests.cs b/Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs index 41f1b0ee..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) { @@ -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/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 390862ed..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) { @@ -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/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 7508fdd5..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) { @@ -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/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 7813c4d1..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) { @@ -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/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 afb05678..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) { @@ -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/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 1fc7c80b..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) { @@ -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/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 cd2a72b5..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) { @@ -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..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) { @@ -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..eeacf690 --- /dev/null +++ b/Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs @@ -0,0 +1,119 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.UpsTests; + +[Collection("Process-wide static state")] +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); + } +} diff --git a/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs b/Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs index f1512d9e..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) { @@ -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/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; 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/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..b7f70a34 --- /dev/null +++ b/Tests/TestConfigs/v4/11-demo-config.yaml @@ -0,0 +1,518 @@ +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: 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/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/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)); + } +} diff --git a/Tests/Yaml/SchemaTests.cs b/Tests/Yaml/SchemaTests.cs index 27a1c3d2..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,10 +62,37 @@ 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)] + [InlineData(3)] + [InlineData(4)] public void All_yaml_files_conform_to_schema(int version) { // Arrange JsonSchema schema = LoadSchema(version); 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 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])) diff --git a/justfile b/justfile index ece81949..8b2a2ad3 100644 --- a/justfile +++ b/justfile @@ -61,6 +61,16 @@ 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("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 @@ -73,9 +83,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 + MCP + 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-mcp 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..05c463e7 --- /dev/null +++ b/schemas/v4/schema.v4.json @@ -0,0 +1,718 @@ +{ + "$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}$" + }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, + "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", + "usb" + ] + }, + "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" + } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "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 + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "other": { + "allOf": [ + { + "$ref": "#/$defs/resourceBase" + }, + { + "type": "object", + "properties": { + "kind": { + "const": "Other" + }, + "model": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/$defs/port" + } + } + } + } + ], + "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", + "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 + } + } +}