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
5 changes: 5 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embed symbols in the assembly (GitHub Packages doesn't take .snupkg). -->
<DebugType>embedded</DebugType>
<!-- No native apphost launcher: the SchemaGen tool runs via `dotnet run` and the
CLI ships as a `dotnet tool` (both invoked through the shared host), so the
apphost is never used. Skipping it also sidesteps the SDK 10.0.301
CreateAppHost/SetUnixFileMode failure on CI runners. -->
<UseAppHost>false</UseAppHost>
</PropertyGroup>

</Project>
17 changes: 17 additions & 0 deletions samples/dev-vm.json
Original file line number Diff line number Diff line change
@@ -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"]
}
7 changes: 5 additions & 2 deletions src/ProxmoxSharp.Api/ProxmoxSharp.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
<OpenApiFile>$(MSBuildProjectDirectory)/obj/openapi.json</OpenApiFile>
<GenStamp>$(MSBuildProjectDirectory)/obj/proxmox-gen.stamp</GenStamp>
<SchemaGenProject>$(RepoRoot)/tools/ProxmoxSharp.SchemaGen/ProxmoxSharp.SchemaGen.csproj</SchemaGenProject>
<!-- Read-path coverage. Widen as needed. -->
<!-- Path coverage (read + write share this scope). Widen as needed. -->
<ApiIncludePaths>/version,/nodes,/cluster,/storage,/access</ApiIncludePaths>
<!-- HTTP verbs to emit. GET = read path; POST/PUT/DELETE enable the VM write
path (#115) — create/setConfig/start/stop/delete under /nodes/{node}/qemu. -->
<ApiMethods>GET,POST,PUT,DELETE</ApiMethods>
</PropertyGroup>

<!-- Inputs for incremental regeneration: the schema + the converter's sources. -->
Expand All @@ -48,7 +51,7 @@
<Message Importance="high" Text="ProxmoxSharp.Api: regenerating client from $(PveSchemaFile)" />
<MakeDir Directories="$(IntermediateOutputPath)" />
<Exec WorkingDirectory="$(RepoRoot)"
Command="dotnet run --project &quot;$(SchemaGenProject)&quot; -c $(Configuration) -- --in &quot;$(PveSchemaFile)&quot; --out &quot;$(OpenApiFile)&quot; --include $(ApiIncludePaths) --methods GET" />
Command="dotnet run --project &quot;$(SchemaGenProject)&quot; -c $(Configuration) -- --in &quot;$(PveSchemaFile)&quot; --out &quot;$(OpenApiFile)&quot; --include $(ApiIncludePaths) --methods $(ApiMethods)" />
<Exec WorkingDirectory="$(RepoRoot)" Command="dotnet tool restore" />
<Exec WorkingDirectory="$(RepoRoot)"
Command="dotnet kiota generate -l CSharp -d &quot;$(OpenApiFile)&quot; -c ProxmoxApiClient -n ProxmoxSharp.Api -o &quot;$(GeneratedDir)&quot; --clean-output" />
Expand Down
155 changes: 148 additions & 7 deletions src/ProxmoxSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <plan|apply|start|stop|delete|pci|show>
// Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
// PROXMOX_VERIFY_TLS (optional, 'false' for self-signed nodes)

Expand All @@ -13,12 +14,20 @@
{
Console.WriteLine(
"""
proxmoxsharp — read-only Proxmox VE client
proxmoxsharp — Proxmox VE client (read + VM write path)

Usage: proxmoxsharp <command>
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 <spec.json> Diff a VM spec against live state (dry-run)
vm apply <spec.json> [--confirm] Apply the diff (dry-run unless --confirm)
vm start <node> <vmid> Start a VM
vm stop <node> <vmid> Hard-stop a VM
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

Config (env): PROXMOX_BASE_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
PROXMOX_VERIFY_TLS (optional, 'false' for self-signed)
Expand Down Expand Up @@ -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 <plan|apply|start|stop|delete|pci|show> — the VM write path (#115).
static async Task<int> 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} <spec.json> [--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 <spec> --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 <node>"); 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 <plan|apply|start|stop|delete|pci|show>");
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> …");
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<QemuVmSpec>(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");
Expand Down
17 changes: 13 additions & 4 deletions src/ProxmoxSharp/ProxmoxApi.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Http.HttpClientLibrary;
using ProxmoxSharp.Api;

Expand All @@ -11,7 +12,17 @@ namespace ProxmoxSharp;
/// </summary>
public static class ProxmoxApi
{
public static ProxmoxApiClient Create(ProxmoxClientOptions options)
public static ProxmoxApiClient Create(ProxmoxClientOptions options) =>
new(CreateAdapter(options));

/// <summary>
/// Builds the configured Kiota <see cref="IRequestAdapter"/> on its own — token
/// auth, base URL, self-signed-cert handling. The write path (<see cref="QemuWriter"/>)
/// 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.
/// </summary>
public static IRequestAdapter CreateAdapter(ProxmoxClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);

Expand All @@ -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);
}
}
Loading
Loading