diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85d0a3c..cf6ec27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,11 +25,5 @@ jobs: # ProxmoxSharp.Api client from the pinned schema via Kiota (Generated/ is # gitignored), then compiles everything; Test skips live integration tests # automatically without a secrets.env. - # - # GITHUB_PACKAGES_PAT authenticates the Fallout-build package feed (nuget.config) - # so the build/ project can restore Fallout.*. PACKAGES_PAT is a PAT with - # read:packages on the Fallout-build org. - name: Build & test (Fallout) - env: - GITHUB_PACKAGES_PAT: ${{ secrets.PACKAGES_PAT }} run: ./build.sh Test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d03ab3e..db9fc63 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,7 @@ name: publish # stable publish. A push to main publishes a PRERELEASE (…-preview.N). # # Requires a nuget.org Trusted Publishing policy for this repo + this workflow file -# (publish.yml), and the NUGET_USER secret (nuget.org profile name). GITHUB_PACKAGES_PAT -# (from PACKAGES_PAT) only authenticates the Fallout-build feed for the build/ project. +# (publish.yml), and the NUGET_USER secret (nuget.org profile name). on: push: branches: [main] @@ -52,11 +51,8 @@ jobs: with: user: ${{ secrets.NUGET_USER }} - # Fallout Pack + Publish. GITHUB_PACKAGES_PAT authenticates the Fallout-build - # feed (build/ project restore); the push uses the short-lived nuget.org key. + # Fallout Pack + Publish; the push uses the short-lived nuget.org key. - name: Publish (Fallout → nuget.org) - env: - GITHUB_PACKAGES_PAT: ${{ secrets.PACKAGES_PAT }} run: | ARGS=(Publish --nuget-api-key "${{ steps.login.outputs.NUGET_API_KEY }}") if [[ -n "${{ steps.ver.outputs.suffix }}" ]]; then diff --git a/build.sh b/build.sh index f334ed5..955c89b 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ProxmoxSharp build entrypoint (Fallout build). Requires the .NET 10 SDK on PATH -# (see global.json) and, for restoring the Fallout.* build packages, GITHUB_PACKAGES_PAT -# in the environment (a PAT with read:packages on the Fallout-build org; see nuget.config). +# (see global.json). Everything, including the Fallout.* build packages, restores from +# nuget.org with no credentials (see nuget.config). # # ./build.sh # default target: Test # ./build.sh Pack --version-suffix preview.42 diff --git a/build/_build.csproj b/build/_build.csproj index 21b16fb..bc7f95c 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -4,7 +4,8 @@ system, a NUKE successor). Restore/Compile/Test/Pack/Publish targets wrap the native `dotnet` toolchain; run via ./build.sh / ./build.ps1 or directly with `dotnet run` on this project (target as the arg). Fallout packages come - from the Fallout-build edge feed (see ../nuget.config); pinned for repro. --> + from nuget.org (10.4.0 stable; the 2026.1.0-preview edge pin was pulled from + the Fallout-build feed, same move as Homelab#390); pinned for repro. --> Exe @@ -20,9 +21,9 @@ - - - + + + diff --git a/nuget.config b/nuget.config index 7082505..089d507 100644 --- a/nuget.config +++ b/nuget.config @@ -2,28 +2,13 @@ + - - - - - - - - - - - - - diff --git a/src/ProxmoxSharp/ClusterSnapshot.cs b/src/ProxmoxSharp/ClusterSnapshot.cs index 5f4e145..815b4f5 100644 --- a/src/ProxmoxSharp/ClusterSnapshot.cs +++ b/src/ProxmoxSharp/ClusterSnapshot.cs @@ -13,6 +13,15 @@ public sealed record NodeSnapshot public string? Status { get; init; } public long? MaxMem { get; init; } public long? Uptime { get; init; } + + /// + /// False when the node could not be queried (offline, or PVE answered 595). Its + /// guests then come from /cluster/resources (status usually "unknown"), + /// and / are empty because they were not read, + /// not because the node has none. + /// + public bool Reachable { get; init; } = true; + public IReadOnlyList Lxc { get; init; } = []; public IReadOnlyList Qemu { get; init; } = []; public IReadOnlyList Storage { get; init; } = []; diff --git a/src/ProxmoxSharp/ProxmoxDiscovery.cs b/src/ProxmoxSharp/ProxmoxDiscovery.cs index 1a809e4..57ebcaa 100644 --- a/src/ProxmoxSharp/ProxmoxDiscovery.cs +++ b/src/ProxmoxSharp/ProxmoxDiscovery.cs @@ -1,4 +1,7 @@ +using Microsoft.Kiota.Abstractions; using ProxmoxSharp.Api; +using ProxmoxSharp.Api.Cluster.Resources; +using ProxmoxSharp.Api.Nodes; namespace ProxmoxSharp; @@ -9,8 +12,20 @@ namespace ProxmoxSharp; /// for the earlier MCP-driven sweep, and the input the hub reconciles against the /// /Infrastructure shapes. /// +/// +/// A node that is not online (powered off, asleep, partitioned) cannot answer +/// per-node calls: PVE proxies them and returns HTTP 595 "no route to host". Such +/// a node comes back with = false, no +/// storage/network, and its guests taken from the cluster-wide +/// /cluster/resources, which still lists them from the shared config. +/// Leaving those guests out instead would make a consumer read "absent" and plan +/// to create them. +/// public sealed class ProxmoxDiscovery { + // PVE's proxy status when the target node is unreachable. + private const int NoRouteToNode = 595; + private readonly ProxmoxApiClient _client; public ProxmoxDiscovery(ProxmoxApiClient client) @@ -19,12 +34,16 @@ public ProxmoxDiscovery(ProxmoxApiClient client) _client = client; } - /// Builds a snapshot of the whole cluster (1 + 4×N read calls, N = node count). + /// + /// Builds a snapshot of the whole cluster (1 + 4×N read calls, N = online node + /// count, plus one /cluster/resources call if any node is unreachable). + /// public async Task DiscoverAsync(CancellationToken cancellationToken = default) { var nodes = (await _client.Nodes.GetAsNodesGetResponseAsync(cancellationToken: cancellationToken) .ConfigureAwait(false))?.Data ?? []; + List? clusterGuests = null; var snapshots = new List(nodes.Count); foreach (var node in nodes) { @@ -33,57 +52,114 @@ public async Task DiscoverAsync(CancellationToken cancellationT continue; } - var nodeBuilder = _client.Nodes[node.Node]; - - var lxc = (await nodeBuilder.Lxc.GetAsLxcGetResponseAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false))?.Data ?? []; - var qemu = (await nodeBuilder.Qemu.GetAsQemuGetResponseAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false))?.Data ?? []; - var storage = (await nodeBuilder.Storage.GetAsStorageGetResponseAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false))?.Data ?? []; - var network = (await nodeBuilder.Network.GetAsNetworkGetResponseAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false))?.Data ?? []; - - snapshots.Add(new NodeSnapshot + NodeSnapshot? snapshot = null; + // A missing status is still tried: the 595 catch covers it if the node is down. + if (node.Status is not (NodesGetResponse_data_status.Offline or NodesGetResponse_data_status.Unknown)) { - Node = node.Node, - Status = node.Status?.ToString(), - MaxMem = node.Maxmem, - Uptime = node.Uptime, - Lxc = lxc.Select(g => new GuestSnapshot - { - VmId = g.Vmid, - Name = g.Name, - Status = g.Status?.ToString(), - MaxMem = g.Maxmem, - Cores = g.Cpus is { } c ? (int)c : null, - Tags = g.Tags, - }).ToList(), - Qemu = qemu.Select(g => new GuestSnapshot + try { - VmId = g.Vmid, - Name = g.Name, - Status = g.Status?.ToString(), - MaxMem = g.Maxmem, - Cores = g.Cpus is { } c ? (int)c : null, - Tags = g.Tags, - }).ToList(), - Storage = storage.Select(s => new StorageSnapshot + snapshot = await DiscoverOnlineNodeAsync(node, cancellationToken).ConfigureAwait(false); + } + catch (ApiException ex) when (ex.ResponseStatusCode == NoRouteToNode) { - Storage = s.Storage, - Type = s.Type, - Active = s.Active, - Content = s.Content, - }).ToList(), - Network = network.Select(n => new NetworkSnapshot - { - Iface = n.Iface, - Type = n.Type?.ToString(), - Address = n.Address, - }).ToList(), - }); + // Went away between the node list and its own calls: treat as offline. + } + } + + if (snapshot is null) + { + clusterGuests ??= await ClusterGuestsAsync(cancellationToken).ConfigureAwait(false); + snapshot = UnreachableNode(node, clusterGuests); + } + + snapshots.Add(snapshot); } return new ClusterSnapshot { Nodes = snapshots }; } + + private async Task DiscoverOnlineNodeAsync(NodesGetResponse_data node, CancellationToken cancellationToken) + { + var nodeBuilder = _client.Nodes[node.Node]; + + var lxc = (await nodeBuilder.Lxc.GetAsLxcGetResponseAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + var qemu = (await nodeBuilder.Qemu.GetAsQemuGetResponseAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + var storage = (await nodeBuilder.Storage.GetAsStorageGetResponseAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + var network = (await nodeBuilder.Network.GetAsNetworkGetResponseAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + + return new NodeSnapshot + { + Node = node.Node!, + Status = node.Status?.ToString(), + MaxMem = node.Maxmem, + Uptime = node.Uptime, + Lxc = lxc.Select(g => new GuestSnapshot + { + VmId = g.Vmid, + Name = g.Name, + Status = g.Status?.ToString(), + MaxMem = g.Maxmem, + Cores = g.Cpus is { } c ? (int)c : null, + Tags = g.Tags, + }).ToList(), + Qemu = qemu.Select(g => new GuestSnapshot + { + VmId = g.Vmid, + Name = g.Name, + Status = g.Status?.ToString(), + MaxMem = g.Maxmem, + Cores = g.Cpus is { } c ? (int)c : null, + Tags = g.Tags, + }).ToList(), + Storage = storage.Select(s => new StorageSnapshot + { + Storage = s.Storage, + Type = s.Type, + Active = s.Active, + Content = s.Content, + }).ToList(), + Network = network.Select(n => new NetworkSnapshot + { + Iface = n.Iface, + Type = n.Type?.ToString(), + Address = n.Address, + }).ToList(), + }; + } + + // Answered by whichever node we are talking to, from the shared cluster config, + // so it lists guests on offline nodes too (with status "unknown"). + private async Task> ClusterGuestsAsync(CancellationToken cancellationToken) => + (await _client.Cluster.Resources.GetAsResourcesGetResponseAsync( + rc => rc.QueryParameters.TypeAsGetTypeQueryParameterType = GetTypeQueryParameterType.Vm, + cancellationToken).ConfigureAwait(false))?.Data ?? []; + + private static NodeSnapshot UnreachableNode(NodesGetResponse_data node, List clusterGuests) + { + var hosted = clusterGuests.Where(r => string.Equals(r.Node, node.Node, StringComparison.Ordinal)).ToList(); + return new NodeSnapshot + { + Node = node.Node!, + Status = node.Status?.ToString(), + MaxMem = node.Maxmem, + Uptime = node.Uptime, + Reachable = false, + Lxc = hosted.Where(r => r.Type == ResourcesGetResponse_data_type.Lxc).Select(FromResource).ToList(), + Qemu = hosted.Where(r => r.Type == ResourcesGetResponse_data_type.Qemu).Select(FromResource).ToList(), + }; + } + + private static GuestSnapshot FromResource(ResourcesGetResponse_data r) => new() + { + VmId = r.Vmid, + Name = r.Name, + Status = r.Status, + MaxMem = r.Maxmem, + Cores = r.Maxcpu is { } c ? (int)c : null, + Tags = r.Tags, + }; } diff --git a/src/ProxmoxSharp/ProxmoxSharp.csproj b/src/ProxmoxSharp/ProxmoxSharp.csproj index 8696ee7..ab66b1f 100644 --- a/src/ProxmoxSharp/ProxmoxSharp.csproj +++ b/src/ProxmoxSharp/ProxmoxSharp.csproj @@ -10,7 +10,7 @@ Bumped to 0.2.0 so the active 0.2.0-preview.N line outranks the earlier 0.1.0 stable in NuGet ordering — a `0.1.0-preview.*` float would otherwise resolve the stale 0.1.0 stable (prereleases sort below their release). --> - 0.2.1 + 0.2.2