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) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-20 12:21:40 -05:00
parent 0bf275ea9f
commit ffec75b461
11 changed files with 932 additions and 9 deletions
@@ -0,0 +1,75 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.Network;
/// <summary>
/// Represents a Software-Defined Networking (SDN) subnet as returned by
/// the /cluster/sdn/vnets/{vnet}/subnets endpoint.
/// Available in Proxmox VE 8.0+.
/// </summary>
public class PveSdnSubnet
{
/// <summary>
/// The subnet CIDR (e.g. "10.0.0.0/24" or "2001:db8::/64").
/// </summary>
[JsonPropertyName("subnet")]
[JsonProperty("subnet")]
public string Subnet { get; set; } = string.Empty;
/// <summary>
/// The VNet this subnet belongs to.
/// </summary>
[JsonPropertyName("vnet")]
[JsonProperty("vnet")]
public string? Vnet { get; set; }
/// <summary>
/// The gateway IP address for this subnet.
/// </summary>
[JsonPropertyName("gateway")]
[JsonProperty("gateway")]
public string? Gateway { get; set; }
/// <summary>
/// Whether SNAT (source NAT) is enabled for this subnet.
/// </summary>
[JsonPropertyName("snat")]
[JsonProperty("snat")]
public int? Snat { get; set; }
/// <summary>
/// The DNS zone name for this subnet.
/// </summary>
[JsonPropertyName("dnszoneprefix")]
[JsonProperty("dnszoneprefix")]
public string? DnsZonePrefix { get; set; }
/// <summary>
/// The DHCP range configuration for automatic IP assignment.
/// </summary>
[JsonPropertyName("dhcp-range")]
[JsonProperty("dhcp-range")]
public string? DhcpRange { get; set; }
/// <summary>
/// The subnet type identifier used internally by PVE.
/// </summary>
[JsonPropertyName("type")]
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// Optional comment or description.
/// </summary>
[JsonPropertyName("comments")]
[JsonProperty("comments")]
public string? Comments { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"SDN Subnet: {Subnet} | VNet: {Vnet ?? "N/A"} | "
+ $"Gateway: {Gateway ?? "N/A"}";
}
}
@@ -113,6 +113,90 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Snapshots
// -------------------------------------------------------------------------
/// <summary>
/// Returns all snapshots for a container.
/// </summary>
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<PveSnapshot[]>() ?? Array.Empty<PveSnapshot>();
}
/// <summary>
/// Creates a snapshot of a container. Returns the task UPID.
/// </summary>
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<string, string>
{
["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);
}
/// <summary>
/// Removes a snapshot from a container. Returns the task UPID.
/// </summary>
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);
}
/// <summary>
/// Rolls a container back to a snapshot. Returns the task UPID.
/// </summary>
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
// -------------------------------------------------------------------------
@@ -239,6 +239,71 @@ namespace PSProxmoxVE.Core.Services
client.DeleteAsync($"cluster/sdn/vnets/{vnet}").GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// SDN Subnets — requires PVE 8.0+
// -------------------------------------------------------------------------
/// <summary>
/// Returns all subnets for an SDN VNet. Requires PVE 8.0+.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="vnet">The SDN VNet identifier.</param>
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<PveSdnSubnet[]>() ?? Array.Empty<PveSdnSubnet>();
}
/// <summary>
/// Creates an SDN subnet on a VNet. Requires PVE 8.0+.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="vnet">The SDN VNet identifier.</param>
/// <param name="config">Subnet configuration parameters including "subnet" (CIDR).</param>
public void CreateSdnSubnet(
PveSession session,
string vnet,
Dictionary<string, object> 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();
}
/// <summary>
/// Removes an SDN subnet from a VNet. Requires PVE 8.0+.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="vnet">The SDN VNet identifier.</param>
/// <param name="subnet">The subnet CIDR to remove.</param>
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
// -------------------------------------------------------------------------
@@ -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
{
/// <summary>
/// <para type="synopsis">Lists SDN subnets for a VNet in Proxmox VE.</para>
/// <para type="description">
/// Returns Software-Defined Networking subnet definitions for the specified VNet.
/// Requires Proxmox VE 8.0 or later.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveSdnSubnet")]
[OutputType(typeof(PveSdnSubnet))]
public class GetPveSdnSubnetCmdlet : PveCmdletBase
{
/// <summary>The SDN VNet to list subnets for.</summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The SDN VNet name.")]
public string Vnet { get; set; } = string.Empty;
/// <summary>Optional subnet CIDR filter.</summary>
[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<PveSdnSubnet>()!;
if (!string.IsNullOrEmpty(Subnet) &&
!string.Equals(subnet.Subnet, Subnet, System.StringComparison.OrdinalIgnoreCase))
continue;
WriteObject(subnet);
}
}
}
}
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Management.Automation;
using PSProxmoxVE.Core.Client;
namespace PSProxmoxVE.Cmdlets.Network
{
/// <summary>
/// <para type="synopsis">Creates a new SDN subnet on a VNet in Proxmox VE.</para>
/// <para type="description">
/// Adds a new Software-Defined Networking subnet to the specified SDN VNet.
/// Requires Proxmox VE 8.0 or later.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.New, "PveSdnSubnet", SupportsShouldProcess = true)]
public class NewPveSdnSubnetCmdlet : PveCmdletBase
{
/// <summary>The SDN VNet to add the subnet to.</summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")]
public string Vnet { get; set; } = string.Empty;
/// <summary>The subnet CIDR notation (e.g. "10.0.0.0/24").</summary>
[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;
/// <summary>The gateway IP address for this subnet.</summary>
[Parameter(Mandatory = false, HelpMessage = "Gateway IP address for the subnet.")]
public string? Gateway { get; set; }
/// <summary>Enable SNAT (source NAT) for this subnet.</summary>
[Parameter(Mandatory = false, HelpMessage = "Enable source NAT for this subnet.")]
public SwitchParameter Snat { get; set; }
/// <summary>DNS zone prefix for this subnet.</summary>
[Parameter(Mandatory = false, HelpMessage = "DNS zone prefix for this subnet.")]
public string? DnsZonePrefix { get; set; }
/// <summary>DHCP range for automatic IP assignment (e.g. "start-address=10.0.0.100,end-address=10.0.0.200").</summary>
[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<string, string>
{
["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();
}
}
}
@@ -0,0 +1,38 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Client;
namespace PSProxmoxVE.Cmdlets.Network
{
/// <summary>
/// <para type="synopsis">Removes an SDN subnet from a VNet in Proxmox VE.</para>
/// <para type="description">
/// Deletes the specified Software-Defined Networking subnet from the given VNet.
/// Requires Proxmox VE 8.0 or later.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Remove, "PveSdnSubnet", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
public class RemovePveSdnSubnetCmdlet : PveCmdletBase
{
/// <summary>The SDN VNet containing the subnet.</summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")]
public string Vnet { get; set; } = string.Empty;
/// <summary>The subnet CIDR to remove (e.g. "10.0.0.0/24").</summary>
[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();
}
}
}
+12
View File
@@ -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.'
}
}