From 9b24eaf0f06db997a47efae561c4c6f2eaa72368 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Tue, 17 Mar 2026 15:45:32 -0500 Subject: [PATCH] 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) --- .../Connection/ConnectPveServerCmdlet.cs | 125 ++++++++++++++++++ .../Connection/DisconnectPveServerCmdlet.cs | 54 ++++++++ .../Connection/TestPveConnectionCmdlet.cs | 42 ++++++ .../Cmdlets/Nodes/GetPveNodeCmdlet.cs | 60 +++++++++ .../Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs | 65 +++++++++ src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs | 39 ++++++ src/PSProxmoxVE/ModuleState.cs | 10 ++ 7 files changed, 395 insertions(+) create mode 100644 src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs create mode 100644 src/PSProxmoxVE/ModuleState.cs diff --git a/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs new file mode 100644 index 0000000..c418f1d --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs @@ -0,0 +1,125 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Authentication; + +namespace PSProxmoxVE.Cmdlets.Connection +{ + /// + /// Establishes an authenticated session to a Proxmox VE server. + /// + /// 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. + /// + /// + [Cmdlet(VerbsCommunications.Connect, "PveServer", + DefaultParameterSetName = ParameterSetCredential)] + [OutputType(typeof(PveSession))] + public sealed class ConnectPveServerCmdlet : PSCmdlet + { + private const string ParameterSetCredential = "Credential"; + private const string ParameterSetApiToken = "ApiToken"; + + /// Hostname or IP address of the Proxmox VE server. + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + public string Server { get; set; } = string.Empty; + + /// API port. Defaults to 8006. + [Parameter(Mandatory = false)] + [ValidateRange(1, 65535)] + public int Port { get; set; } = 8006; + + /// Username and password credential. Username must include a realm, e.g. root@pam. + [Parameter(Mandatory = true, ParameterSetName = ParameterSetCredential)] + [ValidateNotNull] + public PSCredential? Credential { get; set; } + + /// + /// Proxmox VE API token in the format USER@REALM!TOKENID=UUID, + /// e.g. root@pam!mytoken=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. + /// + [Parameter(Mandatory = true, ParameterSetName = ParameterSetApiToken)] + [ValidateNotNullOrEmpty] + public string? ApiToken { get; set; } + + /// When specified, skips TLS certificate validation for the server. + [Parameter(Mandatory = false)] + public SwitchParameter SkipCertificateCheck { get; set; } + + /// When specified, writes the resulting PveSession object to the pipeline. + [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); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs new file mode 100644 index 0000000..eb9a711 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs @@ -0,0 +1,54 @@ +using System; +using System.Management.Automation; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; + +namespace PSProxmoxVE.Cmdlets.Connection +{ + /// + /// Closes the active Proxmox VE session. + /// + /// 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. + /// + /// + [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}."); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs new file mode 100644 index 0000000..77bac3e --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs @@ -0,0 +1,42 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Authentication; + +namespace PSProxmoxVE.Cmdlets.Connection +{ + /// + /// Tests whether an active, non-expired Proxmox VE session exists. + /// + /// 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). + /// + /// + [Cmdlet(VerbsDiagnostic.Test, "PveConnection")] + [OutputType(typeof(bool), ParameterSetName = new[] { "Default" })] + [OutputType(typeof(PveSession), ParameterSetName = new[] { "Detailed" })] + public sealed class TestPveConnectionCmdlet : PSCmdlet + { + /// + /// When specified, writes the PveSession object instead of a boolean. + /// Nothing is written if no session is active. + /// + [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); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs new file mode 100644 index 0000000..f4aa232 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeCmdlet.cs @@ -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 +{ + /// + /// Returns one or more Proxmox VE cluster nodes. + /// + /// Get-PveNode retrieves all nodes visible to the authenticated session. + /// Use -Name to filter to a specific node by exact name. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveNode")] + [OutputType(typeof(PveNode))] + public sealed class GetPveNodeCmdlet : PveCmdletBase + { + /// Optional node name filter. When specified, only the matching node is returned. + [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>( + JsonSerializer.CreateDefault()) ?? new List(); + + foreach (var node in nodes) + { + if (Name is null || string.Equals(node.Name, Name, StringComparison.OrdinalIgnoreCase)) + WriteObject(node); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs new file mode 100644 index 0000000..6d2e839 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeStatusCmdlet.cs @@ -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 +{ + /// + /// Returns detailed status information for a Proxmox VE node. + /// + /// 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. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveNodeStatus")] + [OutputType(typeof(PveNodeStatus))] + public sealed class GetPveNodeStatusCmdlet : PveCmdletBase + { + /// + /// Name of the node to query. Accepts pipeline input via the PveNode.Name property. + /// + [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(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); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs new file mode 100644 index 0000000..f0f051d --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs @@ -0,0 +1,39 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Exceptions; + +namespace PSProxmoxVE.Cmdlets +{ + /// + /// Base class for all PSProxmoxVE cmdlets. Provides optional -Session parameter + /// and a helper method to resolve and validate the active session. + /// + public abstract class PveCmdletBase : PSCmdlet + { + /// + /// An explicit PveSession to use for this cmdlet invocation. + /// When omitted, the module-level session stored by Connect-PveServer is used. + /// + [Parameter(Mandatory = false)] + public PveSession? Session { get; set; } + + /// + /// Returns the session to use for this cmdlet. + /// Resolution order: -Session parameter → ModuleState.ActiveSession. + /// Throws if no session is available, + /// or if the session ticket has expired. + /// + protected PveSession GetSession() + { + var session = Session ?? ModuleState.ActiveSession; + + if (session is null) + throw new PveNotConnectedException(); + + if (session.IsExpired) + throw new PveSessionExpiredException(); + + return session; + } + } +} diff --git a/src/PSProxmoxVE/ModuleState.cs b/src/PSProxmoxVE/ModuleState.cs new file mode 100644 index 0000000..6c5700d --- /dev/null +++ b/src/PSProxmoxVE/ModuleState.cs @@ -0,0 +1,10 @@ +using PSProxmoxVE.Core.Authentication; + +namespace PSProxmoxVE +{ + /// Module-scoped state container for the active PVE session + internal static class ModuleState + { + internal static PveSession? ActiveSession { get; set; } + } +}