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.
+[](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/build.yml)
+[](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/unit-tests.yml)
+[](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*'
+ }
+ }
+}