diff --git a/Directory.Build.props b/Directory.Build.props
index 58e81cd..1e77332 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,6 +14,11 @@
true
embedded
+
+ false
diff --git a/samples/dev-vm.json b/samples/dev-vm.json
new file mode 100644
index 0000000..9292d3d
--- /dev/null
+++ b/samples/dev-vm.json
@@ -0,0 +1,17 @@
+{
+ "node": "desktop-01",
+ "vmid": 9999,
+ "name": "proxmoxsharp-dev",
+ "machine": "q35",
+ "bios": "seabios",
+ "cpu": "host",
+ "cores": 1,
+ "memory": 512,
+ "ostype": "l26",
+ "onboot": false,
+ "scsihw": "virtio-scsi-single",
+ "disks": [{ "id": "scsi0", "storage": "local-lvm", "size": 2, "ssd": true }],
+ "nets": [{ "id": "net0", "model": "virtio", "bridge": "vmbr0" }],
+ "bootOrder": ["scsi0"],
+ "tags": ["dev", "proxmoxsharp"]
+}
diff --git a/src/ProxmoxSharp.Api/ProxmoxSharp.Api.csproj b/src/ProxmoxSharp.Api/ProxmoxSharp.Api.csproj
index b647330..452c1fe 100644
--- a/src/ProxmoxSharp.Api/ProxmoxSharp.Api.csproj
+++ b/src/ProxmoxSharp.Api/ProxmoxSharp.Api.csproj
@@ -30,8 +30,11 @@
$(MSBuildProjectDirectory)/obj/openapi.json
$(MSBuildProjectDirectory)/obj/proxmox-gen.stamp
$(RepoRoot)/tools/ProxmoxSharp.SchemaGen/ProxmoxSharp.SchemaGen.csproj
-
+
/version,/nodes,/cluster,/storage,/access
+
+ GET,POST,PUT,DELETE
@@ -48,7 +51,7 @@
+ Command="dotnet run --project "$(SchemaGenProject)" -c $(Configuration) -- --in "$(PveSchemaFile)" --out "$(OpenApiFile)" --include $(ApiIncludePaths) --methods $(ApiMethods)" />
diff --git a/src/ProxmoxSharp.Cli/Program.cs b/src/ProxmoxSharp.Cli/Program.cs
index 08d38dd..4509857 100644
--- a/src/ProxmoxSharp.Cli/Program.cs
+++ b/src/ProxmoxSharp.Cli/Program.cs
@@ -1,9 +1,10 @@
using System.Text.Json;
using ProxmoxSharp;
+using ProxmoxSharp.Vm;
-// proxmoxsharp — a thin CLI over the ProxmoxSharp library (read-only).
+// proxmoxsharp — a thin CLI over the ProxmoxSharp library.
//
-// Commands: discover | nodes | version
+// Commands: discover | nodes | version | vm
// Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
// PROXMOX_VERIFY_TLS (optional, 'false' for self-signed nodes)
@@ -13,12 +14,20 @@
{
Console.WriteLine(
"""
- proxmoxsharp — read-only Proxmox VE client
+ proxmoxsharp — Proxmox VE client (read + VM write path)
Usage: proxmoxsharp
- discover Dump a structured ClusterSnapshot as JSON
- nodes List cluster nodes
- version Show the PVE version
+ discover Dump a structured ClusterSnapshot as JSON
+ nodes List cluster nodes
+ version Show the PVE version
+
+ vm plan Diff a VM spec against live state (dry-run)
+ vm apply [--confirm] Apply the diff (dry-run unless --confirm)
+ vm start Start a VM
+ vm stop Hard-stop a VM
+ vm delete --confirm [--no-purge] Destroy a VM
+ vm pci List PCI devices (passthrough discovery)
+ vm show Dump a VM's live config
Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
PROXMOX_VERIFY_TLS (optional, 'false' for self-signed)
@@ -61,11 +70,143 @@ version Show the PVE version
Console.WriteLine(version?.Data?.Version ?? "(unknown)");
return 0;
+ case "vm":
+ return await RunVm(args, options);
+
default:
- Console.Error.WriteLine($"Unknown command '{command}'. Try: discover | nodes | version");
+ Console.Error.WriteLine($"Unknown command '{command}'. Try: discover | nodes | version | vm");
return 1;
}
+// vm — the VM write path (#115).
+static async Task RunVm(string[] args, ProxmoxClientOptions options)
+{
+ var sub = args.Length > 1 ? args[1].ToLowerInvariant() : "";
+ var writer = QemuWriter.Create(options);
+
+ switch (sub)
+ {
+ case "plan":
+ case "apply":
+ {
+ if (args.Length < 3) { Console.Error.WriteLine($"usage: proxmoxsharp vm {sub} [--confirm]"); return 2; }
+ var spec = LoadSpec(args[2]);
+ if (spec is null) return 2;
+ var live = await writer.GetConfigRawAsync(spec.Node, spec.Vmid);
+ var plan = VmReconciler.Reconcile(spec, live);
+ PrintPlan(plan);
+
+ if (sub == "plan" || !args.Contains("--confirm"))
+ {
+ if (plan.HasChanges) Console.WriteLine("\n[dry-run] re-run `vm apply --confirm` to apply.");
+ return 0;
+ }
+ if (plan.Kind == VmActionKind.Skip) { Console.WriteLine("\nNothing to apply."); return 0; }
+
+ string? upid;
+ if (plan.Kind == VmActionKind.Create)
+ {
+ Console.WriteLine($"\nCreating VM {spec.Vmid} on {spec.Node}…");
+ upid = await writer.CreateAsync(spec);
+ }
+ else
+ {
+ var changes = plan.Changes.ToDictionary(c => c.Key, c => c.To, StringComparer.Ordinal);
+ Console.WriteLine($"\nApplying {changes.Count} config change(s) to VM {spec.Vmid} on {spec.Node}…");
+ upid = await writer.SetConfigAsync(spec.Node, spec.Vmid, changes);
+ }
+ await WaitAndReport(writer, spec.Node, upid);
+ return 0;
+ }
+
+ case "start":
+ case "stop":
+ {
+ if (!TryNodeVmid(args, out var node, out var vmid)) return 2;
+ var upid = sub == "start" ? await writer.StartAsync(node, vmid) : await writer.StopAsync(node, vmid);
+ await WaitAndReport(writer, node, upid);
+ return 0;
+ }
+
+ case "delete":
+ {
+ if (!TryNodeVmid(args, out var node, out var vmid)) return 2;
+ if (!args.Contains("--confirm")) { Console.Error.WriteLine("Refusing to delete without --confirm."); return 2; }
+ var purge = !args.Contains("--no-purge");
+ Console.WriteLine($"Destroying VM {vmid} on {node} (purge={purge})…");
+ var upid = await writer.DeleteAsync(node, vmid, purge);
+ await WaitAndReport(writer, node, upid);
+ return 0;
+ }
+
+ case "pci":
+ {
+ if (args.Length < 3) { Console.Error.WriteLine("usage: proxmoxsharp vm pci "); return 2; }
+ foreach (var d in await writer.ListPciAsync(args[2]))
+ Console.WriteLine($"{d.Id,-12} iommu={d.IommuGroup?.ToString() ?? "-",-4} {d.VendorName} {d.DeviceName}");
+ return 0;
+ }
+
+ case "show":
+ {
+ if (!TryNodeVmid(args, out var node, out var vmid)) return 2;
+ var cfg = await writer.GetConfigRawAsync(node, vmid);
+ if (cfg is null) { Console.Error.WriteLine($"VM {vmid} not found on {node}."); return 1; }
+ foreach (var (k, v) in cfg.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ Console.WriteLine($"{k}: {v}");
+ return 0;
+ }
+
+ default:
+ Console.Error.WriteLine("usage: proxmoxsharp vm ");
+ return 2;
+ }
+}
+
+static bool TryNodeVmid(string[] args, out string node, out int vmid)
+{
+ node = ""; vmid = 0;
+ if (args.Length < 4 || !int.TryParse(args[3], out vmid))
+ {
+ Console.Error.WriteLine($"usage: proxmoxsharp vm {(args.Length > 1 ? args[1] : "")} …");
+ return false;
+ }
+ node = args[2];
+ return true;
+}
+
+static QemuVmSpec? LoadSpec(string path)
+{
+ if (!File.Exists(path)) { Console.Error.WriteLine($"Spec not found: {path}"); return null; }
+ try
+ {
+ return JsonSerializer.Deserialize(File.ReadAllText(path),
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to parse spec {path}: {ex.Message}");
+ return null;
+ }
+}
+
+static void PrintPlan(VmPlan plan)
+{
+ Console.WriteLine($"VM {plan.Vmid} on {plan.Node}: {plan.Kind}");
+ if (!plan.HasChanges) Console.WriteLine(" (no changes — desired state already satisfied)");
+ foreach (var c in plan.Changes) Console.WriteLine($" {c}");
+ if (plan.UnmanagedKeys.Count > 0)
+ Console.WriteLine($" (unmanaged live keys, left untouched: {string.Join(", ", plan.UnmanagedKeys)})");
+}
+
+static async Task WaitAndReport(QemuWriter writer, string node, string? upid)
+{
+ if (string.IsNullOrEmpty(upid)) { Console.WriteLine("Done (no task)."); return; }
+ Console.WriteLine($"Task {upid} — waiting…");
+ var exit = await writer.WaitForTaskAsync(node, upid);
+ Console.WriteLine($"Task finished: {exit ?? "(no exit status)"}");
+}
+
static ProxmoxClientOptions? LoadOptions()
{
var baseUrl = Environment.GetEnvironmentVariable("PROXMOX_BASE_URL");
diff --git a/src/ProxmoxSharp/ProxmoxApi.cs b/src/ProxmoxSharp/ProxmoxApi.cs
index b44f8c8..3fd0f21 100644
--- a/src/ProxmoxSharp/ProxmoxApi.cs
+++ b/src/ProxmoxSharp/ProxmoxApi.cs
@@ -1,3 +1,4 @@
+using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Http.HttpClientLibrary;
using ProxmoxSharp.Api;
@@ -11,7 +12,17 @@ namespace ProxmoxSharp;
///
public static class ProxmoxApi
{
- public static ProxmoxApiClient Create(ProxmoxClientOptions options)
+ public static ProxmoxApiClient Create(ProxmoxClientOptions options) =>
+ new(CreateAdapter(options));
+
+ ///
+ /// Builds the configured Kiota on its own — token
+ /// auth, base URL, self-signed-cert handling. The write path ()
+ /// shares this so it inherits the exact same auth/TLS wiring as the read client,
+ /// while still being able to hand-build requests for Proxmox's indexed params
+ /// (hostpci0/scsi0/…) that the generated typed query surface can't express.
+ ///
+ public static IRequestAdapter CreateAdapter(ProxmoxClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -25,11 +36,9 @@ public static ProxmoxApiClient Create(ProxmoxClientOptions options)
}
// The generated client registers the default (JSON) serializers itself.
- var adapter = new HttpClientRequestAdapter(authProvider, httpClient: new HttpClient(handler))
+ return new HttpClientRequestAdapter(authProvider, httpClient: new HttpClient(handler))
{
BaseUrl = options.BaseUrl.AbsoluteUri.TrimEnd('/'),
};
-
- return new ProxmoxApiClient(adapter);
}
}
diff --git a/src/ProxmoxSharp/Vm/QemuParamEncoder.cs b/src/ProxmoxSharp/Vm/QemuParamEncoder.cs
new file mode 100644
index 0000000..c097cbe
--- /dev/null
+++ b/src/ProxmoxSharp/Vm/QemuParamEncoder.cs
@@ -0,0 +1,144 @@
+using System.Globalization;
+
+namespace ProxmoxSharp.Vm;
+
+///
+/// Pure translation of a into the flat
+/// key → value Proxmox config params the API expects
+/// (e.g. hostpci0 → "0000:09:00,pcie=1,x-vga=1",
+/// scsi0 → "local-lvm:vm-1003-disk-1,iothread=1,ssd=1").
+///
+/// This is the one place that knows Proxmox's comma-option "cryptic argv" — the
+/// analog of SynoSharp's positional-argv encoding. It is deliberately pure (no I/O)
+/// so the fiddly serialization is exhaustively unit-testable, and so
+/// can diff the result against a live config dict.
+///
+///
+/// Identity (vmid) and placement (node) are NOT config params and are not emitted
+/// here — the writer supplies vmid on create; node is a path segment.
+///
+///
+public static class QemuParamEncoder
+{
+ private static string Bool(bool b) => b ? "1" : "0";
+ private static string Int(int i) => i.ToString(CultureInfo.InvariantCulture);
+
+ /// Full config param set for a create (or the desired-state baseline for a diff).
+ public static IReadOnlyDictionary Encode(QemuVmSpec spec)
+ {
+ ArgumentNullException.ThrowIfNull(spec);
+ var p = new Dictionary(StringComparer.Ordinal);
+
+ void Set(string k, string? v) { if (!string.IsNullOrEmpty(v)) p[k] = v!; }
+
+ Set("name", spec.Name);
+ Set("machine", spec.Machine);
+ Set("bios", spec.Bios);
+ Set("cpu", spec.Cpu);
+ if (spec.Cores is { } c) Set("cores", Int(c));
+ if (spec.Sockets is { } s) Set("sockets", Int(s));
+ if (spec.Memory is { } m) Set("memory", Int(m));
+ if (spec.Numa is { } numa) Set("numa", Bool(numa));
+ Set("ostype", spec.Ostype);
+ if (spec.Agent is { } ag) Set("agent", Bool(ag));
+ if (spec.Onboot is { } ob) Set("onboot", Bool(ob));
+ if (spec.Protection is { } pr) Set("protection", Bool(pr));
+ Set("scsihw", spec.Scsihw);
+ Set("vga", spec.Vga);
+
+ foreach (var d in spec.Disks) Set(d.Id, EncodeDisk(d));
+ if (spec.Efidisk is { } efi) Set("efidisk0", EncodeEfiDisk(efi));
+ if (spec.Tpmstate is { } tpm) Set("tpmstate0", EncodeTpm(tpm));
+ if (spec.Cdrom is { } cd) Set(cd.Bus, EncodeCdrom(cd));
+ foreach (var n in spec.Nets) Set(n.Id, EncodeNet(n));
+ foreach (var h in spec.HostPci) Set(h.Id, EncodeHostPci(h));
+
+ if (spec.BootOrder.Count > 0) Set("boot", EncodeBoot(spec.BootOrder));
+ if (spec.Tags.Count > 0) Set("tags", string.Join(";", spec.Tags));
+
+ return p;
+ }
+
+ /// scsi0/virtio0 value: "<storage>:<volid|size>[,opt=…]".
+ public static string EncodeDisk(QemuDisk d)
+ {
+ ArgumentNullException.ThrowIfNull(d);
+ // Adopt an existing volume (storage:volid) or allocate a fresh one (storage:GB).
+ var volume = (d.Storage, d.Source, d.Size) switch
+ {
+ ({ } st, { } src, _) when !string.IsNullOrEmpty(src) => $"{st}:{src}",
+ ({ } st, _, { } sz) => $"{st}:{Int(sz)}",
+ (_, { } src, _) when !string.IsNullOrEmpty(src) => src!,
+ _ => throw new ArgumentException($"Disk '{d.Id}' needs a storage+source or storage+size."),
+ };
+ var opts = new List { volume };
+ if (d.Discard is true) opts.Add("discard=on");
+ if (d.Iothread is true) opts.Add("iothread=1");
+ if (d.Ssd is true) opts.Add("ssd=1");
+ return string.Join(",", opts);
+ }
+
+ /// efidisk0 value.
+ public static string EncodeEfiDisk(QemuEfiDisk e)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ var volume = !string.IsNullOrEmpty(e.Source) ? $"{e.Storage}:{e.Source}"
+ : !string.IsNullOrEmpty(e.Storage) ? $"{e.Storage}:1" // fresh EFI vars volume
+ : throw new ArgumentException("efidisk needs storage (+ optional source).");
+ var opts = new List { volume };
+ if (!string.IsNullOrEmpty(e.Efitype)) opts.Add($"efitype={e.Efitype}");
+ if (e.PreEnrolledKeys is true) opts.Add("pre-enrolled-keys=1");
+ return string.Join(",", opts);
+ }
+
+ /// tpmstate0 value.
+ public static string EncodeTpm(QemuTpmState t)
+ {
+ ArgumentNullException.ThrowIfNull(t);
+ var volume = !string.IsNullOrEmpty(t.Source) ? $"{t.Storage}:{t.Source}"
+ : !string.IsNullOrEmpty(t.Storage) ? $"{t.Storage}:1"
+ : throw new ArgumentException("tpmstate needs storage (+ optional source).");
+ var opts = new List { volume };
+ if (!string.IsNullOrEmpty(t.Version)) opts.Add($"version={t.Version}");
+ return string.Join(",", opts);
+ }
+
+ /// ide2 (cdrom) value: "<volid>,media=cdrom".
+ public static string EncodeCdrom(QemuCdrom c)
+ {
+ ArgumentNullException.ThrowIfNull(c);
+ var volid = !string.IsNullOrEmpty(c.Source) ? c.Source!
+ : !string.IsNullOrEmpty(c.Storage) && !string.IsNullOrEmpty(c.Iso) ? $"{c.Storage}:iso/{c.Iso}"
+ : throw new ArgumentException("cdrom needs source, or storage+iso.");
+ return $"{volid},media=cdrom";
+ }
+
+ /// net0 value: "<model>[=<mac>],bridge=…[,tag=…][,firewall=1]".
+ public static string EncodeNet(QemuNet n)
+ {
+ ArgumentNullException.ThrowIfNull(n);
+ var head = string.IsNullOrEmpty(n.Mac) ? n.Model : $"{n.Model}={n.Mac}";
+ var opts = new List { head };
+ if (!string.IsNullOrEmpty(n.Bridge)) opts.Add($"bridge={n.Bridge}");
+ if (n.Tag is { } tag) opts.Add($"tag={Int(tag)}");
+ if (n.Firewall is { } fw) opts.Add($"firewall={Bool(fw)}");
+ return string.Join(",", opts);
+ }
+
+ /// hostpci0 value: "<host>[,pcie=1][,x-vga=1][,rombar=0][,romfile=…][,mdev=…]".
+ public static string EncodeHostPci(QemuHostPci h)
+ {
+ ArgumentNullException.ThrowIfNull(h);
+ if (string.IsNullOrEmpty(h.Host)) throw new ArgumentException($"hostpci '{h.Id}' needs a Host PCI address.");
+ var opts = new List { h.Host };
+ if (h.Pcie is true) opts.Add("pcie=1");
+ if (h.XVga is true) opts.Add("x-vga=1");
+ if (h.Rombar is false) opts.Add("rombar=0"); // only emit when explicitly disabled (default is on)
+ if (!string.IsNullOrEmpty(h.Romfile)) opts.Add($"romfile={h.Romfile}");
+ if (!string.IsNullOrEmpty(h.Mdev)) opts.Add($"mdev={h.Mdev}");
+ return string.Join(",", opts);
+ }
+
+ /// boot value: "order=scsi0;ide2;net0".
+ public static string EncodeBoot(IReadOnlyList order) => $"order={string.Join(";", order)}";
+}
diff --git a/src/ProxmoxSharp/Vm/QemuVmSpec.cs b/src/ProxmoxSharp/Vm/QemuVmSpec.cs
new file mode 100644
index 0000000..cea1708
--- /dev/null
+++ b/src/ProxmoxSharp/Vm/QemuVmSpec.cs
@@ -0,0 +1,104 @@
+namespace ProxmoxSharp.Vm;
+
+///
+/// ProxmoxSharp-native desired state for a QEMU/KVM VM — the input to
+/// (→ Proxmox config params) and
+/// (→ a diff against live state).
+///
+/// This is deliberately independent of the Homelab hub's VmSpec shape: the
+/// hub maps its YAML shape onto this record at provision time (the SynoSharp #57
+/// pattern — the client owns its own resource model). Only what the VM write path
+/// actually sets lives here.
+///
+///
+public sealed record QemuVmSpec
+{
+ public required string Node { get; init; }
+ public required int Vmid { get; init; }
+ public string? Name { get; init; }
+
+ public string? Machine { get; init; }
+ public string? Bios { get; init; } // seabios | ovmf
+ public string? Cpu { get; init; }
+ public int? Cores { get; init; }
+ public int? Sockets { get; init; }
+ public int? Memory { get; init; } // MB
+ public bool? Numa { get; init; }
+ public string? Ostype { get; init; }
+ public bool? Agent { get; init; }
+ public bool? Onboot { get; init; }
+ public bool? Protection { get; init; }
+ public string? Scsihw { get; init; }
+ public string? Vga { get; init; }
+
+ public IReadOnlyList Disks { get; init; } = [];
+ public QemuEfiDisk? Efidisk { get; init; }
+ public QemuTpmState? Tpmstate { get; init; }
+ public QemuCdrom? Cdrom { get; init; }
+ public IReadOnlyList Nets { get; init; } = [];
+ public IReadOnlyList HostPci { get; init; } = [];
+
+ public IReadOnlyList BootOrder { get; init; } = [];
+ public IReadOnlyList Tags { get; init; } = [];
+}
+
+/// A QEMU disk (scsiN/virtioN/sataN). is the bus+slot, e.g. "scsi0".
+public sealed record QemuDisk
+{
+ public required string Id { get; init; }
+ public string? Storage { get; init; }
+ public string? Source { get; init; } // existing volume to adopt (e.g. "vm-1003-disk-1")
+ public int? Size { get; init; } // GB, for a freshly-allocated volume
+ public bool? Ssd { get; init; }
+ public bool? Iothread { get; init; }
+ public bool? Discard { get; init; }
+}
+
+/// UEFI vars disk (efidisk0) — needed for bios: ovmf.
+public sealed record QemuEfiDisk
+{
+ public string? Storage { get; init; }
+ public string? Source { get; init; }
+ public string? Efitype { get; init; } // 2m | 4m
+ public bool? PreEnrolledKeys { get; init; }
+}
+
+/// vTPM state disk (tpmstate0).
+public sealed record QemuTpmState
+{
+ public string? Storage { get; init; }
+ public string? Source { get; init; }
+ public string? Version { get; init; } // v1.2 | v2.0
+}
+
+/// Install/boot ISO attached to a CD-ROM (ide2 by default).
+public sealed record QemuCdrom
+{
+ public string? Storage { get; init; }
+ public string? Iso { get; init; }
+ public string? Source { get; init; } // explicit "storage:iso/" override
+ public string Bus { get; init; } = "ide2";
+}
+
+/// A virtual NIC (netN). e.g. "net0".
+public sealed record QemuNet
+{
+ public required string Id { get; init; }
+ public string Model { get; init; } = "virtio";
+ public string? Bridge { get; init; }
+ public string? Mac { get; init; }
+ public int? Tag { get; init; } // VLAN
+ public bool? Firewall { get; init; }
+}
+
+/// A PCI(e) passthrough device (hostpciN) — the gaming GPU. e.g. "hostpci0".
+public sealed record QemuHostPci
+{
+ public required string Id { get; init; }
+ public required string Host { get; init; } // PCI address, e.g. "0000:09:00"
+ public bool? Pcie { get; init; }
+ public bool? XVga { get; init; }
+ public bool? Rombar { get; init; }
+ public string? Romfile { get; init; }
+ public string? Mdev { get; init; }
+}
diff --git a/src/ProxmoxSharp/Vm/QemuWriter.cs b/src/ProxmoxSharp/Vm/QemuWriter.cs
new file mode 100644
index 0000000..81608cb
--- /dev/null
+++ b/src/ProxmoxSharp/Vm/QemuWriter.cs
@@ -0,0 +1,182 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.Kiota.Abstractions;
+using ProxmoxSharp.Api;
+
+namespace ProxmoxSharp.Vm;
+
+///
+/// L1 of the VM write path: the thin layer that actually touches the wire.
+///
+/// create / setConfig / delete carry Proxmox's indexed params (hostpci0/scsi0/…),
+/// which the generated typed query surface can't express, so they're sent as a
+/// form-urlencoded body over the shared Kiota adapter (same token-auth + TLS as the
+/// read client). start / stop / shutdown / pci / task-status have no such params and
+/// use the generated builders directly. All mutating ops return Proxmox's task UPID;
+/// poll it with .
+///
+///
+public sealed class QemuWriter
+{
+ private readonly IRequestAdapter _adapter;
+ private readonly ProxmoxApiClient _client;
+
+ public QemuWriter(IRequestAdapter adapter)
+ {
+ ArgumentNullException.ThrowIfNull(adapter);
+ _adapter = adapter;
+ _client = new ProxmoxApiClient(adapter);
+ }
+
+ /// Build a writer with the same auth/TLS wiring as .
+ public static QemuWriter Create(ProxmoxClientOptions options) => new(ProxmoxApi.CreateAdapter(options));
+
+ private string BaseUrl => _adapter.BaseUrl ?? throw new InvalidOperationException("Adapter has no BaseUrl.");
+
+ // --- reads -------------------------------------------------------------
+
+ ///
+ /// Live config of a VM as a flat key→value dict (concrete keys: scsi0, hostpci0, …),
+ /// the input diffs against. Returns null if the VM does
+ /// not exist (so the reconciler plans a create).
+ ///
+ public async Task?> GetConfigRawAsync(string node, int vmid, CancellationToken ct = default)
+ {
+ var ri = new RequestInformation(Method.GET, "{+baseurl}/nodes/{node}/qemu/{vmid}/config", PathParams(node, vmid));
+ ri.Headers.TryAdd("Accept", "application/json");
+ Stream? stream;
+ try
+ {
+ stream = await _adapter.SendPrimitiveAsync(ri, errorMapping: null, cancellationToken: ct).ConfigureAwait(false);
+ }
+ catch (ApiException)
+ {
+ // Proxmox returns an error (not 404) when the VM config doesn't exist → treat as absent.
+ return null;
+ }
+ if (stream is null) return null;
+
+ await using var _ = stream.ConfigureAwait(false);
+ using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
+ if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
+ return null;
+
+ var config = new Dictionary(StringComparer.Ordinal);
+ foreach (var prop in data.EnumerateObject())
+ {
+ config[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
+ ? prop.Value.GetString()!
+ : prop.Value.GetRawText();
+ }
+ return config;
+ }
+
+ /// PCI devices on a node (for passthrough validation: does 0000:09:00 exist, its IOMMU group, …).
+ public async Task> ListPciAsync(string node, CancellationToken ct = default)
+ {
+ var resp = await _client.Nodes[node].Hardware.Pci.GetAsPciGetResponseAsync(cancellationToken: ct).ConfigureAwait(false);
+ return (resp?.Data ?? [])
+ .Select(d => new PciDevice(d.Id, d.DeviceName, d.VendorName, d.Iommugroup))
+ .ToList();
+ }
+
+ // --- writes (return a task UPID) ---------------------------------------
+
+ /// Create a VM (POST /nodes/{node}/qemu). The full encoded config + vmid go in the form body.
+ public Task CreateAsync(QemuVmSpec spec, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(spec);
+ var form = new Dictionary(QemuParamEncoder.Encode(spec), StringComparer.Ordinal)
+ {
+ ["vmid"] = spec.Vmid.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ };
+ return SendFormAsync(Method.POST, "{+baseurl}/nodes/{node}/qemu",
+ new Dictionary { ["baseurl"] = BaseUrl, ["node"] = spec.Node }, form, ct);
+ }
+
+ /// Apply only the given config keys (PUT …/{vmid}/config). Sync on a stopped VM; a UPID on a running one.
+ public Task SetConfigAsync(string node, int vmid, IReadOnlyDictionary changes, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(changes);
+ if (changes.Count == 0) return Task.FromResult(null);
+ return SendFormAsync(Method.PUT, "{+baseurl}/nodes/{node}/qemu/{vmid}/config", PathParams(node, vmid),
+ new Dictionary(changes, StringComparer.Ordinal), ct);
+ }
+
+ public async Task StartAsync(string node, int vmid, CancellationToken ct = default) =>
+ (await _client.Nodes[node].Qemu[(long)vmid].Status.Start.PostAsStartPostResponseAsync(cancellationToken: ct).ConfigureAwait(false))?.Data;
+
+ /// Graceful ACPI shutdown (guest agent / power button).
+ public async Task ShutdownAsync(string node, int vmid, CancellationToken ct = default) =>
+ (await _client.Nodes[node].Qemu[(long)vmid].Status.Shutdown.PostAsShutdownPostResponseAsync(cancellationToken: ct).ConfigureAwait(false))?.Data;
+
+ /// Hard stop (pull the power).
+ public async Task StopAsync(string node, int vmid, CancellationToken ct = default) =>
+ (await _client.Nodes[node].Qemu[(long)vmid].Status.Stop.PostAsStopPostResponseAsync(cancellationToken: ct).ConfigureAwait(false))?.Data;
+
+ /// Destroy a VM. also removes its disks + any refs (jobs, HA, …).
+ public Task DeleteAsync(string node, int vmid, bool purge = true, CancellationToken ct = default)
+ {
+ var query = purge ? "?purge=1&destroy-unreferenced-disks=1" : "";
+ return SendFormAsync(Method.DELETE, "{+baseurl}/nodes/{node}/qemu/{vmid}" + query, PathParams(node, vmid), form: null, ct);
+ }
+
+ // --- task polling ------------------------------------------------------
+
+ ///
+ /// Poll a task UPID until it leaves the "running" state; returns its exit status
+ /// ("OK" on success). Throws on timeout.
+ ///
+ public async Task WaitForTaskAsync(string node, string upid, TimeSpan? timeout = null, CancellationToken ct = default)
+ {
+ var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromMinutes(5));
+ while (true)
+ {
+ var status = await _client.Nodes[node].Tasks[upid].Status
+ .GetAsStatusGetResponseAsync(cancellationToken: ct).ConfigureAwait(false);
+ var data = status?.Data;
+ // data.Status is a generated enum (Running | Stopped); ToString() gives the member name.
+ if (data is not null && !string.Equals(data.Status?.ToString(), "running", StringComparison.OrdinalIgnoreCase))
+ return data.Exitstatus;
+ if (DateTimeOffset.UtcNow > deadline)
+ throw new TimeoutException($"Task {upid} did not finish within the timeout.");
+ await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
+ }
+ }
+
+ // --- internals ---------------------------------------------------------
+
+ private Dictionary PathParams(string node, int vmid) => new()
+ {
+ ["baseurl"] = BaseUrl,
+ ["node"] = node,
+ ["vmid"] = Id(vmid),
+ };
+
+ private static string Id(int vmid) => vmid.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ private async Task SendFormAsync(
+ Method method, string urlTemplate, Dictionary pathParams,
+ IReadOnlyDictionary? form, CancellationToken ct)
+ {
+ var ri = new RequestInformation(method, urlTemplate, pathParams);
+ ri.Headers.TryAdd("Accept", "application/json");
+ if (form is not null)
+ {
+ var body = string.Join("&", form.Select(kv =>
+ $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
+ ri.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes(body)), "application/x-www-form-urlencoded");
+ }
+
+ var stream = await _adapter.SendPrimitiveAsync(ri, errorMapping: null, cancellationToken: ct).ConfigureAwait(false);
+ if (stream is null) return null;
+ await using var _ = stream.ConfigureAwait(false);
+ using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
+ return doc.RootElement.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.String
+ ? d.GetString()
+ : null;
+ }
+}
+
+/// A PCI device reported by a node (subset relevant to passthrough).
+public sealed record PciDevice(string? Id, string? DeviceName, string? VendorName, long? IommuGroup);
diff --git a/src/ProxmoxSharp/Vm/VmPlan.cs b/src/ProxmoxSharp/Vm/VmPlan.cs
new file mode 100644
index 0000000..5542a2b
--- /dev/null
+++ b/src/ProxmoxSharp/Vm/VmPlan.cs
@@ -0,0 +1,34 @@
+namespace ProxmoxSharp.Vm;
+
+/// What the reconciler decided to do for a VM.
+public enum VmActionKind
+{
+ /// VM does not exist → create it.
+ Create,
+ /// VM exists but some config keys differ → apply only those.
+ SetConfig,
+ /// VM exists and already satisfies the desired state → no-op.
+ Skip,
+}
+
+/// A single config key the reconciler will set (with its prior value, if any).
+public sealed record PlannedChange(string Key, string? From, string To)
+{
+ public override string ToString() =>
+ From is null ? $"+ {Key} = {To}" : $"~ {Key}: {From} -> {To}";
+}
+
+///
+/// The reconciler's verdict for one VM: the action, the exact config changes it
+/// carries, and any live keys the desired spec doesn't manage (reported, never
+/// removed — adoption stays non-destructive).
+///
+public sealed record VmPlan(
+ string Node,
+ int Vmid,
+ VmActionKind Kind,
+ IReadOnlyList Changes,
+ IReadOnlyList UnmanagedKeys)
+{
+ public bool HasChanges => Changes.Count > 0;
+}
diff --git a/src/ProxmoxSharp/Vm/VmReconciler.cs b/src/ProxmoxSharp/Vm/VmReconciler.cs
new file mode 100644
index 0000000..cefc6d5
--- /dev/null
+++ b/src/ProxmoxSharp/Vm/VmReconciler.cs
@@ -0,0 +1,115 @@
+namespace ProxmoxSharp.Vm;
+
+///
+/// Pure diff of a desired against a live Proxmox config
+/// dict (as returned by GET …/qemu/{vmid}/config) → a .
+///
+/// Adoption-safe subset semantics. A config key is considered satisfied
+/// when every comma-option token the desired spec declares is already present in
+/// the live value — extra options Proxmox adds on its own (e.g. size=120G on
+/// a disk, or reordered tokens) are ignored. This is what lets reconciling the
+/// hand-built VM 1003 against the Bazzite spec produce exactly one action,
+/// + hostpci0, instead of churning every Proxmox-normalised field.
+///
+///
+/// Keys present live but absent from the desired spec are reported as
+/// and never removed — we only manage what we declare.
+///
+///
+public static class VmReconciler
+{
+ public static VmPlan Reconcile(QemuVmSpec desired, IReadOnlyDictionary? live)
+ {
+ ArgumentNullException.ThrowIfNull(desired);
+ var want = QemuParamEncoder.Encode(desired);
+
+ if (live is null)
+ {
+ // Create: every desired param is a fresh addition.
+ var creates = want
+ .OrderBy(kv => kv.Key, StringComparer.Ordinal)
+ .Select(kv => new PlannedChange(kv.Key, null, kv.Value))
+ .ToList();
+ return new VmPlan(desired.Node, desired.Vmid, VmActionKind.Create, creates, []);
+ }
+
+ var changes = new List();
+ foreach (var (key, wantVal) in want.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ {
+ if (!live.TryGetValue(key, out var haveVal))
+ {
+ changes.Add(new PlannedChange(key, null, wantVal));
+ }
+ else if (!Satisfied(wantVal, haveVal))
+ {
+ changes.Add(new PlannedChange(key, haveVal, wantVal));
+ }
+ }
+
+ var unmanaged = live.Keys
+ .Where(k => !want.ContainsKey(k))
+ .OrderBy(k => k, StringComparer.Ordinal)
+ .ToList();
+
+ var kind = changes.Count > 0 ? VmActionKind.SetConfig : VmActionKind.Skip;
+ return new VmPlan(desired.Node, desired.Vmid, kind, changes, unmanaged);
+ }
+
+ ///
+ /// True when every token the desired value declares is matched by the live value.
+ /// Tokens are the comma-separated parts of a Proxmox option string (the leading
+ /// positional volume/address plus each k=v option). Extra live tokens are
+ /// allowed (Proxmox-added), which is what keeps adoption non-destructive.
+ ///
+ /// Two tokens match beyond plain equality, so a freshly-created VM also
+ /// re-plans clean (not just adopted ones):
+ ///
+ /// - a bare key satisfies its valued form — e.g. a NIC model virtio
+ /// is satisfied by the live virtio=<mac> Proxmox auto-assigned;
+ /// - a size-allocation volume storage:N is satisfied by the realized
+ /// storage:<volid> + a size=NG token (the volume Proxmox cut).
+ ///
+ ///
+ ///
+ public static bool Satisfied(string want, string have)
+ {
+ var haveTokens = Tokenize(have);
+ return Tokenize(want).All(t => TokenMatched(t, haveTokens));
+ }
+
+ private static bool TokenMatched(string want, HashSet have)
+ {
+ if (have.Contains(want)) return true;
+
+ // (a) bare key ↔ valued form: NIC model `virtio` matches live `virtio=`.
+ if (!want.Contains('=') && have.Any(h => h.StartsWith(want + "=", StringComparison.Ordinal)))
+ return true;
+
+ // (b) size-allocation `storage:N` matches a realized `storage:` + `size=NG`.
+ if (TrySplitSizeAlloc(want, out var storage, out var sizeGb)
+ && have.Contains($"size={sizeGb}G")
+ && have.Any(h => h.StartsWith(storage + ":", StringComparison.Ordinal) && !h.Contains('=')))
+ return true;
+
+ return false;
+ }
+
+ // Recognises a fresh-allocation volume token "storage:N" (exactly one ':' and an
+ // all-digit size) — distinct from an adopted volid "storage:vm-123-disk-0" or a
+ // PCI address "0000:09:00" (two colons), neither of which parse here.
+ private static bool TrySplitSizeAlloc(string token, out string storage, out string sizeGb)
+ {
+ storage = ""; sizeGb = "";
+ var i = token.IndexOf(':');
+ if (i <= 0 || token.IndexOf(':', i + 1) >= 0) return false; // need exactly one ':'
+ var s = token[..i];
+ var n = token[(i + 1)..];
+ if (n.Length == 0 || !n.All(char.IsDigit)) return false;
+ storage = s; sizeGb = n;
+ return true;
+ }
+
+ private static HashSet Tokenize(string value) =>
+ new(value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ StringComparer.Ordinal);
+}
diff --git a/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs b/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs
new file mode 100644
index 0000000..55ca240
--- /dev/null
+++ b/tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs
@@ -0,0 +1,133 @@
+using ProxmoxSharp.Vm;
+
+namespace ProxmoxSharp.Tests;
+
+// The error-prone heart of the VM write path: turning a typed spec into Proxmox's
+// comma-option config strings. Modelled on the real desktop-01 VMs 1002/1003.
+public class QemuParamEncoderTests
+{
+ [Fact]
+ public void HostPci_encodes_the_passthrough_line_like_the_working_win_vm()
+ {
+ // VM 1002's working GPU line: hostpci0: 0000:09:00,pcie=1,x-vga=1
+ var v = QemuParamEncoder.EncodeHostPci(new QemuHostPci
+ {
+ Id = "hostpci0", Host = "0000:09:00", Pcie = true, XVga = true,
+ });
+ Assert.Equal("0000:09:00,pcie=1,x-vga=1", v);
+ }
+
+ [Fact]
+ public void HostPci_omits_rombar_unless_explicitly_disabled()
+ {
+ Assert.Equal("0000:09:00", QemuParamEncoder.EncodeHostPci(new QemuHostPci { Id = "hostpci0", Host = "0000:09:00" }));
+ Assert.Equal("0000:09:00,rombar=0", QemuParamEncoder.EncodeHostPci(new QemuHostPci { Id = "hostpci0", Host = "0000:09:00", Rombar = false }));
+ // rombar defaults on → not emitted even when explicitly true
+ Assert.Equal("0000:09:00", QemuParamEncoder.EncodeHostPci(new QemuHostPci { Id = "hostpci0", Host = "0000:09:00", Rombar = true }));
+ }
+
+ [Fact]
+ public void Disk_adopts_an_existing_volume_with_options()
+ {
+ var v = QemuParamEncoder.EncodeDisk(new QemuDisk
+ {
+ Id = "scsi0", Storage = "local-lvm", Source = "vm-1003-disk-1", Iothread = true, Ssd = true,
+ });
+ Assert.Equal("local-lvm:vm-1003-disk-1,iothread=1,ssd=1", v);
+ }
+
+ [Fact]
+ public void Disk_allocates_a_fresh_volume_by_size_when_no_source()
+ {
+ var v = QemuParamEncoder.EncodeDisk(new QemuDisk { Id = "scsi0", Storage = "local-lvm", Size = 120, Ssd = true });
+ Assert.Equal("local-lvm:120,ssd=1", v);
+ }
+
+ [Fact]
+ public void Disk_without_storage_or_source_throws()
+ {
+ Assert.Throws(() => QemuParamEncoder.EncodeDisk(new QemuDisk { Id = "scsi0" }));
+ }
+
+ [Fact]
+ public void EfiDisk_encodes_adopted_volume_with_secure_boot_keys()
+ {
+ var v = QemuParamEncoder.EncodeEfiDisk(new QemuEfiDisk
+ {
+ Storage = "local-lvm", Source = "vm-1003-disk-0", Efitype = "4m", PreEnrolledKeys = true,
+ });
+ Assert.Equal("local-lvm:vm-1003-disk-0,efitype=4m,pre-enrolled-keys=1", v);
+ }
+
+ [Fact]
+ public void Net_encodes_model_mac_bridge_and_firewall()
+ {
+ var v = QemuParamEncoder.EncodeNet(new QemuNet
+ {
+ Id = "net0", Model = "virtio", Mac = "BC:24:11:03:32:13", Bridge = "vmbr0", Firewall = true,
+ });
+ Assert.Equal("virtio=BC:24:11:03:32:13,bridge=vmbr0,firewall=1", v);
+ }
+
+ [Fact]
+ public void Net_without_mac_lets_proxmox_assign_one()
+ {
+ Assert.Equal("virtio,bridge=vmbr0", QemuParamEncoder.EncodeNet(new QemuNet { Id = "net0", Bridge = "vmbr0" }));
+ }
+
+ [Fact]
+ public void Cdrom_encodes_iso_as_cdrom_media()
+ {
+ var v = QemuParamEncoder.EncodeCdrom(new QemuCdrom { Storage = "local", Iso = "bazzite-deck-stable-live.iso" });
+ Assert.Equal("local:iso/bazzite-deck-stable-live.iso,media=cdrom", v);
+ }
+
+ [Fact]
+ public void Boot_encodes_ordered_device_list()
+ {
+ Assert.Equal("order=scsi0;ide2;net0", QemuParamEncoder.EncodeBoot(["scsi0", "ide2", "net0"]));
+ }
+
+ [Fact]
+ public void Encode_full_bazzite_spec_produces_expected_keys_and_values()
+ {
+ var p = QemuParamEncoder.Encode(Bazzite());
+
+ Assert.Equal("q35", p["machine"]);
+ Assert.Equal("ovmf", p["bios"]);
+ Assert.Equal("host", p["cpu"]);
+ Assert.Equal("6", p["cores"]);
+ Assert.Equal("12288", p["memory"]);
+ Assert.Equal("l26", p["ostype"]);
+ Assert.Equal("1", p["agent"]);
+ Assert.Equal("virtio-scsi-single", p["scsihw"]);
+ Assert.Equal("local-lvm:vm-1003-disk-1,iothread=1,ssd=1", p["scsi0"]);
+ Assert.Equal("local-lvm:vm-1003-disk-0,efitype=4m,pre-enrolled-keys=1", p["efidisk0"]);
+ Assert.Equal("virtio=BC:24:11:03:32:13,bridge=vmbr0", p["net0"]);
+ Assert.Equal("0000:09:00,pcie=1,x-vga=1", p["hostpci0"]);
+ // identity/placement are NOT config params
+ Assert.False(p.ContainsKey("vmid"));
+ Assert.False(p.ContainsKey("node"));
+ }
+
+ // The IaC shape for stacks/Gaming/bazzite.vm.yaml, as a native spec.
+ internal static QemuVmSpec Bazzite() => new()
+ {
+ Node = "desktop-01",
+ Vmid = 1003,
+ Name = "bazzite",
+ Machine = "q35",
+ Bios = "ovmf",
+ Cpu = "host",
+ Cores = 6,
+ Memory = 12288,
+ Ostype = "l26",
+ Agent = true,
+ Scsihw = "virtio-scsi-single",
+ Disks = [new QemuDisk { Id = "scsi0", Storage = "local-lvm", Source = "vm-1003-disk-1", Iothread = true, Ssd = true }],
+ Efidisk = new QemuEfiDisk { Storage = "local-lvm", Source = "vm-1003-disk-0", Efitype = "4m", PreEnrolledKeys = true },
+ // Adoption pins the existing MAC so the reconciler won't try to regenerate it.
+ Nets = [new QemuNet { Id = "net0", Mac = "BC:24:11:03:32:13", Bridge = "vmbr0" }],
+ HostPci = [new QemuHostPci { Id = "hostpci0", Host = "0000:09:00", Pcie = true, XVga = true }],
+ };
+}
diff --git a/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs b/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs
new file mode 100644
index 0000000..960c598
--- /dev/null
+++ b/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs
@@ -0,0 +1,138 @@
+using ProxmoxSharp.Vm;
+
+namespace ProxmoxSharp.Tests;
+
+public class VmReconcilerTests
+{
+ [Fact]
+ public void Absent_vm_plans_a_create_with_all_params()
+ {
+ var plan = VmReconciler.Reconcile(QemuParamEncoderTests.Bazzite(), live: null);
+
+ Assert.Equal(VmActionKind.Create, plan.Kind);
+ Assert.Equal(1003, plan.Vmid);
+ Assert.Contains(plan.Changes, c => c.Key == "hostpci0" && c.From is null);
+ Assert.Contains(plan.Changes, c => c.Key == "scsi0");
+ Assert.All(plan.Changes, c => Assert.Null(c.From)); // everything is a fresh add
+ }
+
+ // The headline acceptance criterion (#115): reconciling the Bazzite spec against
+ // the live, hand-built VM 1003 — identical except it lacks the GPU — must plan
+ // EXACTLY ONE change: + hostpci0. Proxmox's extra size=120G on the disk must NOT
+ // register as drift.
+ [Fact]
+ public void Adopting_1003_plans_only_the_added_gpu()
+ {
+ var live = Live1003WithoutGpu();
+
+ var plan = VmReconciler.Reconcile(QemuParamEncoderTests.Bazzite(), live);
+
+ Assert.Equal(VmActionKind.SetConfig, plan.Kind);
+ var change = Assert.Single(plan.Changes);
+ Assert.Equal("hostpci0", change.Key);
+ Assert.Null(change.From);
+ Assert.Equal("0000:09:00,pcie=1,x-vga=1", change.To);
+ }
+
+ [Fact]
+ public void Fully_satisfied_state_is_a_skip()
+ {
+ var live = Live1003WithoutGpu();
+ live["hostpci0"] = "0000:09:00,pcie=1,x-vga=1"; // GPU already attached
+
+ var plan = VmReconciler.Reconcile(QemuParamEncoderTests.Bazzite(), live);
+
+ Assert.Equal(VmActionKind.Skip, plan.Kind);
+ Assert.False(plan.HasChanges);
+ }
+
+ [Fact]
+ public void Live_only_keys_are_reported_as_unmanaged_never_removed()
+ {
+ var live = Live1003WithoutGpu();
+ live["smbios1"] = "uuid=6f4378f4-cc71-4bdc-9fef-ae234a95457f"; // Proxmox-managed, not in our spec
+
+ var plan = VmReconciler.Reconcile(QemuParamEncoderTests.Bazzite(), live);
+
+ Assert.Contains("smbios1", plan.UnmanagedKeys);
+ Assert.DoesNotContain(plan.Changes, c => c.Key == "smbios1");
+ }
+
+ [Theory]
+ // extra options Proxmox adds (size, reordering) don't count as drift
+ [InlineData("local-lvm:vm-1003-disk-1,iothread=1,ssd=1", "local-lvm:vm-1003-disk-1,iothread=1,size=120G,ssd=1", true)]
+ [InlineData("local-lvm:vm-1003-disk-1,iothread=1,ssd=1", "iothread=1,ssd=1,local-lvm:vm-1003-disk-1,size=120G", true)]
+ // a genuinely different option IS drift
+ [InlineData("local-lvm:vm-1003-disk-1,iothread=1,ssd=1", "local-lvm:vm-1003-disk-1,ssd=1", false)]
+ // a different volume id IS drift
+ [InlineData("local-lvm:vm-1003-disk-1", "local-lvm:vm-9999-disk-1", false)]
+ public void Satisfied_uses_subset_semantics(string want, string have, bool expected)
+ {
+ Assert.Equal(expected, VmReconciler.Satisfied(want, have));
+ }
+
+ [Theory]
+ // a bare NIC model is satisfied by the MAC Proxmox auto-assigned on create
+ [InlineData("virtio,bridge=vmbr0", "virtio=BC:24:11:17:ED:69,bridge=vmbr0", true)]
+ // …but a real model change is still drift
+ [InlineData("e1000,bridge=vmbr0", "virtio=BC:24:11:17:ED:69,bridge=vmbr0", false)]
+ // a size-allocation disk is satisfied by the volume Proxmox realized (volid + size=NG)
+ [InlineData("local-lvm:2,ssd=1", "local-lvm:vm-9999-disk-0,size=2G,ssd=1", true)]
+ // …but a resize (different requested size) IS drift
+ [InlineData("local-lvm:4,ssd=1", "local-lvm:vm-9999-disk-0,size=2G,ssd=1", false)]
+ // …and a wrong storage IS drift
+ [InlineData("local-zfs:2,ssd=1", "local-lvm:vm-9999-disk-0,size=2G,ssd=1", false)]
+ public void Satisfied_treats_created_state_as_idempotent(string want, string have, bool expected)
+ {
+ Assert.Equal(expected, VmReconciler.Satisfied(want, have));
+ }
+
+ // A freshly-CREATED VM (size-alloc disk, unpinned MAC) must re-plan clean — the
+ // gap the live dummy VM 9999 surfaced. Mirrors `qm config 9999` post-create.
+ [Fact]
+ public void Freshly_created_vm_replans_as_skip()
+ {
+ var spec = new QemuVmSpec
+ {
+ Node = "desktop-01", Vmid = 9999, Name = "proxmoxsharp-dev",
+ Machine = "q35", Bios = "seabios", Cpu = "host", Cores = 1, Memory = 512,
+ Ostype = "l26", Onboot = false, Scsihw = "virtio-scsi-single",
+ Disks = [new QemuDisk { Id = "scsi0", Storage = "local-lvm", Size = 2, Ssd = true }],
+ Nets = [new QemuNet { Id = "net0", Bridge = "vmbr0" }], // no MAC pinned
+ BootOrder = ["scsi0"], Tags = ["dev", "proxmoxsharp"],
+ };
+ var live = new Dictionary(StringComparer.Ordinal)
+ {
+ ["machine"] = "q35", ["bios"] = "seabios", ["cpu"] = "host", ["cores"] = "1",
+ ["memory"] = "512", ["ostype"] = "l26", ["onboot"] = "0", ["scsihw"] = "virtio-scsi-single",
+ ["scsi0"] = "local-lvm:vm-9999-disk-0,size=2G,ssd=1", // realized volume
+ ["net0"] = "virtio=BC:24:11:17:ED:69,bridge=vmbr0", // assigned MAC
+ ["boot"] = "order=scsi0", ["name"] = "proxmoxsharp-dev", ["tags"] = "dev;proxmoxsharp",
+ ["digest"] = "abc", ["meta"] = "creation-qemu=11.0.0", ["smbios1"] = "uuid=…",
+ };
+
+ var plan = VmReconciler.Reconcile(spec, live);
+
+ Assert.Equal(VmActionKind.Skip, plan.Kind);
+ Assert.False(plan.HasChanges);
+ }
+
+ // Mirrors the real `qm config 1003` (minus hostpci0): the Bazzite encoder's keys,
+ // but with Proxmox's own additions (disk size, reordered options, extra fields).
+ private static Dictionary Live1003WithoutGpu() => new(StringComparer.Ordinal)
+ {
+ ["machine"] = "q35",
+ ["bios"] = "ovmf",
+ ["cpu"] = "host",
+ ["cores"] = "6",
+ ["memory"] = "12288",
+ ["ostype"] = "l26",
+ ["agent"] = "1",
+ ["scsihw"] = "virtio-scsi-single",
+ ["scsi0"] = "local-lvm:vm-1003-disk-1,iothread=1,size=120G,ssd=1", // + size=120G (Proxmox-added)
+ ["efidisk0"] = "local-lvm:vm-1003-disk-0,efitype=4m,pre-enrolled-keys=1,size=4M",
+ ["net0"] = "virtio=BC:24:11:03:32:13,bridge=vmbr0", // + a MAC we didn't pin
+ ["name"] = "bazzite",
+ ["digest"] = "abc123", // Proxmox bookkeeping
+ };
+}