feat: add AdditionalConfig parameter and guest agent cmdlets (closes #2, #3)

Issue #3 — Set-PveVmConfig / Set-PveContainerConfig:
- Add -AdditionalConfig [Hashtable] for arbitrary PVE config keys
  (scsi0, boot, agent, net0, ide2, cicustom, etc.)
- Add -Delete [string] for removing config keys
- Unit tests for both new parameters

Issue #2 — QEMU Guest Agent cmdlets:
- Test-PveVmGuestAgent: pings guest agent, returns bool
- Get-PveVmGuestNetwork: returns guest network interfaces with IPs
- Invoke-PveVmGuestExec: executes command in guest, returns output
- New models: PveGuestNetworkInterface, PveGuestIpAddress
- Service methods in VmService for all agent operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-19 14:06:55 -05:00
parent c6da302742
commit b12ffc485d
10 changed files with 364 additions and 0 deletions
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.Vms;
/// <summary>
/// Represents an IP address reported by the QEMU guest agent.
/// </summary>
public class PveGuestIpAddress
{
/// <summary>The IP address string (e.g., "192.168.1.100" or "fe80::1").</summary>
[JsonPropertyName("ip-address")]
[JsonProperty("ip-address")]
public string Address { get; set; } = string.Empty;
/// <summary>The address type: "ipv4" or "ipv6".</summary>
[JsonPropertyName("ip-address-type")]
[JsonProperty("ip-address-type")]
public string Type { get; set; } = string.Empty;
/// <summary>The network prefix length (e.g., 24 for a /24 subnet).</summary>
[JsonPropertyName("prefix")]
[JsonProperty("prefix")]
public int Prefix { get; set; }
public override string ToString() => $"{Address}/{Prefix} ({Type})";
}
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.Vms;
/// <summary>
/// Represents a network interface reported by the QEMU guest agent.
/// </summary>
public class PveGuestNetworkInterface
{
/// <summary>The interface name (e.g., "eth0", "ens18", "lo").</summary>
[JsonPropertyName("name")]
[JsonProperty("name")]
public string Name { get; set; } = string.Empty;
/// <summary>The hardware (MAC) address of the interface.</summary>
[JsonPropertyName("hardware-address")]
[JsonProperty("hardware-address")]
public string? HardwareAddress { get; set; }
/// <summary>The IP addresses assigned to this interface.</summary>
[JsonPropertyName("ip-addresses")]
[JsonProperty("ip-addresses")]
public PveGuestIpAddress[] IpAddresses { get; set; } = System.Array.Empty<PveGuestIpAddress>();
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}";
}
}
@@ -353,5 +353,89 @@ namespace PSProxmoxVE.Core.Services
task.Node = node;
return task;
}
// -------------------------------------------------------------------------
// QEMU Guest Agent
// -------------------------------------------------------------------------
/// <summary>
/// Pings the QEMU guest agent on the specified VM. Returns true if responsive.
/// </summary>
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;
}
}
/// <summary>
/// Retrieves network interface information from the QEMU guest agent.
/// </summary>
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<PveGuestNetworkInterface[]>() ?? Array.Empty<PveGuestNetworkInterface>();
}
/// <summary>
/// Executes a command inside the guest via the QEMU guest agent.
/// Returns the PID of the spawned process.
/// </summary>
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<string, string>
{
["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<int>() ?? 0;
return pid;
}
/// <summary>
/// Gets the status/result of a guest agent exec command by PID.
/// </summary>
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();
}
}
}