feat: add cluster config and HA management cmdlets (F060, F053)

Cluster Config (F060) — 11 new cmdlets:
- Get-PveClusterStatus, Get-PveClusterNextId
- Get/Set-PveClusterOption (datacenter settings)
- Get-PveClusterConfig, Get-PveClusterConfigNode
- Add/Remove-PveClusterConfigNode (cluster membership)
- Get-PveClusterJoinInfo, Add-PveClusterMember (join workflow)
- New-PveCluster (create cluster)

HA Management (F053) — 14 new cmdlets:
- Get/New/Set/Remove-PveHaResource (HA managed VMs/CTs)
- Move-PveHaResource (migrate/relocate via HA manager)
- Get/New/Set/Remove-PveHaGroup (node groups + priorities)
- Get-PveHaStatus (HA manager status)
- Get/New/Set/Remove-PveHaRule (PVE 9.0+ version-gated)

All cmdlets follow established conventions:
- sealed classes with [OutputType]
- ConfirmImpact.High on destructive operations (D006)
- SecureString for password parameter in Add-PveClusterMember (D002)
- Uri.EscapeDataString on all path segments (D003)
- Verb class constants (D011)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-24 18:28:11 -05:00
parent 0789601e33
commit da2037d0c5
35 changed files with 2598 additions and 0 deletions
@@ -0,0 +1,46 @@
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.Cluster;
/// <summary>
/// Represents a node entry from the GET /cluster/config/nodes endpoint.
/// </summary>
public class PveClusterConfigNode
{
/// <summary>
/// The node name.
/// </summary>
[JsonProperty("node")]
public string? Name { get; set; }
/// <summary>
/// The numeric node ID within the cluster Corosync configuration.
/// </summary>
[JsonProperty("nodeid")]
public int? NodeId { get; set; }
/// <summary>
/// The address used for Corosync ring 0 communication.
/// </summary>
[JsonProperty("ring0_addr")]
public string? Ring0Addr { get; set; }
/// <summary>
/// The address used for Corosync ring 1 communication.
/// </summary>
[JsonProperty("ring1_addr")]
public string? Ring1Addr { get; set; }
/// <summary>
/// The number of quorum votes assigned to this node.
/// </summary>
[JsonProperty("quorum_votes")]
public int? QuorumVotes { get; set; }
/// <inheritdoc />
public override string ToString()
{
var nodeIdStr = NodeId.HasValue ? $" (ID {NodeId})" : string.Empty;
return $"Node: {Name ?? "N/A"}{nodeIdStr} | Ring0: {Ring0Addr ?? "N/A"}";
}
}
@@ -0,0 +1,43 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace PSProxmoxVE.Core.Models.Cluster;
/// <summary>
/// Represents the response from GET /cluster/config/join, containing
/// the information needed to join this cluster.
/// </summary>
public class PveClusterJoinInfo
{
/// <summary>
/// The SHA digest of the current cluster configuration.
/// </summary>
[JsonProperty("config_digest")]
public string? ConfigDigest { get; set; }
/// <summary>
/// The list of nodes in the cluster with their connection details.
/// This is a complex nested structure returned as a JArray.
/// </summary>
[JsonProperty("nodelist")]
public JArray? Nodelist { get; set; }
/// <summary>
/// The preferred node to connect to when joining.
/// </summary>
[JsonProperty("preferred_node")]
public string? PreferredNode { get; set; }
/// <summary>
/// The Corosync totem configuration as a raw JSON object.
/// </summary>
[JsonProperty("totem")]
public JObject? Totem { get; set; }
/// <inheritdoc />
public override string ToString()
{
var nodeCount = Nodelist?.Count ?? 0;
return $"JoinInfo: PreferredNode={PreferredNode ?? "N/A"} | Nodes={nodeCount}";
}
}
@@ -0,0 +1,129 @@
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.Cluster;
/// <summary>
/// Represents the cluster-wide options returned by GET /cluster/options.
/// </summary>
public class PveClusterOptions
{
/// <summary>
/// The keyboard layout for the web console (e.g., "en-us", "de").
/// </summary>
[JsonProperty("keyboard")]
public string? Keyboard { get; set; }
/// <summary>
/// The default language for the web UI.
/// </summary>
[JsonProperty("language")]
public string? Language { get; set; }
/// <summary>
/// The HTTP proxy for outgoing connections (e.g., for downloading templates).
/// </summary>
[JsonProperty("http_proxy")]
public string? HttpProxy { get; set; }
/// <summary>
/// The sender email address for cluster notification emails.
/// </summary>
[JsonProperty("email_from")]
public string? EmailFrom { get; set; }
/// <summary>
/// The default console viewer (e.g., "applet", "vv", "html5").
/// </summary>
[JsonProperty("console")]
public string? Console { get; set; }
/// <summary>
/// The cluster fencing mode (e.g., "watchdog", "hardware", "both").
/// </summary>
[JsonProperty("fencing")]
public string? Fencing { get; set; }
/// <summary>
/// The default migration settings (type, network).
/// </summary>
[JsonProperty("migration")]
public string? Migration { get; set; }
/// <summary>
/// The MAC address prefix used for auto-generated MAC addresses.
/// </summary>
[JsonProperty("mac_prefix")]
public string? MacPrefix { get; set; }
/// <summary>
/// A description or comment for the cluster.
/// </summary>
[JsonProperty("description")]
public string? Description { get; set; }
/// <summary>
/// The maximum number of parallel worker processes for bulk operations.
/// </summary>
[JsonProperty("max_workers")]
public int? MaxWorkers { get; set; }
/// <summary>
/// HA manager settings.
/// </summary>
[JsonProperty("ha")]
public string? Ha { get; set; }
/// <summary>
/// Bandwidth limit settings for various operations (clone, migration, etc.).
/// </summary>
[JsonProperty("bwlimit")]
public string? BwLimit { get; set; }
/// <summary>
/// Settings controlling next VM/CT ID allocation.
/// </summary>
[JsonProperty("next-id")]
public string? NextId { get; set; }
/// <summary>
/// Cluster resource scheduling settings.
/// </summary>
[JsonProperty("crs")]
public string? Crs { get; set; }
/// <summary>
/// U2F configuration settings.
/// </summary>
[JsonProperty("u2f")]
public string? U2f { get; set; }
/// <summary>
/// WebAuthn configuration settings.
/// </summary>
[JsonProperty("webauthn")]
public string? Webauthn { get; set; }
/// <summary>
/// Tag style configuration for the web UI.
/// </summary>
[JsonProperty("tag-style")]
public string? TagStyle { get; set; }
/// <summary>
/// Notification system configuration.
/// </summary>
[JsonProperty("notify")]
public string? Notify { get; set; }
/// <summary>
/// Custom consent text displayed at login.
/// </summary>
[JsonProperty("consent-text")]
public string? ConsentText { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"ClusterOptions: Language={Language ?? "default"} | Console={Console ?? "default"} | Fencing={Fencing ?? "default"}";
}
}
@@ -0,0 +1,59 @@
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.HA;
/// <summary>
/// Represents a Proxmox VE HA group from the /cluster/ha/groups endpoint.
/// </summary>
public class PveHaGroup
{
/// <summary>
/// The HA group name.
/// </summary>
[JsonProperty("group")]
public string Group { get; set; } = string.Empty;
/// <summary>
/// Comma-separated list of nodes with optional priorities, e.g. "node1:2,node2:1".
/// </summary>
[JsonProperty("nodes")]
public string? Nodes { get; set; }
/// <summary>
/// Whether the group is restricted (1) or not (0). When restricted, resources can
/// only run on group members.
/// </summary>
[JsonProperty("restricted")]
public int? Restricted { get; set; }
/// <summary>
/// Whether failback is disabled (1) or enabled (0). When nofailback is set, the
/// resource will not automatically migrate back to its preferred node after recovery.
/// </summary>
[JsonProperty("nofailback")]
public int? NoFailback { get; set; }
/// <summary>
/// An optional comment describing this HA group.
/// </summary>
[JsonProperty("comment")]
public string? Comment { get; set; }
/// <summary>
/// The object type.
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// The configuration digest, used for conflict detection on updates.
/// </summary>
[JsonProperty("digest")]
public string? Digest { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"HA Group: {Group} | Nodes: {Nodes ?? "N/A"}";
}
}
@@ -0,0 +1,63 @@
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.HA;
/// <summary>
/// Represents a Proxmox VE HA resource from the /cluster/ha/resources endpoint.
/// </summary>
public class PveHaResource
{
/// <summary>
/// The HA resource ID, e.g. "vm:100" or "ct:200".
/// </summary>
[JsonProperty("sid")]
public string Sid { get; set; } = string.Empty;
/// <summary>
/// The requested state: "started", "stopped", "disabled", or "ignored".
/// </summary>
[JsonProperty("state")]
public string? State { get; set; }
/// <summary>
/// The HA group this resource is assigned to.
/// </summary>
[JsonProperty("group")]
public string? Group { get; set; }
/// <summary>
/// Maximum number of relocations before the resource is placed in an error state.
/// </summary>
[JsonProperty("max_relocate")]
public int? MaxRelocate { get; set; }
/// <summary>
/// Maximum number of restart attempts before the resource is placed in an error state.
/// </summary>
[JsonProperty("max_restart")]
public int? MaxRestart { get; set; }
/// <summary>
/// An optional comment describing this HA resource.
/// </summary>
[JsonProperty("comment")]
public string? Comment { get; set; }
/// <summary>
/// The resource type: "vm" or "ct".
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// The configuration digest, used for conflict detection on updates.
/// </summary>
[JsonProperty("digest")]
public string? Digest { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"HA Resource: {Sid} | State: {State ?? "N/A"} | Group: {Group ?? "N/A"}";
}
}
@@ -0,0 +1,53 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace PSProxmoxVE.Core.Models.HA;
/// <summary>
/// Represents a Proxmox VE HA rule from the /cluster/ha/rules endpoint.
/// Available in PVE 9.0 and later.
/// </summary>
public class PveHaRule
{
/// <summary>
/// The rule identifier.
/// </summary>
[JsonProperty("rule")]
public string Rule { get; set; } = string.Empty;
/// <summary>
/// The rule type.
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// An optional comment describing this HA rule.
/// </summary>
[JsonProperty("comment")]
public string? Comment { get; set; }
/// <summary>
/// The rule state: "enabled" or "disabled".
/// </summary>
[JsonProperty("state")]
public string? State { get; set; }
/// <summary>
/// The configuration digest, used for conflict detection on updates.
/// </summary>
[JsonProperty("digest")]
public string? Digest { get; set; }
/// <summary>
/// Rule-specific properties that vary by rule type.
/// </summary>
[JsonProperty("properties")]
public JObject? Properties { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"HA Rule: {Rule} | Type: {Type ?? "N/A"} | State: {State ?? "N/A"}";
}
}
@@ -0,0 +1,58 @@
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.HA;
/// <summary>
/// Represents an entry from the /cluster/ha/status/current endpoint.
/// Entries may be of type "quorum", "manager", or "service".
/// </summary>
public class PveHaStatus
{
/// <summary>
/// The status entry identifier.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// The entry type: "quorum", "manager", or "service".
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// The cluster node name associated with this entry.
/// </summary>
[JsonProperty("node")]
public string? Node { get; set; }
/// <summary>
/// The current status text.
/// </summary>
[JsonProperty("status")]
public string? Status { get; set; }
/// <summary>
/// The Unix timestamp of the status entry.
/// </summary>
[JsonProperty("timestamp")]
public long? Timestamp { get; set; }
/// <summary>
/// The CRM (Cluster Resource Manager) state.
/// </summary>
[JsonProperty("crm_state")]
public string? CrmState { get; set; }
/// <summary>
/// The requested state for a service entry.
/// </summary>
[JsonProperty("request_state")]
public string? RequestState { get; set; }
/// <inheritdoc />
public override string ToString()
{
return $"HA Status: {Id ?? "N/A"} | Node: {Node ?? "N/A"} | Status: {Status ?? "N/A"}";
}
}
@@ -0,0 +1,409 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Cluster;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE cluster configuration API operations
/// (/cluster/config, /cluster/options, /cluster/nextid).
/// </summary>
public class ClusterConfigService
{
private readonly IPveHttpClient? _injectedClient;
/// <summary>
/// Initializes a new instance of the <see cref="ClusterConfigService"/> class.
/// </summary>
public ClusterConfigService() { }
/// <summary>
/// Initializes a new instance of the <see cref="ClusterConfigService"/> class with an injected HTTP client.
/// </summary>
/// <param name="client">The HTTP client to use for API calls. The caller owns its lifetime.</param>
public ClusterConfigService(IPveHttpClient client)
{
_injectedClient = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>
/// Returns the cluster configuration directory (GET /cluster/config).
/// The response is a mixed structure returned as a raw JObject.
/// </summary>
public JObject GetClusterConfig(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/config").GetAwaiter().GetResult();
return JObject.Parse(response);
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Creates a new cluster (POST /cluster/config).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="clusterName">The name for the new cluster.</param>
/// <param name="links">Optional Corosync link addresses (e.g., "0=10.0.0.1,1=10.0.1.1").</param>
/// <param name="nodeid">Optional node ID for this node.</param>
/// <param name="votes">Optional number of quorum votes for this node.</param>
/// <returns>The UPID of the cluster creation task.</returns>
public string CreateCluster(PveSession session, string clusterName, Dictionary<string, string>? links = null, int? nodeid = null, int? votes = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrEmpty(clusterName)) throw new ArgumentNullException(nameof(clusterName));
var data = new Dictionary<string, string>
{
["clustername"] = clusterName
};
if (links != null)
{
foreach (var kvp in links)
data[kvp.Key] = kvp.Value;
}
if (nodeid.HasValue)
data["nodeid"] = nodeid.Value.ToString();
if (votes.HasValue)
data["votes"] = votes.Value.ToString();
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.PostAsync("cluster/config", data).GetAwaiter().GetResult();
var result = JObject.Parse(response)["data"];
return result?.ToString() ?? string.Empty;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the list of nodes in the cluster configuration (GET /cluster/config/nodes).
/// </summary>
public PveClusterConfigNode[] GetConfigNodes(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/config/nodes").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveClusterConfigNode[]>() ?? Array.Empty<PveClusterConfigNode>();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Adds a node to the cluster configuration (POST /cluster/config/nodes/{node}).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The node name to add.</param>
/// <param name="newNodeIp">The IP address of the new node.</param>
/// <param name="links">Optional Corosync link addresses.</param>
/// <param name="nodeid">Optional node ID for the new node.</param>
/// <param name="votes">Optional number of quorum votes.</param>
/// <param name="force">Optional flag to force the operation.</param>
/// <param name="apiversion">Optional API version override.</param>
/// <returns>The UPID of the add-node task.</returns>
public string AddConfigNode(PveSession session, string node, string? newNodeIp = null, Dictionary<string, string>? links = null, int? nodeid = null, int? votes = null, bool? force = null, int? apiversion = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrEmpty(node)) throw new ArgumentNullException(nameof(node));
var data = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(newNodeIp))
data["new_node_ip"] = newNodeIp!;
if (links != null)
{
foreach (var kvp in links)
data[kvp.Key] = kvp.Value;
}
if (nodeid.HasValue)
data["nodeid"] = nodeid.Value.ToString();
if (votes.HasValue)
data["votes"] = votes.Value.ToString();
if (force.HasValue)
data["force"] = force.Value ? "1" : "0";
if (apiversion.HasValue)
data["apiversion"] = apiversion.Value.ToString();
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.PostAsync($"cluster/config/nodes/{Uri.EscapeDataString(node)}", data).GetAwaiter().GetResult();
var result = JObject.Parse(response)["data"];
return result?.ToString() ?? string.Empty;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Removes a node from the cluster configuration (DELETE /cluster/config/nodes/{node}).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The node name to remove.</param>
public void RemoveConfigNode(PveSession session, string node)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrEmpty(node)) throw new ArgumentNullException(nameof(node));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.DeleteAsync($"cluster/config/nodes/{Uri.EscapeDataString(node)}").GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the cluster join information (GET /cluster/config/join).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">Optional node name to get join info for a specific node.</param>
public PveClusterJoinInfo GetJoinInfo(PveSession session, string? node = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var resource = "cluster/config/join";
if (!string.IsNullOrEmpty(node))
resource += $"?node={Uri.EscapeDataString(node!)}";
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveClusterJoinInfo>() ?? new PveClusterJoinInfo();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Joins the current node to an existing cluster (POST /cluster/config/join).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="hostname">The hostname or IP of an existing cluster node.</param>
/// <param name="fingerprint">The TLS certificate fingerprint of the cluster node.</param>
/// <param name="password">The root password for the cluster node (plain string; cmdlet layer handles SecureString conversion per D002).</param>
/// <param name="links">Optional Corosync link addresses.</param>
/// <param name="nodeid">Optional node ID for this node.</param>
/// <param name="votes">Optional number of quorum votes.</param>
/// <param name="force">Optional flag to force the join.</param>
/// <returns>The UPID of the join task.</returns>
public string JoinCluster(PveSession session, string hostname, string fingerprint, string password, Dictionary<string, string>? links = null, int? nodeid = null, int? votes = null, bool? force = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrEmpty(hostname)) throw new ArgumentNullException(nameof(hostname));
if (string.IsNullOrEmpty(fingerprint)) throw new ArgumentNullException(nameof(fingerprint));
if (string.IsNullOrEmpty(password)) throw new ArgumentNullException(nameof(password));
var data = new Dictionary<string, string>
{
["hostname"] = hostname,
["fingerprint"] = fingerprint,
["password"] = password
};
if (links != null)
{
foreach (var kvp in links)
data[kvp.Key] = kvp.Value;
}
if (nodeid.HasValue)
data["nodeid"] = nodeid.Value.ToString();
if (votes.HasValue)
data["votes"] = votes.Value.ToString();
if (force.HasValue)
data["force"] = force.Value ? "1" : "0";
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.PostAsync("cluster/config/join", data).GetAwaiter().GetResult();
var result = JObject.Parse(response)["data"];
return result?.ToString() ?? string.Empty;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the Corosync totem configuration (GET /cluster/config/totem).
/// </summary>
public JObject GetTotem(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/config/totem").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data as JObject ?? new JObject();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the external quorum device (qdevice) status (GET /cluster/config/qdevice).
/// </summary>
public JObject GetQdevice(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/config/qdevice").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data as JObject ?? new JObject();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the cluster API version (GET /cluster/config/apiversion).
/// </summary>
public int GetApiVersion(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/config/apiversion").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<int>() ?? 0;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the cluster-wide options (GET /cluster/options).
/// </summary>
public PveClusterOptions GetClusterOptions(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/options").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveClusterOptions>() ?? new PveClusterOptions();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Sets cluster-wide options (PUT /cluster/options).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="options">A dictionary of option names and values to set.</param>
public void SetClusterOptions(PveSession session, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (options == null) throw new ArgumentNullException(nameof(options));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PutAsync("cluster/options", options).GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the current cluster status (GET /cluster/status).
/// Delegates to the same endpoint as <see cref="ClusterService.GetClusterStatus"/>.
/// </summary>
public PveClusterStatus[] GetClusterStatus(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/status").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveClusterStatus[]>() ?? Array.Empty<PveClusterStatus>();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the next available VM/CT ID (GET /cluster/nextid).
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="vmid">Optional specific VMID to check availability for.</param>
/// <returns>The next available VMID as an integer.</returns>
public int GetNextId(PveSession session, int? vmid = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var resource = "cluster/nextid";
if (vmid.HasValue)
resource += $"?vmid={vmid.Value}";
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
// The API returns the ID as a string, parse it to int
if (data != null && int.TryParse(data.ToString(), out var id))
return id;
return 0;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
}
}
+492
View File
@@ -0,0 +1,492 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.HA;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE High Availability (HA) API operations.
/// </summary>
public class HaService
{
private readonly IPveHttpClient? _injectedClient;
/// <summary>
/// Initializes a new instance of the <see cref="HaService"/> class.
/// </summary>
public HaService() { }
/// <summary>
/// Initializes a new instance of the <see cref="HaService"/> class with an injected HTTP client.
/// </summary>
/// <param name="client">The HTTP client to use for API calls. The caller owns its lifetime.</param>
public HaService(IPveHttpClient client)
{
_injectedClient = client ?? throw new ArgumentNullException(nameof(client));
}
// -------------------------------------------------------------------------
// Resources
// -------------------------------------------------------------------------
/// <summary>
/// Returns all HA resources.
/// </summary>
public PveHaResource[] GetResources(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/ha/resources").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaResource[]>() ?? Array.Empty<PveHaResource>();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns a single HA resource by its SID.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="sid">The resource ID, e.g. "vm:100" or "ct:200".</param>
public PveHaResource GetResource(PveSession session, string sid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(sid)) throw new ArgumentNullException(nameof(sid));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync($"cluster/ha/resources/{Uri.EscapeDataString(sid)}")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaResource>() ?? new PveHaResource();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Creates a new HA resource.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="sid">The resource ID, e.g. "vm:100" or "ct:200".</param>
/// <param name="options">Additional configuration options (state, group, max_relocate, etc.).</param>
public void CreateResource(PveSession session, string sid, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(sid)) throw new ArgumentNullException(nameof(sid));
if (options == null) throw new ArgumentNullException(nameof(options));
var formData = new Dictionary<string, string>(options) { ["sid"] = sid };
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PostAsync("cluster/ha/resources", formData).GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Updates an existing HA resource.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="sid">The resource ID, e.g. "vm:100" or "ct:200".</param>
/// <param name="options">Configuration options to update.</param>
public void UpdateResource(PveSession session, string sid, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(sid)) throw new ArgumentNullException(nameof(sid));
if (options == null) throw new ArgumentNullException(nameof(options));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PutAsync($"cluster/ha/resources/{Uri.EscapeDataString(sid)}", options)
.GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Deletes an HA resource.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="sid">The resource ID, e.g. "vm:100" or "ct:200".</param>
public void DeleteResource(PveSession session, string sid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(sid)) throw new ArgumentNullException(nameof(sid));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.DeleteAsync($"cluster/ha/resources/{Uri.EscapeDataString(sid)}")
.GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Requests migration of an HA resource to another node. Returns the task UPID.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="sid">The resource ID, e.g. "vm:100" or "ct:200".</param>
/// <param name="node">The target node to migrate to.</param>
public string MigrateResource(PveSession session, string sid, string node)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(sid)) throw new ArgumentNullException(nameof(sid));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var formData = new Dictionary<string, string> { ["node"] = node };
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.PostAsync(
$"cluster/ha/resources/{Uri.EscapeDataString(sid)}/migrate", formData)
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToString() ?? string.Empty;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Requests relocation of an HA resource to another node. Returns the task UPID.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="sid">The resource ID, e.g. "vm:100" or "ct:200".</param>
/// <param name="node">The target node to relocate to.</param>
public string RelocateResource(PveSession session, string sid, string node)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(sid)) throw new ArgumentNullException(nameof(sid));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var formData = new Dictionary<string, string> { ["node"] = node };
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.PostAsync(
$"cluster/ha/resources/{Uri.EscapeDataString(sid)}/relocate", formData)
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToString() ?? string.Empty;
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
// -------------------------------------------------------------------------
// Groups
// -------------------------------------------------------------------------
/// <summary>
/// Returns all HA groups.
/// </summary>
public PveHaGroup[] GetGroups(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/ha/groups").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaGroup[]>() ?? Array.Empty<PveHaGroup>();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns a single HA group by name.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="group">The HA group name.</param>
public PveHaGroup GetGroup(PveSession session, string group)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync($"cluster/ha/groups/{Uri.EscapeDataString(group)}")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaGroup>() ?? new PveHaGroup();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Creates a new HA group.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="group">The group name.</param>
/// <param name="nodes">Comma-separated list of nodes with optional priorities, e.g. "node1:2,node2:1".</param>
/// <param name="options">Additional configuration options (restricted, nofailback, comment).</param>
public void CreateGroup(PveSession session, string group, string nodes, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
if (string.IsNullOrWhiteSpace(nodes)) throw new ArgumentNullException(nameof(nodes));
if (options == null) throw new ArgumentNullException(nameof(options));
var formData = new Dictionary<string, string>(options)
{
["group"] = group,
["nodes"] = nodes
};
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PostAsync("cluster/ha/groups", formData).GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Updates an existing HA group.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="group">The group name.</param>
/// <param name="options">Configuration options to update.</param>
public void UpdateGroup(PveSession session, string group, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
if (options == null) throw new ArgumentNullException(nameof(options));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PutAsync($"cluster/ha/groups/{Uri.EscapeDataString(group)}", options)
.GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Deletes an HA group.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="group">The group name.</param>
public void DeleteGroup(PveSession session, string group)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.DeleteAsync($"cluster/ha/groups/{Uri.EscapeDataString(group)}")
.GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------
/// <summary>
/// Returns the current HA status from /cluster/ha/status/current.
/// </summary>
public PveHaStatus[] GetStatus(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/ha/status/current").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaStatus[]>() ?? Array.Empty<PveHaStatus>();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns the full HA manager status as a raw JSON object.
/// </summary>
public JObject GetManagerStatus(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/ha/status/manager_status").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data as JObject ?? new JObject();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
// -------------------------------------------------------------------------
// Rules (PVE 9.0+)
// -------------------------------------------------------------------------
/// <summary>
/// Returns all HA rules. Requires PVE 9.0 or later.
/// </summary>
public PveHaRule[] GetRules(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync("cluster/ha/rules").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaRule[]>() ?? Array.Empty<PveHaRule>();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Returns a single HA rule by its ID. Requires PVE 9.0 or later.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="rule">The rule identifier.</param>
public PveHaRule GetRule(PveSession session, string rule)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(rule)) throw new ArgumentNullException(nameof(rule));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
var response = client.GetAsync($"cluster/ha/rules/{Uri.EscapeDataString(rule)}")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveHaRule>() ?? new PveHaRule();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Creates a new HA rule. Requires PVE 9.0 or later.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="options">Rule configuration options (rule, type, state, comment, etc.).</param>
public void CreateRule(PveSession session, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (options == null) throw new ArgumentNullException(nameof(options));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PostAsync("cluster/ha/rules", options).GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Updates an existing HA rule. Requires PVE 9.0 or later.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="rule">The rule identifier.</param>
/// <param name="options">Configuration options to update.</param>
public void UpdateRule(PveSession session, string rule, Dictionary<string, string> options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(rule)) throw new ArgumentNullException(nameof(rule));
if (options == null) throw new ArgumentNullException(nameof(options));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.PutAsync($"cluster/ha/rules/{Uri.EscapeDataString(rule)}", options)
.GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
/// <summary>
/// Deletes an HA rule. Requires PVE 9.0 or later.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="rule">The rule identifier.</param>
public void DeleteRule(PveSession session, string rule)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(rule)) throw new ArgumentNullException(nameof(rule));
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
try
{
client.DeleteAsync($"cluster/ha/rules/{Uri.EscapeDataString(rule)}")
.GetAwaiter().GetResult();
}
finally
{
if (_injectedClient == null) client.Dispose();
}
}
}
}