From 52f1978de4cf3421c6f54de4f714d91768d635ca Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Tue, 17 Mar 2026 15:45:23 -0500 Subject: [PATCH] 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) --- .../Services/CloudInitService.cs | 99 ++++++ .../Services/ClusterService.cs | 28 ++ .../Services/ContainerService.cs | 263 +++++++++++++++ .../Services/NetworkService.cs | 242 ++++++++++++++ src/PSProxmoxVE.Core/Services/NodeService.cs | 58 ++++ .../Services/SnapshotService.cs | 116 +++++++ .../Services/StorageService.cs | 186 +++++++++++ src/PSProxmoxVE.Core/Services/TaskService.cs | 107 ++++++ .../Services/TemplateService.cs | 80 +++++ src/PSProxmoxVE.Core/Services/UserService.cs | 222 +++++++++++++ src/PSProxmoxVE.Core/Services/VmService.cs | 304 ++++++++++++++++++ 11 files changed, 1705 insertions(+) create mode 100644 src/PSProxmoxVE.Core/Services/CloudInitService.cs create mode 100644 src/PSProxmoxVE.Core/Services/ClusterService.cs create mode 100644 src/PSProxmoxVE.Core/Services/ContainerService.cs create mode 100644 src/PSProxmoxVE.Core/Services/NetworkService.cs create mode 100644 src/PSProxmoxVE.Core/Services/NodeService.cs create mode 100644 src/PSProxmoxVE.Core/Services/SnapshotService.cs create mode 100644 src/PSProxmoxVE.Core/Services/StorageService.cs create mode 100644 src/PSProxmoxVE.Core/Services/TaskService.cs create mode 100644 src/PSProxmoxVE.Core/Services/TemplateService.cs create mode 100644 src/PSProxmoxVE.Core/Services/UserService.cs create mode 100644 src/PSProxmoxVE.Core/Services/VmService.cs 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; + } + } +}