From e259245e090ad40b39f8154d5ea1f5ece5a6e3d5 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 20 Jun 2026 21:24:44 +1200 Subject: [PATCH] =?UTF-8?q?feat(lxc):=20LXC=20lifecycle=20write=20path=20?= =?UTF-8?q?=E2=80=94=20start/stop/shutdown/delete=20+=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PctWriter, the container counterpart to QemuWriter (#14), giving an IaC write path to tear a CT down instead of root SSH + pct. Motivated by Chrison-dev/Homelab#149 (decommissioning Teleport CT 9904 had no sanctioned write path). - PctWriter: StartAsync / StopAsync (hard kill) / ShutdownAsync (graceful + hard-stop fallback) / DeleteAsync (force + purge). Mirrors QemuWriter — raw form requests over the shared Kiota adapter (lifecycle ops carry forceStop/timeout/force/purge query flags), same token-auth/TLS + error-body surfacing, returns a task UPID; WaitForTaskAsync. - CLI: `proxmoxsharp lxc `; delete is gated on --confirm with --force/--no-purge, same discipline as `vm delete`. - Pure query-builder helpers (DeleteQuery/ShutdownQuery) unit-tested (5 new; suite green). Relates to Chrison-dev/Homelab#149 and the converge destroy lifecycle (#101). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ProxmoxSharp.Cli/Program.cs | 65 ++++++++- src/ProxmoxSharp/Lxc/PctWriter.cs | 153 +++++++++++++++++++++ tests/ProxmoxSharp.Tests/PctWriterTests.cs | 33 +++++ 3 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 src/ProxmoxSharp/Lxc/PctWriter.cs create mode 100644 tests/ProxmoxSharp.Tests/PctWriterTests.cs diff --git a/src/ProxmoxSharp.Cli/Program.cs b/src/ProxmoxSharp.Cli/Program.cs index 4509857..867ff5d 100644 --- a/src/ProxmoxSharp.Cli/Program.cs +++ b/src/ProxmoxSharp.Cli/Program.cs @@ -1,10 +1,11 @@ using System.Text.Json; using ProxmoxSharp; +using ProxmoxSharp.Lxc; using ProxmoxSharp.Vm; // proxmoxsharp — a thin CLI over the ProxmoxSharp library. // -// Commands: discover | nodes | version | vm +// Commands: discover | nodes | version | vm <…> | lxc // Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET, // PROXMOX_VERIFY_TLS (optional, 'false' for self-signed nodes) @@ -29,6 +30,11 @@ vm delete --confirm [--no-purge] Destroy a VM vm pci List PCI devices (passthrough discovery) vm show Dump a VM's live config + lxc start Start a container + lxc stop Hard-stop (kill) a container + lxc shutdown Graceful shutdown (hard-stop fallback) + lxc delete --confirm [--force] [--no-purge] Destroy a container + Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET, PROXMOX_VERIFY_TLS (optional, 'false' for self-signed) """); @@ -73,8 +79,11 @@ vm show Dump a VM's live config case "vm": return await RunVm(args, options); + case "lxc": + return await RunLxc(args, options); + default: - Console.Error.WriteLine($"Unknown command '{command}'. Try: discover | nodes | version | vm"); + Console.Error.WriteLine($"Unknown command '{command}'. Try: discover | nodes | version | vm | lxc"); return 1; } @@ -163,12 +172,54 @@ static async Task RunVm(string[] args, ProxmoxClientOptions options) } } +// lxc — the LXC lifecycle write path (#149). +static async Task RunLxc(string[] args, ProxmoxClientOptions options) +{ + var sub = args.Length > 1 ? args[1].ToLowerInvariant() : ""; + var writer = PctWriter.Create(options); + + switch (sub) + { + case "start": + case "stop": + case "shutdown": + { + if (!TryNodeVmid(args, out var node, out var vmid)) return 2; + var upid = sub switch + { + "start" => await writer.StartAsync(node, vmid), + "stop" => await writer.StopAsync(node, vmid), + _ => await writer.ShutdownAsync(node, vmid), + }; + await WaitAndReportLxc(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"); + var force = args.Contains("--force"); + Console.WriteLine($"Destroying LXC {vmid} on {node} (force={force}, purge={purge})…"); + var upid = await writer.DeleteAsync(node, vmid, force, purge); + await WaitAndReportLxc(writer, node, upid); + return 0; + } + + default: + Console.Error.WriteLine("usage: proxmoxsharp lxc [--force] [--no-purge] --confirm"); + 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] : "")} …"); + var cmd = args.Length > 0 ? args[0] : "vm"; + Console.Error.WriteLine($"usage: proxmoxsharp {cmd} {(args.Length > 1 ? args[1] : "")} …"); return false; } node = args[2]; @@ -207,6 +258,14 @@ static async Task WaitAndReport(QemuWriter writer, string node, string? upid) Console.WriteLine($"Task finished: {exit ?? "(no exit status)"}"); } +static async Task WaitAndReportLxc(PctWriter 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/Lxc/PctWriter.cs b/src/ProxmoxSharp/Lxc/PctWriter.cs new file mode 100644 index 0000000..45810ac --- /dev/null +++ b/src/ProxmoxSharp/Lxc/PctWriter.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Serialization; +using ProxmoxSharp.Api; +using ProxmoxSharp.Vm; // ProxmoxApiError (internal, same assembly) + +namespace ProxmoxSharp.Lxc; + +/// +/// LXC lifecycle write path (issue #149): start / stop / shutdown / delete for +/// containers, the counterpart to . Gives the converge +/// destroy lifecycle (and the CLI) an IaC way to tear a CT down — no root SSH + pct. +/// +/// Each lifecycle op carries Proxmox query flags (forceStop/timeout/force/purge), so — +/// like — they go over the shared Kiota adapter as +/// raw form requests (same token-auth + TLS as the read client) rather than the typed +/// builders. All mutating ops return Proxmox's task UPID; poll it with +/// . The form-send + task-poll helpers intentionally mirror +/// QemuWriter; hoist a shared base if a third writer ever appears. +/// +/// +public sealed class PctWriter +{ + private readonly IRequestAdapter _adapter; + private readonly ProxmoxApiClient _client; + + public PctWriter(IRequestAdapter adapter) + { + ArgumentNullException.ThrowIfNull(adapter); + _adapter = adapter; + _client = new ProxmoxApiClient(adapter); + } + + /// Build a writer with the same auth/TLS wiring as . + public static PctWriter Create(ProxmoxClientOptions options) => new(ProxmoxApi.CreateAdapter(options)); + + private string BaseUrl => _adapter.BaseUrl ?? throw new InvalidOperationException("Adapter has no BaseUrl."); + + // --- writes (return a task UPID) --------------------------------------- + + /// Start a container (POST …/lxc/{vmid}/status/start). + public Task StartAsync(string node, int vmid, CancellationToken ct = default) => + SendFormAsync(Method.POST, "{+baseurl}/nodes/{node}/lxc/{vmid}/status/start", PathParams(node, vmid), form: null, ct); + + /// Hard stop — kill the container (POST …/lxc/{vmid}/status/stop). + public Task StopAsync(string node, int vmid, CancellationToken ct = default) => + SendFormAsync(Method.POST, "{+baseurl}/nodes/{node}/lxc/{vmid}/status/stop", PathParams(node, vmid), form: null, ct); + + /// + /// Graceful shutdown (POST …/lxc/{vmid}/status/shutdown). + /// falls back to a hard stop if the clean shutdown exceeds seconds. + /// + public Task ShutdownAsync(string node, int vmid, bool forceStop = true, int timeout = 60, CancellationToken ct = default) => + SendFormAsync(Method.POST, "{+baseurl}/nodes/{node}/lxc/{vmid}/status/shutdown" + ShutdownQuery(forceStop, timeout), + PathParams(node, vmid), form: null, ct); + + /// + /// Destroy a container (DELETE …/lxc/{vmid}). also removes it + /// from related configs (backup jobs, HA, replication) and destroys unreferenced disks; + /// destroys even while it is still running. + /// + public Task DeleteAsync(string node, int vmid, bool force = false, bool purge = true, CancellationToken ct = default) => + SendFormAsync(Method.DELETE, "{+baseurl}/nodes/{node}/lxc/{vmid}" + DeleteQuery(force, purge), + PathParams(node, vmid), form: null, ct); + + // --- query builders (pure — unit-tested) ------------------------------- + + /// Query string for DELETE: force destroy and/or purge refs + unreferenced disks. + public static string DeleteQuery(bool force, bool purge) + { + var parts = new List(); + if (force) parts.Add("force=1"); + if (purge) { parts.Add("purge=1"); parts.Add("destroy-unreferenced-disks=1"); } + return parts.Count > 0 ? "?" + string.Join("&", parts) : ""; + } + + /// Query string for graceful shutdown: hard-stop fallback + timeout (seconds). + public static string ShutdownQuery(bool forceStop, int timeout) => + $"?forceStop={(forceStop ? 1 : 0)}&timeout={timeout.ToString(CultureInfo.InvariantCulture)}"; + + // --- task polling (mirrors QemuWriter.WaitForTaskAsync) ---------------- + + /// + /// 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; + 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 (mirror QemuWriter) ------------------------------------- + + private static readonly Dictionary> ErrorMapping = new() + { + ["4XX"] = static _ => new ProxmoxApiError(), + ["5XX"] = static _ => new ProxmoxApiError(), + }; + + private Dictionary PathParams(string node, int vmid) => new() + { + ["baseurl"] = BaseUrl, + ["node"] = node, + ["vmid"] = vmid.ToString(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"); + } + + Stream? stream; + try + { + stream = await _adapter.SendPrimitiveAsync(ri, ErrorMapping, cancellationToken: ct).ConfigureAwait(false); + } + catch (ProxmoxApiError err) + { + var detail = err.AdditionalData.Count > 0 + ? string.Join("; ", err.AdditionalData.Select(kv => $"{kv.Key}={kv.Value}")) + : "(empty body)"; + throw new InvalidOperationException( + $"Proxmox {method} {urlTemplate} failed (HTTP {err.ResponseStatusCode}): {detail}", err); + } + 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; + } +} diff --git a/tests/ProxmoxSharp.Tests/PctWriterTests.cs b/tests/ProxmoxSharp.Tests/PctWriterTests.cs new file mode 100644 index 0000000..b106403 --- /dev/null +++ b/tests/ProxmoxSharp.Tests/PctWriterTests.cs @@ -0,0 +1,33 @@ +using ProxmoxSharp.Lxc; +using Xunit; + +namespace ProxmoxSharp.Tests; + +// The only branching logic in the LXC lifecycle write path (#149): the Proxmox query +// strings for destroy + graceful shutdown. The wire send + task polling mirror +// QemuWriter and are exercised via the CLI, like the VM path. +public class PctWriterTests +{ + [Fact] + public void DeleteQuery_purge_only_is_the_default_shape() => + Assert.Equal("?purge=1&destroy-unreferenced-disks=1", PctWriter.DeleteQuery(force: false, purge: true)); + + [Fact] + public void DeleteQuery_force_and_purge_combine() => + Assert.Equal("?force=1&purge=1&destroy-unreferenced-disks=1", PctWriter.DeleteQuery(force: true, purge: true)); + + [Fact] + public void DeleteQuery_force_only() => + Assert.Equal("?force=1", PctWriter.DeleteQuery(force: true, purge: false)); + + [Fact] + public void DeleteQuery_neither_is_empty() => + Assert.Equal("", PctWriter.DeleteQuery(force: false, purge: false)); + + [Fact] + public void ShutdownQuery_encodes_forceStop_and_timeout() + { + Assert.Equal("?forceStop=1&timeout=60", PctWriter.ShutdownQuery(forceStop: true, timeout: 60)); + Assert.Equal("?forceStop=0&timeout=120", PctWriter.ShutdownQuery(forceStop: false, timeout: 120)); + } +}