feat(services): implement service layer for all domains

Add service classes for nodes, VMs, containers, storage, snapshots,
network/SDN, users/roles/permissions, templates, cloud-init, tasks,
and cluster. SDN services enforce PVE 8.0+ version guard. Task service
supports polling with configurable timeout and progress callbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-17 15:45:23 -05:00
parent 4ac38f7714
commit 52f1978de4
11 changed files with 1705 additions and 0 deletions
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for managing Cloud-Init configuration on Proxmox VE QEMU/KVM VMs.
/// All operations target the /nodes/{node}/qemu/{vmid}/config endpoint.
/// </summary>
public class CloudInitService
{
// Cloud-Init field names as used in the PVE API
private static readonly string[] CloudInitFields =
{
"ciuser", "cipassword", "sshkeys",
"ipconfig0", "ipconfig1", "ipconfig2", "ipconfig3",
"nameserver", "searchdomain", "cicustom"
};
/// <summary>
/// Retrieves the Cloud-Init specific configuration fields for a VM.
/// Internally fetches the full VM config and extracts the CI fields.
/// </summary>
public PveCloudInitConfig GetCloudInitConfig(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}/qemu/{vmid}/config")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
if (data == null) return new PveCloudInitConfig();
// Extract only the Cloud-Init fields into a reduced JObject for deserialization
var ciObj = new JObject();
foreach (var field in CloudInitFields)
{
if (data[field] != null)
ciObj[field] = data[field];
}
return ciObj.ToObject<PveCloudInitConfig>() ?? new PveCloudInitConfig();
}
/// <summary>
/// Updates Cloud-Init configuration fields on a VM. Only fields present in
/// <paramref name="config"/> are changed; unspecified fields are left as-is.
/// </summary>
/// <remarks>
/// Pass only the Cloud-Init keys you want to change, for example:
/// <c>new Dictionary&lt;string, object&gt; { ["ciuser"] = "ubuntu", ["ipconfig0"] = "ip=dhcp" }</c>
/// </remarks>
public void SetCloudInitConfig(
PveSession session,
string node,
int vmid,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
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.PutAsync($"nodes/{node}/qemu/{vmid}/config", formData)
.GetAwaiter().GetResult();
}
/// <summary>
/// Triggers regeneration of the Cloud-Init ISO drive attached to the VM.
/// This is done by calling the cloudinit dump endpoint, then touching the config to
/// force PVE to rebuild the CD-ROM image on next start.
/// </summary>
/// <returns>
/// The raw Cloud-Init dump (user-data) string as reported by the PVE API.
/// </returns>
public string RegenerateCloudInitImage(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);
// Request a Cloud-Init dump (user-data section) — this causes PVE to rebuild the image
var dumpResponse = client.GetAsync($"nodes/{node}/qemu/{vmid}/cloudinit/dump?type=user")
.GetAwaiter().GetResult();
var data = JObject.Parse(dumpResponse)["data"];
return data?.ToString() ?? string.Empty;
}
}
}
@@ -0,0 +1,28 @@
using System;
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-level API operations.
/// </summary>
public class ClusterService
{
/// <summary>
/// Returns the current cluster status. The response is a mixed array of
/// "cluster" and "node" type entries.
/// </summary>
public PveClusterStatus[] GetClusterStatus(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("cluster/status").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveClusterStatus[]>() ?? Array.Empty<PveClusterStatus>();
}
}
}
@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Containers;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE Linux Container (LXC) API operations.
/// </summary>
public class ContainerService
{
private readonly NodeService _nodeService = new NodeService();
// -------------------------------------------------------------------------
// Read operations
// -------------------------------------------------------------------------
/// <summary>
/// Returns containers. If <paramref name="node"/> is null, queries every cluster node.
/// </summary>
public PveContainer[] GetContainers(PveSession session, string? node = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (node != null)
return GetContainersOnNode(session, node);
var nodes = _nodeService.GetNodes(session);
var all = new List<PveContainer>();
foreach (var n in nodes)
{
try
{
var containers = GetContainersOnNode(session, n.Name);
foreach (var ct in containers)
ct.Node ??= n.Name;
all.AddRange(containers);
}
catch
{
// Skip offline/inaccessible nodes
}
}
return all.ToArray();
}
private PveContainer[] GetContainersOnNode(PveSession session, string node)
{
using var client = new PveHttpClient(session);
var response = client.GetAsync($"nodes/{node}/lxc").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveContainer[]>() ?? Array.Empty<PveContainer>();
}
/// <summary>
/// Returns a single container by its ID on the specified node.
/// </summary>
public PveContainer GetContainer(PveSession session, string node, int vmid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var containers = GetContainersOnNode(session, node);
var ct = containers.FirstOrDefault(c => c.VmId == vmid);
if (ct == null)
throw new InvalidOperationException($"Container {vmid} not found on node '{node}'.");
ct.Node ??= node;
return ct;
}
/// <summary>
/// Returns the full configuration of a container.
/// </summary>
public PveContainerConfig GetContainerConfig(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}/config")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveContainerConfig>() ?? new PveContainerConfig();
}
// -------------------------------------------------------------------------
// Configuration mutation
// -------------------------------------------------------------------------
/// <summary>
/// Updates one or more container configuration settings.
/// </summary>
public void SetContainerConfig(
PveSession session,
string node,
int vmid,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
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.PutAsync($"nodes/{node}/lxc/{vmid}/config", formData)
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/// <summary>
/// Creates a new container. Returns the task UPID.
/// </summary>
public PveTask CreateContainer(
PveSession session,
string node,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
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);
var response = client.PostAsync($"nodes/{node}/lxc", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>Starts a container. Returns the task UPID.</summary>
public PveTask StartContainer(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "start");
/// <summary>Stops a container (hard stop). Returns the task UPID.</summary>
public PveTask StopContainer(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "stop");
/// <summary>Gracefully shuts down a container. Returns the task UPID.</summary>
public PveTask ShutdownContainer(
PveSession session,
string node,
int vmid,
int? timeoutSeconds = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var formData = new Dictionary<string, string>();
if (timeoutSeconds.HasValue)
formData["timeout"] = timeoutSeconds.Value.ToString();
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/status/shutdown", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>Removes a container. Returns the task UPID.</summary>
public PveTask RemoveContainer(
PveSession session,
string node,
int vmid,
bool purge = false)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var purgeParam = purge ? "?purge=1" : "?purge=0";
using var client = new PveHttpClient(session);
var response = client.DeleteAsync($"nodes/{node}/lxc/{vmid}{purgeParam}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>Clones a container. Returns the task UPID.</summary>
public PveTask CloneContainer(
PveSession session,
string node,
int vmid,
int newid,
string? hostname = null,
string? targetNode = null,
bool full = true)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var formData = new Dictionary<string, string>
{
["newid"] = newid.ToString(),
["full"] = full ? "1" : "0"
};
if (!string.IsNullOrEmpty(hostname)) formData["hostname"] = hostname!;
if (!string.IsNullOrEmpty(targetNode)) formData["target"] = targetNode!;
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/clone", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>Migrates a container to another node. Returns the task UPID.</summary>
public PveTask MigrateContainer(
PveSession session,
string node,
int vmid,
string targetNode,
bool online = false)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(targetNode)) throw new ArgumentNullException(nameof(targetNode));
var formData = new Dictionary<string, string>
{
["target"] = targetNode,
["online"] = online ? "1" : "0"
};
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/migrate", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private PveTask PostStatus(PveSession session, string node, int vmid, string action)
{
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.PostAsync($"nodes/{node}/lxc/{vmid}/status/{action}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
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;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE node network and SDN (Software-Defined Networking) API operations.
/// SDN methods require PVE 8.0 or later and will throw <see cref="PveVersionException"/> otherwise.
/// </summary>
public class NetworkService
{
// -------------------------------------------------------------------------
// Node network interfaces
// -------------------------------------------------------------------------
/// <summary>
/// Returns network interface configurations for a node.
/// </summary>
/// <param name="type">
/// Optional interface type filter (e.g. "bridge", "bond", "eth", "vlan", "alias").
/// </param>
public PveNetwork[] GetNetworks(PveSession session, string node, string? type = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var resource = $"nodes/{node}/network";
if (!string.IsNullOrEmpty(type))
resource += $"?type={Uri.EscapeDataString(type!)}";
using var client = new PveHttpClient(session);
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveNetwork[]>() ?? Array.Empty<PveNetwork>();
}
/// <summary>
/// Creates a network interface on a node. Changes are pending until
/// <see cref="ApplyNetworkConfig"/> is called. Returns the new interface config.
/// </summary>
public PveNetwork CreateNetwork(
PveSession session,
string node,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
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);
var response = client.PostAsync($"nodes/{node}/network", formData)
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveNetwork>() ?? new PveNetwork();
}
/// <summary>
/// Updates an existing network interface on a node. Changes are pending until
/// <see cref="ApplyNetworkConfig"/> is called.
/// </summary>
public void SetNetwork(
PveSession session,
string node,
string iface,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(iface)) throw new ArgumentNullException(nameof(iface));
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.PutAsync($"nodes/{node}/network/{iface}", formData)
.GetAwaiter().GetResult();
}
/// <summary>
/// Removes a network interface from a node. Changes are pending until
/// <see cref="ApplyNetworkConfig"/> is called.
/// </summary>
public void RemoveNetwork(PveSession session, string node, string iface)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(iface)) throw new ArgumentNullException(nameof(iface));
using var client = new PveHttpClient(session);
client.DeleteAsync($"nodes/{node}/network/{iface}").GetAwaiter().GetResult();
}
/// <summary>
/// Applies pending network configuration changes on a node. Returns the task UPID.
/// </summary>
public PveTask ApplyNetworkConfig(PveSession session, string node)
{
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.PutAsync($"nodes/{node}/network")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
// -------------------------------------------------------------------------
// SDN — requires PVE 8.0+
// -------------------------------------------------------------------------
/// <summary>
/// Returns all SDN zone definitions. Requires PVE 8.0+.
/// </summary>
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();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveSdnZone[]>() ?? Array.Empty<PveSdnZone>();
}
/// <summary>
/// Returns all SDN VNet definitions. Requires PVE 8.0+.
/// </summary>
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();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveSdnVnet[]>() ?? Array.Empty<PveSdnVnet>();
}
/// <summary>
/// Creates an SDN zone. Requires PVE 8.0+.
/// </summary>
public PveSdnZone CreateSdnZone(
PveSession session,
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);
var formData = config.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? string.Empty);
var response = client.PostAsync("cluster/sdn/zones", formData)
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveSdnZone>() ?? new PveSdnZone();
}
/// <summary>
/// Removes an SDN zone. Requires PVE 8.0+.
/// </summary>
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);
client.DeleteAsync($"cluster/sdn/zones/{zone}").GetAwaiter().GetResult();
}
/// <summary>
/// Creates an SDN VNet. Requires PVE 8.0+.
/// </summary>
public PveSdnVnet CreateSdnVnet(
PveSession session,
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);
var formData = config.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? string.Empty);
var response = client.PostAsync("cluster/sdn/vnets", formData)
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveSdnVnet>() ?? new PveSdnVnet();
}
/// <summary>
/// Removes an SDN VNet. Requires PVE 8.0+.
/// </summary>
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);
client.DeleteAsync($"cluster/sdn/vnets/{vnet}").GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// 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"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -0,0 +1,58 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Nodes;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for node-level and cluster-version Proxmox VE API operations.
/// </summary>
public class NodeService
{
/// <summary>
/// Returns all cluster nodes.
/// </summary>
public PveNode[] GetNodes(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("nodes").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveNode[]>() ?? Array.Empty<PveNode>();
}
/// <summary>
/// Returns detailed status for a specific node.
/// </summary>
public PveNodeStatus GetNodeStatus(PveSession session, string node)
{
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}/status").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveNodeStatus>() ?? new PveNodeStatus();
}
/// <summary>
/// Returns the Proxmox VE version running on the server.
/// </summary>
public PveVersion GetVersion(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("version").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
var versionStr = data?["version"]?.ToString();
if (string.IsNullOrEmpty(versionStr))
throw new InvalidOperationException("Failed to retrieve PVE version from API response.");
return PveVersion.Parse(versionStr);
}
}
}
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE VM snapshot API operations.
/// All operations apply to QEMU/KVM VMs via the /nodes/{node}/qemu/{vmid}/snapshot endpoints.
/// </summary>
public class SnapshotService
{
/// <summary>
/// Returns all snapshots for a VM.
/// </summary>
public PveSnapshot[] GetSnapshots(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}/qemu/{vmid}/snapshot")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveSnapshot[]>() ?? Array.Empty<PveSnapshot>();
}
/// <summary>
/// Creates a snapshot of a VM. Returns the task UPID.
/// </summary>
/// <param name="snapname">Snapshot name (alphanumeric, no spaces).</param>
/// <param name="description">Optional description.</param>
/// <param name="vmstate">Whether to save VM RAM state (live snapshot). Default false.</param>
public PveTask CreateSnapshot(
PveSession session,
string node,
int vmid,
string snapname,
string? description = null,
bool vmstate = false)
{
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,
["vmstate"] = vmstate ? "1" : "0"
};
if (!string.IsNullOrEmpty(description))
formData["description"] = description!;
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/snapshot", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Removes a snapshot from a VM. Returns the task UPID.
/// </summary>
public PveTask RemoveSnapshot(
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}/qemu/{vmid}/snapshot/{snapname}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Rolls a VM back to a snapshot. Returns the task UPID.
/// </summary>
public PveTask RollbackSnapshot(
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}/qemu/{vmid}/snapshot/{snapname}/rollback")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Storage;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE storage API operations.
/// </summary>
public class StorageService
{
// -------------------------------------------------------------------------
// Read operations
// -------------------------------------------------------------------------
/// <summary>
/// Returns storage definitions. If <paramref name="node"/> is null, returns
/// the cluster-wide storage list; otherwise returns storage visible on that node.
/// </summary>
public PveStorage[] GetStorages(PveSession session, string? node = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var resource = node != null
? $"nodes/{node}/storage"
: "storage";
using var client = new PveHttpClient(session);
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveStorage[]>() ?? Array.Empty<PveStorage>();
}
/// <summary>
/// Returns the contents of a storage volume on a specific node.
/// </summary>
/// <param name="contentType">
/// Optional content type filter (e.g. "iso", "vztmpl", "images", "backup").
/// </param>
public PveStorageContent[] GetStorageContent(
PveSession session,
string node,
string storage,
string? contentType = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(storage)) throw new ArgumentNullException(nameof(storage));
var resource = $"nodes/{node}/storage/{storage}/content";
if (!string.IsNullOrEmpty(contentType))
resource += $"?content={Uri.EscapeDataString(contentType!)}";
using var client = new PveHttpClient(session);
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveStorageContent[]>() ?? Array.Empty<PveStorageContent>();
}
// -------------------------------------------------------------------------
// Upload / download
// -------------------------------------------------------------------------
/// <summary>
/// Uploads an ISO (or other file) to a storage on a node. Returns the task UPID.
/// </summary>
/// <param name="checksum">Optional checksum value.</param>
/// <param name="checksumAlgorithm">Optional checksum algorithm (e.g. "sha256").</param>
/// <param name="progressCallback">Optional callback with (bytesSent, totalBytes).</param>
public PveTask UploadIso(
PveSession session,
string node,
string storage,
string filePath,
string? checksum = null,
string? checksumAlgorithm = null,
Action<long, long>? progressCallback = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(storage)) throw new ArgumentNullException(nameof(storage));
if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException(nameof(filePath));
var formFields = new Dictionary<string, string>
{
["content"] = "iso"
};
using var client = new PveHttpClient(session);
var response = client.UploadFileAsync(
$"nodes/{node}/storage/{storage}/upload",
filePath,
formFields,
checksum,
checksumAlgorithm,
progressCallback)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Downloads a file from a URL directly to a storage on a node. Returns the task UPID.
/// </summary>
public PveTask DownloadUrl(
PveSession session,
string node,
string storage,
string url,
string filename,
string contentType)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(storage)) throw new ArgumentNullException(nameof(storage));
if (string.IsNullOrWhiteSpace(url)) throw new ArgumentNullException(nameof(url));
if (string.IsNullOrWhiteSpace(filename)) throw new ArgumentNullException(nameof(filename));
if (string.IsNullOrWhiteSpace(contentType)) throw new ArgumentNullException(nameof(contentType));
var formData = new Dictionary<string, string>
{
["url"] = url,
["filename"] = filename,
["content"] = contentType
};
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/storage/{storage}/download-url", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
// -------------------------------------------------------------------------
// Storage CRUD
// -------------------------------------------------------------------------
/// <summary>
/// Creates a new storage definition at the cluster level. Returns the task UPID or
/// null if the API returns no task (some storage types apply immediately).
/// </summary>
public PveStorage CreateStorage(PveSession session, Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
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);
var response = client.PostAsync("storage", formData).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveStorage>() ?? new PveStorage();
}
/// <summary>
/// Removes a cluster-level storage definition.
/// </summary>
public void RemoveStorage(PveSession session, string storage)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(storage)) throw new ArgumentNullException(nameof(storage));
using var client = new PveHttpClient(session);
client.DeleteAsync($"storage/{storage}").GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -0,0 +1,107 @@
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.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for querying and waiting on Proxmox VE asynchronous tasks (UPIDs).
/// </summary>
public class TaskService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(10);
private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(2);
private static readonly TimeSpan MinPollInterval = TimeSpan.FromSeconds(1);
/// <summary>
/// Returns the current status of a task identified by its UPID.
/// </summary>
public PveTask GetTask(PveSession session, string node, string upid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(upid)) throw new ArgumentNullException(nameof(upid));
using var client = new PveHttpClient(session);
var encodedUpid = Uri.EscapeDataString(upid);
var response = client.GetAsync($"nodes/{node}/tasks/{encodedUpid}/status")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
var task = data?.ToObject<PveTask>() ?? new PveTask { Upid = upid };
task.Node = node;
return task;
}
/// <summary>
/// Returns all log lines for a task identified by its UPID.
/// </summary>
public PveTaskLog[] GetTaskLog(PveSession session, string node, string upid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(upid)) throw new ArgumentNullException(nameof(upid));
using var client = new PveHttpClient(session);
var encodedUpid = Uri.EscapeDataString(upid);
var response = client.GetAsync($"nodes/{node}/tasks/{encodedUpid}/log")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveTaskLog[]>() ?? Array.Empty<PveTaskLog>();
}
/// <summary>
/// Polls the task status until it completes, throws on timeout or failure.
/// </summary>
/// <param name="session">Active PVE session.</param>
/// <param name="node">Node name where the task is running.</param>
/// <param name="upid">Task UPID.</param>
/// <param name="timeout">Maximum time to wait. Defaults to 10 minutes.</param>
/// <param name="pollInterval">Interval between status polls. Defaults to 2 seconds. Minimum 1 second.</param>
/// <param name="progressCallback">Optional callback invoked on each poll with the current task.</param>
/// <returns>The completed <see cref="PveTask"/>.</returns>
/// <exception cref="PveTaskTimeoutException">Thrown when the task does not complete within <paramref name="timeout"/>.</exception>
/// <exception cref="PveTaskFailedException">Thrown when the task completes with a non-OK exit status.</exception>
public PveTask WaitForTask(
PveSession session,
string node,
string upid,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
Action<PveTask>? progressCallback = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(upid)) throw new ArgumentNullException(nameof(upid));
var effectiveTimeout = timeout ?? DefaultTimeout;
var effectivePoll = pollInterval ?? DefaultPollInterval;
if (effectivePoll < MinPollInterval)
effectivePoll = MinPollInterval;
var deadline = DateTime.UtcNow.Add(effectiveTimeout);
while (true)
{
var task = GetTask(session, node, upid);
progressCallback?.Invoke(task);
if (string.Equals(task.Status, "stopped", StringComparison.OrdinalIgnoreCase))
{
if (!string.Equals(task.ExitStatus, "OK", StringComparison.OrdinalIgnoreCase))
throw new PveTaskFailedException(upid, task.ExitStatus ?? "(no exit status)");
return task;
}
if (DateTime.UtcNow >= deadline)
throw new PveTaskTimeoutException(upid, effectiveTimeout);
Thread.Sleep(effectivePoll);
}
}
}
}
@@ -0,0 +1,80 @@
using System;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE VM template operations.
/// Templates are VMs with the "template" flag set to 1.
/// </summary>
public class TemplateService
{
private readonly VmService _vmService = new VmService();
/// <summary>
/// Returns all VM templates. If <paramref name="node"/> is null, searches all cluster nodes.
/// </summary>
public PveVm[] GetTemplates(PveSession session, string? node = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var vms = _vmService.GetVms(session, node);
return vms.Where(v => v.Template == 1).ToArray();
}
/// <summary>
/// Converts an existing VM into a template. Returns the task UPID.
/// </summary>
/// <remarks>
/// The VM must be stopped and must not already be a template.
/// Once converted, this operation cannot be reversed via the API.
/// </remarks>
public PveTask CreateTemplate(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.PostAsync($"nodes/{node}/qemu/{vmid}/template")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Removes a VM template (delegates to <see cref="VmService.RemoveVm"/>).
/// </summary>
/// <param name="purge">
/// If true, also removes all associated backup files and jobs.
/// </param>
public PveTask RemoveTemplate(
PveSession session,
string node,
int vmid,
bool purge = false)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
return _vmService.RemoveVm(session, node, vmid, purge);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Users;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE access control — users, roles, and permissions (ACLs).
/// </summary>
public class UserService
{
// -------------------------------------------------------------------------
// Users
// -------------------------------------------------------------------------
/// <summary>Returns all users.</summary>
public PveUser[] GetUsers(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("access/users").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveUser[]>() ?? Array.Empty<PveUser>();
}
/// <summary>Returns a single user by their user ID (e.g. "admin@pam").</summary>
public PveUser GetUser(PveSession session, string userId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
using var client = new PveHttpClient(session);
var encodedId = Uri.EscapeDataString(userId);
var response = client.GetAsync($"access/users/{encodedId}").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
var user = data?.ToObject<PveUser>() ?? new PveUser();
// The single-user endpoint may not echo back the userid
if (string.IsNullOrEmpty(user.UserId))
user.UserId = userId;
return user;
}
/// <summary>
/// Creates a new user account.
/// </summary>
/// <param name="userId">User ID in "username@realm" format.</param>
/// <param name="config">Additional fields (password, email, firstname, lastname, etc.).</param>
public void CreateUser(
PveSession session,
string userId,
Dictionary<string, object>? config = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
var formData = new Dictionary<string, string> { ["userid"] = userId };
if (config != null)
{
foreach (var kvp in config)
formData[kvp.Key] = kvp.Value?.ToString() ?? string.Empty;
}
using var client = new PveHttpClient(session);
client.PostAsync("access/users", formData).GetAwaiter().GetResult();
}
/// <summary>Removes a user account.</summary>
public void RemoveUser(PveSession session, string userId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
using var client = new PveHttpClient(session);
var encodedId = Uri.EscapeDataString(userId);
client.DeleteAsync($"access/users/{encodedId}").GetAwaiter().GetResult();
}
/// <summary>Updates one or more properties of an existing user.</summary>
public void SetUser(
PveSession session,
string userId,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
var encodedId = Uri.EscapeDataString(userId);
var formData = config.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? string.Empty);
client.PutAsync($"access/users/{encodedId}", formData).GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Roles
// -------------------------------------------------------------------------
/// <summary>Returns all roles.</summary>
public PveRole[] GetRoles(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("access/roles").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveRole[]>() ?? Array.Empty<PveRole>();
}
/// <summary>Creates a new role.</summary>
/// <param name="roleId">Role name.</param>
/// <param name="privileges">Comma-separated list of privilege strings.</param>
public void CreateRole(PveSession session, string roleId, string? privileges = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(roleId)) throw new ArgumentNullException(nameof(roleId));
var formData = new Dictionary<string, string> { ["roleid"] = roleId };
if (!string.IsNullOrEmpty(privileges))
formData["privs"] = privileges!;
using var client = new PveHttpClient(session);
client.PostAsync("access/roles", formData).GetAwaiter().GetResult();
}
/// <summary>Removes a role.</summary>
public void RemoveRole(PveSession session, string roleId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(roleId)) throw new ArgumentNullException(nameof(roleId));
using var client = new PveHttpClient(session);
client.DeleteAsync($"access/roles/{Uri.EscapeDataString(roleId)}")
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Permissions / ACLs
// -------------------------------------------------------------------------
/// <summary>
/// Returns the effective permissions (resolved privilege set) for a user or token.
/// </summary>
/// <param name="userId">Optional user ID to query; defaults to the authenticated user.</param>
/// <param name="path">Optional path to restrict the query.</param>
public PvePermission[] GetPermissions(
PveSession session,
string? userId = null,
string? path = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var resource = "access/permissions";
var queryParts = new List<string>();
if (!string.IsNullOrEmpty(userId))
queryParts.Add($"userid={Uri.EscapeDataString(userId!)}");
if (!string.IsNullOrEmpty(path))
queryParts.Add($"path={Uri.EscapeDataString(path!)}");
if (queryParts.Count > 0)
resource += "?" + string.Join("&", queryParts);
using var client = new PveHttpClient(session);
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
// /access/permissions returns an object keyed by path, not an array
if (data == null) return Array.Empty<PvePermission>();
if (data.Type == JTokenType.Array)
return data.ToObject<PvePermission[]>() ?? Array.Empty<PvePermission>();
// Unwrap path-keyed object into flat list
var result = new List<PvePermission>();
foreach (var prop in ((JObject)data).Properties())
{
var perm = new PvePermission { Path = prop.Name };
result.Add(perm);
}
return result.ToArray();
}
/// <summary>
/// Sets (adds or updates) an ACL entry.
/// </summary>
/// <param name="path">Access control path (e.g. "/", "/nodes/pve", "/vms/100").</param>
/// <param name="roles">Comma-separated role IDs.</param>
/// <param name="users">Comma-separated user IDs.</param>
/// <param name="groups">Comma-separated group names.</param>
/// <param name="propagate">Whether to propagate the permission to sub-paths.</param>
/// <param name="delete">If true, removes the specified ACL entries.</param>
public void SetPermission(
PveSession session,
string path,
string roles,
string? users = null,
string? groups = null,
bool propagate = true,
bool delete = false)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
if (string.IsNullOrWhiteSpace(roles)) throw new ArgumentNullException(nameof(roles));
var formData = new Dictionary<string, string>
{
["path"] = path,
["roles"] = roles,
["propagate"] = propagate ? "1" : "0",
["delete"] = delete ? "1" : "0"
};
if (!string.IsNullOrEmpty(users)) formData["users"] = users!;
if (!string.IsNullOrEmpty(groups)) formData["groups"] = groups!;
using var client = new PveHttpClient(session);
client.PutAsync("access/acl", formData).GetAwaiter().GetResult();
}
}
}
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Nodes;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE QEMU/KVM virtual machine API operations.
/// </summary>
public class VmService
{
private readonly NodeService _nodeService = new NodeService();
// -------------------------------------------------------------------------
// Read operations
// -------------------------------------------------------------------------
/// <summary>
/// Returns VMs. If <paramref name="node"/> is null, queries every cluster node.
/// </summary>
public PveVm[] GetVms(PveSession session, string? node = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (node != null)
return GetVmsOnNode(session, node);
// Query all nodes and aggregate
var nodes = _nodeService.GetNodes(session);
var all = new List<PveVm>();
foreach (var n in nodes)
{
try
{
var vms = GetVmsOnNode(session, n.Name);
// Stamp the node name in case it wasn't returned by the API
foreach (var vm in vms)
vm.Node ??= n.Name;
all.AddRange(vms);
}
catch
{
// Skip nodes that are offline or inaccessible
}
}
return all.ToArray();
}
private PveVm[] GetVmsOnNode(PveSession session, string node)
{
using var client = new PveHttpClient(session);
var response = client.GetAsync($"nodes/{node}/qemu").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveVm[]>() ?? Array.Empty<PveVm>();
}
/// <summary>
/// Returns a single VM by its ID on the specified node.
/// </summary>
public PveVm GetVm(PveSession session, string node, int vmid)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var vms = GetVmsOnNode(session, node);
var vm = vms.FirstOrDefault(v => v.VmId == vmid);
if (vm == null)
throw new InvalidOperationException($"VM {vmid} not found on node '{node}'.");
vm.Node ??= node;
return vm;
}
/// <summary>
/// Returns the full configuration of a VM.
/// </summary>
public PveVmConfig GetVmConfig(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}/qemu/{vmid}/config")
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveVmConfig>() ?? new PveVmConfig();
}
// -------------------------------------------------------------------------
// Configuration mutation
// -------------------------------------------------------------------------
/// <summary>
/// Updates one or more VM configuration settings. Changes are applied immediately (POST).
/// </summary>
public void SetVmConfig(PveSession session, string node, int vmid, Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
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.PutAsync($"nodes/{node}/qemu/{vmid}/config", formData)
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/// <summary>
/// Creates a new VM. Returns the task UPID.
/// </summary>
public PveTask CreateVm(PveSession session, string node, Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
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);
var response = client.PostAsync($"nodes/{node}/qemu", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>Starts a VM. Returns the task UPID.</summary>
public PveTask StartVm(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "start");
/// <summary>Stops a VM (hard power-off). Returns the task UPID.</summary>
public PveTask StopVm(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "stop");
/// <summary>Gracefully shuts down a VM. Returns the task UPID.</summary>
public PveTask ShutdownVm(PveSession session, string node, int vmid, int? timeoutSeconds = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var formData = new Dictionary<string, string>();
if (timeoutSeconds.HasValue)
formData["timeout"] = timeoutSeconds.Value.ToString();
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/status/shutdown", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>Resets a VM (hard reset). Returns the task UPID.</summary>
public PveTask ResetVm(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "reset");
/// <summary>Suspends a VM (writes RAM state to disk). Returns the task UPID.</summary>
public PveTask SuspendVm(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "suspend");
/// <summary>Resumes a suspended VM. Returns the task UPID.</summary>
public PveTask ResumeVm(PveSession session, string node, int vmid)
=> PostStatus(session, node, vmid, "resume");
// -------------------------------------------------------------------------
// Removal / migration / clone
// -------------------------------------------------------------------------
/// <summary>
/// Removes a VM. Returns the task UPID.
/// </summary>
public PveTask RemoveVm(PveSession session, string node, int vmid, bool purge = false)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var purgeParam = purge ? "?purge=1" : "?purge=0";
using var client = new PveHttpClient(session);
var response = client.DeleteAsync($"nodes/{node}/qemu/{vmid}{purgeParam}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Clones a VM. Returns the task UPID.
/// </summary>
public PveTask CloneVm(
PveSession session,
string node,
int vmid,
int newid,
string? name = null,
string? targetNode = null,
bool full = true)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
var formData = new Dictionary<string, string>
{
["newid"] = newid.ToString(),
["full"] = full ? "1" : "0"
};
if (!string.IsNullOrEmpty(name)) formData["name"] = name!;
if (!string.IsNullOrEmpty(targetNode)) formData["target"] = targetNode!;
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/clone", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Migrates a VM to another node. Returns the task UPID.
/// </summary>
public PveTask MigrateVm(
PveSession session,
string node,
int vmid,
string targetNode,
bool online = true)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(targetNode)) throw new ArgumentNullException(nameof(targetNode));
var formData = new Dictionary<string, string>
{
["target"] = targetNode,
["online"] = online ? "1" : "0"
};
using var client = new PveHttpClient(session);
var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/migrate", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
/// <summary>
/// Resizes a disk attached to a VM. Returns the task UPID.
/// </summary>
/// <param name="disk">Disk identifier, e.g. "scsi0" or "virtio0".</param>
/// <param name="size">
/// New absolute size (e.g. "32G") or relative increase with "+" prefix (e.g. "+10G").
/// </param>
public PveTask ResizeDisk(
PveSession session,
string node,
int vmid,
string disk,
string size)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(disk)) throw new ArgumentNullException(nameof(disk));
if (string.IsNullOrWhiteSpace(size)) throw new ArgumentNullException(nameof(size));
var formData = new Dictionary<string, string>
{
["disk"] = disk,
["size"] = size
};
using var client = new PveHttpClient(session);
var response = client.PutAsync($"nodes/{node}/qemu/{vmid}/resize", formData)
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private PveTask PostStatus(PveSession session, string node, int vmid, string action)
{
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.PostAsync($"nodes/{node}/qemu/{vmid}/status/{action}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
private static PveTask ParseTask(string response, string node)
{
var data = JObject.Parse(response)["data"];
// Many endpoints return the UPID string directly as the data value
if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node };
var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node;
return task;
}
}
}