feat(cmdlets): implement connection and node cmdlets

Add ModuleState, PveCmdletBase with -Session parameter and session
validation. Implement Connect-PveServer, Disconnect-PveServer,
Test-PveConnection, Get-PveNode, and Get-PveNodeStatus with pipeline
support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-17 15:45:32 -05:00
parent 52f1978de4
commit 9b24eaf0f0
7 changed files with 395 additions and 0 deletions
@@ -0,0 +1,125 @@
using System;
using System.Management.Automation;
using PSProxmoxVE.Core.Authentication;
namespace PSProxmoxVE.Cmdlets.Connection
{
/// <summary>
/// <para type="synopsis">Establishes an authenticated session to a Proxmox VE server.</para>
/// <para type="description">
/// Connect-PveServer authenticates against the Proxmox VE API using either a
/// PSCredential (username/password) or a pre-generated API token, and stores the
/// resulting session in module state for use by subsequent cmdlets.
/// </para>
/// </summary>
[Cmdlet(VerbsCommunications.Connect, "PveServer",
DefaultParameterSetName = ParameterSetCredential)]
[OutputType(typeof(PveSession))]
public sealed class ConnectPveServerCmdlet : PSCmdlet
{
private const string ParameterSetCredential = "Credential";
private const string ParameterSetApiToken = "ApiToken";
/// <summary>Hostname or IP address of the Proxmox VE server.</summary>
[Parameter(Mandatory = true, Position = 0)]
[ValidateNotNullOrEmpty]
public string Server { get; set; } = string.Empty;
/// <summary>API port. Defaults to 8006.</summary>
[Parameter(Mandatory = false)]
[ValidateRange(1, 65535)]
public int Port { get; set; } = 8006;
/// <summary>Username and password credential. Username must include a realm, e.g. root@pam.</summary>
[Parameter(Mandatory = true, ParameterSetName = ParameterSetCredential)]
[ValidateNotNull]
public PSCredential? Credential { get; set; }
/// <summary>
/// Proxmox VE API token in the format USER@REALM!TOKENID=UUID,
/// e.g. root@pam!mytoken=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.
/// </summary>
[Parameter(Mandatory = true, ParameterSetName = ParameterSetApiToken)]
[ValidateNotNullOrEmpty]
public string? ApiToken { get; set; }
/// <summary>When specified, skips TLS certificate validation for the server.</summary>
[Parameter(Mandatory = false)]
public SwitchParameter SkipCertificateCheck { get; set; }
/// <summary>When specified, writes the resulting PveSession object to the pipeline.</summary>
[Parameter(Mandatory = false)]
public SwitchParameter PassThru { get; set; }
protected override void ProcessRecord()
{
PveSession session;
switch (ParameterSetName)
{
case ParameterSetCredential:
{
if (Credential is null)
ThrowTerminatingError(new ErrorRecord(
new ArgumentNullException(nameof(Credential)),
"CredentialRequired",
ErrorCategory.InvalidArgument,
null));
var username = Credential!.UserName;
var password = Credential.GetNetworkCredential().Password;
try
{
session = PveAuthenticator.AuthenticateWithCredentials(
Server, Port, SkipCertificateCheck.IsPresent, username, password);
}
catch (Exception ex)
{
ThrowTerminatingError(new ErrorRecord(
ex,
"PveAuthenticationFailed",
ErrorCategory.AuthenticationError,
Server));
return; // unreachable — satisfies compiler
}
break;
}
case ParameterSetApiToken:
{
try
{
session = PveAuthenticator.AuthenticateWithApiToken(
Server, Port, SkipCertificateCheck.IsPresent, ApiToken!);
}
catch (Exception ex)
{
ThrowTerminatingError(new ErrorRecord(
ex,
"PveAuthenticationFailed",
ErrorCategory.AuthenticationError,
Server));
return; // unreachable — satisfies compiler
}
break;
}
default:
ThrowTerminatingError(new ErrorRecord(
new InvalidOperationException($"Unknown parameter set: {ParameterSetName}"),
"UnknownParameterSet",
ErrorCategory.InvalidOperation,
null));
return;
}
ModuleState.ActiveSession = session;
WriteVerbose($"Connected to {Server}:{Port} as {session.AuthMode} (PVE {session.ServerVersion}).");
if (PassThru.IsPresent)
WriteObject(session);
}
}
}
@@ -0,0 +1,54 @@
using System;
using System.Management.Automation;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
namespace PSProxmoxVE.Cmdlets.Connection
{
/// <summary>
/// <para type="synopsis">Closes the active Proxmox VE session.</para>
/// <para type="description">
/// Disconnect-PveServer invalidates the module-level session. For ticket-based
/// sessions it additionally attempts a best-effort DELETE against the
/// /access/ticket endpoint to invalidate the server-side ticket. Errors from
/// that request are silently ignored so that the local session is always cleared.
/// </para>
/// </summary>
[Cmdlet(VerbsCommunications.Disconnect, "PveServer",
SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
public sealed class DisconnectPveServerCmdlet : PSCmdlet
{
protected override void ProcessRecord()
{
var session = ModuleState.ActiveSession;
if (session is null)
{
WriteWarning("No active Proxmox VE session to disconnect.");
return;
}
if (!ShouldProcess($"{session.Hostname}:{session.Port}", "Disconnect"))
return;
// Best-effort server-side ticket invalidation for ticket-based auth.
if (session.AuthMode == PveAuthMode.Ticket)
{
try
{
using var client = new PveHttpClient(session);
client.DeleteAsync("/api2/json/access/ticket").GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Non-fatal — we still clear the local session below.
WriteVerbose($"Server-side ticket invalidation failed (ignored): {ex.Message}");
}
}
ModuleState.ActiveSession = null;
WriteVerbose($"Disconnected from {session.Hostname}:{session.Port}.");
}
}
}
@@ -0,0 +1,42 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Authentication;
namespace PSProxmoxVE.Cmdlets.Connection
{
/// <summary>
/// <para type="synopsis">Tests whether an active, non-expired Proxmox VE session exists.</para>
/// <para type="description">
/// Without -Detailed, returns $true if a session is present and not expired, otherwise $false.
/// With -Detailed, returns the PveSession object (or nothing if there is no active session).
/// </para>
/// </summary>
[Cmdlet(VerbsDiagnostic.Test, "PveConnection")]
[OutputType(typeof(bool), ParameterSetName = new[] { "Default" })]
[OutputType(typeof(PveSession), ParameterSetName = new[] { "Detailed" })]
public sealed class TestPveConnectionCmdlet : PSCmdlet
{
/// <summary>
/// When specified, writes the PveSession object instead of a boolean.
/// Nothing is written if no session is active.
/// </summary>
[Parameter(Mandatory = false, ParameterSetName = "Detailed")]
public SwitchParameter Detailed { get; set; }
protected override void ProcessRecord()
{
var session = ModuleState.ActiveSession;
var isConnected = session is not null && !session.IsExpired;
if (Detailed.IsPresent)
{
if (isConnected)
WriteObject(session);
// If not connected, write nothing — caller can check $null.
}
else
{
WriteObject(isConnected);
}
}
}
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Nodes;
namespace PSProxmoxVE.Cmdlets.Nodes
{
/// <summary>
/// <para type="synopsis">Returns one or more Proxmox VE cluster nodes.</para>
/// <para type="description">
/// Get-PveNode retrieves all nodes visible to the authenticated session.
/// Use -Name to filter to a specific node by exact name.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveNode")]
[OutputType(typeof(PveNode))]
public sealed class GetPveNodeCmdlet : PveCmdletBase
{
/// <summary>Optional node name filter. When specified, only the matching node is returned.</summary>
[Parameter(Mandatory = false, Position = 0)]
[ValidateNotNullOrEmpty]
public string? Name { get; set; }
protected override void ProcessRecord()
{
var session = GetSession();
string responseBody;
try
{
using var client = new PveHttpClient(session);
responseBody = client.GetAsync("/api2/json/nodes").GetAwaiter().GetResult();
}
catch (Exception ex)
{
ThrowTerminatingError(new ErrorRecord(
ex,
"GetPveNodeFailed",
ErrorCategory.ConnectionError,
session.Hostname));
return;
}
var json = JObject.Parse(responseBody);
var dataToken = json["data"] ?? throw new InvalidOperationException("Response did not contain a 'data' field.");
var nodes = dataToken.ToObject<List<PveNode>>(
JsonSerializer.CreateDefault()) ?? new List<PveNode>();
foreach (var node in nodes)
{
if (Name is null || string.Equals(node.Name, Name, StringComparison.OrdinalIgnoreCase))
WriteObject(node);
}
}
}
}
@@ -0,0 +1,65 @@
using System;
using System.Management.Automation;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Nodes;
namespace PSProxmoxVE.Cmdlets.Nodes
{
/// <summary>
/// <para type="synopsis">Returns detailed status information for a Proxmox VE node.</para>
/// <para type="description">
/// Get-PveNodeStatus retrieves CPU, memory, disk, swap, network and uptime statistics
/// for the specified node from the /nodes/{node}/status endpoint.
/// The -Node parameter accepts values from the pipeline by property name, so you can
/// pipe output from Get-PveNode directly.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveNodeStatus")]
[OutputType(typeof(PveNodeStatus))]
public sealed class GetPveNodeStatusCmdlet : PveCmdletBase
{
/// <summary>
/// Name of the node to query. Accepts pipeline input via the PveNode.Name property.
/// </summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
[ValidateNotNullOrEmpty]
public string Node { get; set; } = string.Empty;
protected override void ProcessRecord()
{
var session = GetSession();
var resource = $"/api2/json/nodes/{Uri.EscapeDataString(Node)}/status";
string responseBody;
try
{
using var client = new PveHttpClient(session);
responseBody = client.GetAsync(resource).GetAwaiter().GetResult();
}
catch (Exception ex)
{
ThrowTerminatingError(new ErrorRecord(
ex,
"GetPveNodeStatusFailed",
ErrorCategory.ConnectionError,
Node));
return;
}
var json = JObject.Parse(responseBody);
var dataToken = json["data"] ?? throw new InvalidOperationException("Response did not contain a 'data' field.");
var status = dataToken.ToObject<PveNodeStatus>(JsonSerializer.CreateDefault())
?? throw new InvalidOperationException("Failed to deserialize node status.");
// The /nodes/{node}/status response does not include the node name; populate it.
if (string.IsNullOrEmpty(status.Name))
status.Name = Node;
WriteObject(status);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Exceptions;
namespace PSProxmoxVE.Cmdlets
{
/// <summary>
/// Base class for all PSProxmoxVE cmdlets. Provides optional -Session parameter
/// and a helper method to resolve and validate the active session.
/// </summary>
public abstract class PveCmdletBase : PSCmdlet
{
/// <summary>
/// An explicit PveSession to use for this cmdlet invocation.
/// When omitted, the module-level session stored by Connect-PveServer is used.
/// </summary>
[Parameter(Mandatory = false)]
public PveSession? Session { get; set; }
/// <summary>
/// Returns the session to use for this cmdlet.
/// Resolution order: -Session parameter → ModuleState.ActiveSession.
/// Throws <see cref="PveNotConnectedException"/> if no session is available,
/// or <see cref="PveSessionExpiredException"/> if the session ticket has expired.
/// </summary>
protected PveSession GetSession()
{
var session = Session ?? ModuleState.ActiveSession;
if (session is null)
throw new PveNotConnectedException();
if (session.IsExpired)
throw new PveSessionExpiredException();
return session;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
using PSProxmoxVE.Core.Authentication;
namespace PSProxmoxVE
{
/// <summary>Module-scoped state container for the active PVE session</summary>
internal static class ModuleState
{
internal static PveSession? ActiveSession { get; set; }
}
}