Changed vhd helper to a powershell based approach from a diskpart approach. Also changed from vhd to vhdx

This commit is contained in:
Carl Johnsen
2026-02-13 16:44:37 +01:00
parent bc5dc36658
commit 10be6fe6c8
+199 -124
View File
@@ -23,15 +23,16 @@ using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;
using System.Threading;
using NUnit.Framework;
namespace Duplicati.UnitTest
{
/// <summary>
/// Helper class for managing VHD (Virtual Hard Disk) files using diskpart.
/// Helper class for managing VHD (Virtual Hard Disk) files using PowerShell.
/// This class provides methods to create, attach, format, and detach VHD files
/// for testing disk image backup and restore operations.
/// </summary>
@@ -66,16 +67,12 @@ namespace Duplicati.UnitTest
}
}
// Create the VHD using diskpart
var script = $@"create vdisk file=""{vhdPath}"" maximum={sizeMB} type=expandable
attach vdisk";
RunDiskpart(script);
// Create the VHD using PowerShell
var script = $@"New-VHD -Path '{vhdPath}' -SizeBytes {sizeMB}MB -Fixed | Out-Null; Mount-DiskImage -ImagePath '{vhdPath}' | Out-Null";
RunPowerShell(script);
// Wait a moment for the disk to be attached
Thread.Sleep(500);
// Get the disk number
int diskNumber = GetDiskNumber(vhdPath);
// Wait for the disk to be attached and get the disk number
int diskNumber = WaitForDiskAttachment(vhdPath, TimeSpan.FromSeconds(30));
if (diskNumber < 0)
{
throw new InvalidOperationException($"Failed to get disk number for VHD: {vhdPath}");
@@ -84,6 +81,27 @@ attach vdisk";
return $"\\\\.\\PhysicalDrive{diskNumber}";
}
/// <summary>
/// Waits for a VHD to be attached and returns its disk number.
/// </summary>
/// <param name="vhdPath">The path to the VHD file.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <returns>The disk number, or -1 if not found within the timeout.</returns>
private static int WaitForDiskAttachment(string vhdPath, TimeSpan timeout)
{
var startTime = DateTime.UtcNow;
while (DateTime.UtcNow - startTime < timeout)
{
var diskNumber = GetDiskNumber(vhdPath);
if (diskNumber >= 0)
{
return diskNumber;
}
Thread.Sleep(100);
}
return -1;
}
/// <summary>
/// Initializes a disk with the specified partition table type (GPT or MBR).
/// </summary>
@@ -91,12 +109,45 @@ attach vdisk";
/// <param name="tableType">The partition table type ("gpt" or "mbr").</param>
public static void InitializeDisk(int diskNumber, string tableType)
{
var script = $@"select disk {diskNumber}
convert {tableType.ToLowerInvariant()}";
RunDiskpart(script);
var partitionStyle = tableType.ToLowerInvariant() switch
{
"gpt" => "GPT",
"mbr" => "MBR",
_ => throw new ArgumentException($"Invalid partition table type: {tableType}", nameof(tableType))
};
// Wait for the conversion to complete
Thread.Sleep(200);
var script = $@"
$disk = Get-Disk -Number {diskNumber}
if ($disk.PartitionStyle -ne 'RAW') {{
Clear-Disk -Number {diskNumber} -RemoveData -Confirm:$false
}}
Initialize-Disk -Number {diskNumber} -PartitionStyle {partitionStyle}
";
RunPowerShell(script);
// Wait for the initialization to complete
WaitForDiskInitialization(diskNumber, TimeSpan.FromSeconds(30));
}
/// <summary>
/// Waits for a disk to be initialized.
/// </summary>
/// <param name="diskNumber">The disk number.</param>
/// <param name="timeout">Maximum time to wait.</param>
private static void WaitForDiskInitialization(int diskNumber, TimeSpan timeout)
{
var startTime = DateTime.UtcNow;
while (DateTime.UtcNow - startTime < timeout)
{
var script = $"(Get-Disk -Number {diskNumber}).PartitionStyle";
var result = RunPowerShell(script)?.Trim();
if (result == "GPT" || result == "MBR")
{
return;
}
Thread.Sleep(100);
}
throw new TimeoutException($"Disk {diskNumber} initialization timed out");
}
/// <summary>
@@ -109,23 +160,58 @@ convert {tableType.ToLowerInvariant()}";
/// <returns>The assigned drive letter.</returns>
public static char CreateAndFormatPartition(int diskNumber, string fsType, long sizeMB = 0)
{
var sizeArg = sizeMB > 0 ? $"size={sizeMB}" : "";
var fsTypeLower = fsType.ToLowerInvariant();
var fsTypeUpper = fsType.ToUpperInvariant();
// Find an available drive letter
char driveLetter = FindAvailableDriveLetter();
var script = $@"select disk {diskNumber}
create partition primary {sizeArg}
format fs={fsTypeLower} quick
assign letter={driveLetter}";
var sizeParam = sizeMB > 0 ? $"-Size {sizeMB}MB" : "-UseMaximumSize";
RunDiskpart(script);
var script = $@"
$partition = New-Partition -DiskNumber {diskNumber} {sizeParam} -AssignDriveLetter
# Wait for the partition to be ready
$timeout = (Get-Date).AddSeconds(30)
while ((Get-Date) -lt $timeout) {{
$vol = Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.PartitionNumber -eq $partition.PartitionNumber }} | Get-Volume
if ($vol) {{ break }}
Start-Sleep -Milliseconds 100
}}
# Format the volume
$vol = Format-Volume -Partition $partition -FileSystem {fsTypeUpper} -NewFileSystemLabel 'TestVol' -Confirm:$false
# Get the assigned drive letter
(Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.PartitionNumber -eq $partition.PartitionNumber }} | Get-Volume).DriveLetter
";
// Wait for formatting to complete
Thread.Sleep(500);
var result = RunPowerShell(script);
var assignedLetter = result?.Trim();
return driveLetter;
if (!string.IsNullOrEmpty(assignedLetter) && assignedLetter.Length == 1 && char.IsLetter(assignedLetter[0]))
{
return char.ToUpperInvariant(assignedLetter[0]);
}
// Fallback: try to find the drive letter by checking the disk
return FindDriveLetterForDisk(diskNumber);
}
/// <summary>
/// Finds the drive letter assigned to a disk.
/// </summary>
/// <param name="diskNumber">The disk number.</param>
/// <returns>The drive letter, or throws if not found.</returns>
private static char FindDriveLetterForDisk(int diskNumber)
{
var script = $@"
Get-Partition -DiskNumber {diskNumber} | Get-Volume | Where-Object {{ $_.DriveLetter -ne $null }} | Select-Object -ExpandProperty DriveLetter
";
var result = RunPowerShell(script)?.Trim();
if (!string.IsNullOrEmpty(result) && result.Length >= 1 && char.IsLetter(result[0]))
{
return char.ToUpperInvariant(result[0]);
}
throw new InvalidOperationException($"Could not find drive letter for disk {diskNumber}");
}
/// <summary>
@@ -180,9 +266,13 @@ assign letter={driveLetter}";
{
try
{
var script = $@"select vdisk file=""{vhdPath}""
detach vdisk";
RunDiskpart(script);
var script = $@"
$image = Get-DiskImage -ImagePath '{vhdPath}' -ErrorAction SilentlyContinue
if ($image -and $image.Attached) {{
Dismount-DiskImage -ImagePath '{vhdPath}'
}}
";
RunPowerShell(script);
}
catch
{
@@ -197,109 +287,85 @@ detach vdisk";
/// <returns>The disk number, or -1 if not found.</returns>
public static int GetDiskNumber(string vhdPath)
{
// Use diskpart to list vdisks and find our disk number
var output = RunDiskpart("list vdisk");
// Parse the output to find the disk number
// The output format includes the VHD file path and associated disk number
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
try
{
if (line.Contains(vhdPath, StringComparison.OrdinalIgnoreCase))
var script = $@"
$diskImage = Get-DiskImage -ImagePath '{vhdPath}' -ErrorAction SilentlyContinue
if ($diskImage -and $diskImage.Attached) {{
Get-Disk | Where-Object {{ $_.Path -like '*{Path.GetFileName(vhdPath)}*' -or ($_ | Get-DiskImage).ImagePath -eq '{vhdPath}' }} | Select-Object -ExpandProperty Number
}}
";
var result = RunPowerShell(script)?.Trim();
if (!string.IsNullOrEmpty(result) && int.TryParse(result, out int diskNumber))
{
// Try to extract disk number from the line
// Format typically includes: Disk ###, State, Type, File
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < parts.Length; i++)
{
if (parts[i].StartsWith("Disk", StringComparison.OrdinalIgnoreCase) &&
i + 1 < parts.Length)
{
var diskNumStr = parts[i + 1].Trim();
if (int.TryParse(diskNumStr, out int diskNumber))
{
return diskNumber;
}
}
}
return diskNumber;
}
// Alternative approach: query by disk image path
script = $@"
Get-DiskImage -ImagePath '{vhdPath}' | Get-Disk | Select-Object -ExpandProperty Number
";
result = RunPowerShell(script)?.Trim();
if (!string.IsNullOrEmpty(result) && int.TryParse(result, out diskNumber))
{
return diskNumber;
}
}
// Alternative approach: use WMI or check diskpart list disk output
// Try to find the disk by looking for recently attached disks
var listDiskOutput = RunDiskpart("list disk");
var diskLines = listDiskOutput.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
// Find the highest disk number (usually the most recently attached VHD)
int maxDiskNumber = -1;
foreach (var line in diskLines)
catch (Exception ex)
{
if (line.StartsWith(" Disk ", StringComparison.OrdinalIgnoreCase))
{
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && int.TryParse(parts[1], out int diskNum))
{
if (diskNum > maxDiskNumber)
{
maxDiskNumber = diskNum;
}
}
}
TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}");
}
return maxDiskNumber;
return -1;
}
/// <summary>
/// Runs a diskpart script and returns the output.
/// Runs a PowerShell script and returns the output.
/// </summary>
/// <param name="script">The diskpart script to execute.</param>
/// <returns>The output from diskpart.</returns>
public static string RunDiskpart(string script)
/// <param name="script">The PowerShell script to execute.</param>
/// <returns>The output from PowerShell.</returns>
public static string RunPowerShell(string script)
{
var scriptPath = Path.GetTempFileName() + ".txt";
try
var startInfo = new ProcessStartInfo
{
File.WriteAllText(scriptPath, script, Encoding.ASCII);
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{EscapeForCommandLine(script)}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
Verb = "runas" // Request elevation
};
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "diskpart.exe",
Arguments = $"/s \"{scriptPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
Verb = "runas" // Request elevation
}
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0 && !string.IsNullOrEmpty(error))
{
throw new InvalidOperationException($"diskpart failed with exit code {process.ExitCode}: {error}\nScript:\n{script}");
}
return output;
}
finally
using var process = Process.Start(startInfo);
if (process == null)
{
try
{
File.Delete(scriptPath);
}
catch
{
// Ignore cleanup errors
}
throw new InvalidOperationException("Failed to start PowerShell process");
}
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0 && !string.IsNullOrEmpty(error))
{
throw new InvalidOperationException($"PowerShell failed with exit code {process.ExitCode}: {error}\nScript:\n{script}");
}
return output;
}
/// <summary>
/// Escapes a PowerShell script for use on the command line.
/// </summary>
/// <param name="script">The script to escape.</param>
/// <returns>The escaped script.</returns>
private static string EscapeForCommandLine(string script)
{
// Replace double quotes with escaped double quotes for command line
return script.Replace("\"", "\\\"");
}
/// <summary>
@@ -377,26 +443,35 @@ detach vdisk";
}
/// <summary>
/// Gets detailed information about a disk using diskpart.
/// Gets detailed information about a disk using PowerShell.
/// </summary>
/// <param name="diskNumber">The disk number.</param>
/// <returns>The diskpart output for the disk details.</returns>
/// <returns>The PowerShell output for the disk details.</returns>
public static string GetDiskDetails(int diskNumber)
{
var script = $@"select disk {diskNumber}
detail disk";
return RunDiskpart(script);
var script = $@"
Write-Output ""Disk Details for Disk {diskNumber}:""
Write-Output ""================================""
Get-Disk -Number {diskNumber} | Format-List
Write-Output ''
Write-Output ""Partitions:""
Write-Output ""===========""
Get-Partition -DiskNumber {diskNumber} | Format-Table
";
return RunPowerShell(script);
}
/// <summary>
/// Gets the volume information for a drive letter.
/// </summary>
/// <param name="driveLetter">The drive letter.</param>
/// <returns>The volume information.</returns>
public static string GetVolumeInfo(char driveLetter)
{
var script = $@"select volume {driveLetter}
detail volume";
return RunDiskpart(script);
var script = $@"
Get-Volume -DriveLetter {driveLetter} | Format-List
";
return RunPowerShell(script);
}
}
}