mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-07-26 07:58:14 +00:00
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:
@@ -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}'...");
|
||||
|
||||
@@ -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' {
|
||||
|
||||
Reference in New Issue
Block a user