From 0e39295ce744212469497fd4c33d44910a55b6e1 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 8 Jun 2026 22:39:31 +1200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20VM=20write=20path=20=E2=80=94=20cre?= =?UTF-8?q?ate/setConfig/start/stop/delete=20+=20reconciler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends ProxmoxSharp from read-only into a layered VM write path (dry-run by default), so callers never hand-write Proxmox's comma-option config strings. - Codegen now emits write verbs: ProxmoxSharp.Api.csproj drives Kiota with --methods GET,POST,PUT,DELETE (new ApiMethods property; scope unchanged). - L1 QemuWriter: create/setConfig/delete via a form-urlencoded body over the shared adapter (Proxmox indexed params hostpci0/scsi0/netN can't be expressed by the generated typed query surface); start/stop/shutdown/pci/config-read/ task-poll via the generated builders. ProxmoxApi.CreateAdapter shares the exact auth/TLS wiring with the read client. - L2 QemuVmSpec + QemuParamEncoder (pure option-string serialization) + VmReconciler with adoption-safe subset diffing (Proxmox-added options like size=120G don't register as drift). - L3 CLI: vm plan | apply | start | stop | delete | pci | show (--confirm to mutate; dry-run by default). - 19 new unit tests for the encoder + reconciler (full suite 25, green). Verified live: `vm plan` vs a real VM yields only the genuine diff; full create -> start -> stop -> delete lifecycle exercised on a throwaway VM. Part of Homelab #115 (gaming VM as IaC). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ProxmoxSharp.Api/ProxmoxSharp.Api.csproj | 7 +- src/ProxmoxSharp.Cli/Program.cs | 155 ++++++++++++++- src/ProxmoxSharp/ProxmoxApi.cs | 17 +- src/ProxmoxSharp/Vm/QemuParamEncoder.cs | 144 ++++++++++++++ src/ProxmoxSharp/Vm/QemuVmSpec.cs | 104 ++++++++++ src/ProxmoxSharp/Vm/QemuWriter.cs | 182 ++++++++++++++++++ src/ProxmoxSharp/Vm/VmPlan.cs | 34 ++++ src/ProxmoxSharp/Vm/VmReconciler.cs | 73 +++++++ .../QemuParamEncoderTests.cs | 133 +++++++++++++ tests/ProxmoxSharp.Tests/VmReconcilerTests.cs | 92 +++++++++ 10 files changed, 928 insertions(+), 13 deletions(-) create mode 100644 src/ProxmoxSharp/Vm/QemuParamEncoder.cs create mode 100644 src/ProxmoxSharp/Vm/QemuVmSpec.cs create mode 100644 src/ProxmoxSharp/Vm/QemuWriter.cs create mode 100644 src/ProxmoxSharp/Vm/VmPlan.cs create mode 100644 src/ProxmoxSharp/Vm/VmReconciler.cs create mode 100644 tests/ProxmoxSharp.Tests/QemuParamEncoderTests.cs create mode 100644 tests/ProxmoxSharp.Tests/VmReconcilerTests.cs 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..09d66bb --- /dev/null +++ b/src/ProxmoxSharp/Vm/VmReconciler.cs @@ -0,0 +1,73 @@ +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 present in 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. + /// + public static bool Satisfied(string want, string have) + { + var haveTokens = Tokenize(have); + return Tokenize(want).All(haveTokens.Contains); + } + + 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..1dd4fa8 --- /dev/null +++ b/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs @@ -0,0 +1,92 @@ +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)); + } + + // 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 + }; +} From 66e7eabc573c8280e0ac4f63206e19845440ca74 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 13 Jun 2026 00:26:51 +1200 Subject: [PATCH 2/3] fix: idempotent reconcile for freshly-created VMs + dev-vm sample A VM created from a fresh-allocation spec (storage:N disk, unpinned NIC) re-planned as false drift, because Proxmox realizes those as storage:+size=NG and an auto-assigned MAC. The reconciler now matches: - a bare NIC model (virtio) against the live virtio=, and - a size-allocation (storage:N) against the realized storage: + size=NG. Resize, model-change and wrong-storage are still flagged as real drift. Found by creating a persistent dummy VM (9999) and re-planning it. Adds the samples/dev-vm.json fixture used to exercise the write path, and 6 reconciler tests (suite 31, green). Live: VM 9999 now re-plans as Skip. Part of Homelab #115. Co-Authored-By: Claude Opus 4.8 (1M context) --- samples/dev-vm.json | 17 +++++++ src/ProxmoxSharp/Vm/VmReconciler.cs | 46 ++++++++++++++++++- tests/ProxmoxSharp.Tests/VmReconcilerTests.cs | 46 +++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 samples/dev-vm.json 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/Vm/VmReconciler.cs b/src/ProxmoxSharp/Vm/VmReconciler.cs index 09d66bb..cefc6d5 100644 --- a/src/ProxmoxSharp/Vm/VmReconciler.cs +++ b/src/ProxmoxSharp/Vm/VmReconciler.cs @@ -56,15 +56,57 @@ public static VmPlan Reconcile(QemuVmSpec desired, IReadOnlyDictionary - /// True when every token the desired value declares is present in the live value. + /// 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(haveTokens.Contains); + 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) => diff --git a/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs b/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs index 1dd4fa8..960c598 100644 --- a/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs +++ b/tests/ProxmoxSharp.Tests/VmReconcilerTests.cs @@ -71,6 +71,52 @@ public void Satisfied_uses_subset_semantics(string want, string have, bool expec 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) From e1a0fb7f37e2b398563f402beae519b6c13501a1 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 13 Jun 2026 00:30:26 +1200 Subject: [PATCH 3/3] ci: disable apphost generation (sidestep SDK 10.0.301 CreateAppHost flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI build broke on SchemaGen with "CreateAppHost task failed / could not find apphost" (SetUnixFileMode) after the runner's SDK moved to 10.0.301 — unrelated to any source change. SchemaGen runs via `dotnet run` and the CLI ships as a `dotnet tool`; neither uses a native apphost. Set UseAppHost=false repo-wide (no-op for libraries) to skip the buggy task. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Build.props | 5 +++++ 1 file changed, 5 insertions(+) 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