Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions src/ProxmoxSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <plan|apply|start|stop|delete|pci|show>
// Commands: discover | nodes | version | vm <…> | lxc <start|stop|shutdown|delete>
// Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
// PROXMOX_VERIFY_TLS (optional, 'false' for self-signed nodes)

Expand All @@ -29,6 +30,11 @@ vm delete <node> <vmid> --confirm [--no-purge] Destroy a VM
vm pci <node> List PCI devices (passthrough discovery)
vm show <node> <vmid> Dump a VM's live config

lxc start <node> <vmid> Start a container
lxc stop <node> <vmid> Hard-stop (kill) a container
lxc shutdown <node> <vmid> Graceful shutdown (hard-stop fallback)
lxc delete <node> <vmid> --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)
""");
Expand Down Expand Up @@ -73,8 +79,11 @@ vm show <node> <vmid> 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;
}

Expand Down Expand Up @@ -163,12 +172,54 @@ static async Task<int> RunVm(string[] args, ProxmoxClientOptions options)
}
}

// lxc <start|stop|shutdown|delete> — the LXC lifecycle write path (#149).
static async Task<int> 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 <start|stop|shutdown|delete> <node> <vmid> [--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] : "<sub>")} <node> <vmid> …");
var cmd = args.Length > 0 ? args[0] : "vm";
Console.Error.WriteLine($"usage: proxmoxsharp {cmd} {(args.Length > 1 ? args[1] : "<sub>")} <node> <vmid> …");
return false;
}
node = args[2];
Expand Down Expand Up @@ -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");
Expand Down
153 changes: 153 additions & 0 deletions src/ProxmoxSharp/Lxc/PctWriter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// LXC lifecycle write path (issue #149): start / stop / shutdown / delete for
/// containers, the counterpart to <see cref="QemuWriter"/>. Gives the converge
/// destroy lifecycle (and the CLI) an IaC way to tear a CT down — no root SSH + pct.
/// <para>
/// Each lifecycle op carries Proxmox query flags (forceStop/timeout/force/purge), so —
/// like <see cref="QemuWriter.DeleteAsync"/> — 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
/// <see cref="WaitForTaskAsync"/>. The form-send + task-poll helpers intentionally mirror
/// QemuWriter; hoist a shared base if a third writer ever appears.
/// </para>
/// </summary>
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);
}

/// <summary>Build a writer with the same auth/TLS wiring as <see cref="ProxmoxApi.Create"/>.</summary>
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) ---------------------------------------

/// <summary>Start a container (POST …/lxc/{vmid}/status/start).</summary>
public Task<string?> StartAsync(string node, int vmid, CancellationToken ct = default) =>
SendFormAsync(Method.POST, "{+baseurl}/nodes/{node}/lxc/{vmid}/status/start", PathParams(node, vmid), form: null, ct);

/// <summary>Hard stop — kill the container (POST …/lxc/{vmid}/status/stop).</summary>
public Task<string?> StopAsync(string node, int vmid, CancellationToken ct = default) =>
SendFormAsync(Method.POST, "{+baseurl}/nodes/{node}/lxc/{vmid}/status/stop", PathParams(node, vmid), form: null, ct);

/// <summary>
/// Graceful shutdown (POST …/lxc/{vmid}/status/shutdown). <paramref name="forceStop"/>
/// falls back to a hard stop if the clean shutdown exceeds <paramref name="timeout"/> seconds.
/// </summary>
public Task<string?> 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);

/// <summary>
/// Destroy a container (DELETE …/lxc/{vmid}). <paramref name="purge"/> also removes it
/// from related configs (backup jobs, HA, replication) and destroys unreferenced disks;
/// <paramref name="force"/> destroys even while it is still running.
/// </summary>
public Task<string?> 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) -------------------------------

/// <summary>Query string for DELETE: force destroy and/or purge refs + unreferenced disks.</summary>
public static string DeleteQuery(bool force, bool purge)
{
var parts = new List<string>();
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) : "";
}

/// <summary>Query string for graceful shutdown: hard-stop fallback + timeout (seconds).</summary>
public static string ShutdownQuery(bool forceStop, int timeout) =>
$"?forceStop={(forceStop ? 1 : 0)}&timeout={timeout.ToString(CultureInfo.InvariantCulture)}";

// --- task polling (mirrors QemuWriter.WaitForTaskAsync) ----------------

/// <summary>
/// Poll a task UPID until it leaves the "running" state; returns its exit status
/// ("OK" on success). Throws on timeout.
/// </summary>
public async Task<string?> 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<string, ParsableFactory<IParsable>> ErrorMapping = new()
{
["4XX"] = static _ => new ProxmoxApiError(),
["5XX"] = static _ => new ProxmoxApiError(),
};

private Dictionary<string, object> PathParams(string node, int vmid) => new()
{
["baseurl"] = BaseUrl,
["node"] = node,
["vmid"] = vmid.ToString(CultureInfo.InvariantCulture),
};

private async Task<string?> SendFormAsync(
Method method, string urlTemplate, Dictionary<string, object> pathParams,
IReadOnlyDictionary<string, string>? 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<Stream>(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;
}
}
33 changes: 33 additions & 0 deletions tests/ProxmoxSharp.Tests/PctWriterTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading