feat: add -Timeout parameter with state polling to lifecycle cmdlets

All VM and container lifecycle cmdlets (Start, Stop, Restart, Suspend,
Resume, Reset) now poll VM/container status when -Wait is specified,
blocking until the expected state is reached or -Timeout (default 60s)
is exceeded.

Implementation:
- PveCmdletBase.WaitForStatusTransition() — shared helper that waits
  for PVE task completion then polls status via API
- 9 cmdlets updated: 6 VM (Start, Stop, Restart, Suspend, Resume,
  Reset) + 3 container (Start, Stop, Restart)
- -Timeout parameter with [ValidateRange(1, 3600)] on each

Integration tests:
- Replace all manual Start-Sleep + polling loops with -Wait -Timeout
- Switch to real Ubuntu cloud OVA (571 MB) for Import-PveOva testing
- OVA test verifies full import + VM start

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-20 15:15:30 -05:00
parent d1eb674c92
commit 2b772ebc65
12 changed files with 185 additions and 171 deletions
@@ -33,10 +33,11 @@ namespace PSProxmoxVE.Cmdlets.Containers
/// <summary> /// <summary>
/// <para type="description"> /// <para type="description">
/// Timeout in seconds for the graceful shutdown phase. Defaults to 60 seconds. /// Timeout in seconds for the graceful shutdown phase and -Wait status polling. Defaults to 60 seconds.
/// </para> /// </para>
/// </summary> /// </summary>
[Parameter(Mandatory = false, HelpMessage = "Maximum time to wait for the task.")] [Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60; public int Timeout { get; set; } = 60;
/// <summary> /// <summary>
@@ -52,7 +53,6 @@ namespace PSProxmoxVE.Cmdlets.Containers
var session = GetSession(); var session = GetSession();
var containerService = new ContainerService(); var containerService = new ContainerService();
var taskService = new TaskService();
WriteVerbose($"Restarting container {VmId} on node '{Node}'..."); WriteVerbose($"Restarting container {VmId} on node '{Node}'...");
@@ -60,13 +60,13 @@ namespace PSProxmoxVE.Cmdlets.Containers
var shutdownTask = containerService.ShutdownContainer(session, Node, VmId, Timeout); var shutdownTask = containerService.ShutdownContainer(session, Node, VmId, Timeout);
if (Wait.IsPresent) if (Wait.IsPresent)
taskService.WaitForTask(session, shutdownTask.Node ?? Node, shutdownTask.Upid!, null, null, null); WaitForStatusTransition(session, Node, shutdownTask, VmId, "stopped", Timeout, isContainer: true);
// Start // Start
var startTask = containerService.StartContainer(session, Node, VmId); var startTask = containerService.StartContainer(session, Node, VmId);
if (Wait.IsPresent) if (Wait.IsPresent)
startTask = taskService.WaitForTask(session, startTask.Node ?? Node, startTask.Upid!, null, null, null); startTask = WaitForStatusTransition(session, Node, startTask, VmId, "running", Timeout, isContainer: true);
WriteObject(startTask); WriteObject(startTask);
} }
@@ -36,6 +36,11 @@ namespace PSProxmoxVE.Cmdlets.Containers
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"Container {VmId} on node '{Node}'", "Start-PveContainer")) if (!ShouldProcess($"Container {VmId} on node '{Node}'", "Start-PveContainer"))
@@ -49,8 +54,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "running", Timeout, isContainer: true);
task = taskService.WaitForTask(session, task.Node ?? Node, task.Upid!, null, null, null);
} }
WriteObject(task); WriteObject(task);
@@ -38,6 +38,11 @@ namespace PSProxmoxVE.Cmdlets.Containers
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"Container {VmId} on node '{Node}'", "Stop-PveContainer")) if (!ShouldProcess($"Container {VmId} on node '{Node}'", "Stop-PveContainer"))
@@ -51,8 +56,7 @@ namespace PSProxmoxVE.Cmdlets.Containers
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "stopped", Timeout, isContainer: true);
task = taskService.WaitForTask(session, task.Node ?? Node, task.Upid!, null, null, null);
} }
WriteObject(task); WriteObject(task);
+67
View File
@@ -1,6 +1,9 @@
using System;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Exceptions; using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets namespace PSProxmoxVE.Cmdlets
{ {
@@ -35,5 +38,69 @@ namespace PSProxmoxVE.Cmdlets
return session; return session;
} }
/// <summary>
/// Waits for a PVE task to complete, then optionally polls VM status until
/// it matches <paramref name="expectedStatus"/>. Used by lifecycle cmdlets
/// (Start, Stop, Suspend, Resume, etc.) when -Wait is specified.
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
/// <param name="task">The task returned by the lifecycle API call.</param>
/// <param name="vmid">The VM or container ID to poll.</param>
/// <param name="expectedStatus">The expected status string (e.g. "running", "stopped", "paused").</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for the status transition. Default 60.</param>
/// <param name="isContainer">True to poll container status instead of VM status.</param>
/// <returns>The completed task.</returns>
protected PveTask WaitForStatusTransition(
PveSession session,
string node,
PveTask task,
int vmid,
string expectedStatus,
int timeoutSeconds = 60,
bool isContainer = false)
{
var taskService = new TaskService();
// First wait for the PVE task to complete
if (!string.IsNullOrEmpty(task.Upid))
{
task = taskService.WaitForTask(session, node, task.Upid, null, null, null);
}
// Then poll until VM/container reaches the expected status
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
try
{
string? currentStatus;
if (isContainer)
{
var ct = new ContainerService().GetContainer(session, node, vmid);
currentStatus = ct.Status;
}
else
{
var vm = new VmService().GetVm(session, node, vmid);
currentStatus = vm.Status;
}
if (string.Equals(currentStatus, expectedStatus, StringComparison.OrdinalIgnoreCase))
return task;
}
catch
{
// Ignore transient errors during polling
}
System.Threading.Thread.Sleep(2000);
}
throw new PveTaskTimeoutException(
task.Upid ?? "unknown",
TimeSpan.FromSeconds(timeoutSeconds));
}
} }
} }
@@ -38,6 +38,11 @@ namespace PSProxmoxVE.Cmdlets.Vms
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Reset-PveVm")) if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Reset-PveVm"))
@@ -51,8 +56,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "running", Timeout);
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
} }
WriteObject(task); WriteObject(task);
@@ -33,10 +33,11 @@ namespace PSProxmoxVE.Cmdlets.Vms
/// <summary> /// <summary>
/// <para type="description"> /// <para type="description">
/// Timeout in seconds for the graceful shutdown phase. Defaults to 60 seconds. /// Timeout in seconds for the graceful shutdown phase and -Wait status polling. Defaults to 60 seconds.
/// </para> /// </para>
/// </summary> /// </summary>
[Parameter(Mandatory = false, HelpMessage = "Maximum time to wait for the task.")] [Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60; public int Timeout { get; set; } = 60;
/// <summary> /// <summary>
@@ -52,7 +53,6 @@ namespace PSProxmoxVE.Cmdlets.Vms
var session = GetSession(); var session = GetSession();
var vmService = new VmService(); var vmService = new VmService();
var taskService = new TaskService();
WriteVerbose($"Restarting VM {VmId} on node '{Node}'..."); WriteVerbose($"Restarting VM {VmId} on node '{Node}'...");
@@ -60,13 +60,13 @@ namespace PSProxmoxVE.Cmdlets.Vms
var shutdownTask = vmService.ShutdownVm(session, Node, VmId, Timeout); var shutdownTask = vmService.ShutdownVm(session, Node, VmId, Timeout);
if (Wait.IsPresent) if (Wait.IsPresent)
taskService.WaitForTask(session, Node, shutdownTask.Upid, null, null, null); WaitForStatusTransition(session, Node, shutdownTask, VmId, "stopped", Timeout);
// Start // Start
var startTask = vmService.StartVm(session, Node, VmId); var startTask = vmService.StartVm(session, Node, VmId);
if (Wait.IsPresent) if (Wait.IsPresent)
startTask = taskService.WaitForTask(session, Node, startTask.Upid, null, null, null); startTask = WaitForStatusTransition(session, Node, startTask, VmId, "running", Timeout);
WriteObject(startTask); WriteObject(startTask);
} }
@@ -36,6 +36,11 @@ namespace PSProxmoxVE.Cmdlets.Vms
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Resume-PveVm")) if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Resume-PveVm"))
@@ -49,8 +54,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "running", Timeout);
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
} }
WriteObject(task); WriteObject(task);
@@ -36,6 +36,11 @@ namespace PSProxmoxVE.Cmdlets.Vms
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Start-PveVm")) if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Start-PveVm"))
@@ -49,8 +54,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "running", Timeout);
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
} }
WriteObject(task); WriteObject(task);
@@ -38,6 +38,11 @@ namespace PSProxmoxVE.Cmdlets.Vms
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Stop-PveVm")) if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Stop-PveVm"))
@@ -51,8 +56,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "stopped", Timeout);
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
} }
WriteObject(task); WriteObject(task);
@@ -37,6 +37,11 @@ namespace PSProxmoxVE.Cmdlets.Vms
[Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")] [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
public SwitchParameter Wait { get; set; } public SwitchParameter Wait { get; set; }
/// <summary>Maximum seconds to wait for the status transition when -Wait is specified. Default 60.</summary>
[Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 60).")]
[ValidateRange(1, 3600)]
public int Timeout { get; set; } = 60;
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Suspend-PveVm")) if (!ShouldProcess($"VM {VmId} on node '{Node}'", "Suspend-PveVm"))
@@ -50,8 +55,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent) if (Wait.IsPresent)
{ {
var taskService = new TaskService(); task = WaitForStatusTransition(session, Node, task, VmId, "paused", Timeout);
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
} }
WriteObject(task); WriteObject(task);
@@ -412,10 +412,10 @@ Describe 'Integration Tests' -Tag 'Integration' {
It 'Should start and stop a VM' { It 'Should start and stop a VM' {
if (Skip-IfNoTestVm) { return } if (Skip-IfNoTestVm) { return }
$startTask = Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait $startTask = Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30
$startTask | Should -Not -BeNullOrEmpty $startTask | Should -Not -BeNullOrEmpty
$stopTask = Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Confirm:$false $stopTask = Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false
$stopTask | Should -Not -BeNullOrEmpty $stopTask | Should -Not -BeNullOrEmpty
} }
@@ -423,13 +423,13 @@ Describe 'Integration Tests' -Tag 'Integration' {
if (Skip-IfNoTestVm) { return } if (Skip-IfNoTestVm) { return }
# Start the VM first # Start the VM first
Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait | Out-Null Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 | Out-Null
# Hard reset (no ACPI — works even without guest OS) # Hard reset (no ACPI — works even without guest OS)
$task = Reset-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Confirm:$false $task = Reset-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Confirm:$false | Out-Null Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
It 'Should clone a VM' { It 'Should clone a VM' {
@@ -488,7 +488,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
# Ensure the VM is stopped for snapshot # Ensure the VM is stopped for snapshot
$vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId } $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId }
if ($vm.Status -eq 'running') { if ($vm.Status -eq 'running') {
Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Confirm:$false | Out-Null Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
$snapName = 'pester-snap' $snapName = 'pester-snap'
@@ -625,7 +625,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
$task.IsSuccessful | Should -BeTrue $task.IsSuccessful | Should -BeTrue
# Clean up # Clean up
Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Confirm:$false | Out-Null Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
} }
@@ -900,7 +900,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
It 'Should start a container (Start-PveContainer)' { It 'Should start a container (Start-PveContainer)' {
if (Skip-IfNoTestContainer) { return } if (Skip-IfNoTestContainer) { return }
$task = Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait $task = Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
$ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId
@@ -910,7 +910,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
It 'Should stop a container (Stop-PveContainer)' { It 'Should stop a container (Stop-PveContainer)' {
if (Skip-IfNoTestContainer) { return } if (Skip-IfNoTestContainer) { return }
$task = Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Confirm:$false $task = Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
$ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId
@@ -921,16 +921,16 @@ Describe 'Integration Tests' -Tag 'Integration' {
if (Skip-IfNoTestContainer) { return } if (Skip-IfNoTestContainer) { return }
# Start first so we can restart # Start first so we can restart
Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait | Out-Null Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 | Out-Null
$task = Restart-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait $task = Restart-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
$ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId
$ct.Status | Should -Be 'running' $ct.Status | Should -Be 'running'
# Stop for subsequent tests # Stop for subsequent tests
Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Confirm:$false | Out-Null Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
It 'Should clone a container (Copy-PveContainer)' { It 'Should clone a container (Copy-PveContainer)' {
@@ -964,7 +964,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
# Ensure container is stopped # Ensure container is stopped
$ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId
if ($ct.Status -eq 'running') { if ($ct.Status -eq 'running') {
Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Confirm:$false | Out-Null Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
$task = New-PveContainerSnapshot ` $task = New-PveContainerSnapshot `
@@ -1084,7 +1084,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
It 'Should start the Linux VM (Start-PveVm)' { It 'Should start the Linux VM (Start-PveVm)' {
if (Skip-IfNoLinuxVm) { return } if (Skip-IfNoLinuxVm) { return }
$task = Start-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait $task = Start-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
} }
@@ -1152,33 +1152,19 @@ Describe 'Integration Tests' -Tag 'Integration' {
It 'Should suspend and resume a running VM (Suspend-PveVm / Resume-PveVm)' { It 'Should suspend and resume a running VM (Suspend-PveVm / Resume-PveVm)' {
if (Skip-IfNoLinuxVm) { return } if (Skip-IfNoLinuxVm) { return }
# Suspend — sends QMP stop # Suspend — -Wait -Timeout polls until status is 'paused'
{ Suspend-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -ErrorAction Stop } | $suspendTask = Suspend-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30
Should -Not -Throw $suspendTask | Should -Not -BeNullOrEmpty
# Poll for paused status (may take several seconds) $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:LinuxVmId }
$paused = $false $vm.Status | Should -Be 'paused'
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Seconds 2
$vm = Get-PveVm -Node $script:Node |
Where-Object { $_.VmId -eq $script:LinuxVmId }
if ($vm.Status -eq 'paused') { $paused = $true; break }
}
$paused | Should -BeTrue -Because 'VM should transition to paused within 20s'
# Resume — sends QMP cont # Resume — -Wait -Timeout polls until status is 'running'
{ Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -ErrorAction Stop } | $resumeTask = Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30
Should -Not -Throw $resumeTask | Should -Not -BeNullOrEmpty
# Poll for running status $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:LinuxVmId }
$running = $false $vm.Status | Should -Be 'running'
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Seconds 2
$vm = Get-PveVm -Node $script:Node |
Where-Object { $_.VmId -eq $script:LinuxVmId }
if ($vm.Status -eq 'running') { $running = $true; break }
}
$running | Should -BeTrue -Because 'VM should return to running within 20s'
} }
It 'Should gracefully restart a VM via ACPI (Restart-PveVm)' { It 'Should gracefully restart a VM via ACPI (Restart-PveVm)' {
@@ -1188,16 +1174,12 @@ Describe 'Integration Tests' -Tag 'Integration' {
$vm = Get-PveVm -Node $script:Node | $vm = Get-PveVm -Node $script:Node |
Where-Object { $_.VmId -eq $script:LinuxVmId } Where-Object { $_.VmId -eq $script:LinuxVmId }
if ($vm.Status -eq 'paused') { if ($vm.Status -eq 'paused') {
Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -ErrorAction SilentlyContinue | Out-Null Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 15 -ErrorAction SilentlyContinue | Out-Null
Start-Sleep -Seconds 3
} }
if (Skip-IfNoLinuxVm) { return }
$task = Restart-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Confirm:$false $task = Restart-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 60 -Confirm:$false
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
# Wait a moment for the VM to come back up
Start-Sleep -Seconds 10
$vm = Get-PveVm -Node $script:Node | $vm = Get-PveVm -Node $script:Node |
Where-Object { $_.VmId -eq $script:LinuxVmId } Where-Object { $_.VmId -eq $script:LinuxVmId }
$vm.Status | Should -Be 'running' $vm.Status | Should -Be 'running'
@@ -1206,7 +1188,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
It 'Should gracefully stop a VM via ACPI (Stop-PveVm)' { It 'Should gracefully stop a VM via ACPI (Stop-PveVm)' {
if (Skip-IfNoLinuxVm) { return } if (Skip-IfNoLinuxVm) { return }
$task = Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Confirm:$false $task = Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false
$task | Should -Not -BeNullOrEmpty $task | Should -Not -BeNullOrEmpty
$vm = Get-PveVm -Node $script:Node | $vm = Get-PveVm -Node $script:Node |
@@ -1232,32 +1214,43 @@ Describe 'Integration Tests' -Tag 'Integration' {
$metadata.Disks.Count | Should -BeGreaterThan 0 $metadata.Disks.Count | Should -BeGreaterThan 0
} }
It 'Should upload OVA and create VM (Import-PveOva)' { It 'Should import OVA as a VM (Import-PveOva)' {
if (Skip-IfNoTarget) { return } if (Skip-IfNoTarget) { return }
if (-not $script:OvaPath -or -not (Test-Path $script:OvaPath)) { if (-not $script:OvaPath -or -not (Test-Path $script:OvaPath)) {
Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH not set or file not found' Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH not set or file not found'
return return
} }
try { $vm = Import-PveOva -Node $script:Node -Storage $script:Storage `
$vm = Import-PveOva -Node $script:Node -Storage $script:Storage ` -Path $script:OvaPath -TargetStorage 'local-lvm' `
-Path $script:OvaPath -TargetStorage 'local-lvm' ` -Name 'pester-ova-vm' -Wait
-Name 'pester-ova-vm' -Wait
$vm | Should -Not -BeNullOrEmpty $vm | Should -Not -BeNullOrEmpty
$script:CreatedVmIds.Add($vm.VmId) $script:CreatedVmIds.Add($vm.VmId)
}
catch { # Verify the VM exists
# Disk import may fail with synthetic test VMDK — verify VM was at least created $found = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' | Select-Object -First 1
$found = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' -ErrorAction SilentlyContinue | $found | Should -Not -BeNullOrEmpty
Select-Object -First 1 }
if ($found) {
$script:CreatedVmIds.Add($found.VmId) It 'Should start the OVA-imported VM' {
Set-ItResult -Skipped -Because "OVA upload and VM creation succeeded but disk import failed (synthetic VMDK): $_" if (Skip-IfNoTarget) { return }
} else {
throw $ovaVm = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' -ErrorAction SilentlyContinue |
} Select-Object -First 1
if (-not $ovaVm) {
Set-ItResult -Skipped -Because 'OVA VM was not imported'
return
} }
$task = Start-PveVm -Node $script:Node -VmId $ovaVm.VmId -Wait -Timeout 60
$task | Should -Not -BeNullOrEmpty
$vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $ovaVm.VmId }
$vm.Status | Should -Be 'running'
# Clean up
Stop-PveVm -Node $script:Node -VmId $ovaVm.VmId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
} }
@@ -1270,7 +1263,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
$vm = Get-PveVm -Node $script:Node | $vm = Get-PveVm -Node $script:Node |
Where-Object { $_.VmId -eq $script:LinuxVmId } Where-Object { $_.VmId -eq $script:LinuxVmId }
if ($vm.Status -eq 'running') { if ($vm.Status -eq 'running') {
Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Confirm:$false | Out-Null Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
{ New-PveTemplate -Node $script:Node -VmId $script:LinuxVmId -Confirm:$false -ErrorAction Stop } | { New-PveTemplate -Node $script:Node -VmId $script:LinuxVmId -Confirm:$false -ErrorAction Stop } |
@@ -1327,7 +1320,7 @@ Describe 'Integration Tests' -Tag 'Integration' {
# Ensure stopped # Ensure stopped
$vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId } $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId }
if ($vm.Status -eq 'running') { if ($vm.Status -eq 'running') {
Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Confirm:$false | Out-Null Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null
} }
{ Remove-PveVm ` { Remove-PveVm `
@@ -56,90 +56,16 @@ else
echo "Cloud image already cached at ${CLOUD_IMAGE_PATH}" echo "Cloud image already cached at ${CLOUD_IMAGE_PATH}"
fi fi
# Create a minimal test OVA for Import-PveOva testing # Download Ubuntu cloud OVA for Import-PveOva testing
# OVA = TAR containing an OVF descriptor + a small VMDK/raw disk OVA_URL="https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.ova"
OVA_PATH="${OUTPUT_DIR}/test-appliance.ova" OVA_FILENAME="ubuntu-24.04-server-cloudimg-amd64.ova"
OVA_PATH="${OUTPUT_DIR}/${OVA_FILENAME}"
if [ ! -f "${OVA_PATH}" ]; then if [ ! -f "${OVA_PATH}" ]; then
echo "Creating minimal test OVA..." echo "Downloading Ubuntu cloud OVA (this may take a few minutes)..."
OVA_TMPDIR=$(mktemp -d) curl -fSL -o "${OVA_PATH}" "${OVA_URL}"
echo "Downloaded OVA ($(du -h "${OVA_PATH}" | cut -f1))"
# Create a minimal valid sparse VMDK using qemu-img
qemu-img create -f vmdk -o subformat=streamOptimized "${OVA_TMPDIR}/test-disk.vmdk" 64M 2>/dev/null
# Create OVF descriptor
cat > "${OVA_TMPDIR}/test-appliance.ovf" <<'OVF'
<?xml version="1.0" encoding="UTF-8"?>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"
xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1">
<References>
<File ovf:id="file1" ovf:href="test-disk.vmdk" ovf:size="1048576"/>
</References>
<DiskSection>
<Info>Virtual disk information</Info>
<Disk ovf:diskId="vmdisk1" ovf:fileRef="file1" ovf:capacity="1073741824" ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"/>
</DiskSection>
<VirtualSystem ovf:id="test-appliance">
<Info>A minimal test appliance</Info>
<OperatingSystemSection ovf:id="101">
<Info>Linux 64-bit</Info>
<Description>Linux</Description>
</OperatingSystemSection>
<VirtualHardwareSection>
<Info>Virtual hardware requirements</Info>
<System>
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
<vssd:InstanceID>0</vssd:InstanceID>
<vssd:VirtualSystemIdentifier>test-appliance</vssd:VirtualSystemIdentifier>
<vssd:VirtualSystemType>vmx-13</vssd:VirtualSystemType>
</System>
<Item>
<rasd:Description>Number of Virtual CPUs</rasd:Description>
<rasd:ElementName>1 virtual CPU(s)</rasd:ElementName>
<rasd:InstanceID>1</rasd:InstanceID>
<rasd:ResourceType>3</rasd:ResourceType>
<rasd:VirtualQuantity>1</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>
<rasd:Description>Memory Size</rasd:Description>
<rasd:ElementName>256MB of memory</rasd:ElementName>
<rasd:InstanceID>2</rasd:InstanceID>
<rasd:ResourceType>4</rasd:ResourceType>
<rasd:VirtualQuantity>256</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:ElementName>SCSI Controller</rasd:ElementName>
<rasd:InstanceID>3</rasd:InstanceID>
<rasd:ResourceSubType>lsilogic</rasd:ResourceSubType>
<rasd:ResourceType>6</rasd:ResourceType>
</Item>
<Item>
<rasd:ElementName>Hard Disk 1</rasd:ElementName>
<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>
<rasd:InstanceID>4</rasd:InstanceID>
<rasd:Parent>3</rasd:Parent>
<rasd:ResourceType>17</rasd:ResourceType>
</Item>
<Item>
<rasd:Connection>VM Network</rasd:Connection>
<rasd:ElementName>Ethernet adapter 1</rasd:ElementName>
<rasd:InstanceID>5</rasd:InstanceID>
<rasd:ResourceSubType>E1000</rasd:ResourceSubType>
<rasd:ResourceType>10</rasd:ResourceType>
</Item>
</VirtualHardwareSection>
</VirtualSystem>
</Envelope>
OVF
# Pack as OVA (TAR, OVF first per spec)
(cd "${OVA_TMPDIR}" && tar cf "${OVA_PATH}" test-appliance.ovf test-disk.vmdk)
rm -rf "${OVA_TMPDIR}"
echo "Created test OVA at ${OVA_PATH} ($(du -h "${OVA_PATH}" | cut -f1))"
else else
echo "Test OVA already cached at ${OVA_PATH}" echo "OVA already cached at ${OVA_PATH}"
fi fi
echo "CLOUD_IMAGE_PATH=${CLOUD_IMAGE_PATH}" echo "CLOUD_IMAGE_PATH=${CLOUD_IMAGE_PATH}"