feat(cmdlets): implement storage and ISO upload cmdlets

Add Get-PveStorage, Get-PveStorageContent, Send-PveIso (with manual
multipart upload and Write-Progress), Invoke-PveStorageDownload,
New-PveStorage, and Remove-PveStorage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-17 15:46:27 -05:00
parent 6bf5d195af
commit 0c11bc4b1b
6 changed files with 505 additions and 0 deletions
@@ -0,0 +1,59 @@
using System;
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Storage;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Storage
{
/// <summary>
/// <para type="synopsis">Lists storage definitions on a Proxmox VE node or cluster.</para>
/// <para type="description">
/// Returns storage definitions visible to the specified node. When Node is omitted the
/// cluster-level storage list is queried. Results can be filtered by Type and ContentType.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveStorage")]
[OutputType(typeof(PveStorage))]
public class GetPveStorageCmdlet : PveCmdletBase
{
/// <summary>
/// The Proxmox VE node name. Accepts pipeline input from Get-PveNode (PveNode.Name).
/// When omitted the cluster-wide storage list is used.
/// </summary>
[Parameter(Mandatory = false, ValueFromPipelineByPropertyName = true)]
[Alias("NodeName")]
public string? Node { get; set; }
/// <summary>Filter results to a specific storage type (e.g., "dir", "nfs", "zfspool").</summary>
[Parameter(Mandatory = false)]
public string? Type { get; set; }
/// <summary>Filter results to storages that support the given content type (e.g., "iso", "backup").</summary>
[Parameter(Mandatory = false)]
public string? ContentType { get; set; }
protected override void ProcessRecord()
{
var session = GetSession();
var service = new StorageService();
var storages = service.GetStorages(session, Node);
foreach (var storage in storages)
{
if (!string.IsNullOrEmpty(Node))
storage.Node = Node;
if (!string.IsNullOrEmpty(Type) &&
!string.Equals(storage.Type, Type, StringComparison.OrdinalIgnoreCase))
continue;
if (!string.IsNullOrEmpty(ContentType) && storage.Content != null &&
!storage.Content.Contains(ContentType))
continue;
WriteObject(storage);
}
}
}
}
@@ -0,0 +1,47 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Storage;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Storage
{
/// <summary>
/// <para type="synopsis">Lists content items within a Proxmox VE storage.</para>
/// <para type="description">
/// Returns volume entries (ISOs, templates, backups, disk images) stored in the
/// specified storage on the given node. Optionally filter by content type.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveStorageContent")]
[OutputType(typeof(PveStorageContent))]
public class GetPveStorageContentCmdlet : PveCmdletBase
{
/// <summary>The Proxmox VE node name that hosts the storage.</summary>
[Parameter(Mandatory = true, Position = 0)]
public string Node { get; set; } = string.Empty;
/// <summary>
/// The storage identifier. Accepts pipeline input from Get-PveStorage (PveStorage.Storage).
/// </summary>
[Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true)]
public string Storage { get; set; } = string.Empty;
/// <summary>Filter results to a specific content type (e.g., "iso", "vztmpl", "backup", "images").</summary>
[Parameter(Mandatory = false)]
public string? ContentType { get; set; }
protected override void ProcessRecord()
{
var session = GetSession();
var service = new StorageService();
var items = service.GetStorageContent(session, Node, Storage, ContentType);
foreach (var content in items)
{
content.Storage = Storage;
content.Node = Node;
WriteObject(content);
}
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Cmdlets.Storage
{
/// <summary>
/// <para type="synopsis">Downloads a file from a URL directly into a Proxmox VE storage.</para>
/// <para type="description">
/// Instructs the Proxmox VE node to download a file (e.g., an ISO or container template)
/// from an external URL and save it to the specified storage. Returns a PveTask.
/// </para>
/// </summary>
[Cmdlet(VerbsLifecycle.Invoke, "PveStorageDownload", SupportsShouldProcess = true)]
[OutputType(typeof(PveTask))]
public class InvokePveStorageDownloadCmdlet : PveCmdletBase
{
/// <summary>The Proxmox VE node that will perform the download.</summary>
[Parameter(Mandatory = true, Position = 0)]
public string Node { get; set; } = string.Empty;
/// <summary>The target storage identifier.</summary>
[Parameter(Mandatory = true, Position = 1)]
public string Storage { get; set; } = string.Empty;
/// <summary>The URL to download the file from.</summary>
[Parameter(Mandatory = true, Position = 2)]
public string Url { get; set; } = string.Empty;
/// <summary>The filename to save the downloaded file as on the storage.</summary>
[Parameter(Mandatory = true, Position = 3)]
public string Filename { get; set; } = string.Empty;
/// <summary>The content type category for the downloaded file. Defaults to "iso".</summary>
[Parameter(Mandatory = false)]
[ValidateSet("iso", "vztmpl", "backup", "import", IgnoreCase = true)]
public string ContentType { get; set; } = "iso";
/// <summary>When specified, waits for the download task to complete before returning.</summary>
[Parameter(Mandatory = false)]
public SwitchParameter Wait { get; set; }
protected override void ProcessRecord()
{
if (!ShouldProcess($"{Node}/{Storage}/{Filename}", $"Download from {Url}"))
return;
var session = GetSession();
using var client = new PveHttpClient(session);
var resource = $"/nodes/{Node}/storage/{Storage}/download-url";
var data = new Dictionary<string, string>
{
["url"] = Url,
["filename"] = Filename,
["content"] = ContentType
};
var json = client.PostAsync(resource, data).GetAwaiter().GetResult();
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
var task = new PveTask { Upid = upid, Node = Node, Status = "running" };
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var data = statusRoot["data"];
var status = data?["status"]?.ToString();
var exitStatus = data?["exitstatus"]?.ToString();
if (status == "stopped")
{
return new PveTask
{
Upid = upid,
Node = node,
Status = status,
ExitStatus = exitStatus
};
}
}
}
}
}
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Storage;
namespace PSProxmoxVE.Cmdlets.Storage
{
/// <summary>
/// <para type="synopsis">Creates a new storage definition in Proxmox VE.</para>
/// <para type="description">
/// Adds a new storage backend to the Proxmox VE cluster configuration.
/// The storage will be available cluster-wide after creation.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.New, "PveStorage", SupportsShouldProcess = true)]
[OutputType(typeof(PveStorage))]
public class NewPveStorageCmdlet : PveCmdletBase
{
/// <summary>The unique storage identifier/name.</summary>
[Parameter(Mandatory = true, Position = 0)]
public string Storage { get; set; } = string.Empty;
/// <summary>The storage type (e.g., "dir", "nfs", "lvm", "zfspool", "cephfs", "rbd").</summary>
[Parameter(Mandatory = true, Position = 1)]
[ValidateSet("dir", "nfs", "lvm", "lvmthin", "zfspool", "zfs", "cephfs", "rbd",
"iscsi", "iscsidirect", "glusterfs", "cifs", "pbs", IgnoreCase = true)]
public string Type { get; set; } = string.Empty;
/// <summary>Comma-separated list of content types to support (e.g., "iso,vztmpl,backup").</summary>
[Parameter(Mandatory = false)]
public string? Content { get; set; }
/// <summary>Base directory path (for "dir" type storages).</summary>
[Parameter(Mandatory = false)]
public string? Path { get; set; }
/// <summary>NFS/CIFS server hostname or IP address.</summary>
[Parameter(Mandatory = false)]
public string? Server { get; set; }
/// <summary>NFS export path or CIFS share name.</summary>
[Parameter(Mandatory = false)]
public string? Export { get; set; }
/// <summary>LVM volume group name (for "lvm"/"lvmthin" types).</summary>
[Parameter(Mandatory = false)]
public string? VgName { get; set; }
/// <summary>LVM thin pool name (for "lvmthin" type).</summary>
[Parameter(Mandatory = false)]
public string? ThinPool { get; set; }
/// <summary>ZFS pool name (for "zfspool" type).</summary>
[Parameter(Mandatory = false)]
public string? Pool { get; set; }
/// <summary>Ceph pool name (for "rbd"/"cephfs" types).</summary>
[Parameter(Mandatory = false)]
public string? CephPool { get; set; }
/// <summary>Monitor list for Ceph storages (comma-separated host:port pairs).</summary>
[Parameter(Mandatory = false)]
public string? MonHost { get; set; }
/// <summary>Whether this storage is shared across cluster nodes.</summary>
[Parameter(Mandatory = false)]
public SwitchParameter Shared { get; set; }
/// <summary>Whether this storage is enabled. Defaults to enabled.</summary>
[Parameter(Mandatory = false)]
public SwitchParameter Disable { get; set; }
/// <summary>Limit nodes that can access this storage (comma-separated node names).</summary>
[Parameter(Mandatory = false)]
public string? Nodes { get; set; }
protected override void ProcessRecord()
{
if (!ShouldProcess(Storage, "Create PVE Storage"))
return;
var session = GetSession();
using var client = new PveHttpClient(session);
var data = new Dictionary<string, string>
{
["storage"] = Storage,
["type"] = Type
};
if (!string.IsNullOrEmpty(Content)) data["content"] = Content;
if (!string.IsNullOrEmpty(Path)) data["path"] = Path;
if (!string.IsNullOrEmpty(Server)) data["server"] = Server;
if (!string.IsNullOrEmpty(Export)) data["export"] = Export;
if (!string.IsNullOrEmpty(VgName)) data["vgname"] = VgName;
if (!string.IsNullOrEmpty(ThinPool)) data["thinpool"] = ThinPool;
if (!string.IsNullOrEmpty(Pool)) data["pool"] = Pool;
if (!string.IsNullOrEmpty(CephPool)) data["pool"] = CephPool;
if (!string.IsNullOrEmpty(MonHost)) data["monhost"] = MonHost;
if (!string.IsNullOrEmpty(Nodes)) data["nodes"] = Nodes;
if (Shared.IsPresent) data["shared"] = "1";
if (Disable.IsPresent) data["disable"] = "1";
client.PostAsync("/storage", data).GetAwaiter().GetResult();
// Return the storage object representing what was created
var storage = new PveStorage
{
Storage = Storage,
Type = Type,
Content = Content,
Enabled = Disable.IsPresent ? 0 : 1,
Shared = Shared.IsPresent ? 1 : 0
};
WriteObject(storage);
}
}
}
@@ -0,0 +1,33 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Client;
namespace PSProxmoxVE.Cmdlets.Storage
{
/// <summary>
/// <para type="synopsis">Removes a storage definition from Proxmox VE.</para>
/// <para type="description">
/// Deletes the specified storage definition from the Proxmox VE cluster configuration.
/// This does not delete the underlying data — only the storage reference is removed.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Remove, "PveStorage",
SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
public class RemovePveStorageCmdlet : PveCmdletBase
{
/// <summary>The storage identifier to remove.</summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Storage { get; set; } = string.Empty;
protected override void ProcessRecord()
{
if (!ShouldProcess(Storage, "Remove PVE Storage"))
return;
var session = GetSession();
using var client = new PveHttpClient(session);
client.DeleteAsync($"/storage/{Storage}").GetAwaiter().GetResult();
}
}
}
@@ -0,0 +1,144 @@
using System;
using System.IO;
using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
namespace PSProxmoxVE.Cmdlets.Storage
{
/// <summary>
/// <para type="synopsis">Uploads a local ISO file to a Proxmox VE storage.</para>
/// <para type="description">
/// Uploads an ISO image from the local filesystem to the specified node/storage using
/// the Proxmox VE upload API. Streams the file in 4 MB chunks and reports progress
/// via Write-Progress. Returns a PveTask representing the upload job.
/// </para>
/// </summary>
[Cmdlet(VerbsCommunications.Send, "PveIso", SupportsShouldProcess = true)]
[OutputType(typeof(PveTask))]
public class SendPveIsoCmdlet : PveCmdletBase
{
/// <summary>The Proxmox VE node to upload to.</summary>
[Parameter(Mandatory = true, Position = 0)]
public string Node { get; set; } = string.Empty;
/// <summary>The target storage identifier (must support "iso" content).</summary>
[Parameter(Mandatory = true, Position = 1)]
public string Storage { get; set; } = string.Empty;
/// <summary>
/// The full local path to the ISO file to upload. The file must exist.
/// </summary>
[Parameter(Mandatory = true, Position = 2)]
[FileExistsValidation]
public string Path { get; set; } = string.Empty;
/// <summary>Optional checksum value to verify the uploaded file.</summary>
[Parameter(Mandatory = false)]
public string? Checksum { get; set; }
/// <summary>Checksum algorithm used for verification.</summary>
[Parameter(Mandatory = false)]
[ValidateSet("md5", "sha1", "sha256", IgnoreCase = true)]
public string? ChecksumAlgorithm { get; set; }
/// <summary>
/// When specified, waits for the upload task to complete before returning.
/// </summary>
[Parameter(Mandatory = false)]
public SwitchParameter Wait { get; set; }
protected override void ProcessRecord()
{
var fileName = System.IO.Path.GetFileName(Path);
if (!ShouldProcess($"{Node}/{Storage}/{fileName}", "Upload ISO"))
return;
var session = GetSession();
using var client = new PveHttpClient(session);
var resource = $"/nodes/{Node}/storage/{Storage}/upload";
var activityId = 1;
var progressRecord = new ProgressRecord(activityId,
$"Uploading ISO to {Node}/{Storage}",
$"Uploading {fileName}...");
var json = client.UploadFileAsync(
resource,
Path,
formFields: new System.Collections.Generic.Dictionary<string, string>
{
["content"] = "iso"
},
checksum: Checksum,
checksumAlgorithm: ChecksumAlgorithm,
progressCallback: (bytesSent, total) =>
{
if (total > 0)
{
var pct = (int)((bytesSent * 100L) / total);
progressRecord.PercentComplete = pct;
progressRecord.StatusDescription = $"{bytesSent / 1024 / 1024} MB / {total / 1024 / 1024} MB";
WriteProgress(progressRecord);
}
}
).GetAwaiter().GetResult();
progressRecord.RecordType = ProgressRecordType.Completed;
WriteProgress(progressRecord);
var root = JObject.Parse(json);
var upid = root["data"]?.ToString() ?? string.Empty;
var task = new PveTask { Upid = upid, Node = Node, Status = "running" };
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{
task = WaitForTask(client, Node, upid);
}
WriteObject(task);
}
private static PveTask WaitForTask(PveHttpClient client, string node, string upid)
{
var encodedUpid = Uri.EscapeDataString(upid);
var statusResource = $"/nodes/{node}/tasks/{encodedUpid}/status";
while (true)
{
System.Threading.Thread.Sleep(2000);
var statusJson = client.GetAsync(statusResource).GetAwaiter().GetResult();
var statusRoot = JObject.Parse(statusJson);
var data = statusRoot["data"];
var status = data?["status"]?.ToString();
var exitStatus = data?["exitstatus"]?.ToString();
if (status == "stopped")
{
return new PveTask
{
Upid = upid,
Node = node,
Status = status,
ExitStatus = exitStatus
};
}
}
}
}
/// <summary>Validates that a file path exists on disk.</summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
internal sealed class FileExistsValidationAttribute : ValidateArgumentsAttribute
{
protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics)
{
var path = arguments as string;
if (string.IsNullOrEmpty(path) || !File.Exists(path))
throw new ValidationMetadataException($"File not found: {path}");
}
}
}