diff --git a/src/PSProxmoxVE.Core/Models/Vms/PveGuestIpAddress.cs b/src/PSProxmoxVE.Core/Models/Vms/PveGuestIpAddress.cs new file mode 100644 index 0000000..ce645d4 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Vms/PveGuestIpAddress.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Vms; + +/// +/// Represents an IP address reported by the QEMU guest agent. +/// +public class PveGuestIpAddress +{ + /// The IP address string (e.g., "192.168.1.100" or "fe80::1"). + [JsonPropertyName("ip-address")] + [JsonProperty("ip-address")] + public string Address { get; set; } = string.Empty; + + /// The address type: "ipv4" or "ipv6". + [JsonPropertyName("ip-address-type")] + [JsonProperty("ip-address-type")] + public string Type { get; set; } = string.Empty; + + /// The network prefix length (e.g., 24 for a /24 subnet). + [JsonPropertyName("prefix")] + [JsonProperty("prefix")] + public int Prefix { get; set; } + + public override string ToString() => $"{Address}/{Prefix} ({Type})"; +} diff --git a/src/PSProxmoxVE.Core/Models/Vms/PveGuestNetworkInterface.cs b/src/PSProxmoxVE.Core/Models/Vms/PveGuestNetworkInterface.cs new file mode 100644 index 0000000..63410e3 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Vms/PveGuestNetworkInterface.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Vms; + +/// +/// Represents a network interface reported by the QEMU guest agent. +/// +public class PveGuestNetworkInterface +{ + /// The interface name (e.g., "eth0", "ens18", "lo"). + [JsonPropertyName("name")] + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + /// The hardware (MAC) address of the interface. + [JsonPropertyName("hardware-address")] + [JsonProperty("hardware-address")] + public string? HardwareAddress { get; set; } + + /// The IP addresses assigned to this interface. + [JsonPropertyName("ip-addresses")] + [JsonProperty("ip-addresses")] + public PveGuestIpAddress[] IpAddresses { get; set; } = System.Array.Empty(); + + public override string ToString() + { + var ips = IpAddresses.Length > 0 + ? string.Join(", ", System.Array.ConvertAll(IpAddresses, ip => ip.ToString())) + : "no addresses"; + return $"{Name} ({HardwareAddress ?? "N/A"}): {ips}"; + } +} diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs index 826a203..c041d7c 100644 --- a/src/PSProxmoxVE.Core/Services/VmService.cs +++ b/src/PSProxmoxVE.Core/Services/VmService.cs @@ -353,5 +353,89 @@ namespace PSProxmoxVE.Core.Services task.Node = node; return task; } + + // ------------------------------------------------------------------------- + // QEMU Guest Agent + // ------------------------------------------------------------------------- + + /// + /// Pings the QEMU guest agent on the specified VM. Returns true if responsive. + /// + public bool PingGuestAgent(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); + try + { + client.PostAsync($"nodes/{node}/qemu/{vmid}/agent/ping").GetAwaiter().GetResult(); + return true; + } + catch + { + return false; + } + } + + /// + /// Retrieves network interface information from the QEMU guest agent. + /// + public PveGuestNetworkInterface[] GetGuestNetworkInterfaces(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}/agent/network-get-interfaces") + .GetAwaiter().GetResult(); + var data = JObject.Parse(response)["data"]; + var result = data?["result"]; + return result?.ToObject() ?? Array.Empty(); + } + + /// + /// Executes a command inside the guest via the QEMU guest agent. + /// Returns the PID of the spawned process. + /// + public int ExecuteGuestCommand(PveSession session, string node, int vmid, + string command, string[]? args = null) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(command)) throw new ArgumentNullException(nameof(command)); + + using var client = new PveHttpClient(session); + var data = new Dictionary + { + ["command"] = command + }; + + if (args != null && args.Length > 0) + { + // PVE expects input-data for arguments passed as a JSON-encoded string array + var argsJson = Newtonsoft.Json.JsonConvert.SerializeObject(args); + data["input-data"] = argsJson; + } + + var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/agent/exec", data) + .GetAwaiter().GetResult(); + var pid = JObject.Parse(response)["data"]?["pid"]?.ToObject() ?? 0; + return pid; + } + + /// + /// Gets the status/result of a guest agent exec command by PID. + /// + public JObject GetGuestExecStatus(PveSession session, string node, int vmid, int pid) + { + 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}/agent/exec-status?pid={pid}") + .GetAwaiter().GetResult(); + return JObject.Parse(response)["data"] as JObject ?? new JObject(); + } } } diff --git a/src/PSProxmoxVE/Cmdlets/Containers/SetPveContainerConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Containers/SetPveContainerConfigCmdlet.cs index bb1fc2a..822e0a2 100644 --- a/src/PSProxmoxVE/Cmdlets/Containers/SetPveContainerConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Containers/SetPveContainerConfigCmdlet.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Generic; using System.Management.Automation; using PSProxmoxVE.Core.Services; @@ -77,6 +78,25 @@ namespace PSProxmoxVE.Cmdlets.Containers [Parameter(Mandatory = false)] public string? SearchDomain { get; set; } + /// + /// + /// Hashtable of additional configuration keys to set. Use this for any PVE config + /// option not exposed as a named parameter (e.g., net0, mp0, rootfs). + /// Values are merged after named parameters and can override them. + /// + /// + [Parameter(Mandatory = false)] + public Hashtable? AdditionalConfig { get; set; } + + /// + /// + /// Comma-separated list of configuration keys to delete. + /// Maps to the PVE API "delete" parameter. + /// + /// + [Parameter(Mandatory = false)] + public string? Delete { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"Container {VmId} on node '{Node}'", "Set-PveContainerConfig")) @@ -104,6 +124,15 @@ namespace PSProxmoxVE.Cmdlets.Containers if (!string.IsNullOrEmpty(SearchDomain)) config["searchdomain"] = SearchDomain!; + if (AdditionalConfig != null) + { + foreach (DictionaryEntry entry in AdditionalConfig) + config[entry.Key.ToString()!] = entry.Value ?? string.Empty; + } + + if (!string.IsNullOrEmpty(Delete)) + config["delete"] = Delete!; + containerService.SetContainerConfig(session, Node, VmId, config); } } diff --git a/src/PSProxmoxVE/Cmdlets/Vms/GetPveVmGuestNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/GetPveVmGuestNetworkCmdlet.cs new file mode 100644 index 0000000..c39fb4e --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Vms/GetPveVmGuestNetworkCmdlet.cs @@ -0,0 +1,37 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Vms +{ + /// + /// Gets network interface information from the QEMU guest agent. + /// + /// Queries the QEMU guest agent running inside the specified VM for its network + /// interface configuration, including interface names, MAC addresses, and IP addresses. + /// The guest agent must be installed and running inside the VM. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveVmGuestNetwork")] + [OutputType(typeof(PveGuestNetworkInterface))] + public sealed class GetPveVmGuestNetworkCmdlet : PveCmdletBase + { + /// The Proxmox VE node name. + [Parameter(Mandatory = true, Position = 0)] + public string Node { get; set; } = string.Empty; + + /// The VM identifier. + [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true)] + public int VmId { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new VmService(); + var interfaces = service.GetGuestNetworkInterfaces(session, Node, VmId); + + foreach (var iface in interfaces) + WriteObject(iface); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs new file mode 100644 index 0000000..057ee93 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs @@ -0,0 +1,72 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Vms +{ + /// + /// Executes a command inside a VM via the QEMU guest agent. + /// + /// Sends a command to the QEMU guest agent running inside the specified VM for execution. + /// Returns the result including stdout, stderr, and exit code. The guest agent must be + /// installed and running inside the VM. + /// + /// + [Cmdlet(VerbsLifecycle.Invoke, "PveVmGuestExec")] + [OutputType(typeof(PSObject))] + public sealed class InvokePveVmGuestExecCmdlet : PveCmdletBase + { + /// The Proxmox VE node name. + [Parameter(Mandatory = true, Position = 0)] + public string Node { get; set; } = string.Empty; + + /// The VM identifier. + [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true)] + public int VmId { get; set; } + + /// The command to execute inside the guest. + [Parameter(Mandatory = true, Position = 2)] + public string Command { get; set; } = string.Empty; + + /// Optional arguments to pass to the command. + [Parameter(Mandatory = false)] + public string[]? Args { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new VmService(); + + var pid = service.ExecuteGuestCommand(session, Node, VmId, Command, Args); + + // Poll for completion + Newtonsoft.Json.Linq.JObject result; + do + { + System.Threading.Thread.Sleep(1000); + result = service.GetGuestExecStatus(session, Node, VmId, pid); + } while (result["exited"]?.ToObject() != 1); + + var output = new PSObject(); + output.Properties.Add(new PSNoteProperty("ExitCode", result["exitcode"]?.ToObject() ?? -1)); + output.Properties.Add(new PSNoteProperty("Stdout", DecodeBase64(result["out-data"]?.ToString()))); + output.Properties.Add(new PSNoteProperty("Stderr", DecodeBase64(result["err-data"]?.ToString()))); + output.Properties.Add(new PSNoteProperty("Pid", pid)); + + WriteObject(output); + } + + private static string DecodeBase64(string? encoded) + { + if (string.IsNullOrEmpty(encoded)) return string.Empty; + try + { + var bytes = System.Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(bytes); + } + catch + { + return encoded!; + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Vms/SetPveVmConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/SetPveVmConfigCmdlet.cs index 1a16aa5..5f09d48 100644 --- a/src/PSProxmoxVE/Cmdlets/Vms/SetPveVmConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Vms/SetPveVmConfigCmdlet.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Generic; using System.Management.Automation; using PSProxmoxVE.Core.Services; @@ -83,6 +84,25 @@ namespace PSProxmoxVE.Cmdlets.Vms [Parameter(Mandatory = false)] public string? OsType { get; set; } + /// + /// + /// Hashtable of additional configuration keys to set. Use this for any PVE config + /// option not exposed as a named parameter (e.g., scsi0, boot, agent, net0, ide2). + /// Values are merged after named parameters and can override them. + /// + /// + [Parameter(Mandatory = false)] + public Hashtable? AdditionalConfig { get; set; } + + /// + /// + /// Comma-separated list of configuration keys to delete (e.g., "ide2,args"). + /// Maps to the PVE API "delete" parameter. + /// + /// + [Parameter(Mandatory = false)] + public string? Delete { get; set; } + protected override void ProcessRecord() { if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Set-PveVmConfig")) @@ -112,6 +132,15 @@ namespace PSProxmoxVE.Cmdlets.Vms if (!string.IsNullOrEmpty(OsType)) config["ostype"] = OsType!; + if (AdditionalConfig != null) + { + foreach (DictionaryEntry entry in AdditionalConfig) + config[entry.Key.ToString()!] = entry.Value ?? string.Empty; + } + + if (!string.IsNullOrEmpty(Delete)) + config["delete"] = Delete!; + vmService.SetVmConfig(session, Node, VmId, config); } } diff --git a/src/PSProxmoxVE/Cmdlets/Vms/TestPveVmGuestAgentCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/TestPveVmGuestAgentCmdlet.cs new file mode 100644 index 0000000..07aa4de --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Vms/TestPveVmGuestAgentCmdlet.cs @@ -0,0 +1,32 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Vms +{ + /// + /// Tests whether the QEMU guest agent is responding on a VM. + /// + /// Pings the QEMU guest agent on the specified virtual machine. + /// Returns $true if the agent responds, $false otherwise. + /// + /// + [Cmdlet(VerbsDiagnostic.Test, "PveVmGuestAgent")] + [OutputType(typeof(bool))] + public sealed class TestPveVmGuestAgentCmdlet : PveCmdletBase + { + /// The Proxmox VE node name. + [Parameter(Mandatory = true, Position = 0)] + public string Node { get; set; } = string.Empty; + + /// The VM identifier. + [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true)] + public int VmId { get; set; } + + protected override void ProcessRecord() + { + var session = GetSession(); + var service = new VmService(); + WriteObject(service.PingGuestAgent(session, Node, VmId)); + } + } +} diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1 index 87d4f14..7fef605 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.psd1 +++ b/src/PSProxmoxVE/PSProxmoxVE.psd1 @@ -75,6 +75,11 @@ 'Set-PveVmConfig', 'Resize-PveVmDisk', + # QEMU Guest Agent + 'Test-PveVmGuestAgent', + 'Get-PveVmGuestNetwork', + 'Invoke-PveVmGuestExec', + # Containers 'Get-PveContainer', 'New-PveContainer', diff --git a/tests/PSProxmoxVE.Tests/Vms/VmConfigCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Vms/VmConfigCmdlets.Tests.ps1 index a8a2937..2e64127 100644 --- a/tests/PSProxmoxVE.Tests/Vms/VmConfigCmdlets.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Vms/VmConfigCmdlets.Tests.ps1 @@ -212,6 +212,22 @@ Describe 'Set-PveVmConfig' { $script:Cmd.Parameters['OsType'].ParameterType | Should -Be ([string]) } + It 'Should have AdditionalConfig parameter (optional, Hashtable)' { + $script:Cmd.Parameters.ContainsKey('AdditionalConfig') | Should -BeTrue + $script:Cmd.Parameters['AdditionalConfig'].ParameterType | Should -Be ([hashtable]) + $isMandatory = $script:Cmd.Parameters['AdditionalConfig'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + + It 'Should have Delete parameter (optional, String)' { + $script:Cmd.Parameters.ContainsKey('Delete') | Should -BeTrue + $script:Cmd.Parameters['Delete'].ParameterType | Should -Be ([string]) + $isMandatory = $script:Cmd.Parameters['Delete'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + It 'Should have Session parameter (inherited from PveCmdletBase)' { $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue }