Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 2 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 5 additions & 4 deletions build/_build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand All @@ -20,9 +21,9 @@

<ItemGroup>
<!-- Pinned to the set used across the homelab (matches Homelab/build). Bump deliberately. -->
<PackageReference Include="Fallout.Build" Version="2026.1.0-preview.5.gd84fad5cd7" />
<PackageReference Include="Fallout.Common" Version="2026.1.0-preview.5.gd84fad5cd7" />
<PackageReference Include="Fallout.Components" Version="2026.1.0-preview.5.gd84fad5cd7" />
<PackageReference Include="Fallout.Build" Version="10.4.0" />
<PackageReference Include="Fallout.Common" Version="10.4.0" />
<PackageReference Include="Fallout.Components" Version="10.4.0" />
</ItemGroup>

</Project>
23 changes: 4 additions & 19 deletions nuget.config
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,13 @@
<configuration>
<packageSources>
<clear />
<!-- nuget.org ONLY. Fallout.* (the build system, used only by build/) is the 10.4.0
stable line there. It used to come from the Fallout-build GitHub Packages feed,
pinned to a 2026.1.0-preview edge build that has since been pulled; that feed
and the PACKAGES_PAT it needed are no longer in the build path. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<!-- Fallout (Chris's C#/.NET build system, NUKE successor) ships its edge
channel here — consumed ONLY by the Fallout build under build/. -->
<add key="fallout" value="https://nuget.pkg.github.com/Fallout-build/index.json" />
</packageSources>
<packageSourceCredentials>
<!-- Fallout-build GitHub Packages needs auth. Token comes from the
GITHUB_PACKAGES_PAT env var (fine-grained PAT with read:packages);
never hard-coded — %VAR% is expanded by NuGet at restore. In CI it's
wired from the PACKAGES_PAT secret (see .github/workflows). -->
<fallout>
<add key="Username" value="ChrisonSimtian" />
<add key="ClearTextPassword" value="%GITHUB_PACKAGES_PAT%" />
</fallout>
</packageSourceCredentials>
<!-- Route Fallout.* exclusively to the Fallout-build edge feed (a more-specific
pattern than the nuget.org '*' catch-all, so it wins); everything else
(Microsoft.Kiota.Bundle, runtime deps) resolves from nuget.org. -->
<packageSourceMapping>
<packageSource key="fallout">
<package pattern="Fallout.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
Expand Down
9 changes: 9 additions & 0 deletions src/ProxmoxSharp/ClusterSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ public sealed record NodeSnapshot
public string? Status { get; init; }
public long? MaxMem { get; init; }
public long? Uptime { get; init; }

/// <summary>
/// False when the node could not be queried (offline, or PVE answered 595). Its
/// guests then come from <c>/cluster/resources</c> (status usually <c>"unknown"</c>),
/// and <see cref="Storage"/>/<see cref="Network"/> are empty because they were not read,
/// not because the node has none.
/// </summary>
public bool Reachable { get; init; } = true;

public IReadOnlyList<GuestSnapshot> Lxc { get; init; } = [];
public IReadOnlyList<GuestSnapshot> Qemu { get; init; } = [];
public IReadOnlyList<StorageSnapshot> Storage { get; init; } = [];
Expand Down
170 changes: 123 additions & 47 deletions src/ProxmoxSharp/ProxmoxDiscovery.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Microsoft.Kiota.Abstractions;
using ProxmoxSharp.Api;
using ProxmoxSharp.Api.Cluster.Resources;
using ProxmoxSharp.Api.Nodes;

namespace ProxmoxSharp;

Expand All @@ -9,8 +12,20 @@ namespace ProxmoxSharp;
/// for the earlier MCP-driven sweep, and the input the hub reconciles against the
/// <c>/Infrastructure</c> shapes.
/// </summary>
/// <remarks>
/// 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 <see cref="NodeSnapshot.Reachable"/> = <c>false</c>, no
/// storage/network, and its guests taken from the cluster-wide
/// <c>/cluster/resources</c>, which still lists them from the shared config.
/// Leaving those guests out instead would make a consumer read "absent" and plan
/// to create them.
/// </remarks>
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)
Expand All @@ -19,12 +34,16 @@ public ProxmoxDiscovery(ProxmoxApiClient client)
_client = client;
}

/// <summary>Builds a snapshot of the whole cluster (1 + 4×N read calls, N = node count).</summary>
/// <summary>
/// Builds a snapshot of the whole cluster (1 + 4×N read calls, N = online node
/// count, plus one <c>/cluster/resources</c> call if any node is unreachable).
/// </summary>
public async Task<ClusterSnapshot> DiscoverAsync(CancellationToken cancellationToken = default)
{
var nodes = (await _client.Nodes.GetAsNodesGetResponseAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false))?.Data ?? [];

List<ResourcesGetResponse_data>? clusterGuests = null;
var snapshots = new List<NodeSnapshot>(nodes.Count);
foreach (var node in nodes)
{
Expand All @@ -33,57 +52,114 @@ public async Task<ClusterSnapshot> 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<NodeSnapshot> 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<List<ResourcesGetResponse_data>> 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<ResourcesGetResponse_data> 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,
};
}
2 changes: 1 addition & 1 deletion src/ProxmoxSharp/ProxmoxSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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). -->
<VersionPrefix>0.2.1</VersionPrefix>
<VersionPrefix>0.2.2</VersionPrefix>
<!-- Public nuget.org ID uses the reserved Chrison.* prefix (the bare
'ProxmoxSharp' ID is taken on nuget.org by an unrelated project).
AssemblyName/RootNamespace stay 'ProxmoxSharp' (project name) so
Expand Down
Loading
Loading