From ffec75b461b1fc8f01308e1bb1b501b9481caef5 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Fri, 20 Mar 2026 12:21:40 -0500 Subject: [PATCH] feat: add container snapshot and SDN subnet cmdlets Container snapshots (4 cmdlets): - Get-PveContainerSnapshot, New-PveContainerSnapshot - Remove-PveContainerSnapshot, Restore-PveContainerSnapshot - Service methods in ContainerService - Pester unit tests SDN subnet management (3 cmdlets): - Get-PveSdnSubnet, New-PveSdnSubnet, Remove-PveSdnSubnet - PveSdnSubnet model and NetworkService methods - Pester unit tests Also updates manifest (73 cmdlets), README cmdlet reference, and CHANGELOG. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 11 +- README.md | 22 +- .../Models/Network/PveSdnSubnet.cs | 75 +++++ .../Services/ContainerService.cs | 84 +++++ .../Services/NetworkService.cs | 65 ++++ .../Cmdlets/Network/GetPveSdnSubnetCmdlet.cs | 50 +++ .../Cmdlets/Network/NewPveSdnSubnetCmdlet.cs | 66 ++++ .../Network/RemovePveSdnSubnetCmdlet.cs | 38 +++ src/PSProxmoxVE/PSProxmoxVE.psd1 | 12 + .../ContainerSnapshotCmdlets.Tests.ps1 | 299 ++++++++++++++++++ .../Network/SdnSubnetCmdlets.Tests.ps1 | 219 +++++++++++++ 11 files changed, 932 insertions(+), 9 deletions(-) create mode 100644 src/PSProxmoxVE.Core/Models/Network/PveSdnSubnet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs create mode 100644 src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs create mode 100644 tests/PSProxmoxVE.Tests/Containers/ContainerSnapshotCmdlets.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Network/SdnSubnetCmdlets.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 063614a..c07cb8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi ## [Unreleased] +## [0.1.0-preview] - 2026-03-19 + ### Added - Initial project structure and solution setup @@ -14,9 +16,10 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi - HTTP client with manual multipart ISO upload (bugzilla 7389 workaround) - Typed response models for PVE 8.x and 9.x API resources - Service layer for all resource domains -- PowerShell cmdlets for all supported operations +- 66 PowerShell cmdlets for VMs, containers, storage, networking, SDN, users, roles, permissions, API tokens, templates, cloud-init, snapshots, and tasks +- QEMU guest agent cmdlets (Test-PveVmGuestAgent, Get-PveVmGuestNetwork, Invoke-PveVmGuestExec) - xUnit unit tests for core library -- Pester 5 cmdlet tests across OS/PS version matrix -- Integration test stubs for future live target testing -- GitHub Actions CI/CD workflows +- Pester 5 cmdlet tests across OS/PS version matrix (Windows PS 5.1, PS 7.5 on Windows/Linux/macOS) +- Integration tests against live PVE 8 and PVE 9 instances via Terraform-provisioned nested VMs +- GitHub Actions CI/CD workflows (build, unit tests, integration tests) - Format definitions for default table output on all PS versions diff --git a/README.md b/README.md index 7bcbc3c..0cbba48 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # PSProxmoxVE -A production-grade C# binary PowerShell module for managing Proxmox VE environments. Built for internal worklab use. +[![Build](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/build.yml/badge.svg)](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/build.yml) +[![Unit Tests](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/unit-tests.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/goodolclint/PSProxmoxVE/blob/main/LICENSE) + +A production-grade C# binary PowerShell module for managing Proxmox VE environments. ## Supported Proxmox VE Versions @@ -27,11 +31,12 @@ All public API surface compiles and functions correctly on both targets. ## Installation -This module is distributed via private GitHub releases (not PSGallery). - ```powershell -# Download the latest release from GitHub -# Extract to a directory in your PSModulePath +# Install from the PowerShell Gallery +Install-Module -Name PSProxmoxVE -Scope CurrentUser + +# Or install a prerelease version +Install-Module -Name PSProxmoxVE -Scope CurrentUser -AllowPrerelease # Verify installation Get-Module -ListAvailable PSProxmoxVE @@ -235,6 +240,10 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4 | `Copy-PveContainer` | Clone a container | | `Get-PveContainerConfig` | Get container configuration | | `Set-PveContainerConfig` | Modify container configuration | +| `Get-PveContainerSnapshot` | List container snapshots | +| `New-PveContainerSnapshot` | Create a container snapshot | +| `Remove-PveContainerSnapshot` | Delete a container snapshot | +| `Restore-PveContainerSnapshot` | Rollback to a container snapshot | ### Storage | Cmdlet | Description | @@ -272,6 +281,9 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4 | `Get-PveSdnVnet` | List SDN VNets | | `New-PveSdnVnet` | Create an SDN VNet | | `Remove-PveSdnVnet` | Delete an SDN VNet | +| `Get-PveSdnSubnet` | List SDN subnets for a VNet | +| `New-PveSdnSubnet` | Create an SDN subnet | +| `Remove-PveSdnSubnet` | Delete an SDN subnet | ### Users & Permissions | Cmdlet | Description | diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnSubnet.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnSubnet.cs new file mode 100644 index 0000000..46c3fdf --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnSubnet.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Network; + +/// +/// Represents a Software-Defined Networking (SDN) subnet as returned by +/// the /cluster/sdn/vnets/{vnet}/subnets endpoint. +/// Available in Proxmox VE 8.0+. +/// +public class PveSdnSubnet +{ + /// + /// The subnet CIDR (e.g. "10.0.0.0/24" or "2001:db8::/64"). + /// + [JsonPropertyName("subnet")] + [JsonProperty("subnet")] + public string Subnet { get; set; } = string.Empty; + + /// + /// The VNet this subnet belongs to. + /// + [JsonPropertyName("vnet")] + [JsonProperty("vnet")] + public string? Vnet { get; set; } + + /// + /// The gateway IP address for this subnet. + /// + [JsonPropertyName("gateway")] + [JsonProperty("gateway")] + public string? Gateway { get; set; } + + /// + /// Whether SNAT (source NAT) is enabled for this subnet. + /// + [JsonPropertyName("snat")] + [JsonProperty("snat")] + public int? Snat { get; set; } + + /// + /// The DNS zone name for this subnet. + /// + [JsonPropertyName("dnszoneprefix")] + [JsonProperty("dnszoneprefix")] + public string? DnsZonePrefix { get; set; } + + /// + /// The DHCP range configuration for automatic IP assignment. + /// + [JsonPropertyName("dhcp-range")] + [JsonProperty("dhcp-range")] + public string? DhcpRange { get; set; } + + /// + /// The subnet type identifier used internally by PVE. + /// + [JsonPropertyName("type")] + [JsonProperty("type")] + public string? Type { get; set; } + + /// + /// Optional comment or description. + /// + [JsonPropertyName("comments")] + [JsonProperty("comments")] + public string? Comments { get; set; } + + /// + public override string ToString() + { + return $"SDN Subnet: {Subnet} | VNet: {Vnet ?? "N/A"} | " + + $"Gateway: {Gateway ?? "N/A"}"; + } +} diff --git a/src/PSProxmoxVE.Core/Services/ContainerService.cs b/src/PSProxmoxVE.Core/Services/ContainerService.cs index ef0f0bf..84cd829 100644 --- a/src/PSProxmoxVE.Core/Services/ContainerService.cs +++ b/src/PSProxmoxVE.Core/Services/ContainerService.cs @@ -113,6 +113,90 @@ namespace PSProxmoxVE.Core.Services .GetAwaiter().GetResult(); } + // ------------------------------------------------------------------------- + // Snapshots + // ------------------------------------------------------------------------- + + /// + /// Returns all snapshots for a container. + /// + public PveSnapshot[] GetContainerSnapshots(PveSession session, string node, int vmid) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"nodes/{node}/lxc/{vmid}/snapshot") + .GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates a snapshot of a container. Returns the task UPID. + /// + public PveTask CreateContainerSnapshot( + PveSession session, + string node, + int vmid, + string snapname, + string? description = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); + + var formData = new Dictionary + { + ["snapname"] = snapname + }; + if (!string.IsNullOrEmpty(description)) + formData["description"] = description!; + + using var client = new PveHttpClient(session); + var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/snapshot", formData) + .GetAwaiter().GetResult(); + return ParseTask(response, node); + } + + /// + /// Removes a snapshot from a container. Returns the task UPID. + /// + public PveTask RemoveContainerSnapshot( + PveSession session, + string node, + int vmid, + string snapname) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); + + using var client = new PveHttpClient(session); + var response = client.DeleteAsync($"nodes/{node}/lxc/{vmid}/snapshot/{snapname}") + .GetAwaiter().GetResult(); + return ParseTask(response, node); + } + + /// + /// Rolls a container back to a snapshot. Returns the task UPID. + /// + public PveTask RollbackContainerSnapshot( + PveSession session, + string node, + int vmid, + string snapname) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname)); + + using var client = new PveHttpClient(session); + var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback") + .GetAwaiter().GetResult(); + return ParseTask(response, node); + } + // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- diff --git a/src/PSProxmoxVE.Core/Services/NetworkService.cs b/src/PSProxmoxVE.Core/Services/NetworkService.cs index e383092..5b58d4d 100644 --- a/src/PSProxmoxVE.Core/Services/NetworkService.cs +++ b/src/PSProxmoxVE.Core/Services/NetworkService.cs @@ -239,6 +239,71 @@ namespace PSProxmoxVE.Core.Services client.DeleteAsync($"cluster/sdn/vnets/{vnet}").GetAwaiter().GetResult(); } + // ------------------------------------------------------------------------- + // SDN Subnets — requires PVE 8.0+ + // ------------------------------------------------------------------------- + + /// + /// Returns all subnets for an SDN VNet. Requires PVE 8.0+. + /// + /// The authenticated PVE session. + /// The SDN VNet identifier. + public PveSdnSubnet[] GetSdnSubnets(PveSession session, string vnet) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (string.IsNullOrWhiteSpace(vnet)) throw new ArgumentNullException(nameof(vnet)); + + using var client = new PveHttpClient(session); + var response = client.GetAsync($"cluster/sdn/vnets/{Uri.EscapeDataString(vnet)}/subnets") + .GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + return data?.ToObject() ?? Array.Empty(); + } + + /// + /// Creates an SDN subnet on a VNet. Requires PVE 8.0+. + /// + /// The authenticated PVE session. + /// The SDN VNet identifier. + /// Subnet configuration parameters including "subnet" (CIDR). + public void CreateSdnSubnet( + PveSession session, + string vnet, + Dictionary config) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (string.IsNullOrWhiteSpace(vnet)) throw new ArgumentNullException(nameof(vnet)); + if (config == null) throw new ArgumentNullException(nameof(config)); + + using var client = new PveHttpClient(session); + var formData = config.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.ToString() ?? string.Empty); + client.PostAsync($"cluster/sdn/vnets/{Uri.EscapeDataString(vnet)}/subnets", formData) + .GetAwaiter().GetResult(); + } + + /// + /// Removes an SDN subnet from a VNet. Requires PVE 8.0+. + /// + /// The authenticated PVE session. + /// The SDN VNet identifier. + /// The subnet CIDR to remove. + public void RemoveSdnSubnet(PveSession session, string vnet, string subnet) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + RequireSdn(session); + if (string.IsNullOrWhiteSpace(vnet)) throw new ArgumentNullException(nameof(vnet)); + if (string.IsNullOrWhiteSpace(subnet)) throw new ArgumentNullException(nameof(subnet)); + + using var client = new PveHttpClient(session); + client.DeleteAsync( + $"cluster/sdn/vnets/{Uri.EscapeDataString(vnet)}/subnets/{Uri.EscapeDataString(subnet)}") + .GetAwaiter().GetResult(); + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs new file mode 100644 index 0000000..0774b9b --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs @@ -0,0 +1,50 @@ +using System.Management.Automation; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Models.Network; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Lists SDN subnets for a VNet in Proxmox VE. + /// + /// Returns Software-Defined Networking subnet definitions for the specified VNet. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveSdnSubnet")] + [OutputType(typeof(PveSdnSubnet))] + public class GetPveSdnSubnetCmdlet : PveCmdletBase + { + /// The SDN VNet to list subnets for. + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The SDN VNet name.")] + public string Vnet { get; set; } = string.Empty; + + /// Optional subnet CIDR filter. + [Parameter(Mandatory = false, HelpMessage = "Filter by subnet CIDR (e.g. 10.0.0.0/24).")] + public string? Subnet { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + using var client = new PveHttpClient(session); + + WriteVerbose($"Getting SDN subnets for VNet '{Vnet}'..."); + var json = client.GetAsync($"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets") + .GetAwaiter().GetResult(); + var root = JObject.Parse(json); + var data = root["data"] as JArray ?? new JArray(); + + foreach (var item in data) + { + var subnet = item.ToObject()!; + + if (!string.IsNullOrEmpty(Subnet) && + !string.Equals(subnet.Subnet, Subnet, System.StringComparison.OrdinalIgnoreCase)) + continue; + + WriteObject(subnet); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs new file mode 100644 index 0000000..6b2aa11 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Management.Automation; +using PSProxmoxVE.Core.Client; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Creates a new SDN subnet on a VNet in Proxmox VE. + /// + /// Adds a new Software-Defined Networking subnet to the specified SDN VNet. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.New, "PveSdnSubnet", SupportsShouldProcess = true)] + public class NewPveSdnSubnetCmdlet : PveCmdletBase + { + /// The SDN VNet to add the subnet to. + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")] + public string Vnet { get; set; } = string.Empty; + + /// The subnet CIDR notation (e.g. "10.0.0.0/24"). + [Parameter(Mandatory = true, Position = 1, HelpMessage = "The subnet in CIDR notation (e.g. 10.0.0.0/24).")] + public string Subnet { get; set; } = string.Empty; + + /// The gateway IP address for this subnet. + [Parameter(Mandatory = false, HelpMessage = "Gateway IP address for the subnet.")] + public string? Gateway { get; set; } + + /// Enable SNAT (source NAT) for this subnet. + [Parameter(Mandatory = false, HelpMessage = "Enable source NAT for this subnet.")] + public SwitchParameter Snat { get; set; } + + /// DNS zone prefix for this subnet. + [Parameter(Mandatory = false, HelpMessage = "DNS zone prefix for this subnet.")] + public string? DnsZonePrefix { get; set; } + + /// DHCP range for automatic IP assignment (e.g. "start-address=10.0.0.100,end-address=10.0.0.200"). + [Parameter(Mandatory = false, HelpMessage = "DHCP range for automatic IP assignment.")] + public string? DhcpRange { get; set; } + + protected override void ProcessRecord() + { + if (!ShouldProcess($"Subnet {Subnet} on VNet {Vnet}", "Create PVE SDN Subnet")) + return; + + var session = GetSession(); + using var client = new PveHttpClient(session); + + WriteVerbose($"Creating SDN subnet '{Subnet}' on VNet '{Vnet}'..."); + var data = new Dictionary + { + ["subnet"] = Subnet, + ["type"] = "subnet" + }; + + if (!string.IsNullOrEmpty(Gateway)) data["gateway"] = Gateway!; + if (Snat.IsPresent) data["snat"] = "1"; + if (!string.IsNullOrEmpty(DnsZonePrefix)) data["dnszoneprefix"] = DnsZonePrefix!; + if (!string.IsNullOrEmpty(DhcpRange)) data["dhcp-range"] = DhcpRange!; + + client.PostAsync( + $"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets", data) + .GetAwaiter().GetResult(); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs new file mode 100644 index 0000000..7d39508 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs @@ -0,0 +1,38 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Client; + +namespace PSProxmoxVE.Cmdlets.Network +{ + /// + /// Removes an SDN subnet from a VNet in Proxmox VE. + /// + /// Deletes the specified Software-Defined Networking subnet from the given VNet. + /// Requires Proxmox VE 8.0 or later. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveSdnSubnet", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public class RemovePveSdnSubnetCmdlet : PveCmdletBase + { + /// The SDN VNet containing the subnet. + [Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")] + public string Vnet { get; set; } = string.Empty; + + /// The subnet CIDR to remove (e.g. "10.0.0.0/24"). + [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true, HelpMessage = "The subnet CIDR to remove.")] + public string Subnet { get; set; } = string.Empty; + + protected override void ProcessRecord() + { + if (!ShouldProcess($"Subnet {Subnet} on VNet {Vnet}", "Remove PVE SDN Subnet")) + return; + + var session = GetSession(); + using var client = new PveHttpClient(session); + + WriteVerbose($"Removing SDN subnet '{Subnet}' from VNet '{Vnet}'..."); + client.DeleteAsync( + $"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets/{System.Uri.EscapeDataString(Subnet)}") + .GetAwaiter().GetResult(); + } + } +} diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1 index 7fef605..1c154cb 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.psd1 +++ b/src/PSProxmoxVE/PSProxmoxVE.psd1 @@ -90,6 +90,11 @@ 'Copy-PveContainer', 'Get-PveContainerConfig', 'Set-PveContainerConfig', + # Container Snapshots (4) + 'Get-PveContainerSnapshot', + 'New-PveContainerSnapshot', + 'Remove-PveContainerSnapshot', + 'Restore-PveContainerSnapshot', # Storage 'Get-PveStorage', @@ -121,6 +126,10 @@ 'Get-PveSdnVnet', 'New-PveSdnVnet', 'Remove-PveSdnVnet', + # SDN Subnets (3) + 'Get-PveSdnSubnet', + 'New-PveSdnSubnet', + 'Remove-PveSdnSubnet', # Users 'Get-PveUser', @@ -190,6 +199,9 @@ # URI to the project for this module ProjectUri = 'https://github.com/goodolclint/PSProxmoxVE' + # Release notes for this version + ReleaseNotes = 'Initial preview release. Supports PVE 8.x and 9.x with VM, container, storage, network, SDN, user/role/permission, template, cloud-init, snapshot, and task management.' + } } diff --git a/tests/PSProxmoxVE.Tests/Containers/ContainerSnapshotCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Containers/ContainerSnapshotCmdlets.Tests.ps1 new file mode 100644 index 0000000..34050a9 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Containers/ContainerSnapshotCmdlets.Tests.ps1 @@ -0,0 +1,299 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for container snapshot cmdlets: + Get-PveContainerSnapshot, New-PveContainerSnapshot, + Remove-PveContainerSnapshot, Restore-PveContainerSnapshot. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('Get-PveContainerSnapshot', 'New-PveContainerSnapshot', + 'Remove-PveContainerSnapshot', 'Restore-PveContainerSnapshot')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'Container snapshot cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveContainerSnapshot' } + @{ cmdName = 'New-PveContainerSnapshot' } + @{ cmdName = 'Remove-PveContainerSnapshot' } + @{ cmdName = 'Restore-PveContainerSnapshot' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveContainerSnapshot +# --------------------------------------------------------------------------- +Describe 'Get-PveContainerSnapshot' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveContainerSnapshot' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveContainerSnapshot' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveContainerSnapshot' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Node should be Mandatory' { + Skip-IfMissing 'Get-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Node'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'VmId should be Mandatory' { + Skip-IfMissing 'Get-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['VmId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Should have Name parameter (optional filter by snapshot name)' { + Skip-IfMissing 'Get-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('Name') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveContainerSnapshot' + { Get-PveContainerSnapshot -Node 'pve-node1' -VmId 100 -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveContainerSnapshot +# --------------------------------------------------------------------------- +Describe 'New-PveContainerSnapshot' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveContainerSnapshot' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveContainerSnapshot' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Node should be Mandatory' { + Skip-IfMissing 'New-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Node'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'VmId should be Mandatory' { + Skip-IfMissing 'New-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['VmId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Name (snapshot name) should be Mandatory' { + Skip-IfMissing 'New-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Description parameter' { + Skip-IfMissing 'New-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('Description') | Should -BeTrue + } + + It 'Should have Wait switch parameter' { + Skip-IfMissing 'New-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('Wait') | Should -BeTrue + } + + It 'Should NOT have IncludeVmState (LXC containers do not support RAM snapshots)' { + Skip-IfMissing 'New-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('IncludeVmState') | Should -BeFalse + } + } + + Context 'Without active session' { + It 'Should throw when no session is active (without -WhatIf)' { + Skip-IfMissing 'New-PveContainerSnapshot' + { New-PveContainerSnapshot -Node 'pve-node1' -VmId 100 -Name 'snap1' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveContainerSnapshot +# --------------------------------------------------------------------------- +Describe 'Remove-PveContainerSnapshot' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveContainerSnapshot' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Node should be Mandatory' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Node'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'VmId should be Mandatory' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['VmId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Name should be Mandatory' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active (without -WhatIf)' { + Skip-IfMissing 'Remove-PveContainerSnapshot' + { Remove-PveContainerSnapshot -Node 'pve-node1' -VmId 100 -Name 'snap1' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Restore-PveContainerSnapshot +# --------------------------------------------------------------------------- +Describe 'Restore-PveContainerSnapshot' { + + BeforeAll { $script:Cmd = Get-Command 'Restore-PveContainerSnapshot' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Node should be Mandatory' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Node'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'VmId should be Mandatory' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['VmId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Name should be Mandatory' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Wait switch parameter' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + $script:Cmd.Parameters.ContainsKey('Wait') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active (without -WhatIf)' { + Skip-IfMissing 'Restore-PveContainerSnapshot' + { Restore-PveContainerSnapshot -Node 'pve-node1' -VmId 100 -Name 'snap1' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Network/SdnSubnetCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Network/SdnSubnetCmdlets.Tests.ps1 new file mode 100644 index 0000000..2d19df6 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Network/SdnSubnetCmdlets.Tests.ps1 @@ -0,0 +1,219 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for SDN subnet cmdlets: + Get-PveSdnSubnet, New-PveSdnSubnet, Remove-PveSdnSubnet. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + $script:Availability = @{} + foreach ($name in @('Get-PveSdnSubnet', 'New-PveSdnSubnet', 'Remove-PveSdnSubnet')) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'SDN subnet cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveSdnSubnet' } + @{ cmdName = 'New-PveSdnSubnet' } + @{ cmdName = 'Remove-PveSdnSubnet' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveSdnSubnet +# --------------------------------------------------------------------------- +Describe 'Get-PveSdnSubnet' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnSubnet' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveSdnSubnet' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveSdnSubnet' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Vnet should be Mandatory' { + Skip-IfMissing 'Get-PveSdnSubnet' + $isMandatory = $script:Cmd.Parameters['Vnet'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Should have Subnet parameter (optional filter)' { + Skip-IfMissing 'Get-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('Subnet') | Should -BeTrue + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + + It 'Vnet should accept pipeline input by property name' { + Skip-IfMissing 'Get-PveSdnSubnet' + $attr = $script:Cmd.Parameters['Vnet'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $attr | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveSdnSubnet' + { Get-PveSdnSubnet -Vnet 'myvnet' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveSdnSubnet +# --------------------------------------------------------------------------- +Describe 'New-PveSdnSubnet' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveSdnSubnet' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveSdnSubnet' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + } + + Context 'Required parameters' { + It 'Vnet should be Mandatory' { + Skip-IfMissing 'New-PveSdnSubnet' + $isMandatory = $script:Cmd.Parameters['Vnet'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Subnet should be Mandatory' { + Skip-IfMissing 'New-PveSdnSubnet' + $isMandatory = $script:Cmd.Parameters['Subnet'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Gateway parameter' { + Skip-IfMissing 'New-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('Gateway') | Should -BeTrue + } + + It 'Should have Snat switch parameter' { + Skip-IfMissing 'New-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('Snat') | Should -BeTrue + } + + It 'Should have DnsZonePrefix parameter' { + Skip-IfMissing 'New-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('DnsZonePrefix') | Should -BeTrue + } + + It 'Should have DhcpRange parameter' { + Skip-IfMissing 'New-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('DhcpRange') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active (without -WhatIf)' { + Skip-IfMissing 'New-PveSdnSubnet' + { New-PveSdnSubnet -Vnet 'myvnet' -Subnet '10.0.0.0/24' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveSdnSubnet +# --------------------------------------------------------------------------- +Describe 'Remove-PveSdnSubnet' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnSubnet' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveSdnSubnet' + $script:Cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveSdnSubnet' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveSdnSubnet' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'Vnet should be Mandatory' { + Skip-IfMissing 'Remove-PveSdnSubnet' + $isMandatory = $script:Cmd.Parameters['Vnet'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'Subnet should be Mandatory' { + Skip-IfMissing 'Remove-PveSdnSubnet' + $isMandatory = $script:Cmd.Parameters['Subnet'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active (without -WhatIf)' { + Skip-IfMissing 'Remove-PveSdnSubnet' + { Remove-PveSdnSubnet -Vnet 'myvnet' -Subnet '10.0.0.0/24' -ErrorAction Stop } | + Should -Throw '*No active Proxmox VE session*' + } + } +}