using System;
using System.Collections.Generic;
using System.Threading;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Models.Cluster;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Services
{
///
/// Service for Proxmox VE cluster configuration API operations
/// (/cluster/config, /cluster/options, /cluster/nextid).
///
public class ClusterConfigService : PveServiceBase
{
private static readonly TimeSpan DefaultQuorumTimeout = TimeSpan.FromSeconds(60);
private static readonly TimeSpan QuorumPollInterval = TimeSpan.FromSeconds(2);
private readonly ClusterService _clusterService;
///
/// Initializes a new instance of the class.
///
public ClusterConfigService()
{
_clusterService = new ClusterService();
}
///
/// Initializes a new instance of the class with an injected HTTP client.
///
/// The HTTP client to use for API calls. The caller owns its lifetime.
public ClusterConfigService(IPveHttpClient client) : base(client)
{
_clusterService = new ClusterService(client);
}
///
/// Returns the cluster configuration directory (GET /cluster/config).
/// The response is a mixed structure returned as a Dictionary.
///
public Dictionary GetClusterConfig(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/config").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data is JObject obj ? JsonHelper.ToDictionary(obj) : new Dictionary();
});
}
///
/// Creates a new cluster (POST /cluster/config).
///
/// The authenticated PVE session.
/// The name for the new cluster.
/// Optional Corosync link addresses, using keys link0..link7 (e.g., "link0=10.0.0.1").
/// Optional node ID for this node.
/// Optional number of quorum votes for this node.
/// The UPID of the cluster creation task.
public string CreateCluster(PveSession session, string clusterName, Dictionary? 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
{
["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();
return Invoke(session, client =>
{
var response = client.PostAsync("cluster/config", data).GetAwaiter().GetResult();
var result = JObject.Parse(response)["data"];
return result?.ToString() ?? string.Empty;
});
}
///
/// Returns the list of nodes in the cluster configuration (GET /cluster/config/nodes).
///
public PveClusterConfigNode[] GetConfigNodes(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/config/nodes").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject() ?? Array.Empty();
});
}
///
/// Adds a node to the cluster configuration (POST /cluster/config/nodes/{node}).
///
/// The authenticated PVE session.
/// The node name to add.
/// The IP address of the new node.
/// Optional Corosync link addresses.
/// Optional node ID for the new node.
/// Optional number of quorum votes.
/// Optional flag to force the operation.
/// Optional API version override.
/// The UPID of the add-node task.
public string AddConfigNode(PveSession session, string node, string? newNodeIp = null, Dictionary? 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();
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();
return Invoke(session, client =>
{
var response = client.PostAsync($"cluster/config/nodes/{Uri.EscapeDataString(node)}", data).GetAwaiter().GetResult();
var result = JObject.Parse(response)["data"];
return result?.ToString() ?? string.Empty;
});
}
///
/// Removes a node from the cluster configuration (DELETE /cluster/config/nodes/{node}).
///
/// The authenticated PVE session.
/// The node name to remove.
public void RemoveConfigNode(PveSession session, string node)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrEmpty(node)) throw new ArgumentNullException(nameof(node));
Invoke(session, client =>
{
client.DeleteAsync($"cluster/config/nodes/{Uri.EscapeDataString(node)}").GetAwaiter().GetResult();
});
}
///
/// Returns the cluster join information (GET /cluster/config/join).
///
/// The authenticated PVE session.
/// Optional node name to get join info for a specific node.
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!)}";
return Invoke(session, client =>
{
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject() ?? new PveClusterJoinInfo();
});
}
///
/// Joins the current node to an existing cluster (POST /cluster/config/join).
///
/// The authenticated PVE session.
/// The hostname or IP of an existing cluster node.
/// The TLS certificate fingerprint of the cluster node.
/// The root password for the cluster node (plain string; cmdlet layer handles SecureString conversion per ADR 0002).
/// Optional Corosync link addresses.
/// Optional node ID for this node.
/// Optional number of quorum votes.
/// Optional flag to force the join.
/// The UPID of the join task.
public string JoinCluster(PveSession session, string hostname, string fingerprint, string password, Dictionary? 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
{
["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";
return Invoke(session, client =>
{
var response = client.PostAsync("cluster/config/join", data).GetAwaiter().GetResult();
var result = JObject.Parse(response)["data"];
return result?.ToString() ?? string.Empty;
});
}
///
/// Returns the Corosync totem configuration (GET /cluster/config/totem).
///
public Dictionary GetTotem(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/config/totem").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return JsonHelper.ToDictionary(data as JObject);
});
}
///
/// Returns the external quorum device (qdevice) status (GET /cluster/config/qdevice).
///
public Dictionary GetQdevice(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/config/qdevice").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return JsonHelper.ToDictionary(data as JObject);
});
}
///
/// Returns the cluster API version (GET /cluster/config/apiversion).
///
public int GetApiVersion(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/config/apiversion").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject() ?? 0;
});
}
///
/// Returns the cluster-wide options (GET /cluster/options).
///
public PveClusterOptions GetClusterOptions(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
return Invoke(session, client =>
{
var response = client.GetAsync("cluster/options").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject() ?? new PveClusterOptions();
});
}
///
/// Sets cluster-wide options (PUT /cluster/options).
///
/// The authenticated PVE session.
/// A dictionary of option names and values to set.
public void SetClusterOptions(PveSession session, Dictionary options)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (options == null) throw new ArgumentNullException(nameof(options));
Invoke(session, client =>
{
client.PutAsync("cluster/options", options).GetAwaiter().GetResult();
});
}
///
/// Blocks until the cluster reports quorum (GET /cluster/status, quorate = 1).
///
/// The authenticated PVE session.
/// Maximum time to wait. Defaults to 60 seconds.
///
/// The cluster-create task completes before corosync converges; until the node
/// is quorate it rejects joins with "cluster not ready - no quorum?". API errors
/// during that window are transient and are retried until the deadline.
///
/// Quorum was not reached before the deadline.
public void WaitForQuorum(PveSession session, TimeSpan? timeout = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var effectiveTimeout = timeout ?? DefaultQuorumTimeout;
var deadline = DateTime.UtcNow.Add(effectiveTimeout);
while (true)
{
try
{
foreach (var entry in _clusterService.GetClusterStatus(session))
{
if (string.Equals(entry.Type, "cluster", StringComparison.OrdinalIgnoreCase)
&& entry.Quorate == 1)
return;
}
}
catch (PveApiException)
{
// pmxcfs and corosync restart while the cluster forms.
}
if (DateTime.UtcNow >= deadline)
throw new TimeoutException(
$"Cluster did not reach quorum within {effectiveTimeout.TotalSeconds:0} seconds.");
Thread.Sleep(QuorumPollInterval);
}
}
///
/// Returns the next available VM/CT ID (GET /cluster/nextid).
///
/// The authenticated PVE session.
/// Optional specific VMID to check availability for.
/// The next available VMID as an integer.
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}";
return Invoke(session, client =>
{
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
if (data == null)
throw new InvalidOperationException("API response for next VMID did not contain a 'data' field.");
if (int.TryParse(data.ToString(), out var id))
return id;
throw new InvalidOperationException($"API returned unexpected next VMID value: '{data}'");
});
}
}
}