diff --git a/src/PSProxmoxVE.Core/Services/CloudInitService.cs b/src/PSProxmoxVE.Core/Services/CloudInitService.cs
new file mode 100644
index 0000000..72f9d55
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/CloudInitService.cs
@@ -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
+{
+ ///
+ /// Service for managing Cloud-Init configuration on Proxmox VE QEMU/KVM VMs.
+ /// All operations target the /nodes/{node}/qemu/{vmid}/config endpoint.
+ ///
+ 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"
+ };
+
+ ///
+ /// Retrieves the Cloud-Init specific configuration fields for a VM.
+ /// Internally fetches the full VM config and extracts the CI fields.
+ ///
+ 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() ?? new PveCloudInitConfig();
+ }
+
+ ///
+ /// Updates Cloud-Init configuration fields on a VM. Only fields present in
+ /// are changed; unspecified fields are left as-is.
+ ///
+ ///
+ /// Pass only the Cloud-Init keys you want to change, for example:
+ /// new Dictionary<string, object> { ["ciuser"] = "ubuntu", ["ipconfig0"] = "ip=dhcp" }
+ ///
+ public void SetCloudInitConfig(
+ PveSession session,
+ string node,
+ int vmid,
+ Dictionary 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// The raw Cloud-Init dump (user-data) string as reported by the PVE API.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/ClusterService.cs b/src/PSProxmoxVE.Core/Services/ClusterService.cs
new file mode 100644
index 0000000..d781651
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/ClusterService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE cluster-level API operations.
+ ///
+ public class ClusterService
+ {
+ ///
+ /// Returns the current cluster status. The response is a mixed array of
+ /// "cluster" and "node" type entries.
+ ///
+ 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() ?? Array.Empty();
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/ContainerService.cs b/src/PSProxmoxVE.Core/Services/ContainerService.cs
new file mode 100644
index 0000000..ef0f0bf
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/ContainerService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE Linux Container (LXC) API operations.
+ ///
+ public class ContainerService
+ {
+ private readonly NodeService _nodeService = new NodeService();
+
+ // -------------------------------------------------------------------------
+ // Read operations
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns containers. If is null, queries every cluster node.
+ ///
+ 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();
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Returns a single container by its ID on the specified node.
+ ///
+ 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;
+ }
+
+ ///
+ /// Returns the full configuration of a container.
+ ///
+ 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() ?? new PveContainerConfig();
+ }
+
+ // -------------------------------------------------------------------------
+ // Configuration mutation
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Updates one or more container configuration settings.
+ ///
+ public void SetContainerConfig(
+ PveSession session,
+ string node,
+ int vmid,
+ Dictionary 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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates a new container. Returns the task UPID.
+ ///
+ public PveTask CreateContainer(
+ PveSession session,
+ string node,
+ Dictionary 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);
+ }
+
+ /// Starts a container. Returns the task UPID.
+ public PveTask StartContainer(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "start");
+
+ /// Stops a container (hard stop). Returns the task UPID.
+ public PveTask StopContainer(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "stop");
+
+ /// Gracefully shuts down a container. Returns the task UPID.
+ 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();
+ 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);
+ }
+
+ /// Removes a container. Returns the task UPID.
+ 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);
+ }
+
+ /// Clones a container. Returns the task UPID.
+ 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
+ {
+ ["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);
+ }
+
+ /// Migrates a container to another node. Returns the task UPID.
+ 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
+ {
+ ["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() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/NetworkService.cs b/src/PSProxmoxVE.Core/Services/NetworkService.cs
new file mode 100644
index 0000000..eb1c253
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/NetworkService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE node network and SDN (Software-Defined Networking) API operations.
+ /// SDN methods require PVE 8.0 or later and will throw otherwise.
+ ///
+ public class NetworkService
+ {
+ // -------------------------------------------------------------------------
+ // Node network interfaces
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns network interface configurations for a node.
+ ///
+ ///
+ /// Optional interface type filter (e.g. "bridge", "bond", "eth", "vlan", "alias").
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a network interface on a node. Changes are pending until
+ /// is called. Returns the new interface config.
+ ///
+ public PveNetwork CreateNetwork(
+ PveSession session,
+ string node,
+ Dictionary 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() ?? new PveNetwork();
+ }
+
+ ///
+ /// Updates an existing network interface on a node. Changes are pending until
+ /// is called.
+ ///
+ public void SetNetwork(
+ PveSession session,
+ string node,
+ string iface,
+ Dictionary 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();
+ }
+
+ ///
+ /// Removes a network interface from a node. Changes are pending until
+ /// is called.
+ ///
+ 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();
+ }
+
+ ///
+ /// Applies pending network configuration changes on a node. Returns the task UPID.
+ ///
+ 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+
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns all SDN zone definitions. Requires PVE 8.0+.
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Returns all SDN VNet definitions. Requires PVE 8.0+.
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates an SDN zone. Requires PVE 8.0+.
+ ///
+ public PveSdnZone CreateSdnZone(
+ PveSession session,
+ Dictionary 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() ?? new PveSdnZone();
+ }
+
+ ///
+ /// Removes an SDN zone. Requires PVE 8.0+.
+ ///
+ 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();
+ }
+
+ ///
+ /// Creates an SDN VNet. Requires PVE 8.0+.
+ ///
+ public PveSdnVnet CreateSdnVnet(
+ PveSession session,
+ Dictionary 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() ?? new PveSdnVnet();
+ }
+
+ ///
+ /// Removes an SDN VNet. Requires PVE 8.0+.
+ ///
+ 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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Throws if the session's server version is below 8.0.
+ ///
+ 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() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/NodeService.cs b/src/PSProxmoxVE.Core/Services/NodeService.cs
new file mode 100644
index 0000000..ecacbc8
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/NodeService.cs
@@ -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
+{
+ ///
+ /// Service for node-level and cluster-version Proxmox VE API operations.
+ ///
+ public class NodeService
+ {
+ ///
+ /// Returns all cluster nodes.
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Returns detailed status for a specific node.
+ ///
+ 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() ?? new PveNodeStatus();
+ }
+
+ ///
+ /// Returns the Proxmox VE version running on the server.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/SnapshotService.cs b/src/PSProxmoxVE.Core/Services/SnapshotService.cs
new file mode 100644
index 0000000..0af377f
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/SnapshotService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE VM snapshot API operations.
+ /// All operations apply to QEMU/KVM VMs via the /nodes/{node}/qemu/{vmid}/snapshot endpoints.
+ ///
+ public class SnapshotService
+ {
+ ///
+ /// Returns all snapshots for a VM.
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a snapshot of a VM. Returns the task UPID.
+ ///
+ /// Snapshot name (alphanumeric, no spaces).
+ /// Optional description.
+ /// Whether to save VM RAM state (live snapshot). Default false.
+ 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
+ {
+ ["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);
+ }
+
+ ///
+ /// Removes a snapshot from a VM. Returns the task UPID.
+ ///
+ 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);
+ }
+
+ ///
+ /// Rolls a VM back to a snapshot. Returns the task UPID.
+ ///
+ 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() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/StorageService.cs b/src/PSProxmoxVE.Core/Services/StorageService.cs
new file mode 100644
index 0000000..0351837
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/StorageService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE storage API operations.
+ ///
+ public class StorageService
+ {
+ // -------------------------------------------------------------------------
+ // Read operations
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns storage definitions. If is null, returns
+ /// the cluster-wide storage list; otherwise returns storage visible on that node.
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Returns the contents of a storage volume on a specific node.
+ ///
+ ///
+ /// Optional content type filter (e.g. "iso", "vztmpl", "images", "backup").
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Upload / download
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Uploads an ISO (or other file) to a storage on a node. Returns the task UPID.
+ ///
+ /// Optional checksum value.
+ /// Optional checksum algorithm (e.g. "sha256").
+ /// Optional callback with (bytesSent, totalBytes).
+ public PveTask UploadIso(
+ PveSession session,
+ string node,
+ string storage,
+ string filePath,
+ string? checksum = null,
+ string? checksumAlgorithm = null,
+ Action? 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
+ {
+ ["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);
+ }
+
+ ///
+ /// Downloads a file from a URL directly to a storage on a node. Returns the task UPID.
+ ///
+ 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
+ {
+ ["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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 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).
+ ///
+ public PveStorage CreateStorage(PveSession session, Dictionary 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() ?? new PveStorage();
+ }
+
+ ///
+ /// Removes a cluster-level storage definition.
+ ///
+ 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() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/TaskService.cs b/src/PSProxmoxVE.Core/Services/TaskService.cs
new file mode 100644
index 0000000..c576916
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/TaskService.cs
@@ -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
+{
+ ///
+ /// Service for querying and waiting on Proxmox VE asynchronous tasks (UPIDs).
+ ///
+ 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);
+
+ ///
+ /// Returns the current status of a task identified by its UPID.
+ ///
+ 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() ?? new PveTask { Upid = upid };
+ task.Node = node;
+ return task;
+ }
+
+ ///
+ /// Returns all log lines for a task identified by its UPID.
+ ///
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Polls the task status until it completes, throws on timeout or failure.
+ ///
+ /// Active PVE session.
+ /// Node name where the task is running.
+ /// Task UPID.
+ /// Maximum time to wait. Defaults to 10 minutes.
+ /// Interval between status polls. Defaults to 2 seconds. Minimum 1 second.
+ /// Optional callback invoked on each poll with the current task.
+ /// The completed .
+ /// Thrown when the task does not complete within .
+ /// Thrown when the task completes with a non-OK exit status.
+ public PveTask WaitForTask(
+ PveSession session,
+ string node,
+ string upid,
+ TimeSpan? timeout = null,
+ TimeSpan? pollInterval = null,
+ Action? 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);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/TemplateService.cs b/src/PSProxmoxVE.Core/Services/TemplateService.cs
new file mode 100644
index 0000000..5919d7a
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/TemplateService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE VM template operations.
+ /// Templates are VMs with the "template" flag set to 1.
+ ///
+ public class TemplateService
+ {
+ private readonly VmService _vmService = new VmService();
+
+ ///
+ /// Returns all VM templates. If is null, searches all cluster nodes.
+ ///
+ 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();
+ }
+
+ ///
+ /// Converts an existing VM into a template. Returns the task UPID.
+ ///
+ ///
+ /// The VM must be stopped and must not already be a template.
+ /// Once converted, this operation cannot be reversed via the API.
+ ///
+ 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);
+ }
+
+ ///
+ /// Removes a VM template (delegates to ).
+ ///
+ ///
+ /// If true, also removes all associated backup files and jobs.
+ ///
+ 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() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/UserService.cs b/src/PSProxmoxVE.Core/Services/UserService.cs
new file mode 100644
index 0000000..4191ca8
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/UserService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE access control — users, roles, and permissions (ACLs).
+ ///
+ public class UserService
+ {
+ // -------------------------------------------------------------------------
+ // Users
+ // -------------------------------------------------------------------------
+
+ /// Returns all users.
+ 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() ?? Array.Empty();
+ }
+
+ /// Returns a single user by their user ID (e.g. "admin@pam").
+ 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() ?? new PveUser();
+ // The single-user endpoint may not echo back the userid
+ if (string.IsNullOrEmpty(user.UserId))
+ user.UserId = userId;
+ return user;
+ }
+
+ ///
+ /// Creates a new user account.
+ ///
+ /// User ID in "username@realm" format.
+ /// Additional fields (password, email, firstname, lastname, etc.).
+ public void CreateUser(
+ PveSession session,
+ string userId,
+ Dictionary? config = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
+
+ var formData = new Dictionary { ["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();
+ }
+
+ /// Removes a user account.
+ 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();
+ }
+
+ /// Updates one or more properties of an existing user.
+ public void SetUser(
+ PveSession session,
+ string userId,
+ Dictionary 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
+ // -------------------------------------------------------------------------
+
+ /// Returns all roles.
+ 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() ?? Array.Empty();
+ }
+
+ /// Creates a new role.
+ /// Role name.
+ /// Comma-separated list of privilege strings.
+ 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 { ["roleid"] = roleId };
+ if (!string.IsNullOrEmpty(privileges))
+ formData["privs"] = privileges!;
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync("access/roles", formData).GetAwaiter().GetResult();
+ }
+
+ /// Removes a role.
+ 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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns the effective permissions (resolved privilege set) for a user or token.
+ ///
+ /// Optional user ID to query; defaults to the authenticated user.
+ /// Optional path to restrict the query.
+ 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();
+ 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();
+ if (data.Type == JTokenType.Array)
+ return data.ToObject() ?? Array.Empty();
+
+ // Unwrap path-keyed object into flat list
+ var result = new List();
+ foreach (var prop in ((JObject)data).Properties())
+ {
+ var perm = new PvePermission { Path = prop.Name };
+ result.Add(perm);
+ }
+ return result.ToArray();
+ }
+
+ ///
+ /// Sets (adds or updates) an ACL entry.
+ ///
+ /// Access control path (e.g. "/", "/nodes/pve", "/vms/100").
+ /// Comma-separated role IDs.
+ /// Comma-separated user IDs.
+ /// Comma-separated group names.
+ /// Whether to propagate the permission to sub-paths.
+ /// If true, removes the specified ACL entries.
+ 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
+ {
+ ["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();
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs
new file mode 100644
index 0000000..2b36348
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/VmService.cs
@@ -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
+{
+ ///
+ /// Service for Proxmox VE QEMU/KVM virtual machine API operations.
+ ///
+ public class VmService
+ {
+ private readonly NodeService _nodeService = new NodeService();
+
+ // -------------------------------------------------------------------------
+ // Read operations
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns VMs. If is null, queries every cluster node.
+ ///
+ 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();
+ 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() ?? Array.Empty();
+ }
+
+ ///
+ /// Returns a single VM by its ID on the specified node.
+ ///
+ 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;
+ }
+
+ ///
+ /// Returns the full configuration of a VM.
+ ///
+ 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() ?? new PveVmConfig();
+ }
+
+ // -------------------------------------------------------------------------
+ // Configuration mutation
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Updates one or more VM configuration settings. Changes are applied immediately (POST).
+ ///
+ public void SetVmConfig(PveSession session, string node, int vmid, Dictionary 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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates a new VM. Returns the task UPID.
+ ///
+ public PveTask CreateVm(PveSession session, string node, Dictionary 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);
+ }
+
+ /// Starts a VM. Returns the task UPID.
+ public PveTask StartVm(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "start");
+
+ /// Stops a VM (hard power-off). Returns the task UPID.
+ public PveTask StopVm(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "stop");
+
+ /// Gracefully shuts down a VM. Returns the task UPID.
+ 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();
+ 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);
+ }
+
+ /// Resets a VM (hard reset). Returns the task UPID.
+ public PveTask ResetVm(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "reset");
+
+ /// Suspends a VM (writes RAM state to disk). Returns the task UPID.
+ public PveTask SuspendVm(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "suspend");
+
+ /// Resumes a suspended VM. Returns the task UPID.
+ public PveTask ResumeVm(PveSession session, string node, int vmid)
+ => PostStatus(session, node, vmid, "resume");
+
+ // -------------------------------------------------------------------------
+ // Removal / migration / clone
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Removes a VM. Returns the task UPID.
+ ///
+ 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);
+ }
+
+ ///
+ /// Clones a VM. Returns the task UPID.
+ ///
+ 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
+ {
+ ["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);
+ }
+
+ ///
+ /// Migrates a VM to another node. Returns the task UPID.
+ ///
+ 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
+ {
+ ["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);
+ }
+
+ ///
+ /// Resizes a disk attached to a VM. Returns the task UPID.
+ ///
+ /// Disk identifier, e.g. "scsi0" or "virtio0".
+ ///
+ /// New absolute size (e.g. "32G") or relative increase with "+" prefix (e.g. "+10G").
+ ///
+ 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
+ {
+ ["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() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}