feat: add two-tier version gating (introduced vs default) for cmdlets

Replace the service-layer RequireSdn hard block with a cmdlet-level
RequireVersion helper in PveCmdletBase that supports two tiers:

- Introduced version: hard fail — the API endpoint doesn't exist
- Default version: warning only — feature exists but may not be
  enabled; users who manually enabled it can proceed

Applied to:
- SDN Zone/VNet/Subnet: introduced 6.2, default 8.0
- SDN IPAM/DNS/Controller: introduced 6.2, default 8.1
- Import-PveVmDisk, Import-PveOva: introduced 8.1

Also fixes integration tests:
- Remove duplicate OVA Import context
- Use systemd calendar format for backup schedule (PVE 9)
- Add SDN IPAM/DNS/Controller read-only integration tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-21 08:40:23 -05:00
parent c6fd0d36fc
commit 27ecca6251
23 changed files with 117 additions and 28 deletions
+18 -28
View File
@@ -4,7 +4,6 @@ using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Models.Network;
using PSProxmoxVE.Core.Models.Vms;
@@ -140,7 +139,7 @@ namespace PSProxmoxVE.Core.Services
public PveSdnZone[] GetSdnZones(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
using var client = new PveHttpClient(session);
var response = client.GetAsync("cluster/sdn/zones").GetAwaiter().GetResult();
@@ -155,7 +154,7 @@ namespace PSProxmoxVE.Core.Services
public PveSdnVnet[] GetSdnVnets(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
using var client = new PveHttpClient(session);
var response = client.GetAsync("cluster/sdn/vnets").GetAwaiter().GetResult();
@@ -173,7 +172,7 @@ namespace PSProxmoxVE.Core.Services
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
@@ -194,7 +193,7 @@ namespace PSProxmoxVE.Core.Services
public void RemoveSdnZone(PveSession session, string zone)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (string.IsNullOrWhiteSpace(zone)) throw new ArgumentNullException(nameof(zone));
using var client = new PveHttpClient(session);
@@ -211,7 +210,7 @@ namespace PSProxmoxVE.Core.Services
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
@@ -232,7 +231,7 @@ namespace PSProxmoxVE.Core.Services
public void RemoveSdnVnet(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);
@@ -251,7 +250,7 @@ namespace PSProxmoxVE.Core.Services
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);
@@ -273,7 +272,7 @@ namespace PSProxmoxVE.Core.Services
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));
@@ -294,7 +293,7 @@ namespace PSProxmoxVE.Core.Services
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));
@@ -314,7 +313,7 @@ namespace PSProxmoxVE.Core.Services
public PveSdnIpam[] GetSdnIpams(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
using var client = new PveHttpClient(session);
var response = client.GetAsync("cluster/sdn/ipams").GetAwaiter().GetResult();
@@ -328,7 +327,7 @@ namespace PSProxmoxVE.Core.Services
public void CreateSdnIpam(PveSession session, Dictionary<string, string> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
@@ -341,7 +340,7 @@ namespace PSProxmoxVE.Core.Services
public void RemoveSdnIpam(PveSession session, string ipam)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (string.IsNullOrWhiteSpace(ipam)) throw new ArgumentNullException(nameof(ipam));
using var client = new PveHttpClient(session);
@@ -359,7 +358,7 @@ namespace PSProxmoxVE.Core.Services
public PveSdnDns[] GetSdnDnsPlugins(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
using var client = new PveHttpClient(session);
var response = client.GetAsync("cluster/sdn/dns").GetAwaiter().GetResult();
@@ -373,7 +372,7 @@ namespace PSProxmoxVE.Core.Services
public void CreateSdnDnsPlugin(PveSession session, Dictionary<string, string> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
@@ -386,7 +385,7 @@ namespace PSProxmoxVE.Core.Services
public void RemoveSdnDnsPlugin(PveSession session, string dns)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (string.IsNullOrWhiteSpace(dns)) throw new ArgumentNullException(nameof(dns));
using var client = new PveHttpClient(session);
@@ -404,7 +403,7 @@ namespace PSProxmoxVE.Core.Services
public PveSdnController[] GetSdnControllers(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
using var client = new PveHttpClient(session);
var response = client.GetAsync("cluster/sdn/controllers").GetAwaiter().GetResult();
@@ -418,7 +417,7 @@ namespace PSProxmoxVE.Core.Services
public void CreateSdnController(PveSession session, Dictionary<string, string> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
@@ -431,7 +430,7 @@ namespace PSProxmoxVE.Core.Services
public void RemoveSdnController(PveSession session, string controller)
{
if (session == null) throw new ArgumentNullException(nameof(session));
RequireSdn(session);
if (string.IsNullOrWhiteSpace(controller)) throw new ArgumentNullException(nameof(controller));
using var client = new PveHttpClient(session);
@@ -443,15 +442,6 @@ namespace PSProxmoxVE.Core.Services
// Private helpers
// -------------------------------------------------------------------------
/// <summary>
/// Throws <see cref="PveVersionException"/> if the session's server version is below 8.0.
/// </summary>
private static void RequireSdn(PveSession session)
{
if (session.ServerVersion != null && !session.ServerVersion.IsAtLeast(8, 0))
throw new PveVersionException(8, 0, session.ServerVersion);
}
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord()
{
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose("Getting SDN controllers...");
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord()
{
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose("Getting SDN DNS plugins...");
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord()
{
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose("Getting SDN IPAM plugins...");
@@ -27,6 +27,7 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord()
{
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Getting SDN subnets for VNet '{Vnet}'...");
@@ -27,6 +27,7 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord()
{
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose("Getting SDN VNets...");
@@ -22,6 +22,7 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord()
{
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose("Getting SDN zones...");
@@ -41,6 +41,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose($"Creating SDN controller '{Controller}' of type '{Type}'...");
@@ -45,6 +45,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose($"Creating SDN DNS plugin '{Dns}' of type '{Type}'...");
@@ -41,6 +41,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose($"Creating SDN IPAM plugin '{Ipam}' of type '{Type}'...");
@@ -44,6 +44,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Creating SDN subnet '{Subnet}' on VNet '{Vnet}'...");
@@ -39,6 +39,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Creating SDN VNet '{Vnet}'...");
@@ -56,6 +56,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Creating SDN zone '{Zone}'...");
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose($"Removing SDN controller '{Controller}'...");
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose($"Removing SDN DNS plugin '{Dns}'...");
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN IPAM/DNS/Controller", 6, 2, 8, 1);
var service = new NetworkService();
WriteVerbose($"Removing SDN IPAM plugin '{Ipam}'...");
@@ -27,6 +27,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Removing SDN subnet '{Subnet}' from VNet '{Vnet}'...");
@@ -22,6 +22,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Removing SDN VNet '{Vnet}'...");
@@ -23,6 +23,7 @@ namespace PSProxmoxVE.Cmdlets.Network
return;
var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Removing SDN zone '{Zone}'...");
+50
View File
@@ -41,6 +41,56 @@ namespace PSProxmoxVE.Cmdlets
return session;
}
/// <summary>
/// Checks the connected PVE server version against a two-tier requirement:
/// <list type="bullet">
/// <item><b>Introduced</b> — the API endpoint was added in this version.
/// If the server is older, the cmdlet emits a terminating error because
/// the endpoint does not exist at all.</item>
/// <item><b>Default</b> (optional) — the feature is installed/enabled by
/// default since this version. If the server is between <paramref name="introducedMajor"/>
/// and <paramref name="defaultMajor"/>, a warning is emitted but the call
/// proceeds, allowing users who manually enabled the feature to succeed.</item>
/// </list>
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="featureName">Human-readable name shown in messages (e.g. "SDN IPAM").</param>
/// <param name="introducedMajor">Major version that introduced the API endpoint.</param>
/// <param name="introducedMinor">Minor version that introduced the API endpoint.</param>
/// <param name="defaultMajor">Major version where the feature is enabled by default (null to skip warning tier).</param>
/// <param name="defaultMinor">Minor version where the feature is enabled by default.</param>
protected void RequireVersion(
PveSession session,
string featureName,
int introducedMajor,
int introducedMinor,
int? defaultMajor = null,
int? defaultMinor = null)
{
var version = session.ServerVersion;
if (version == null) return; // version unknown — optimistic, let the call proceed
// Hard fail: endpoint does not exist
if (!version.IsAtLeast(introducedMajor, introducedMinor))
{
ThrowTerminatingError(new ErrorRecord(
new PveVersionException(introducedMajor, introducedMinor, version),
"PveVersionTooOld",
ErrorCategory.InvalidOperation,
null));
return;
}
// Soft warning: feature exists but may not be enabled by default
if (defaultMajor.HasValue && defaultMinor.HasValue
&& !version.IsAtLeast(defaultMajor.Value, defaultMinor.Value))
{
WriteWarning(
$"{featureName} is available since PVE {introducedMajor}.{introducedMinor} but is not enabled by default until PVE {defaultMajor}.{defaultMinor}. " +
$"Connected server is PVE {version}. The command will proceed, but may fail if the feature is not manually enabled.");
}
}
/// <summary>
/// Waits for a PVE task to complete, then optionally polls VM status until
/// it matches <paramref name="expectedStatus"/>. Used by lifecycle cmdlets
@@ -137,6 +137,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
return;
var session = GetSession();
RequireVersion(session, "VM disk import", 8, 1);
var vmService = new VmService();
var taskService = new TaskService();
@@ -76,6 +76,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
return;
var session = GetSession();
RequireVersion(session, "VM disk import", 8, 1);
var vmService = new VmService();
WriteVerbose($"Importing disk to VM {VmId} slot {Disk}: {Source} -> {TargetStorage}...");
@@ -826,6 +826,35 @@ Describe 'Integration Tests' -Tag 'Integration' {
}
}
# -----------------------------------------------------------------------
Context 'SDN — IPAM, DNS, Controllers' {
It 'Should list SDN IPAM plugins (Get-PveSdnIpam)' {
if (Skip-IfNoTarget) { return }
if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return }
# PVE always has a built-in 'pve' IPAM plugin
$ipams = Get-PveSdnIpam
$ipams | Should -Not -BeNullOrEmpty
($ipams | Where-Object { $_.Type -eq 'pve' }) | Should -Not -BeNullOrEmpty
}
It 'Should list SDN DNS plugins (Get-PveSdnDns)' {
if (Skip-IfNoTarget) { return }
if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return }
# Zero DNS plugins is acceptable; just verify no throw.
{ Get-PveSdnDns -ErrorAction Stop } | Should -Not -Throw
}
It 'Should list SDN controllers (Get-PveSdnController)' {
if (Skip-IfNoTarget) { return }
if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return }
# Zero controllers is acceptable; just verify no throw.
{ Get-PveSdnController -ErrorAction Stop } | Should -Not -Throw
}
}
# -----------------------------------------------------------------------
Context 'Templates' {
It 'Should list templates' {