From 4fa3d82f44171eb9190a154d934c9bc6cba4bd63 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:17:40 -0500 Subject: [PATCH 01/23] Route VM lifecycle tools through one PowerShell script pipeline Add HyperVScript, a builder that every host operation now uses: one hardened module bootstrap shared with the typed HyperVService, in-script resolution of a VM by exact name or GUID (one PowerShell process per tool call instead of two), state assertions, typed parameter rendering with literal quoting, and a uniform failure envelope { ok:false, error, errorType, errorId, category }. Move the inline create/remove/configure/export/import/network/disk scripts out of the MCP tool layer into VmLifecycleService, so tool methods only parse, validate and call a service. Typed power, checkpoint and firmware results use the same failure envelope. The version string is read from the assembly. Add a recording mock of the Hyper-V module selected through HYPERV_CONTROL_MODULE_PATH, a MockHyperV test helper, and lifecycle tests that assert on the cmdlets and parameters each operation emits. Scaffold the job runner, image services, tool partials and validation phases for the lifecycle v2 and image work that follows. --- src/HyperVControl/Extracted/HyperVService.cs | 72 +--- src/HyperVControl/HyperVTools.Debug.cs | 2 +- src/HyperVControl/HyperVTools.Image.cs | 6 + src/HyperVControl/HyperVTools.ImageBuild.cs | 6 + src/HyperVControl/HyperVTools.Vm.cs | 53 +++ src/HyperVControl/HyperVTools.cs | 149 +++---- src/HyperVControl/Program.cs | 8 +- src/HyperVControl/Services/ControlService.cs | 30 +- src/HyperVControl/Services/HyperVScript.cs | 219 +++++++++++ .../Services/ImageBuildService.cs | 12 + .../Services/ImageCatalogService.cs | 11 + src/HyperVControl/Services/JobRunner.cs | 106 +++++ .../Services/PowerShellRunner.cs | 4 + .../Services/VmLifecycleService.cs | 135 +++++++ .../HyperVControl.Tests.csproj | 4 + tests/HyperVControl.Tests/LifecycleTests.cs | 103 +++++ tests/HyperVControl.Tests/MockHyperV.cs | 63 +++ .../MockHyperV/Hyper-V/Hyper-V.psd1 | 12 + .../MockHyperV/Hyper-V/Hyper-V.psm1 | 362 ++++++++++++++++++ tests/HyperVControl.Tests/PhaseStubs.cs | 42 ++ tests/HyperVControl.Tests/Program.cs | 12 +- 21 files changed, 1223 insertions(+), 188 deletions(-) create mode 100644 src/HyperVControl/HyperVTools.Image.cs create mode 100644 src/HyperVControl/HyperVTools.ImageBuild.cs create mode 100644 src/HyperVControl/HyperVTools.Vm.cs create mode 100644 src/HyperVControl/Services/HyperVScript.cs create mode 100644 src/HyperVControl/Services/ImageBuildService.cs create mode 100644 src/HyperVControl/Services/ImageCatalogService.cs create mode 100644 src/HyperVControl/Services/JobRunner.cs create mode 100644 src/HyperVControl/Services/VmLifecycleService.cs create mode 100644 tests/HyperVControl.Tests/LifecycleTests.cs create mode 100644 tests/HyperVControl.Tests/MockHyperV.cs create mode 100644 tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psd1 create mode 100644 tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psm1 create mode 100644 tests/HyperVControl.Tests/PhaseStubs.cs diff --git a/src/HyperVControl/Extracted/HyperVService.cs b/src/HyperVControl/Extracted/HyperVService.cs index 73410dc..fd63a6f 100644 --- a/src/HyperVControl/Extracted/HyperVService.cs +++ b/src/HyperVControl/Extracted/HyperVService.cs @@ -17,24 +17,10 @@ public sealed class HyperVService PropertyNameCaseInsensitive = true, }; - private const string ScriptPreamble = + // The module bootstrap, authorization probe and capability detection are shared with every + // other host script through HyperVScript, so the test module seam applies here as well. + private static readonly string ScriptPreamble = Services.HyperVScript.ModuleBootstrap + """ - [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) - $ErrorActionPreference = 'Stop' - $ProgressPreference = 'SilentlyContinue' - $WarningPreference = 'SilentlyContinue' - $PSModuleAutoLoadingPreference = 'None' - - $systemModuleRoot = [System.IO.Path]::Combine($PSHOME, 'Modules') - $env:PSModulePath = $systemModuleRoot - Import-Module -Name ([System.IO.Path]::Combine( - $systemModuleRoot, - 'Microsoft.PowerShell.Management', - 'Microsoft.PowerShell.Management.psd1')) -ErrorAction Stop - Import-Module -Name ([System.IO.Path]::Combine( - $systemModuleRoot, - 'Microsoft.PowerShell.Utility', - 'Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop function Write-Result( [string]$Status, @@ -68,25 +54,13 @@ public sealed class HyperVService } | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress -Depth 6 } - function Test-HyperVAuthorization { - try { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = [Security.Principal.WindowsPrincipal]::new($identity) - $hyperVAdministrators = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-578') - return $principal.IsInRole($hyperVAdministrators) -or - $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } catch { - return $false - } - } - function Write-CapabilityFailure([System.Management.Automation.ErrorRecord]$Record) { $permissionDenied = $Record.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::PermissionDenied -or $Record.Exception -is [UnauthorizedAccessException] -or ($Record.Exception.GetType().FullName -eq 'Microsoft.HyperV.PowerShell.VirtualizationException' -and - -not (Test-HyperVAuthorization)) + -not (Test-ControlAuthorization)) $status = if ($permissionDenied) { 'accessDenied' } else { 'unavailable' } Write-Result $status $Record.Exception.Message @() $null } @@ -145,41 +119,9 @@ function Test-HyperVAuthorization { } } - $hyperVModuleRoot = Join-Path $systemModuleRoot 'Hyper-V' - $hyperVManifests = if ([System.IO.Directory]::Exists($hyperVModuleRoot)) { - @([System.IO.Directory]::GetFiles( - $hyperVModuleRoot, - 'Hyper-V.psd1', - [System.IO.SearchOption]::AllDirectories)) - } else { - @() - } - - if ($hyperVManifests.Count -eq 0) { - Write-Result 'notInstalled' 'The Hyper-V management tools are not installed.' @() $null - exit 0 - } - - $vmms = Microsoft.PowerShell.Management\Get-Service -Name 'vmms' -ErrorAction SilentlyContinue - if ($null -eq $vmms) { - Write-Result 'notInstalled' 'The Hyper-V Virtual Machine Management service is not installed.' @() $null - exit 0 - } - - # A stopped service fails every Hyper-V cmdlet with the same "object was not found" - # record one unreadable machine produces, so it is ruled out here, by name. - if ($vmms.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Running) { - Write-Result 'unavailable' 'The Hyper-V Virtual Machine Management service is not running.' @() $null - exit 0 - } - - try { - $hyperVManifest = $hyperVManifests | - Sort-Object -Descending | - Select-Object -First 1 - Import-Module -Name $hyperVManifest -ErrorAction Stop - } catch { - Write-CapabilityFailure $_ + $hyperVStatus = Import-ControlHyperV + if ($hyperVStatus.state -ne 'available') { + Write-Result $hyperVStatus.state $hyperVStatus.detail @() $null exit 0 } """; diff --git a/src/HyperVControl/HyperVTools.Debug.cs b/src/HyperVControl/HyperVTools.Debug.cs index d533460..03dd832 100644 --- a/src/HyperVControl/HyperVTools.Debug.cs +++ b/src/HyperVControl/HyperVTools.Debug.cs @@ -41,7 +41,7 @@ public Task KernelConfigure(string machine, string action, strin var serialAttach = $"com:pipe,port={pipePath},resets=0,reconnect"; if (action == "serial_host") { - var result = await service.PowerShell.JsonAsync(Vm(vm.Id) + RequireOff + $"Set-VMComPort -VM $vm -Number {comPort} -Path {L(pipePath)}\nGet-VMComPort -VM $vm -Number {comPort} | Select-Object Name,Path | ConvertTo-Json -Compress", ct); + var result = await service.Lifecycle.SetComPortAsync(vm.Id, comPort, pipePath, ct); return new { success = true, phase = "host_serial_configured", result, kernelAttachString = serialAttach, next = "Start the VM, wait for PowerShell Direct readiness, then call serial_guest with the same COM port. This step has not configured guest BCD." }; } diff --git a/src/HyperVControl/HyperVTools.Image.cs b/src/HyperVControl/HyperVTools.Image.cs new file mode 100644 index 0000000..a47b7a6 --- /dev/null +++ b/src/HyperVControl/HyperVTools.Image.cs @@ -0,0 +1,6 @@ +namespace HyperVControl; + +/// Image catalog, library and job tools (hyperv_image, hyperv_jobs). Implemented in phase 3. +internal sealed partial class HyperVTools +{ +} diff --git a/src/HyperVControl/HyperVTools.ImageBuild.cs b/src/HyperVControl/HyperVTools.ImageBuild.cs new file mode 100644 index 0000000..35d1843 --- /dev/null +++ b/src/HyperVControl/HyperVTools.ImageBuild.cs @@ -0,0 +1,6 @@ +namespace HyperVControl; + +/// Golden image build tool (hyperv_image_build). Implemented in phase 3. +internal sealed partial class HyperVTools +{ +} diff --git a/src/HyperVControl/HyperVTools.Vm.cs b/src/HyperVControl/HyperVTools.Vm.cs new file mode 100644 index 0000000..676ba51 --- /dev/null +++ b/src/HyperVControl/HyperVTools.Vm.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace HyperVControl; + +/// VM lifecycle, media and network tools: thin wrappers over VmLifecycleService. +internal sealed partial class HyperVTools +{ + [McpServerTool(Name = "hyperv_vm_create")] + [Description("Create a Generation 1/2 VM with existing VHD or new VHDX. All paths are absolute host paths. Returns VM configuration; leaves VM Off.")] + public Task CreateVm(string name, string path, string vhdPath, int generation = 2, int memoryMb = 4096, + int processors = 2, int newDiskGb = 0, string? switchName = null, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.CreateAsync(name, path, vhdPath, generation, memoryMb, processors, newDiskGb, switchName, ct)); + + [McpServerTool(Name = "hyperv_vm_remove", Destructive = true)] + [Description("Unregister exactly one Off VM by GUID. Hyper-V checkpoint merges may occur. Leaves standalone VHD files on disk.")] + public Task RemoveVm(string machineId, CancellationToken ct) => Run(async () => + await service.Lifecycle.RemoveAsync(Guid.Parse(machineId), ct)); + + [McpServerTool(Name = "hyperv_vm_configure", Destructive = true)] + [Description("Configure an Off VM's CPU, memory, dynamic memory, checkpoint type or name. Omitted values remain unchanged.")] + public Task ConfigureVm(string machine, int? processors = null, int? memoryMb = null, + bool? dynamicMemory = null, string? checkpointType = null, string? newName = null, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.ConfigureAsync(machine, processors, memoryMb, dynamicMemory, checkpointType, newName, ct)); + + [McpServerTool(Name = "hyperv_vm_export")] + [Description("Export a VM including its checkpoint chain to an absolute host directory.")] + public Task Export(string machine, string destination, CancellationToken ct) => Run(async () => + await service.Lifecycle.ExportAsync(machine, destination, ct)); + + [McpServerTool(Name = "hyperv_vm_import")] + [Description("Import an exported .vmcx by copying it and generating a new VM ID. This is also the clone path. Supply fresh VM and VHD destination directories.")] + public Task Import(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => Run(async () => + await service.Lifecycle.ImportAsync(configurationPath, vmDestination, vhdDestination, ct)); + + [McpServerTool(Name = "hyperv_network", Destructive = true)] + [Description("List virtual switches, create private/internal switches, remove an exact switch, or connect/disconnect a named VM adapter. External switch creation is deliberately explicit via host administration.")] + public Task Network(string action = "list", string? machine = null, string? switchName = null, string adapterName = "Network Adapter", string switchType = "Private", CancellationToken ct = default) => Run(async () => action switch + { + "list" => await service.Lifecycle.ListSwitchesAsync(ct), + "create" => await service.Lifecycle.CreateSwitchAsync(switchName ?? throw new ArgumentException("Name and Private/Internal switchType required."), switchType, ct), + "remove" => await service.Lifecycle.RemoveSwitchAsync(switchName ?? throw new ArgumentException("switchName required."), ct), + "connect" => await service.Lifecycle.ConnectAdapterAsync(machine ?? throw new ArgumentException("machine required."), adapterName, switchName ?? throw new ArgumentException("switchName required."), ct), + "disconnect" => await service.Lifecycle.ConnectAdapterAsync(machine ?? throw new ArgumentException("machine required."), adapterName, null, ct), + _ => throw new ArgumentException("Unknown network action.") + }); + + [McpServerTool(Name = "hyperv_disk", Destructive = true)] + [Description("Manage VM media: list, attach_vhd, detach_vhd by exact path, attach_iso, eject_iso, or resize_vhd. Changes require VM Off; does not delete disk files.")] + public Task Disk(string machine, string action = "list", string? path = null, long? sizeGb = null, CancellationToken ct = default) => Run(async () => + action == "list" ? await service.Lifecycle.InfoAsync(machine, ct) : await service.Lifecycle.DiskAsync(machine, action, path, sizeGb, ct)); +} diff --git a/src/HyperVControl/HyperVTools.cs b/src/HyperVControl/HyperVTools.cs index b7509a5..6b17cc9 100644 --- a/src/HyperVControl/HyperVTools.cs +++ b/src/HyperVControl/HyperVTools.cs @@ -1,16 +1,17 @@ using System.ComponentModel; -using System.Net; -using System.Security.Cryptography; using System.Text.Json; -using System.Text.RegularExpressions; using HyperVControl.Extracted; using HyperVControl.Services; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using static HyperVControl.Services.ControlService; namespace HyperVControl; +/// +/// The MCP contract. Tool methods parse and validate arguments, call one service, and wrap the +/// outcome; no PowerShell text lives in this layer. Every failure uses the same envelope: +/// { ok:false, error, errorType, ... }, which maps to an MCP error. +/// [McpServerToolType] internal sealed partial class HyperVTools(ControlService service) { @@ -24,13 +25,24 @@ internal static bool IsFailure(JsonElement json) => json.ValueKind == JsonValueK ((json.TryGetProperty("success", out var success) && success.ValueKind == JsonValueKind.False) || (json.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.False) || (json.TryGetProperty("exitCode", out var exitCode) && exitCode.ValueKind == JsonValueKind.Number && exitCode.GetInt32() != 0)); + internal static async Task Run(Func> action) { try { return Result(await action()); } catch (OperationCanceledException) { throw; } + catch (HyperVOperationException ex) { return Result(new { ok = false, error = ex.Message, errorType = ex.ErrorType, errorId = ex.ErrorId, category = ex.Category }); } catch (Exception ex) { return Result(new { ok = false, error = ex.Message, errorType = ex.GetType().Name }); } } + /// Typed results share the failure envelope; successes pass through unchanged. + internal static object Outcome(bool success, string? error, HyperVCapability capability, object result) => success ? result : new + { + ok = false, + error = error ?? capability.Detail ?? "Hyper-V reported a failure.", + errorType = capability.IsAvailable ? "HyperVOperationFailed" : capability.State.ToString(), + result + }; + [McpServerTool(Name = "hyperv_capabilities", ReadOnly = true)] [Description("Probe local Hyper-V rights, host availability, console and guest prerequisites, and installed debugger versions. No VM mutations.")] public Task Capabilities(CancellationToken ct) => Run(async () => new @@ -49,131 +61,60 @@ internal static async Task Run(Func> action) [McpServerTool(Name = "hyperv_vm_info", ReadOnly = true)] [Description("Read detailed VM hardware, integration services, disks, network, COM ports and current checkpoint ID.")] - public Task Info(string machine, CancellationToken ct) => Run(async () => await service.InfoAsync((await service.ResolveAsync(machine, ct)).Id, ct)); + public Task Info(string machine, CancellationToken ct) => Run(async () => await service.Lifecycle.InfoAsync(machine, ct)); [McpServerTool(Name = "hyperv_power", Destructive = true)] [Description("Change VM power: start, shutdown (graceful), restart (graceful stop/wait/start), turn_off (hard), reset (hard), pause, resume, save. No automatic hard-power fallback.")] public Task Power(string machine, string action, CancellationToken ct) => Run(async () => { var vm = await service.ResolveAsync(machine, ct); - if (action == "reset") return await service.PowerShell.JsonAsync(Vm(vm.Id) + "Restart-VM -VM $vm -Force -Confirm:$false\n" + Vm(vm.Id) + VmJson, ct); + if (action == "reset") return await service.Lifecycle.PowerResetAsync(vm.Id.ToString("D"), ct); var op = action switch { "start" => HyperVPowerAction.Start, "shutdown" => HyperVPowerAction.Shutdown, "turn_off" => HyperVPowerAction.TurnOff, "restart" => HyperVPowerAction.Restart, "pause" => HyperVPowerAction.Suspend, "resume" => HyperVPowerAction.Resume, "save" => HyperVPowerAction.Save, _ => throw new ArgumentException("Unknown power action.") }; - return await service.HyperV.ChangePowerStateAsync(vm.Id, op, ct); + var result = await service.HyperV.ChangePowerStateAsync(vm.Id, op, ct); + return Outcome(result.Success, result.Error, result.Capability, result); }); [McpServerTool(Name = "hyperv_checkpoints", ReadOnly = true)] [Description("List a VM's checkpoint tree with IDs and its current parent checkpoint. Current is not necessarily newest.")] - public Task Checkpoints(string machine, CancellationToken ct) => Run(async () => await service.HyperV.GetCheckpointsAsync((await service.ResolveAsync(machine, ct)).Id, ct)); + public Task Checkpoints(string machine, CancellationToken ct) => Run(async () => + { + var result = await service.HyperV.GetCheckpointsAsync((await service.ResolveAsync(machine, ct)).Id, ct); + return Outcome(result.Success, result.Error, result.Capability, result); + }); [McpServerTool(Name = "hyperv_checkpoint", Destructive = true)] [Description("Create, restore, remove, or rename one VM checkpoint. Resolve by exact name/ID; 'current' means current parent. Restore discards changes. Deletion does not implicitly include children.")] public Task Checkpoint(string machine, string action, string checkpoint, string? newName = null, CancellationToken ct = default) => Run(async () => { var id = (await service.ResolveAsync(machine, ct)).Id; - if (action == "create") return await service.HyperV.CreateCheckpointAsync(id, checkpoint, ct); - var cid = await service.CheckpointIdAsync(id, checkpoint, ct); - return action switch - { - "restore" => await service.HyperV.RestoreCheckpointAsync(id, cid, ct), - "remove" => await service.HyperV.DeleteCheckpointAsync(id, cid, ct), - "rename" when !string.IsNullOrWhiteSpace(newName) => await service.PowerShell.JsonAsync(Vm(id) + $"Get-VMSnapshot -VM $vm | Where-Object Id -EQ ([guid]'{cid:D}') | Rename-VMSnapshot -NewName {L(newName)}\n@{{success=$true}} | ConvertTo-Json -Compress", ct), - _ => throw new ArgumentException("Use create/restore/remove/rename; rename needs newName.") - }; - }); - - [McpServerTool(Name = "hyperv_firmware", Destructive = true)] - [Description("Set and verify Secure Boot on an Off Generation 2 VM. Change only for the requested guest task; debugging tools never disable it automatically.")] - public Task Firmware(string machine, bool secureBoot, CancellationToken ct) => Run(async () => await service.HyperV.SetSecureBootAsync((await service.ResolveAsync(machine, ct)).Id, secureBoot, ct)); - - [McpServerTool(Name = "hyperv_vm_create")] - [Description("Create a Generation 1/2 VM with existing VHD or new VHDX. All paths are absolute host paths. Returns VM configuration; leaves VM Off.")] - public Task CreateVm(string name, string path, string vhdPath, int generation = 2, int memoryMb = 4096, - int processors = 2, int newDiskGb = 0, string? switchName = null, CancellationToken ct = default) => Run(async () => - { - FullPath(path); FullPath(vhdPath); - if (string.IsNullOrWhiteSpace(name) || generation is < 1 or > 2 || memoryMb is < 32 or > 1048576 || processors is < 1 or > 2048 || newDiskGb < 0) - throw new ArgumentException("Invalid VM name, generation, memory, processors or disk size."); - var disk = newDiskGb == 0 ? $"-VHDPath {L(vhdPath)}" : $"-NewVHDPath {L(vhdPath)} -NewVHDSizeBytes ([long]{newDiskGb}*1GB)"; - var net = switchName == null ? "" : "-SwitchName " + L(switchName); - return await service.PowerShell.JsonAsync($"$vm=New-VM -Name {L(name)} -Path {L(path)} -Generation {generation} -MemoryStartupBytes ([long]{memoryMb}*1MB) {disk} {net}\nSet-VMProcessor -VM $vm -Count {processors}\n" + VmJson, ct); - }); - - [McpServerTool(Name = "hyperv_vm_remove", Destructive = true)] - [Description("Unregister exactly one Off VM by GUID. Hyper-V checkpoint merges may occur. Leaves standalone VHD files on disk.")] - public Task RemoveVm(string machineId, CancellationToken ct) => Run(async () => - { - var id = Guid.Parse(machineId); - return await service.PowerShell.JsonAsync(Vm(id) + RequireOff + "Remove-VM -VM $vm -Force\n@{success=$true} | ConvertTo-Json -Compress", ct, 300); - }); - - [McpServerTool(Name = "hyperv_vm_configure", Destructive = true)] - [Description("Configure an Off VM's CPU, memory, dynamic memory, checkpoint type or name. Omitted values remain unchanged.")] - public Task ConfigureVm(string machine, int? processors = null, int? memoryMb = null, - bool? dynamicMemory = null, string? checkpointType = null, string? newName = null, CancellationToken ct = default) => Run(async () => - { - var id = (await service.ResolveAsync(machine, ct)).Id; - var script = Vm(id) + RequireOff; - if (processors is not null) { if (processors is < 1 or > 2048) throw new ArgumentException("Invalid processors."); script += $"Set-VMProcessor -VM $vm -Count {processors}\n"; } - if (memoryMb is not null) { if (memoryMb is < 32 or > 1048576) throw new ArgumentException("Invalid memoryMb."); script += $"Set-VMMemory -VM $vm -StartupBytes ([long]{memoryMb}*1MB)\n"; } - if (dynamicMemory is not null) script += $"Set-VMMemory -VM $vm -DynamicMemoryEnabled:${dynamicMemory.ToString()!.ToLowerInvariant()}\n"; - if (checkpointType is not null) + if (action == "create") { - if (checkpointType is not ("Standard" or "Production" or "ProductionOnly" or "Disabled")) throw new ArgumentException("Invalid checkpointType."); - script += $"Set-VM -VM $vm -CheckpointType {checkpointType}\n"; + var created = await service.HyperV.CreateCheckpointAsync(id, checkpoint, ct); + return Outcome(created.Success, created.Error, created.Capability, created); } - if (newName is not null) script += "Rename-VM -VM $vm -NewName " + L(newName) + "\n"; - return await service.PowerShell.JsonAsync(script + Vm(id) + VmJson, ct); - }); - - [McpServerTool(Name = "hyperv_vm_export")] - [Description("Export a VM including its checkpoint chain to an absolute host directory.")] - public Task Export(string machine, string destination, CancellationToken ct) => Run(async () => - await service.PowerShell.JsonAsync(Vm((await service.ResolveAsync(machine, ct)).Id) + $"Export-VM -VM $vm -Path {L(FullPath(destination))}\n@{{success=$true}} | ConvertTo-Json -Compress", ct, 1800)); - - [McpServerTool(Name = "hyperv_vm_import")] - [Description("Import an exported .vmcx by copying it and generating a new VM ID. This is also the clone path. Supply fresh VM and VHD destination directories.")] - public Task Import(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => Run(async () => - await service.PowerShell.JsonAsync($"$vm=Import-VM -Path {L(FullPath(configurationPath))} -Copy -GenerateNewId -VirtualMachinePath {L(FullPath(vmDestination))} -VhdDestinationPath {L(FullPath(vhdDestination))}\n" + VmJson, ct, 1800)); - - [McpServerTool(Name = "hyperv_network", Destructive = true)] - [Description("List virtual switches, create private/internal switches, remove an exact switch, or connect/disconnect a named VM adapter. External switch creation is deliberately explicit via host administration.")] - public Task Network(string action = "list", string? machine = null, string? switchName = null, string adapterName = "Network Adapter", string switchType = "Private", CancellationToken ct = default) => Run(async () => - { - if (action == "list") return await service.PowerShell.JsonAsync("ConvertTo-Json -InputObject @(Get-VMSwitch | Select-Object Id,Name,SwitchType,NetAdapterInterfaceDescription) -Compress", ct); - if (action == "create") + var cid = await service.CheckpointIdAsync(id, checkpoint, ct); + switch (action) { - if (switchType is not ("Private" or "Internal") || string.IsNullOrWhiteSpace(switchName)) throw new ArgumentException("Name and Private/Internal switchType required."); - return await service.PowerShell.JsonAsync($"New-VMSwitch -Name {L(switchName)} -SwitchType {switchType} | Select-Object Id,Name,SwitchType | ConvertTo-Json -Compress", ct); + case "restore": + var restored = await service.HyperV.RestoreCheckpointAsync(id, cid, ct); + return Outcome(restored.Success, restored.Error, restored.Capability, restored); + case "remove": + var removed = await service.HyperV.DeleteCheckpointAsync(id, cid, ct); + return Outcome(removed.Success, removed.Error, removed.Capability, removed); + case "rename" when !string.IsNullOrWhiteSpace(newName): + return await service.Lifecycle.RenameCheckpointAsync(id, cid, newName, ct); + default: + throw new ArgumentException("Use create/restore/remove/rename; rename needs newName."); } - if (action == "remove") - return await service.PowerShell.JsonAsync($"$s=@(Get-VMSwitch | Where-Object Name -EQ {L(switchName ?? throw new ArgumentException("switchName required."))}); if($s.Count -ne 1){{throw 'Switch missing or ambiguous'}}; $s[0] | Remove-VMSwitch -Force\n@{{success=$true}} | ConvertTo-Json -Compress", ct); - var id = (await service.ResolveAsync(machine ?? throw new ArgumentException("machine required."), ct)).Id; - var select = Vm(id) + $"$a=@(Get-VMNetworkAdapter -VM $vm | Where-Object Name -EQ {L(adapterName)}); if($a.Count -ne 1){{throw 'Adapter missing or ambiguous'}}\n"; - var op = action switch { "connect" => $"Connect-VMNetworkAdapter -VMNetworkAdapter $a[0] -SwitchName {L(switchName ?? throw new ArgumentException("switchName required."))}", - "disconnect" => "Disconnect-VMNetworkAdapter -VMNetworkAdapter $a[0]", _ => throw new ArgumentException("Unknown network action.") }; - return await service.PowerShell.JsonAsync(select + op + "\n@{success=$true} | ConvertTo-Json -Compress", ct); }); - [McpServerTool(Name = "hyperv_disk", Destructive = true)] - [Description("Manage VM media: list, attach_vhd, detach_vhd by exact path, attach_iso, eject_iso, or resize_vhd. Changes require VM Off; does not delete disk files.")] - public Task Disk(string machine, string action = "list", string? path = null, long? sizeGb = null, CancellationToken ct = default) => Run(async () => + [McpServerTool(Name = "hyperv_firmware", Destructive = true)] + [Description("Set and verify Secure Boot on an Off Generation 2 VM. Change only for the requested guest task; debugging tools never disable it automatically.")] + public Task Firmware(string machine, bool secureBoot, CancellationToken ct) => Run(async () => { - var id = (await service.ResolveAsync(machine, ct)).Id; - if (action == "list") return await service.InfoAsync(id, ct); - var script = Vm(id) + RequireOff; - var literal = path == null ? "" : L(FullPath(path)); - if (action is not "eject_iso" && path == null) throw new ArgumentException("path required."); - script += action switch - { - "attach_vhd" => $"Add-VMHardDiskDrive -VM $vm -Path {literal}", - "detach_vhd" => $"Get-VMHardDiskDrive -VM $vm | Where-Object Path -EQ {literal} | Remove-VMHardDiskDrive", - "attach_iso" => $"$d=@(Get-VMDvdDrive -VM $vm); if($d.Count -eq 0){{Add-VMDvdDrive -VM $vm -Path {literal}}} elseif($d.Count -eq 1){{$d[0] | Set-VMDvdDrive -Path {literal}}} else{{throw 'Multiple DVD drives; use an exact device configuration'}}", - "eject_iso" => "Get-VMDvdDrive -VM $vm | Set-VMDvdDrive -Path $null", - "resize_vhd" when sizeGb > 0 => $"$d=@(Get-VMHardDiskDrive -VM $vm | Where-Object Path -EQ {literal}); if($d.Count -ne 1){{throw 'Disk is not attached exactly once to this VM'}}; Resize-VHD -Path {literal} -SizeBytes ([long]{sizeGb}*1GB)", - _ => throw new ArgumentException("Unknown disk action or missing sizeGb.") - }; - return await service.PowerShell.JsonAsync(script + "\n@{success=$true} | ConvertTo-Json -Compress", ct, 300); + var result = await service.HyperV.SetSecureBootAsync((await service.ResolveAsync(machine, ct)).Id, secureBoot, ct); + return Outcome(result.Success, result.Error, result.Capability, result); }); } diff --git a/src/HyperVControl/Program.cs b/src/HyperVControl/Program.cs index a8eb6a8..147792d 100644 --- a/src/HyperVControl/Program.cs +++ b/src/HyperVControl/Program.cs @@ -14,13 +14,17 @@ internal static class Program internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; + /// The project version; the only place it is read from, for --version and the MCP server info. + internal static string Version { get; } = typeof(Program).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + public static async Task Main(string[] args) { try { if (args.Length == 0 || args[0] != "--worker") { Console.InputEncoding = new UTF8Encoding(false); Console.OutputEncoding = new UTF8Encoding(false); } - if (args.Contains("--version")) { Console.WriteLine("Hyper-V Control 0.1.0"); return 0; } + if (args.Contains("--version")) { Console.WriteLine("Hyper-V Control " + Version); return 0; } if (args.Contains("--help")) { Console.WriteLine("HyperVControl.exe [--elevate]\nC# MCP server over stdio. --elevate uses a same-user UAC worker when necessary.\nNo HTTP listener. Log output goes to stderr. See the bundled skill and README."); @@ -42,7 +46,7 @@ public static async Task Main(string[] args) var tools = new HyperVTools(control); var options = new McpServerOptions { - ServerInfo = new() { Name = "hyper-v-control", Version = "0.1.0" }, + ServerInfo = new() { Name = "hyper-v-control", Version = Version }, ServerInstructions = "Manage local Hyper-V VMs by exact name/ID. Start with hyperv_capabilities and hyperv_list. Basic console works at boot without guest credentials; Enhanced requires a supported guest and visible console. PowerShell Direct and UI Automation require guest credentials. Kernel debugger sessions target a VM, never the host kernel. Debugger command timeouts return pending sessions; poll or break them instead of starting another debugger.", ToolCollection = [] }; diff --git a/src/HyperVControl/Services/ControlService.cs b/src/HyperVControl/Services/ControlService.cs index 99dc38c..825fedd 100644 --- a/src/HyperVControl/Services/ControlService.cs +++ b/src/HyperVControl/Services/ControlService.cs @@ -3,11 +3,16 @@ namespace HyperVControl.Services; +/// Composition root for the services behind the MCP tools; owns their lifetimes. internal sealed class ControlService : IDisposable { public PowerShellRunner PowerShell { get; } = new(); public CredentialStore Credentials { get; } = new(); public HyperVService HyperV { get; } + public VmLifecycleService Lifecycle { get; } + public JobRunner Jobs { get; } = new(); + public ImageCatalogService Images { get; } + public ImageBuildService ImageBuild { get; } public GuestService Guest { get; } public DebuggerService Debugger { get; } public ConsoleService Console { get; } @@ -22,6 +27,9 @@ public ControlService() var result = await PowerShell.RunAsync(script, ct, 360); return new(result.ExitCode, result.Stdout, result.Stderr); }); + Lifecycle = new(PowerShell); + Images = new(PowerShell, Jobs); + ImageBuild = new(PowerShell, Jobs); Guest = new(PowerShell, Credentials); Debugger = new(PowerShell); Console = new(Credentials); @@ -38,6 +46,11 @@ public ControlService() }); } + /// + /// Resolves an exact name or GUID through the typed inventory. Tools that need the VM's state + /// before choosing a channel (console, guest, debugger) use this; host operations resolve inside + /// their own script instead, through . + /// public async Task ResolveAsync(string machine, CancellationToken ct) { var inventory = await HyperV.GetInventoryAsync(ct); @@ -52,22 +65,9 @@ public async Task ResolveAsync(string machine, CancellationToken }; } - internal static string Vm(Guid id) => $"$vm = Get-VM -Id ([guid]'{id:D}') -ErrorAction Stop\n"; - internal static string VmJson => "$vm | Select-Object Id,Name,State,Generation,ProcessorCount,MemoryStartup,MemoryAssigned,DynamicMemoryEnabled,CheckpointType,Path | ConvertTo-Json -Depth 6 -Compress"; - internal static string RequireOff => "if ([string]$vm.State -ne 'Off') { throw 'This configuration change requires the VM to be Off.' }\n"; + /// Single-quoted PowerShell literal for guest-side scripts. internal static string L(string text) => PowerShellRunner.Literal(text); - public async Task InfoAsync(Guid id, CancellationToken ct) => await PowerShell.JsonAsync(Vm(id) + """ - @{ vm=($vm | Select-Object Id,Name,State,Generation,Version,ProcessorCount,MemoryStartup,MemoryAssigned,MemoryMinimum,MemoryMaximum,DynamicMemoryEnabled,CheckpointType,ParentSnapshotId,Path); - firmware=$(if($vm.Generation -eq 2){ Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={$_.SecureBoot.ToString()}},SecureBootTemplate,PreferredNetworkBootProtocol }else{$null}); - network=@(Get-VMNetworkAdapter -VM $vm | Select-Object Id,Name,SwitchName,MacAddress,IPAddresses,Status); - disks=@(Get-VMHardDiskDrive -VM $vm | Select-Object Path,ControllerType,ControllerNumber,ControllerLocation); - dvd=@(Get-VMDvdDrive -VM $vm | Select-Object Path,ControllerNumber,ControllerLocation); - com=@(Get-VMComPort -VM $vm | Select-Object Name,Path); - integration=@(Get-VMIntegrationService -VM $vm | Select-Object Id,Name,Enabled,PrimaryStatusDescription) - } | ConvertTo-Json -Depth 8 -Compress - """, ct); - public async Task CheckpointIdAsync(Guid id, string checkpoint, CancellationToken ct) { var list = await HyperV.GetCheckpointsAsync(id, ct); @@ -87,7 +87,7 @@ internal static string FullPath(string path) public void Dispose() { - Console.Dispose(); Debugger.Dispose(); + Console.Dispose(); Debugger.Dispose(); Jobs.Dispose(); foreach (var gate in UiGates.Values) gate.Dispose(); } } diff --git a/src/HyperVControl/Services/HyperVScript.cs b/src/HyperVControl/Services/HyperVScript.cs new file mode 100644 index 0000000..3050f45 --- /dev/null +++ b/src/HyperVControl/Services/HyperVScript.cs @@ -0,0 +1,219 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace HyperVControl.Services; + +/// A Hyper-V host operation that failed inside Windows PowerShell, carrying the uniform error envelope. +internal sealed class HyperVOperationException(string message, string errorType, string? errorId = null, string? category = null) : Exception(message) +{ + /// Stable classification: InvalidVmState, NotFound, AmbiguousName, AccessDenied, InvalidArgument, + /// HyperVNotInstalled, HyperVUnavailable, InvalidResponse or HyperVOperationFailed. + public string ErrorType { get; } = errorType; + /// PowerShell's FullyQualifiedErrorId, when the failure came from a cmdlet. + public string? ErrorId { get; } = errorId; + public string? Category { get; } = category; +} + +/// One PowerShell argument rendered as script text. Strings always become single-quoted literals. +internal readonly record struct PsValue(string Text) +{ + public static PsValue Raw(string expression) => new(expression); + public static PsValue Of(string value) => new(PowerShellRunner.Literal(value)); + public static PsValue Of(int value) => new(value.ToString(CultureInfo.InvariantCulture)); + public static PsValue Of(long value) => new(value.ToString(CultureInfo.InvariantCulture)); + public static PsValue Of(bool value) => new(value ? "$true" : "$false"); + public static PsValue Of(Guid value) => new($"([guid]'{value:D}')"); + public static PsValue Megabytes(long value) => new($"([long]{value.ToString(CultureInfo.InvariantCulture)}*1MB)"); + public static PsValue Gigabytes(long value) => new($"([long]{value.ToString(CultureInfo.InvariantCulture)}*1GB)"); + public static PsValue Token(string value, params string[] allowed) => allowed.Contains(value, StringComparer.Ordinal) + ? new(value) : throw new ArgumentException($"Invalid value '{value}'. Use one of: {string.Join(", ", allowed)}."); + public bool IsBoolean => Text is "$true" or "$false"; + public override string ToString() => Text; +} + +/// +/// Builds one host-side Windows PowerShell script: hardened module bootstrap, VM resolution by exact +/// name or ID inside the script (one process per tool call), state assertions, typed parameter +/// rendering, and a uniform JSON envelope that unwraps. +/// +internal sealed class HyperVScript +{ + /// Test seam: a module manifest or directory imported instead of the system Hyper-V module. + public const string ModulePathVariable = "HYPERV_CONTROL_MODULE_PATH"; + internal const string Sentinel = "##HYPERV-CONTROL##"; + + private readonly StringBuilder body = new(); + private string result = "$null"; + + /// + /// Shared by every host script, including the typed HyperVService envelopes: pins the module + /// path, imports the inbox modules explicitly, and defines the control helpers. + /// + internal const string ModuleBootstrap = + """ + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + $WarningPreference = 'SilentlyContinue' + $PSModuleAutoLoadingPreference = 'None' + + $systemModuleRoot = [System.IO.Path]::Combine($PSHOME, 'Modules') + $env:PSModulePath = $systemModuleRoot + Import-Module -Name ([System.IO.Path]::Combine($systemModuleRoot, 'Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Management.psd1')) -ErrorAction Stop + Import-Module -Name ([System.IO.Path]::Combine($systemModuleRoot, 'Microsoft.PowerShell.Utility', 'Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop + Import-Module -Name ([System.IO.Path]::Combine($systemModuleRoot, 'CimCmdlets', 'CimCmdlets.psd1')) -ErrorAction SilentlyContinue + + function Test-ControlAuthorization { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + $hyperVAdministrators = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-578') + return $principal.IsInRole($hyperVAdministrators) -or + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } + } + + # Imports the Hyper-V module, or a test module named by HYPERV_CONTROL_MODULE_PATH, and reports + # the host capability without throwing. The vmms check is skipped for test modules. + function Import-ControlHyperV { + $override = $env:HYPERV_CONTROL_MODULE_PATH + if (-not [string]::IsNullOrWhiteSpace($override)) { + Import-Module -Name $override -Force -ErrorAction Stop + return @{ state = 'available'; errorType = $null; detail = $null } + } + $hyperVModuleRoot = [System.IO.Path]::Combine($systemModuleRoot, 'Hyper-V') + $manifests = if ([System.IO.Directory]::Exists($hyperVModuleRoot)) { + @([System.IO.Directory]::GetFiles($hyperVModuleRoot, 'Hyper-V.psd1', [System.IO.SearchOption]::AllDirectories)) + } else { @() } + if ($manifests.Count -eq 0) { + return @{ state = 'notInstalled'; errorType = 'HyperVNotInstalled'; detail = 'The Hyper-V management tools are not installed.' } + } + $vmms = Microsoft.PowerShell.Management\Get-Service -Name 'vmms' -ErrorAction SilentlyContinue + if ($null -eq $vmms) { + return @{ state = 'notInstalled'; errorType = 'HyperVNotInstalled'; detail = 'The Hyper-V Virtual Machine Management service is not installed.' } + } + # A stopped service fails every Hyper-V cmdlet with the same "object was not found" + # record one unreadable machine produces, so it is ruled out here, by name. + if ($vmms.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Running) { + return @{ state = 'unavailable'; errorType = 'HyperVUnavailable'; detail = 'The Hyper-V Virtual Machine Management service is not running.' } + } + try { + Import-Module -Name ($manifests | Microsoft.PowerShell.Utility\Sort-Object -Descending | Microsoft.PowerShell.Utility\Select-Object -First 1) -ErrorAction Stop + } catch { + $denied = $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::PermissionDenied -or $_.Exception -is [UnauthorizedAccessException] + return @{ state = $(if ($denied) { 'accessDenied' } else { 'unavailable' }); errorType = $(if ($denied) { 'AccessDenied' } else { 'HyperVUnavailable' }); detail = $_.Exception.Message } + } + return @{ state = 'available'; errorType = $null; detail = $null } + } + + function Stop-Control([string]$Type, [string]$Message) { + $Global:HyperVControlErrorType = $Type + throw [System.InvalidOperationException]::new($Message) + } + + function Get-ControlErrorType($Record) { + $id = [string]$Record.FullyQualifiedErrorId + $category = [string]$Record.CategoryInfo.Category + $exception = $Record.Exception + if ($exception -is [UnauthorizedAccessException] -or $category -eq 'PermissionDenied') { return 'AccessDenied' } + if ($id -like 'InvalidState,*') { return 'InvalidVmState' } + if ($category -eq 'ObjectNotFound' -or $id -like 'ObjectNotFound,*') { return 'NotFound' } + if ($category -eq 'InvalidArgument' -or $exception -is [System.ArgumentException] -or $exception -is [System.Management.Automation.ParameterBindingException]) { return 'InvalidArgument' } + if ($exception.GetType().FullName -eq 'Microsoft.HyperV.PowerShell.VirtualizationException' -and -not (Test-ControlAuthorization)) { return 'AccessDenied' } + return 'HyperVOperationFailed' + } + + # Exact name (case-insensitive) or GUID. Ambiguous names fail so that duplicate VM names, + # which Hyper-V allows, never select the wrong machine. + function Resolve-ControlVm([string]$Machine) { + $id = [guid]::Empty + if ([guid]::TryParse($Machine, [ref]$id)) { + return (Hyper-V\Get-VM -Id $id -ErrorAction Stop) + } + $found = @(Hyper-V\Get-VM -ErrorAction Stop | Microsoft.PowerShell.Core\Where-Object { $_.Name -eq $Machine }) + if ($found.Count -eq 1) { return $found[0] } + if ($found.Count -eq 0) { Stop-Control 'NotFound' ("VM '" + $Machine + "' not found. Call hyperv_list to get exact names/IDs.") } + Stop-Control 'AmbiguousName' 'VM name is ambiguous. Use its ID.' + } + + function Assert-ControlVmState($Vm, [string[]]$States) { + $current = [string]$Vm.State + if ($States -notcontains $current) { + Stop-Control 'InvalidVmState' ('This operation requires the VM to be ' + ($States -join ' or ') + ' (current state: ' + $current + ').') + } + } + """; + + /// Resolves $vm from an exact name or GUID inside the script. + public HyperVScript Vm(string machine) => Line("$vm = Resolve-ControlVm " + PowerShellRunner.Literal(machine)); + public HyperVScript Vm(Guid id) => Vm(id.ToString("D")); + + /// Fails with InvalidVmState unless $vm is in one of the given states. + public HyperVScript RequireState(params string[] states) => + Line("Assert-ControlVmState $vm @(" + string.Join(", ", states.Select(PowerShellRunner.Literal)) + ")"); + public HyperVScript RequireOff() => RequireState("Off"); + + public HyperVScript Line(string text) { body.Append(text).Append('\n'); return this; } + + /// + /// Appends one command. Parameters with a null value are omitted; booleans render as + /// -Name:$true so switches and bool parameters both bind; strings are always literals. + /// + public HyperVScript Cmd(string command, params (string Name, PsValue? Value)[] parameters) + { + var line = new StringBuilder(command); + foreach (var (name, value) in parameters) + { + if (value is null) continue; + line.Append(" -").Append(name).Append(value.Value.IsBoolean ? ':' : ' ').Append(value.Value.Text); + } + return Line(line.ToString()); + } + + /// The expression whose value becomes the tool result (serialized to JSON, depth 12). + public HyperVScript Returns(string expression) { result = expression; return this; } + + public string Body => body.ToString(); + + public override string ToString() => ModuleBootstrap + "\n" + Envelope(body.ToString(), result); + + private static string Envelope(string body, string result) => + "$Global:HyperVControlErrorType = $null\n" + + "try {\n" + + "$__status = Import-ControlHyperV\n" + + "if ($__status.state -ne 'available') { Stop-Control $__status.errorType $__status.detail }\n" + + body + + "$__result = " + result + "\n" + + "$__json = @{ ok = $true; data = $__result } | Microsoft.PowerShell.Utility\\ConvertTo-Json -Depth 12 -Compress\n" + + "} catch {\n" + + "$__type = if ($Global:HyperVControlErrorType) { $Global:HyperVControlErrorType } else { Get-ControlErrorType $_ }\n" + + "$__id = if ($Global:HyperVControlErrorType) { $null } else { [string]$_.FullyQualifiedErrorId }\n" + + "$__json = @{ ok = $false; error = [string]$_.Exception.Message; errorType = $__type; errorId = $__id; category = [string]$_.CategoryInfo.Category } | Microsoft.PowerShell.Utility\\ConvertTo-Json -Compress\n" + + "}\n" + + "[Console]::Out.WriteLine(\"`n" + Sentinel + "\" + $__json)\n"; + + /// Unwraps the envelope written by ; throws the uniform exception on failure. + internal static JsonElement Unwrap(ProcessResult result) + { + var marker = result.Stdout.LastIndexOf(Sentinel, StringComparison.Ordinal); + if (marker < 0) + { + var error = result.Stderr.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault(); + throw new HyperVOperationException(error ?? $"Windows PowerShell exited with code {result.ExitCode} without a result.", "InvalidResponse"); + } + var json = result.Stdout[(marker + Sentinel.Length)..].Trim(); + JsonElement root; + try { root = JsonDocument.Parse(json).RootElement.Clone(); } + catch (JsonException ex) { throw new HyperVOperationException("PowerShell returned an invalid JSON result: " + json[..Math.Min(2000, json.Length)], "InvalidResponse", null, ex.Message); } + if (root.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.False) + throw new HyperVOperationException( + root.TryGetProperty("error", out var e) ? e.GetString() ?? "Unknown error" : "Unknown error", + root.TryGetProperty("errorType", out var t) ? t.GetString() ?? "HyperVOperationFailed" : "HyperVOperationFailed", + root.TryGetProperty("errorId", out var i) ? i.GetString() : null, + root.TryGetProperty("category", out var c) ? c.GetString() : null); + return root.TryGetProperty("data", out var data) ? data : default; + } +} diff --git a/src/HyperVControl/Services/ImageBuildService.cs b/src/HyperVControl/Services/ImageBuildService.cs new file mode 100644 index 0000000..01461f2 --- /dev/null +++ b/src/HyperVControl/Services/ImageBuildService.cs @@ -0,0 +1,12 @@ +namespace HyperVControl.Services; + +/// +/// Golden image builds: a Windows ISO applied offline to a bootable VHDX with an unattend file, +/// and a Linux cloud image converted with a NoCloud seed. Implemented in phase 3; this scaffold +/// only fixes the composition point. +/// +internal sealed class ImageBuildService(PowerShellRunner runner, JobRunner jobs) +{ + internal PowerShellRunner Runner { get; } = runner; + internal JobRunner Jobs { get; } = jobs; +} diff --git a/src/HyperVControl/Services/ImageCatalogService.cs b/src/HyperVControl/Services/ImageCatalogService.cs new file mode 100644 index 0000000..4bdeefb --- /dev/null +++ b/src/HyperVControl/Services/ImageCatalogService.cs @@ -0,0 +1,11 @@ +namespace HyperVControl.Services; + +/// +/// Image catalogs (Microsoft's Quick Create gallery feed plus custom catalogs), the local image +/// library and templates. Implemented in phase 3; this scaffold only fixes the composition point. +/// +internal sealed class ImageCatalogService(PowerShellRunner runner, JobRunner jobs) +{ + internal PowerShellRunner Runner { get; } = runner; + internal JobRunner Jobs { get; } = jobs; +} diff --git a/src/HyperVControl/Services/JobRunner.cs b/src/HyperVControl/Services/JobRunner.cs new file mode 100644 index 0000000..01d8882 --- /dev/null +++ b/src/HyperVControl/Services/JobRunner.cs @@ -0,0 +1,106 @@ +using System.Collections.Concurrent; + +namespace HyperVControl.Services; + +internal enum JobStatus { Queued, Running, Succeeded, Failed, Cancelled } + +/// Progress reported by a job; any field may be null when the job cannot measure it. +internal sealed record JobProgress(double? Percent = null, long? BytesDone = null, long? BytesTotal = null, string? Message = null); + +/// Snapshot of one background job, serialized as the hyperv_jobs result. +internal sealed record JobInfo(string Id, string Name, JobStatus Status, DateTimeOffset StartedAt, DateTimeOffset? FinishedAt, + JobProgress Progress, object? Result, string? Error, string? ErrorType); + +/// +/// In-process background jobs for work that outlives one tool call (downloads, extraction, export, +/// import, clones, image builds). Jobs belong to this MCP process: a job's work receives a +/// cancellation token, and cancellation is the only way to stop it. +/// +internal sealed class JobRunner : IDisposable +{ + private sealed class Job(string id, string name) + { + public readonly string Id = id; + public readonly string Name = name; + public readonly DateTimeOffset StartedAt = DateTimeOffset.UtcNow; + public readonly CancellationTokenSource Cancellation = new(); + public volatile JobStatus Status = JobStatus.Queued; + public DateTimeOffset? FinishedAt; + public JobProgress Progress = new(); + public object? Result; + public string? Error; + public string? ErrorType; + public Task Completion = Task.CompletedTask; + + public JobInfo Snapshot() => new(Id, Name, Status, StartedAt, FinishedAt, Progress, Result, Error, ErrorType); + } + + private sealed class Reporter(Job job) : IProgress + { + public void Report(JobProgress value) => job.Progress = value; + } + + private readonly ConcurrentDictionary jobs = new(); + + /// Starts on the thread pool and returns immediately. + public JobInfo Start(string name, Func, CancellationToken, Task> work) + { + var job = new Job(Guid.NewGuid().ToString("N")[..12], name); + jobs[job.Id] = job; + job.Completion = Task.Run(async () => + { + job.Status = JobStatus.Running; + try + { + job.Result = await work(new Reporter(job), job.Cancellation.Token); + job.Status = JobStatus.Succeeded; + } + catch (OperationCanceledException) when (job.Cancellation.IsCancellationRequested) + { + job.Status = JobStatus.Cancelled; job.Error = "The job was cancelled."; job.ErrorType = "Cancelled"; + } + catch (HyperVOperationException ex) + { + job.Status = JobStatus.Failed; job.Error = ex.Message; job.ErrorType = ex.ErrorType; + } + catch (Exception ex) + { + job.Status = JobStatus.Failed; job.Error = ex.Message; job.ErrorType = ex.GetType().Name; + } + finally { job.FinishedAt = DateTimeOffset.UtcNow; } + }); + return job.Snapshot(); + } + + public JobInfo? Get(string id) => jobs.TryGetValue(id, out var job) ? job.Snapshot() : null; + + public IReadOnlyList List() => jobs.Values.OrderByDescending(j => j.StartedAt).Select(j => j.Snapshot()).ToList(); + + public JobInfo Cancel(string id) + { + var job = jobs.TryGetValue(id, out var found) ? found : throw new ArgumentException($"Unknown job '{id}'."); + if (job.Status is JobStatus.Queued or JobStatus.Running) job.Cancellation.Cancel(); + return job.Snapshot(); + } + + /// Waits up to for completion and returns the latest snapshot either way. + public async Task WaitAsync(string id, TimeSpan timeout, CancellationToken ct) + { + var job = jobs.TryGetValue(id, out var found) ? found : throw new ArgumentException($"Unknown job '{id}'."); + await Task.WhenAny(job.Completion, Task.Delay(timeout, ct)); + ct.ThrowIfCancellationRequested(); + return job.Snapshot(); + } + + /// Starts a job and, when is positive, returns its state after that wait. + public async Task StartAndWaitAsync(string name, Func, CancellationToken, Task> work, int waitSeconds, CancellationToken ct) + { + var started = Start(name, work); + return waitSeconds > 0 ? await WaitAsync(started.Id, TimeSpan.FromSeconds(Math.Min(waitSeconds, 3600)), ct) : started; + } + + public void Dispose() + { + foreach (var job in jobs.Values) { job.Cancellation.Cancel(); job.Cancellation.Dispose(); } + } +} diff --git a/src/HyperVControl/Services/PowerShellRunner.cs b/src/HyperVControl/Services/PowerShellRunner.cs index fc07e6d..2d24749 100644 --- a/src/HyperVControl/Services/PowerShellRunner.cs +++ b/src/HyperVControl/Services/PowerShellRunner.cs @@ -67,6 +67,10 @@ public async Task RunAsync(string script, CancellationToken ct, i } } + /// Runs a built host script and unwraps its envelope; failures throw . + public async Task InvokeAsync(HyperVScript script, CancellationToken ct, int timeoutSeconds = 120) => + HyperVScript.Unwrap(await RunAsync(script.ToString(), ct, timeoutSeconds)); + public async Task JsonAsync(string script, CancellationToken ct, int timeoutSeconds = 120) { var result = await RunAsync(""" diff --git a/src/HyperVControl/Services/VmLifecycleService.cs b/src/HyperVControl/Services/VmLifecycleService.cs new file mode 100644 index 0000000..714d58b --- /dev/null +++ b/src/HyperVControl/Services/VmLifecycleService.cs @@ -0,0 +1,135 @@ +using System.Text.Json; + +namespace HyperVControl.Services; + +/// +/// VM lifecycle and hardware operations on the local host. Every operation builds one +/// and runs it through the shared runner, so a tool call costs one +/// PowerShell process, every failure carries the uniform envelope, and tests can drive this +/// layer against a mock Hyper-V module without a Hyper-V host. +/// +internal sealed class VmLifecycleService(PowerShellRunner runner) +{ + internal const string VmProperties = "Id,Name,State,Generation,ProcessorCount,MemoryStartup,MemoryAssigned,DynamicMemoryEnabled,CheckpointType,Path"; + internal const string VmProjection = "($vm | Microsoft.PowerShell.Utility\\Select-Object " + VmProperties + ")"; + private const string Success = "@{ success = $true }"; + private const string Refresh = "$vm = Hyper-V\\Get-VM -Id $vm.Id -ErrorAction Stop"; + private static readonly string[] CheckpointTypes = ["Standard", "Production", "ProductionOnly", "Disabled"]; + + private static PsValue VmRef => PsValue.Raw("$vm"); + + public Task InfoAsync(string machine, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machine).Returns(""" + @{ vm=($vm | Select-Object Id,Name,State,Generation,Version,ProcessorCount,MemoryStartup,MemoryAssigned,MemoryMinimum,MemoryMaximum,DynamicMemoryEnabled,CheckpointType,ParentSnapshotId,Path); + firmware=$(if($vm.Generation -eq 2){ Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={$_.SecureBoot.ToString()}},SecureBootTemplate,PreferredNetworkBootProtocol }else{$null}); + network=@(Get-VMNetworkAdapter -VM $vm | Select-Object Id,Name,SwitchName,MacAddress,IPAddresses,Status); + disks=@(Get-VMHardDiskDrive -VM $vm | Select-Object Path,ControllerType,ControllerNumber,ControllerLocation); + dvd=@(Get-VMDvdDrive -VM $vm | Select-Object Path,ControllerNumber,ControllerLocation); + com=@(Get-VMComPort -VM $vm | Select-Object Name,Path); + integration=@(Get-VMIntegrationService -VM $vm | Select-Object Id,Name,Enabled,PrimaryStatusDescription) + } + """), ct); + + public Task CreateAsync(string name, string path, string vhdPath, int generation, int memoryMb, int processors, int newDiskGb, string? switchName, CancellationToken ct) + { + ControlService.FullPath(path); ControlService.FullPath(vhdPath); + if (string.IsNullOrWhiteSpace(name) || generation is < 1 or > 2 || memoryMb is < 32 or > 1048576 || processors is < 1 or > 2048 || newDiskGb < 0) + throw new ArgumentException("Invalid VM name, generation, memory, processors or disk size."); + var script = new HyperVScript() + .Cmd("$vm = New-VM", + ("Name", PsValue.Of(name)), ("Path", PsValue.Of(path)), ("Generation", PsValue.Of(generation)), + ("MemoryStartupBytes", PsValue.Megabytes(memoryMb)), + newDiskGb == 0 ? ("VHDPath", PsValue.Of(vhdPath)) : ("NewVHDPath", PsValue.Of(vhdPath)), + ("NewVHDSizeBytes", newDiskGb == 0 ? null : PsValue.Gigabytes(newDiskGb)), + ("SwitchName", switchName is null ? null : PsValue.Of(switchName))) + .Cmd("Set-VMProcessor", ("VM", VmRef), ("Count", PsValue.Of(processors))) + .Returns(VmProjection); + return runner.InvokeAsync(script, ct); + } + + /// Unregisters an Off VM. Disk files are never deleted here. + public Task RemoveAsync(Guid machineId, CancellationToken ct) => runner.InvokeAsync( + new HyperVScript().Vm(machineId).RequireOff().Cmd("Remove-VM", ("VM", VmRef), ("Force", PsValue.Of(true))).Returns(Success), ct, 300); + + public Task ConfigureAsync(string machine, int? processors, int? memoryMb, bool? dynamicMemory, string? checkpointType, string? newName, CancellationToken ct) + { + if (processors is < 1 or > 2048) throw new ArgumentException("Invalid processors."); + if (memoryMb is < 32 or > 1048576) throw new ArgumentException("Invalid memoryMb."); + var script = new HyperVScript().Vm(machine).RequireOff(); + if (processors is not null) script.Cmd("Set-VMProcessor", ("VM", VmRef), ("Count", PsValue.Of(processors.Value))); + if (memoryMb is not null) script.Cmd("Set-VMMemory", ("VM", VmRef), ("StartupBytes", PsValue.Megabytes(memoryMb.Value))); + if (dynamicMemory is not null) script.Cmd("Set-VMMemory", ("VM", VmRef), ("DynamicMemoryEnabled", PsValue.Of(dynamicMemory.Value))); + if (checkpointType is not null) script.Cmd("Set-VM", ("VM", VmRef), ("CheckpointType", PsValue.Token(checkpointType, CheckpointTypes))); + if (newName is not null) script.Cmd("Rename-VM", ("VM", VmRef), ("NewName", PsValue.Of(newName))); + return runner.InvokeAsync(script.Line(Refresh).Returns(VmProjection), ct); + } + + public Task ExportAsync(string machine, string destination, CancellationToken ct) => runner.InvokeAsync( + new HyperVScript().Vm(machine).Cmd("Export-VM", ("VM", VmRef), ("Path", PsValue.Of(ControlService.FullPath(destination)))).Returns(Success), ct, 1800); + + public Task ImportAsync(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => runner.InvokeAsync( + new HyperVScript().Cmd("$vm = Import-VM", + ("Path", PsValue.Of(ControlService.FullPath(configurationPath))), ("Copy", PsValue.Of(true)), ("GenerateNewId", PsValue.Of(true)), + ("VirtualMachinePath", PsValue.Of(ControlService.FullPath(vmDestination))), ("VhdDestinationPath", PsValue.Of(ControlService.FullPath(vhdDestination)))) + .Returns(VmProjection), ct, 1800); + + public Task ListSwitchesAsync(CancellationToken ct) => runner.InvokeAsync( + new HyperVScript().Returns("@(Get-VMSwitch | Select-Object Id,Name,SwitchType,NetAdapterInterfaceDescription)"), ct); + + public Task CreateSwitchAsync(string name, string switchType, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name and Private/Internal switchType required."); + return runner.InvokeAsync(new HyperVScript() + .Cmd("$switch = New-VMSwitch", ("Name", PsValue.Of(name)), ("SwitchType", PsValue.Token(switchType, "Private", "Internal"))) + .Returns("($switch | Select-Object Id,Name,SwitchType)"), ct); + } + + public Task RemoveSwitchAsync(string name, CancellationToken ct) => runner.InvokeAsync(new HyperVScript() + .Line("$switches = @(Get-VMSwitch | Where-Object { $_.Name -eq " + PowerShellRunner.Literal(name) + " })") + .Line("if ($switches.Count -ne 1) { Stop-Control 'NotFound' 'Switch missing or ambiguous' }") + .Cmd("$switches[0] | Remove-VMSwitch", ("Force", PsValue.Of(true))) + .Returns(Success), ct); + + public Task ConnectAdapterAsync(string machine, string adapterName, string? switchName, CancellationToken ct) + { + var script = new HyperVScript().Vm(machine) + .Line("$adapters = @(Get-VMNetworkAdapter -VM $vm | Where-Object { $_.Name -eq " + PowerShellRunner.Literal(adapterName) + " })") + .Line("if ($adapters.Count -ne 1) { Stop-Control 'NotFound' 'Adapter missing or ambiguous' }"); + if (switchName is null) script.Cmd("Disconnect-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$adapters[0]"))); + else script.Cmd("Connect-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$adapters[0]")), ("SwitchName", PsValue.Of(switchName))); + return runner.InvokeAsync(script.Returns(Success), ct); + } + + /// Media changes: attach_vhd, detach_vhd, attach_iso, eject_iso, resize_vhd. Requires Off. + public Task DiskAsync(string machine, string action, string? path, long? sizeGb, CancellationToken ct) + { + if (action is not "eject_iso" && path == null) throw new ArgumentException("path required."); + var literal = path == null ? "" : PowerShellRunner.Literal(ControlService.FullPath(path)); + var script = new HyperVScript().Vm(machine).RequireOff(); + switch (action) + { + case "attach_vhd": script.Cmd("Add-VMHardDiskDrive", ("VM", VmRef), ("Path", PsValue.Raw(literal))); break; + case "detach_vhd": script.Line($"Get-VMHardDiskDrive -VM $vm | Where-Object {{ $_.Path -eq {literal} }} | Remove-VMHardDiskDrive"); break; + case "attach_iso": script.Line($"$dvd = @(Get-VMDvdDrive -VM $vm); if ($dvd.Count -eq 0) {{ Add-VMDvdDrive -VM $vm -Path {literal} }} elseif ($dvd.Count -eq 1) {{ $dvd[0] | Set-VMDvdDrive -Path {literal} }} else {{ Stop-Control 'InvalidArgument' 'Multiple DVD drives; use an exact device configuration' }}"); break; + case "eject_iso": script.Line("Get-VMDvdDrive -VM $vm | Set-VMDvdDrive -Path $null"); break; + case "resize_vhd" when sizeGb > 0: + script.Line($"$disks = @(Get-VMHardDiskDrive -VM $vm | Where-Object {{ $_.Path -eq {literal} }}); if ($disks.Count -ne 1) {{ Stop-Control 'NotFound' 'Disk is not attached exactly once to this VM' }}") + .Cmd("Resize-VHD", ("Path", PsValue.Raw(literal)), ("SizeBytes", PsValue.Gigabytes(sizeGb.Value))); + break; + default: throw new ArgumentException("Unknown disk action or missing sizeGb."); + } + return runner.InvokeAsync(script.Returns(Success), ct, 300); + } + + public Task PowerResetAsync(string machine, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machine) + .Cmd("Restart-VM", ("VM", VmRef), ("Force", PsValue.Of(true)), ("Confirm", PsValue.Of(false))) + .Line(Refresh).Returns(VmProjection), ct); + + public Task RenameCheckpointAsync(Guid machineId, Guid checkpointId, string newName, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machineId) + .Line($"Get-VMSnapshot -VM $vm | Where-Object {{ $_.Id -eq ([guid]'{checkpointId:D}') }} | Rename-VMSnapshot -NewName {PowerShellRunner.Literal(newName)}") + .Returns(Success), ct); + + /// Maps a COM port to a named pipe on an Off VM and reads the port back. + public Task SetComPortAsync(Guid machineId, int number, string pipePath, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machineId).RequireOff() + .Cmd("Set-VMComPort", ("VM", VmRef), ("Number", PsValue.Of(number)), ("Path", PsValue.Of(pipePath))) + .Returns($"(Get-VMComPort -VM $vm -Number {number} | Select-Object Name,Path)"), ct); +} diff --git a/tests/HyperVControl.Tests/HyperVControl.Tests.csproj b/tests/HyperVControl.Tests/HyperVControl.Tests.csproj index 874d271..6bcb264 100644 --- a/tests/HyperVControl.Tests/HyperVControl.Tests.csproj +++ b/tests/HyperVControl.Tests/HyperVControl.Tests.csproj @@ -8,4 +8,8 @@ true + + + + diff --git a/tests/HyperVControl.Tests/LifecycleTests.cs b/tests/HyperVControl.Tests/LifecycleTests.cs new file mode 100644 index 0000000..4f805b1 --- /dev/null +++ b/tests/HyperVControl.Tests/LifecycleTests.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; + +namespace HyperVControl.Tests; + +/// +/// VM lifecycle behavior against the recording mock module: resolution, state gating, parameter +/// rendering, the uniform error envelope and the typed inventory path. No Hyper-V host is touched. +/// +internal static class LifecycleTests +{ + internal static async Task RunAsync(Action check) + { + var offVm = Guid.NewGuid(); var runningVm = Guid.NewGuid(); var duplicateA = Guid.NewGuid(); var duplicateB = Guid.NewGuid(); + using var mock = new MockHyperV(new + { + vms = new object[] + { + new { id = offVm, name = "Lab A", state = "Off", generation = 2, processorCount = 2, memoryStartup = 4294967296L, dynamicMemory = false, disks = new[] { @"C:\MockVHDs\lab-a.vhdx" }, snapshots = Array.Empty() }, + new { id = runningVm, name = "Lab B", state = "Running", generation = 2, processorCount = 4, memoryStartup = 2147483648L, dynamicMemory = true, disks = new[] { @"C:\MockVHDs\lab-b.vhdx" }, snapshots = Array.Empty() }, + new { id = duplicateA, name = "Dup", state = "Off", generation = 2, processorCount = 1, memoryStartup = 1073741824L, dynamicMemory = false, disks = Array.Empty(), snapshots = Array.Empty() }, + new { id = duplicateB, name = "Dup", state = "Off", generation = 1, processorCount = 1, memoryStartup = 1073741824L, dynamicMemory = false, disks = Array.Empty(), snapshots = Array.Empty() } + }, + switches = new object[] { new { id = Guid.Parse("c08cb7b8-9b3c-408e-8e30-5e16a3aeb444"), name = "Default Switch", type = "Internal" } } + }); + using var service = new ControlService(); + + var inventory = await service.HyperV.GetInventoryAsync(); + check(inventory.Capability.IsAvailable && inventory.Machines.Count == 4 && inventory.Machines.Any(m => m.Id == runningVm && m.State == Extracted.HyperVMachineState.Running), + "Typed inventory reaches the mock module through the shared bootstrap"); + + mock.ClearCalls(); + var configured = await service.Lifecycle.ConfigureAsync("Lab A", 4, null, null, null, null, default); + var setProcessor = mock.Calls().Where(c => c.Cmdlet == "Set-VMProcessor").ToList(); + check(configured.GetProperty("ProcessorCount").GetInt32() == 4 && setProcessor.Count == 1 + && setProcessor[0].Parameters.GetProperty("Count").GetInt32() == 4 && setProcessor[0].Parameters.GetProperty("VM").GetString() == offVm.ToString("D"), + "Configure on an Off VM resolves by name in-script, runs Set-VMProcessor and returns the refreshed projection"); + + mock.ClearCalls(); + var refused = await Expect(() => service.Lifecycle.ConfigureAsync("Lab B", 2, null, null, null, null, default)); + check(refused?.ErrorType == "InvalidVmState" && refused.Message.Contains("Off") && !mock.Calls().Any(c => c.Cmdlet.StartsWith("Set-")), + "Configure on a Running VM fails with InvalidVmState before any mutation"); + + var ambiguous = await Expect(() => service.Lifecycle.InfoAsync("Dup", default)); + var missing = await Expect(() => service.Lifecycle.InfoAsync("no-such-vm", default)); + var missingId = await Expect(() => service.Lifecycle.InfoAsync(Guid.NewGuid().ToString("D"), default)); + check(ambiguous?.ErrorType == "AmbiguousName" && missing?.ErrorType == "NotFound" && missingId?.ErrorType == "NotFound", + "Ambiguous names, unknown names and unknown GUIDs classify as AmbiguousName / NotFound"); + + mock.ClearCalls(); + var removeRunning = await Expect(() => service.Lifecycle.RemoveAsync(runningVm, default)); + check(removeRunning?.ErrorType == "InvalidVmState" && !mock.Called("Remove-VM"), "Remove on a Running VM is refused without calling Remove-VM"); + mock.ClearCalls(); + var removed = await service.Lifecycle.RemoveAsync(offVm, default); + var removeCall = mock.Calls().Single(c => c.Cmdlet == "Remove-VM"); + check(removed.GetProperty("success").GetBoolean() && removeCall.Parameters.GetProperty("Force").GetBoolean() + && removeCall.Parameters.GetProperty("VM").GetString() == offVm.ToString("D") && (await service.HyperV.GetInventoryAsync()).Machines.Count == 3, + "Remove on an Off VM runs Remove-VM -Force for that VM only"); + + mock.ClearCalls(); + var name = "Lab 'quoted' \u7a7a\u95f4; $pwn = 1"; + var created = await service.Lifecycle.CreateAsync(name, @"C:\MockVMs", @"C:\MockVHDs\new.vhdx", 2, 2048, 2, 20, "Default Switch", default); + var newVm = mock.Calls().Single(c => c.Cmdlet == "New-VM").Parameters; + check(created.GetProperty("Name").GetString() == name && newVm.GetProperty("Name").GetString() == name + && newVm.GetProperty("Generation").GetInt32() == 2 && newVm.GetProperty("MemoryStartupBytes").GetInt64() == 2048L * 1024 * 1024 + && newVm.GetProperty("NewVHDPath").GetString() == @"C:\MockVHDs\new.vhdx" && newVm.GetProperty("NewVHDSizeBytes").GetInt64() == 20L * 1024 * 1024 * 1024 + && newVm.GetProperty("SwitchName").GetString() == "Default Switch" && !newVm.TryGetProperty("VHDPath", out _) + && mock.Calls().Any(c => c.Cmdlet == "Set-VMProcessor" && c.Parameters.GetProperty("Count").GetInt32() == 2), + "Create renders typed New-VM parameters, omits unused ones, and preserves quotes and Unicode in the name as data"); + + var info = await service.Lifecycle.InfoAsync("Lab B", default); + check(info.GetProperty("vm").GetProperty("Name").GetString() == "Lab B" && info.GetProperty("network").GetArrayLength() == 1 + && info.GetProperty("disks").GetArrayLength() == 1 && info.GetProperty("integration").GetArrayLength() == 6 && info.GetProperty("firmware").GetProperty("SecureBoot").GetString() == "On", + "Info projection assembles firmware, network, disks and integration services in one process"); + + var tools = new HyperVTools(service); + var failure = await tools.ConfigureVm("Lab B", processors: 2); + using (var document = JsonDocument.Parse(failure.Content.OfType().Single().Text)) + check(failure.IsError == true && document.RootElement.GetProperty("ok").ValueKind == JsonValueKind.False + && document.RootElement.GetProperty("errorType").GetString() == "InvalidVmState" && document.RootElement.GetProperty("error").GetString()!.Length > 0, + "Tool failures carry the uniform ok/error/errorType envelope"); + + mock.ClearCalls(); + var power = await tools.Power("Lab B", "shutdown", default); + using (var document = JsonDocument.Parse(power.Content.OfType().Single().Text)) + check(power.IsError != true && document.RootElement.GetProperty("success").GetBoolean() + && document.RootElement.GetProperty("machine").GetProperty("state").GetString() == "off" && mock.Called("Stop-VM"), + "Typed power operations run through the same mock module and report the updated state"); + + var unknownAction = await tools.Power("Lab B", "delete_everything", default); + using (var document = JsonDocument.Parse(unknownAction.Content.OfType().Single().Text)) + check(unknownAction.IsError == true && document.RootElement.GetProperty("ok").ValueKind == JsonValueKind.False + && document.RootElement.GetProperty("errorType").GetString() == "ArgumentException", + "Argument errors keep the same envelope with the exception type as errorType"); + } + + private static async Task Expect(Func action) + { + try { await action(); return null; } + catch (HyperVOperationException ex) { return ex; } + } +} diff --git a/tests/HyperVControl.Tests/MockHyperV.cs b/tests/HyperVControl.Tests/MockHyperV.cs new file mode 100644 index 0000000..f8524eb --- /dev/null +++ b/tests/HyperVControl.Tests/MockHyperV.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using HyperVControl.Services; + +namespace HyperVControl.Tests; + +/// +/// Stages the recording mock of the Hyper-V module and points the server at it through the +/// environment the spawned PowerShell processes inherit. One instance per scenario; dispose to +/// restore the real module for later tests. +/// +internal sealed class MockHyperV : IDisposable +{ + private const string StateVariable = "HYPERV_CONTROL_MOCK_STATE"; + private const string LogVariable = "HYPERV_CONTROL_MOCK_LOG"; + + public string Directory { get; } + public string StatePath { get; } + public string LogPath { get; } + public string ManifestPath { get; } + + public MockHyperV(object state) + { + Directory = Path.Combine(Path.GetTempPath(), "HyperVControl-mock-" + Guid.NewGuid().ToString("N")); + var module = Path.Combine(Directory, "Hyper-V"); + Copy(Path.Combine(AppContext.BaseDirectory, "MockHyperV", "Hyper-V"), module); + ManifestPath = Path.Combine(module, "Hyper-V.psd1"); + StatePath = Path.Combine(Directory, "state.json"); + LogPath = Path.Combine(Directory, "calls.jsonl"); + File.WriteAllText(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + Environment.SetEnvironmentVariable(HyperVScript.ModulePathVariable, ManifestPath); + Environment.SetEnvironmentVariable(StateVariable, StatePath); + Environment.SetEnvironmentVariable(LogVariable, LogPath); + } + + /// Every cmdlet invocation recorded since the last , in order. + public IReadOnlyList<(string Cmdlet, JsonElement Parameters)> Calls() => !File.Exists(LogPath) ? [] : + File.ReadAllLines(LogPath).Where(line => line.Length > 0).Select(line => + { + var entry = JsonDocument.Parse(line).RootElement.Clone(); + return (entry.GetProperty("cmdlet").GetString()!, entry.GetProperty("parameters")); + }).ToList(); + + public bool Called(string cmdlet) => Calls().Any(call => call.Cmdlet == cmdlet); + + public void ClearCalls() { if (File.Exists(LogPath)) File.Delete(LogPath); } + + public JsonElement State() => JsonDocument.Parse(File.ReadAllText(StatePath)).RootElement.Clone(); + + public void Dispose() + { + Environment.SetEnvironmentVariable(HyperVScript.ModulePathVariable, null); + Environment.SetEnvironmentVariable(StateVariable, null); + Environment.SetEnvironmentVariable(LogVariable, null); + try { System.IO.Directory.Delete(Directory, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + + private static void Copy(string source, string destination) + { + System.IO.Directory.CreateDirectory(destination); + foreach (var file in System.IO.Directory.GetFiles(source)) File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), true); + foreach (var directory in System.IO.Directory.GetDirectories(source)) Copy(directory, Path.Combine(destination, Path.GetFileName(directory))); + } +} diff --git a/tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psd1 b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psd1 new file mode 100644 index 0000000..5542f0f --- /dev/null +++ b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psd1 @@ -0,0 +1,12 @@ +@{ + RootModule = 'Hyper-V.psm1' + ModuleVersion = '2.0.0.0' + GUID = 'b2f2d2b0-6a0e-4c7e-9f0a-1c2d3e4f5a6b' + Author = 'HyperVControl tests' + Description = 'Recording mock of the Hyper-V module for HyperVControl unit tests. Selected through HYPERV_CONTROL_MODULE_PATH; never installed.' + PowerShellVersion = '5.1' + FunctionsToExport = '*' + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} diff --git a/tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psm1 b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psm1 new file mode 100644 index 0000000..dc94e4f --- /dev/null +++ b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/Hyper-V.psm1 @@ -0,0 +1,362 @@ +# Recording mock of the Hyper-V PowerShell module for HyperVControl unit tests. +# The test harness selects it through HYPERV_CONTROL_MODULE_PATH; it is never shipped or installed. +# State: the JSON file named by HYPERV_CONTROL_MOCK_STATE ({ vms: [...], switches: [...] }). +# Calls: every cmdlet appends { cmdlet, parameters } to the JSON-lines file HYPERV_CONTROL_MOCK_LOG, +# so tests assert on exactly which cmdlets ran with which bound parameters. +# Feature areas add cmdlets in *.functions.ps1 files beside this module; they are dot-sourced below. +Set-StrictMode -Off +$script:StatePath = $env:HYPERV_CONTROL_MOCK_STATE +$script:LogPath = $env:HYPERV_CONTROL_MOCK_LOG + +function Read-MockState { + if ($script:StatePath -and (Test-Path -LiteralPath $script:StatePath)) { + $state = Get-Content -LiteralPath $script:StatePath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($null -eq $state.PSObject.Properties['vms']) { $state | Add-Member -NotePropertyName vms -NotePropertyValue @() } + if ($null -eq $state.PSObject.Properties['switches']) { $state | Add-Member -NotePropertyName switches -NotePropertyValue @() } + return $state + } + return [pscustomobject]@{ vms = @(); switches = @() } +} + +function Write-MockState($State) { + if ($script:StatePath) { $State | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $script:StatePath -Encoding UTF8 } +} + +function ConvertTo-MockLogValue($Value) { + if ($null -eq $Value) { return $null } + if ($Value -is [switch]) { return [bool]$Value } + if ($Value -is [bool] -or $Value -is [string] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double]) { return $Value } + if ($Value -is [guid]) { return [string]$Value } + if ($Value -is [array]) { return @($Value | ForEach-Object { ConvertTo-MockLogValue $_ }) } + if ($Value -is [pscustomobject] -and $null -ne $Value.PSObject.Properties['Id']) { return [string]$Value.Id } + return [string]$Value +} + +function Write-MockCall([string]$Cmdlet, [System.Collections.IDictionary]$Parameters) { + $entry = [ordered]@{ cmdlet = $Cmdlet; parameters = [ordered]@{} } + foreach ($key in @($Parameters.Keys)) { $entry.parameters[$key] = ConvertTo-MockLogValue $Parameters[$key] } + if ($script:LogPath) { Add-Content -LiteralPath $script:LogPath -Value ($entry | ConvertTo-Json -Compress -Depth 6) -Encoding UTF8 } +} + +function ConvertTo-MockVm($Row) { + [pscustomobject]@{ + Id = [guid]$Row.id + Name = [string]$Row.name + State = [string]$Row.state + Generation = [int]$Row.generation + Version = '12.0' + ProcessorCount = [int]$Row.processorCount + MemoryStartup = [long]$Row.memoryStartup + MemoryAssigned = [long]0 + MemoryMinimum = [long]536870912 + MemoryMaximum = [long]1099511627776 + DynamicMemoryEnabled = [bool]$Row.dynamicMemory + CheckpointType = 'Production' + ParentSnapshotId = $null + Path = 'C:\MockVMs' + Uptime = [timespan]::Zero + GuestOperatingSystem = $null + Notes = '' + } +} + +function Get-MockRow($Vm) { + $state = Read-MockState + return @($state.vms | Where-Object { [string]$_.id -eq [string]$Vm.Id }) | Select-Object -First 1 +} + +function Update-MockVm($Vm, [string]$Property, $Value) { + $state = Read-MockState + foreach ($row in @($state.vms)) { + if ([string]$row.id -eq [string]$Vm.Id) { + if ($null -eq $row.PSObject.Properties[$Property]) { $row | Add-Member -NotePropertyName $Property -NotePropertyValue $Value } + else { $row.$Property = $Value } + } + } + Write-MockState $state +} + +function Remove-MockVm($Vm) { + $state = Read-MockState + $state.vms = @($state.vms | Where-Object { [string]$_.id -ne [string]$Vm.Id }) + Write-MockState $state +} + +function Get-VM { + [CmdletBinding()] + param([Parameter(Position = 0)][string[]]$Name, [guid[]]$Id) + Write-MockCall 'Get-VM' $PSBoundParameters + $rows = @((Read-MockState).vms) + if ($PSBoundParameters.ContainsKey('Id')) { + foreach ($one in $Id) { + $row = @($rows | Where-Object { [string]$_.id -eq [string]$one }) + if ($row.Count -eq 0) { + $exception = [System.Management.Automation.ItemNotFoundException]::new("Hyper-V was unable to find a virtual machine with ID $one.") + Write-Error -Exception $exception -ErrorId 'InvalidParameter,Microsoft.HyperV.PowerShell.Commands.GetVM' -Category ObjectNotFound -ErrorAction Stop + } + ConvertTo-MockVm $row[0] + } + return + } + if ($PSBoundParameters.ContainsKey('Name')) { + $patterns = $Name + $rows = @($rows | Where-Object { $candidate = [string]$_.name; @($patterns | Where-Object { $candidate -like $_ }).Count -gt 0 }) + } + foreach ($row in $rows) { ConvertTo-MockVm $row } +} + +function Get-VMHost { + [CmdletBinding()] param() + Write-MockCall 'Get-VMHost' $PSBoundParameters + [pscustomobject]@{ VirtualHardDiskPath = 'C:\MockVHDs'; VirtualMachinePath = 'C:\MockVMs'; EnableEnhancedSessionMode = $true; LogicalProcessorCount = 8; MemoryCapacity = [long]34359738368 } +} + +function Get-VMHostSupportedVersion { + [CmdletBinding()] param([switch]$Default) + Write-MockCall 'Get-VMHostSupportedVersion' $PSBoundParameters + if ($Default) { [pscustomobject]@{ Name = 'Microsoft Windows 11'; Version = [version]'12.0'; IsDefault = $true } } + else { foreach ($v in '8.0', '9.0', '10.0', '11.0', '12.0') { [pscustomobject]@{ Name = "Version $v"; Version = [version]$v; IsDefault = ($v -eq '12.0') } } } +} + +function Get-VMSwitch { + [CmdletBinding()] + param([Parameter(Position = 0)][string[]]$Name, [guid[]]$Id) + Write-MockCall 'Get-VMSwitch' $PSBoundParameters + foreach ($row in @((Read-MockState).switches)) { + if ($PSBoundParameters.ContainsKey('Name') -and @($Name | Where-Object { [string]$row.name -like $_ }).Count -eq 0) { continue } + if ($PSBoundParameters.ContainsKey('Id') -and @($Id | Where-Object { [string]$_ -eq [string]$row.id }).Count -eq 0) { continue } + [pscustomobject]@{ Id = [guid]$row.id; Name = [string]$row.name; SwitchType = [string]$row.type; NetAdapterInterfaceDescription = $null } + } +} + +function New-VMSwitch { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Name, [string]$SwitchType, [string]$NetAdapterName, [bool]$AllowManagementOS) + Write-MockCall 'New-VMSwitch' $PSBoundParameters + $state = Read-MockState + $row = [pscustomobject]@{ id = [guid]::NewGuid().ToString(); name = $Name; type = $(if ($SwitchType) { $SwitchType } else { 'External' }) } + $state.switches = @($state.switches) + $row + Write-MockState $state + [pscustomobject]@{ Id = [guid]$row.id; Name = $row.name; SwitchType = $row.type; NetAdapterInterfaceDescription = $null } +} + +function Remove-VMSwitch { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMSwitch, [string[]]$Name, [switch]$Force) + process { + Write-MockCall 'Remove-VMSwitch' $PSBoundParameters + $state = Read-MockState + $target = if ($VMSwitch) { [string]$VMSwitch.Id } else { $null } + $state.switches = @($state.switches | Where-Object { ($null -ne $target -and [string]$_.id -ne $target) -or ($null -eq $target -and ($Name -notcontains [string]$_.name)) }) + Write-MockState $state + } +} + +function New-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Name, [string]$Path, [int]$Generation = 1, [long]$MemoryStartupBytes = 536870912, + [string]$VHDPath, [string]$NewVHDPath, [long]$NewVHDSizeBytes, [string]$SwitchName, [switch]$NoVHD, [string]$Version, [string]$BootDevice, [switch]$Force) + Write-MockCall 'New-VM' $PSBoundParameters + $state = Read-MockState + $disks = @() + if ($VHDPath) { $disks += $VHDPath } elseif ($NewVHDPath) { $disks += $NewVHDPath } + $row = [pscustomobject]@{ id = [guid]::NewGuid().ToString(); name = $Name; state = 'Off'; generation = $Generation; processorCount = 1; memoryStartup = $MemoryStartupBytes; dynamicMemory = $false; disks = $disks; snapshots = @() } + $state.vms = @($state.vms) + $row + Write-MockState $state + ConvertTo-MockVm $row +} + +function Remove-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Force) + process { Write-MockCall 'Remove-VM' $PSBoundParameters; if ($VM) { Remove-MockVm $VM } } +} + +function Rename-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string]$Name, [string]$NewName, [switch]$Passthru) + process { Write-MockCall 'Rename-VM' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'name' $NewName } } +} + +function Set-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [int]$ProcessorCount, [long]$MemoryStartupBytes, [long]$MemoryMinimumBytes, [long]$MemoryMaximumBytes, + [switch]$DynamicMemory, [switch]$StaticMemory, [string]$CheckpointType, [bool]$AutomaticCheckpointsEnabled, [string]$Notes, + [string]$AutomaticStartAction, [int]$AutomaticStartDelay, [string]$AutomaticStopAction, [string]$NewVMName, [string]$EnhancedSessionTransportType, + [string]$SnapshotFileLocation, [string]$SmartPagingFilePath, [switch]$Passthru) + process { + Write-MockCall 'Set-VM' $PSBoundParameters + if ($VM) { + if ($PSBoundParameters.ContainsKey('ProcessorCount')) { Update-MockVm $VM 'processorCount' $ProcessorCount } + if ($PSBoundParameters.ContainsKey('MemoryStartupBytes')) { Update-MockVm $VM 'memoryStartup' $MemoryStartupBytes } + if ($DynamicMemory) { Update-MockVm $VM 'dynamicMemory' $true } + if ($StaticMemory) { Update-MockVm $VM 'dynamicMemory' $false } + if ($PSBoundParameters.ContainsKey('NewVMName')) { Update-MockVm $VM 'name' $NewVMName } + } + } +} + +function Get-VMProcessor { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { Write-MockCall 'Get-VMProcessor' $PSBoundParameters; [pscustomobject]@{ VMId = $VM.Id; Count = $VM.ProcessorCount; Reserve = 0; Maximum = 100; RelativeWeight = 100; ExposeVirtualizationExtensions = $false } } +} + +function Set-VMProcessor { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [int]$Count, [long]$Reserve, [long]$Maximum, [int]$RelativeWeight, [bool]$ExposeVirtualizationExtensions, [switch]$Passthru) + process { Write-MockCall 'Set-VMProcessor' $PSBoundParameters; if ($VM -and $PSBoundParameters.ContainsKey('Count')) { Update-MockVm $VM 'processorCount' $Count } } +} + +function Get-VMMemory { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { Write-MockCall 'Get-VMMemory' $PSBoundParameters; [pscustomobject]@{ VMId = $VM.Id; Startup = $VM.MemoryStartup; Minimum = $VM.MemoryMinimum; Maximum = $VM.MemoryMaximum; DynamicMemoryEnabled = $VM.DynamicMemoryEnabled; Buffer = 20; Priority = 50 } } +} + +function Set-VMMemory { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [long]$StartupBytes, [long]$MinimumBytes, [long]$MaximumBytes, [bool]$DynamicMemoryEnabled, [int]$Buffer, [int]$Priority, [switch]$Passthru) + process { + Write-MockCall 'Set-VMMemory' $PSBoundParameters + if ($VM) { + if ($PSBoundParameters.ContainsKey('StartupBytes')) { Update-MockVm $VM 'memoryStartup' $StartupBytes } + if ($PSBoundParameters.ContainsKey('DynamicMemoryEnabled')) { Update-MockVm $VM 'dynamicMemory' $DynamicMemoryEnabled } + } + } +} + +function Start-VM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Start-VM' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'state' 'Running' } } } +function Stop-VM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Force, [switch]$TurnOff, [switch]$Save, [switch]$Passthru) + process { Write-MockCall 'Stop-VM' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'state' $(if ($Save) { 'Saved' } else { 'Off' }) } } } +function Suspend-VM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Suspend-VM' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'state' 'Paused' } } } +function Resume-VM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Resume-VM' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'state' 'Running' } } } +function Restart-VM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Force, [switch]$Passthru) + process { Write-MockCall 'Restart-VM' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'state' 'Running' } } } + +function Export-VM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [string]$Path, [switch]$Passthru) + process { Write-MockCall 'Export-VM' $PSBoundParameters } } + +function Import-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Path, [switch]$Copy, [switch]$GenerateNewId, [string]$VirtualMachinePath, [string]$VhdDestinationPath, [string]$SnapshotFilePath, [string]$SmartPagingFilePath, [switch]$Register) + Write-MockCall 'Import-VM' $PSBoundParameters + $state = Read-MockState + $row = [pscustomobject]@{ id = [guid]::NewGuid().ToString(); name = 'Imported ' + [IO.Path]::GetFileNameWithoutExtension($Path); state = 'Off'; generation = 2; processorCount = 2; memoryStartup = [long]4294967296; dynamicMemory = $false; disks = @(); snapshots = @() } + $state.vms = @($state.vms) + $row + Write-MockState $state + ConvertTo-MockVm $row +} + +function ConvertTo-MockSnapshot($Row, $VmId) { + [pscustomobject]@{ Id = [guid]$Row.id; VMId = [guid]$VmId; Name = [string]$Row.name; CreationTime = [datetime]$Row.creationTime; ParentSnapshotId = $(if ($Row.parentId) { [guid]$Row.parentId } else { $null }); SnapshotType = 'Standard' } +} + +function Get-VMSnapshot { + [CmdletBinding()] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name, [guid[]]$Id, $ParentOf, [string]$SnapshotType) + process { + Write-MockCall 'Get-VMSnapshot' $PSBoundParameters + if ($ParentOf) { return } + $row = Get-MockRow $VM + if ($null -eq $row) { return } + foreach ($snapshot in @($row.snapshots)) { if ($null -ne $snapshot) { ConvertTo-MockSnapshot $snapshot $VM.Id } } + } +} + +function Checkpoint-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [string]$SnapshotName, [switch]$Passthru) + process { + Write-MockCall 'Checkpoint-VM' $PSBoundParameters + $snapshot = [pscustomobject]@{ id = [guid]::NewGuid().ToString(); name = $SnapshotName; creationTime = [datetime]::UtcNow.ToString('o'); parentId = $null } + $row = Get-MockRow $VM + $snapshots = @() + if ($row -and $row.snapshots) { $snapshots = @($row.snapshots) } + Update-MockVm $VM 'snapshots' (@($snapshots) + $snapshot) + if ($Passthru) { ConvertTo-MockSnapshot $snapshot $VM.Id } + } +} + +function Restore-VMSnapshot { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMSnapshot, [string]$Name, [switch]$Passthru) + process { Write-MockCall 'Restore-VMSnapshot' $PSBoundParameters } } +function Remove-VMSnapshot { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMSnapshot, [string]$Name, [switch]$IncludeAllChildSnapshots, [switch]$Passthru) + process { Write-MockCall 'Remove-VMSnapshot' $PSBoundParameters } } +function Rename-VMSnapshot { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMSnapshot, [string]$Name, [string]$NewName, [switch]$Passthru) + process { Write-MockCall 'Rename-VMSnapshot' $PSBoundParameters } } + +function Get-VMFirmware { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { Write-MockCall 'Get-VMFirmware' $PSBoundParameters; [pscustomobject]@{ VMId = $VM.Id; SecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; PreferredNetworkBootProtocol = 'IPv4'; BootOrder = @() } } } +function Set-VMFirmware { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$EnableSecureBoot, [string]$SecureBootTemplate, $FirstBootDevice, $BootOrder, [string]$PreferredNetworkBootProtocol, [switch]$Passthru) + process { Write-MockCall 'Set-VMFirmware' $PSBoundParameters } } + +function Get-VMNetworkAdapter { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$Name, [switch]$All) + process { Write-MockCall 'Get-VMNetworkAdapter' $PSBoundParameters; if ($VM) { [pscustomobject]@{ Id = 'Microsoft:' + [string]$VM.Id + '\7B2C38CE-D044-4725-9470-15D22D5D7B8E'; VMId = $VM.Id; Name = 'Network Adapter'; SwitchName = 'Default Switch'; MacAddress = '00155D000001'; IPAddresses = @(); Status = $null } } } } +function Add-VMNetworkAdapter { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$Name, [string]$SwitchName, [switch]$Passthru) + process { Write-MockCall 'Add-VMNetworkAdapter' $PSBoundParameters } } +function Remove-VMNetworkAdapter { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [switch]$Passthru) + process { Write-MockCall 'Remove-VMNetworkAdapter' $PSBoundParameters } } +function Set-VMNetworkAdapter { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [string]$StaticMacAddress, [switch]$DynamicMacAddress, [string]$MacAddressSpoofing, [string]$DhcpGuard, [string]$RouterGuard, [switch]$Passthru) + process { Write-MockCall 'Set-VMNetworkAdapter' $PSBoundParameters } } +function Connect-VMNetworkAdapter { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [string]$SwitchName, [switch]$Passthru) + process { Write-MockCall 'Connect-VMNetworkAdapter' $PSBoundParameters } } +function Disconnect-VMNetworkAdapter { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [switch]$Passthru) + process { Write-MockCall 'Disconnect-VMNetworkAdapter' $PSBoundParameters } } + +function Get-VMHardDiskDrive { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation) + process { + Write-MockCall 'Get-VMHardDiskDrive' $PSBoundParameters + $row = Get-MockRow $VM + if ($null -eq $row) { return } + $location = 0 + foreach ($disk in @($row.disks)) { if ($null -ne $disk) { [pscustomobject]@{ VMId = $VM.Id; Path = [string]$disk; ControllerType = 'SCSI'; ControllerNumber = 0; ControllerLocation = $location }; $location++ } } + } } +function Add-VMHardDiskDrive { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$Path, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation, [switch]$Passthru) + process { Write-MockCall 'Add-VMHardDiskDrive' $PSBoundParameters; if ($VM) { $row = Get-MockRow $VM; $disks = @(); if ($row -and $row.disks) { $disks = @($row.disks) }; Update-MockVm $VM 'disks' (@($disks) + $Path) } } } +function Remove-VMHardDiskDrive { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMHardDiskDrive, [string[]]$VMName, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation, [switch]$Passthru) + process { Write-MockCall 'Remove-VMHardDiskDrive' $PSBoundParameters } } + +function Get-VMDvdDrive { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [int]$ControllerNumber, [int]$ControllerLocation) + process { Write-MockCall 'Get-VMDvdDrive' $PSBoundParameters; $row = Get-MockRow $VM; if ($row -and $row.dvd) { [pscustomobject]@{ VMId = $VM.Id; Path = [string]$row.dvd; ControllerNumber = 0; ControllerLocation = 1 } } } } +function Add-VMDvdDrive { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$Path, [int]$ControllerNumber, [int]$ControllerLocation, [switch]$Passthru) + process { Write-MockCall 'Add-VMDvdDrive' $PSBoundParameters; if ($VM) { Update-MockVm $VM 'dvd' $Path; if ($Passthru) { [pscustomobject]@{ VMId = $VM.Id; Path = $Path; ControllerNumber = 0; ControllerLocation = 1 } } } } } +function Set-VMDvdDrive { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMDvdDrive, [string[]]$VMName, [string]$Path, [switch]$Passthru) + process { Write-MockCall 'Set-VMDvdDrive' $PSBoundParameters } } +function Remove-VMDvdDrive { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VMDvdDrive, [string[]]$VMName, [switch]$Passthru) + process { Write-MockCall 'Remove-VMDvdDrive' $PSBoundParameters } } + +function New-VHD { [CmdletBinding(SupportsShouldProcess)] param([Parameter(Position = 0)][string]$Path, [long]$SizeBytes, [switch]$Dynamic, [switch]$Fixed, [switch]$Differencing, [string]$ParentPath, [long]$BlockSizeBytes) + Write-MockCall 'New-VHD' $PSBoundParameters + [pscustomobject]@{ Path = $Path; VhdFormat = 'VHDX'; VhdType = $(if ($Differencing) { 'Differencing' } elseif ($Fixed) { 'Fixed' } else { 'Dynamic' }); ParentPath = $ParentPath; Size = $SizeBytes; FileSize = [long]4194304; Attached = $false } } +function Get-VHD { [CmdletBinding()] param([Parameter(Position = 0, ValueFromPipeline)][string[]]$Path, [guid[]]$VMId) + process { Write-MockCall 'Get-VHD' $PSBoundParameters; foreach ($p in $Path) { [pscustomobject]@{ Path = $p; VhdFormat = 'VHDX'; VhdType = 'Dynamic'; ParentPath = $null; Size = [long]68719476736; FileSize = [long]4194304; Attached = $false; DiskIdentifier = [guid]::NewGuid().ToString() } } } } +function Resize-VHD { [CmdletBinding(SupportsShouldProcess)] param([Parameter(Position = 0)][string]$Path, [long]$SizeBytes, [switch]$ToMinimumSize, [switch]$Passthru) + Write-MockCall 'Resize-VHD' $PSBoundParameters } +function Test-VHD { [CmdletBinding()] param([Parameter(Position = 0)][string]$Path) Write-MockCall 'Test-VHD' $PSBoundParameters; $true } + +function Get-VMComPort { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [int]$Number) + process { Write-MockCall 'Get-VMComPort' $PSBoundParameters; foreach ($n in 1, 2) { if (-not $PSBoundParameters.ContainsKey('Number') -or $Number -eq $n) { [pscustomobject]@{ VMId = $VM.Id; Name = "COM $n"; Path = ''; Number = $n } } } } } +function Set-VMComPort { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [int]$Number, [string]$Path, [switch]$Passthru) + process { Write-MockCall 'Set-VMComPort' $PSBoundParameters } } + +function Get-VMIntegrationService { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name) + process { Write-MockCall 'Get-VMIntegrationService' $PSBoundParameters; foreach ($service in 'Guest Service Interface', 'Heartbeat', 'Key-Value Pair Exchange', 'Shutdown', 'Time Synchronization', 'VSS') { [pscustomobject]@{ VMId = $VM.Id; Id = 'Microsoft:' + [string]$VM.Id + '\' + [guid]::NewGuid().ToString().ToUpperInvariant(); Name = $service; Enabled = ($service -ne 'Guest Service Interface'); PrimaryStatusDescription = $null } } } } +function Enable-VMIntegrationService { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Enable-VMIntegrationService' $PSBoundParameters } } +function Disable-VMIntegrationService { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Disable-VMIntegrationService' $PSBoundParameters } } + +function Set-VMKeyProtector { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [switch]$NewLocalKeyProtector, [switch]$RestoreLastKnownGoodKeyProtector, [byte[]]$KeyProtector, [switch]$Passthru) + process { Write-MockCall 'Set-VMKeyProtector' $PSBoundParameters } } +function Get-VMKeyProtector { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { Write-MockCall 'Get-VMKeyProtector' $PSBoundParameters; [byte[]]@() } } +function Enable-VMTPM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [switch]$Passthru) + process { Write-MockCall 'Enable-VMTPM' $PSBoundParameters } } +function Disable-VMTPM { [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [switch]$Passthru) + process { Write-MockCall 'Disable-VMTPM' $PSBoundParameters } } +function Get-VMSecurity { [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { Write-MockCall 'Get-VMSecurity' $PSBoundParameters; [pscustomobject]@{ VMId = $VM.Id; TpmEnabled = $false; KsdEnabled = $false; Shielded = $false; EncryptStateAndVmMigrationTraffic = $false; VirtualizationBasedSecurityOptOut = $false } } } + +Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.functions.ps1' | ForEach-Object { . $_.FullName } +Export-ModuleMember -Function * diff --git a/tests/HyperVControl.Tests/PhaseStubs.cs b/tests/HyperVControl.Tests/PhaseStubs.cs new file mode 100644 index 0000000..9f6d40c --- /dev/null +++ b/tests/HyperVControl.Tests/PhaseStubs.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace HyperVControl.Tests; + +/// Calls one MCP tool through the harness client: name, arguments, allowError, label. +internal delegate Task ToolCall(string name, object? arguments = null, bool allowError = false, string? label = null); + +/// Lifecycle v2 unit tests against the mock module (phase 2). +internal static class LifecycleV2Tests +{ + internal static Task RunAsync(Action check) => Task.CompletedTask; +} + +/// Image catalog, library and job unit tests (phase 3). +internal static class ImageTests +{ + internal static Task RunAsync(Action check) => Task.CompletedTask; +} + +/// Golden image build unit tests (phase 3). +internal static class ImageBuildTests +{ + internal static Task RunAsync(Action check) => Task.CompletedTask; +} + +/// Live validation of lifecycle v2 on throwaway VMs (phase 2). Requires an elevated host and explicit authorization. +internal static class LifecycleLiveTests +{ + internal static Task RunAsync(ToolCall call, Action check, string results) => Task.CompletedTask; +} + +/// Live validation of image fetch, library and deploy (phase 3). +internal static class ImageLiveTests +{ + internal static Task RunAsync(ToolCall call, Action check, string results) => Task.CompletedTask; +} + +/// Live validation of golden image builds (phase 3). +internal static class ImageBuildLiveTests +{ + internal static Task RunAsync(ToolCall call, Action check, string results) => Task.CompletedTask; +} diff --git a/tests/HyperVControl.Tests/Program.cs b/tests/HyperVControl.Tests/Program.cs index 97b53e4..4c76be9 100644 --- a/tests/HyperVControl.Tests/Program.cs +++ b/tests/HyperVControl.Tests/Program.cs @@ -24,7 +24,14 @@ Check(HyperVGuestUiService.ValidateUiCommand("inspect") == "inspect", "UI inspection routing"); try { HyperVGuestUiService.ValidateUiCommand("send-keys"); throw new Exception("accepted unsupported UI input"); } catch (ArgumentException) { Console.WriteLine("PASS UI keyboard redirects to console"); } Check(HyperVGuestUiService.BuildWindowsArgumentLine(["one two", "a\"b", @"C:\tail space\"]).Contains("\\\""), "Windows argv quote escaping"); - try { await HyperVControl.Tests.ReviewRegressionTests.RunAsync(Check); } + try + { + await HyperVControl.Tests.ReviewRegressionTests.RunAsync(Check); + await HyperVControl.Tests.LifecycleTests.RunAsync(Check); + await HyperVControl.Tests.LifecycleV2Tests.RunAsync(Check); + await HyperVControl.Tests.ImageTests.RunAsync(Check); + await HyperVControl.Tests.ImageBuildTests.RunAsync(Check); + } catch (Exception ex) { Console.Error.WriteLine(ex); Environment.ExitCode = 1; return; } Console.WriteLine("UNIT PASS"); return; @@ -220,5 +227,8 @@ await Call(step.GetProperty("name").GetString()!,step.GetProperty("arguments"), Check(originalIds.SetEquals(finalIds),"All original checkpoint IDs preserved; only test checkpoint removed"); Check(original.GetProperty("currentParentCheckpointId").GetString()==finalCheckpoints.GetProperty("currentParentCheckpointId").GetString(),"Original checkpoint branch restored"); } +else if (args[0] == "lifecycle-live") await HyperVControl.Tests.LifecycleLiveTests.RunAsync(Call, Check, results); +else if (args[0] == "images-live") await HyperVControl.Tests.ImageLiveTests.RunAsync(Call, Check, results); +else if (args[0] == "image-build-live") await HyperVControl.Tests.ImageBuildLiveTests.RunAsync(Call, Check, results); else throw new ArgumentException("Unknown validation phase."); Console.WriteLine(args[0].ToUpperInvariant()+" PASS"); From e9883ccd4ea9bffab3959e153b4d71a6d3589240 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:19:09 -0500 Subject: [PATCH 02/23] Describe the shared script pipeline and mock module in the contribution guide --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f452156..da27341 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Component-specific tooling lives in its own directory (`runtimes/rizin/`, `tests Do not add Git submodules: Claude Code clones a marketplace with every submodule into each user's plugin cache. Record build-time sources in a lock file and fetch them into ignored `build/` at build time, as `runtimes/rizin/build_bundle.py` does. A checkout that initialized the old submodules keeps an untracked `sources/` directory; delete it rather than adding it back (the checker rejects gitlink entries). -Within Hyper-V Control, `HyperVTools*` exposes the MCP contract, `Services/` owns host/guest operations and resource lifetimes, and `Extracted/` contains the core Hyper-V service, console and guest UI implementation. Keep protocol handling out of the extracted layer. Changes to VM state, credentials, elevation, or debugger lifetime need behavior tests at their corresponding boundary. +Within Hyper-V Control, `HyperVTools*` exposes the MCP contract and contains no PowerShell text: a tool method parses and validates its arguments, calls one service, and returns an object. `Services/` owns host/guest operations and resource lifetimes. Every host-side Hyper-V script is built with `Services/HyperVScript.cs` (shared module bootstrap, in-script VM resolution by exact name or GUID, state assertions, typed parameter rendering, one failure envelope `{ ok:false, error, errorType, errorId, category }`) and executed through `PowerShellRunner.InvokeAsync`; `VmLifecycleService`, `ImageCatalogService` and `ImageBuildService` own lifecycle, catalog and image work, and `JobRunner` hosts operations that outlive one tool call. `Extracted/` contains the core Hyper-V inventory/power/checkpoint service, console and guest UI implementation; keep protocol handling out of it. Changes to VM state, credentials, elevation, or debugger lifetime need behavior tests at their corresponding boundary. Host scripts are tested without a Hyper-V host against the recording mock module under `tests/HyperVControl.Tests/MockHyperV/` (selected through `HYPERV_CONTROL_MODULE_PATH`; add cmdlets in `*.functions.ps1` files), which records every cmdlet call with its bound parameters; live phases of the harness (`lifecycle-live`, `images-live`, `image-build-live`) create and remove throwaway VMs named `HyperVControl-validation-*` and need explicit authorization. ## Sources of truth From 7a3a7bffb1d68904f8556739d6307bc3acf378ac Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:57:56 -0500 Subject: [PATCH 03/23] Add the image catalog, library, fetch jobs and deploy tools Implement phase 3a of the Hyper-V Control plan: hyperv_image (list, fetch, register, remove, catalogs, deploy) and hyperv_jobs (list, status, wait, cancel) over a rewritten ImageCatalogService. Catalogs use Quick Create's gallery schema and are parsed as leniently as VMCreate does: UTF-16 or UTF-8 with or without a BOM, trailing commas, comments, case-insensitive names, string-or-bool secureBoot, any hash prefix treated as SHA-256, and the min/max host version filter. Each image carries warnings: the expired WinDev2407Eval evaluation (2024-10-23), other likely expired Windows evaluations, images older than three years, Ubuntu releases out of standard support, missing or invalid hashes and unsupported disk types. Listing never touches a disk URL, and recommends the newest supported image per OS family. catalogs.json starts with Microsoft's feed; custom catalogs may be URLs, files or UNC paths. The library lives under \HyperVControl\Images (read once from Get-VMHost and cached) or HYPERV_CONTROL_IMAGE_ROOT. library.json and catalogs.json are updated under an in-process gate and a cross-process lock file, and replaced atomically. A fetch is a job. One suffix-range GET resolves redirects, the size and the zip's central directory (Zip64 aware), so free space for the download plus the extracted entry is checked before downloading. The download resumes a partial file with an HTTP Range request, hashes as it writes, and keeps the partial file when the connection drops or stalls; a SHA-256 mismatch deletes it. The archiveRelativePath entry is extracted by streaming, with magic bytes checked before and after, the entry is recorded and the archive deleted unless keepArchive. Cancelling deletes the partial download and any extraction output. Register adopts a local .vhdx or .iso in place; templates become read-only. Remove refuses while a VM attaches the file (disk or DVD) or any disk chain, including checkpoint disks and recorded clones, has it as a differencing parent. Deploy creates a differencing child (or a full copy) under the VM's folder, creates the VM through VmLifecycleService.CreateAsync, then applies the lab defaults in one script: static memory, automatic checkpoints off, Standard checkpoints, Secure Boot by OS family or off when the image says so, HvSocket for Linux, and a local key protector plus vTPM for Windows after the template. A failed deploy removes the half-created VM and disk. Unit tests drive the saved live feed, a UTF-8 custom catalog, a hand-built Zip64 archive and a loopback HTTP server (redirects, ranges, a dropped and a stalled download) against the recording mock module, which gains state-driven Get-VMHost, Get-VHD parent chains, New-VHD and checkpoint disks in images.functions.ps1. ImageLiveTests covers the images-live phase. --- src/HyperVControl/HyperVTools.Image.cs | 63 +- .../Services/ImageCatalogFeed.cs | 272 ++++++ .../Services/ImageCatalogService.cs | 829 +++++++++++++++++- src/HyperVControl/Services/ImageDownloader.cs | 188 ++++ src/HyperVControl/Services/ImageFiles.cs | 293 +++++++ src/HyperVControl/Services/ImageLibrary.cs | 132 +++ tests/HyperVControl.Tests/ImageLiveTests.cs | 223 +++++ tests/HyperVControl.Tests/ImageTests.cs | 621 +++++++++++++ .../MockHyperV/Fixtures/GalleryHyperV.json | Bin 0 -> 24744 bytes .../MockHyperV/Hyper-V/images.functions.ps1 | 79 ++ tests/HyperVControl.Tests/PhaseStubs.cs | 12 - 11 files changed, 2697 insertions(+), 15 deletions(-) create mode 100644 src/HyperVControl/Services/ImageCatalogFeed.cs create mode 100644 src/HyperVControl/Services/ImageDownloader.cs create mode 100644 src/HyperVControl/Services/ImageFiles.cs create mode 100644 src/HyperVControl/Services/ImageLibrary.cs create mode 100644 tests/HyperVControl.Tests/ImageLiveTests.cs create mode 100644 tests/HyperVControl.Tests/ImageTests.cs create mode 100644 tests/HyperVControl.Tests/MockHyperV/Fixtures/GalleryHyperV.json create mode 100644 tests/HyperVControl.Tests/MockHyperV/Hyper-V/images.functions.ps1 diff --git a/src/HyperVControl/HyperVTools.Image.cs b/src/HyperVControl/HyperVTools.Image.cs index a47b7a6..d1318be 100644 --- a/src/HyperVControl/HyperVTools.Image.cs +++ b/src/HyperVControl/HyperVTools.Image.cs @@ -1,6 +1,67 @@ +using System.ComponentModel; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + namespace HyperVControl; -/// Image catalog, library and job tools (hyperv_image, hyperv_jobs). Implemented in phase 3. +/// Image catalog, library and job tools (hyperv_image, hyperv_jobs): thin wrappers over +/// ImageCatalogService and JobRunner. internal sealed partial class HyperVTools { + [McpServerTool(Name = "hyperv_jobs")] + [Description("List, inspect, wait for or cancel background jobs (image fetches) owned by this MCP process; jobs end with it. action=list|status|wait|cancel; jobId for status/wait/cancel; waitSeconds (0-3600, default 30) bounds wait and how long cancel waits for cleanup. A job reports status (queued, running, succeeded, failed, cancelled), progress {percent, bytesDone, bytesTotal, message}, result, error and errorType. Cancelling a fetch deletes its partial download and extraction output; an interrupted download keeps its partial file and the next fetch resumes it.")] + public Task JobsTool(string action = "list", string? jobId = null, int waitSeconds = 30, CancellationToken ct = default) => Run(async () => + { + if (waitSeconds is < 0 or > 3600) throw new ArgumentException("waitSeconds must be between 0 and 3600."); + string Id() => string.IsNullOrWhiteSpace(jobId) ? throw new ArgumentException("jobId required; call hyperv_jobs with action=list.") : jobId.Trim(); + switch (action) + { + case "list": return new { jobs = service.Jobs.List() }; + case "status": return service.Jobs.Get(Id()) ?? throw new ArgumentException($"Unknown job '{jobId}'. Jobs belong to the MCP process that started them; call hyperv_jobs with action=list."); + case "wait": return await service.Jobs.WaitAsync(Id(), TimeSpan.FromSeconds(waitSeconds), ct); + case "cancel": + var id = Id(); + service.Jobs.Cancel(id); + // Give the job a moment to observe the cancellation and delete its partial output. + return await service.Jobs.WaitAsync(id, TimeSpan.FromSeconds(Math.Clamp(waitSeconds, 1, 60)), ct); + default: throw new ArgumentException("Use list, status, wait or cancel."); + } + }); + + [McpServerTool(Name = "hyperv_image", Destructive = true, OpenWorld = true)] + [Description("Image catalogs, the local image library and VM deployment from it. action=list: catalog images (Microsoft's Quick Create gallery plus custom catalogs) and library entries with warnings (expired evaluations, stale or unsupported releases, missing hashes) and a recommendation; never downloads a disk. fetch: imageName (catalog to disambiguate) runs a hyperv_jobs job that downloads with resume, verifies SHA-256 and magic bytes, extracts the .vhdx into the library and deletes the archive unless keepArchive; waitSeconds (0-3600) waits for it. register: adopt a local .vhdx/.iso at path as name (osFamily windows|linux|other, kind image|template, secureBoot, notes) without copying. remove: name, deleteFile; refused while a VM attaches the file or a disk uses it as a differencing parent. catalogs: catalogAction list|add|remove with location (http(s) URL, file, or UNC path to a gallery JSON) and catalog (name). deploy: imageName (library entry), vmName, linked (differencing child, default) or a full copy, memoryMb, processors, switchName (default: the Default Switch), vmPath, notes, start; applies lab defaults: static memory, automatic checkpoints off, Standard checkpoints, Secure Boot per OS family, HvSocket for Linux, vTPM for Windows. Library root: \\HyperVControl\\Images or HYPERV_CONTROL_IMAGE_ROOT.")] + public Task ImageTool( + [Description("list | fetch | register | remove | catalogs | deploy")] string action = "list", + [Description("fetch: catalog image name; deploy: library entry name")] string? imageName = null, + [Description("fetch: catalog to search; catalogs add/remove: catalog name")] string? catalog = null, + [Description("fetch: keep the downloaded zip in the cache after extraction")] bool keepArchive = false, + [Description("fetch: seconds to wait for the job (0-3600); 0 returns the job at once")] int waitSeconds = 0, + [Description("register: absolute path of a local .vhdx or .iso")] string? path = null, + [Description("register: new library entry name; remove: library entry name")] string? name = null, + [Description("register: windows | linux | other (inferred from the name when omitted)")] string? osFamily = null, + [Description("register: image | template (templates are made read-only)")] string kind = "image", + [Description("register: Secure Boot for deploys; omit to follow the OS family")] bool? secureBoot = null, + [Description("register: entry notes; deploy: VM notes")] string? notes = null, + [Description("remove: also delete the image file and any kept archive")] bool deleteFile = false, + [Description("catalogs: list | add | remove")] string catalogAction = "list", + [Description("catalogs add/remove: http(s) URL, file: URI, or absolute local or UNC path of a gallery JSON")] string? location = null, + [Description("deploy: name of the new VM")] string? vmName = null, + [Description("deploy: true creates a differencing child of the image; false copies it")] bool linked = true, + int memoryMb = 4096, + int processors = 2, + [Description("deploy: virtual switch name; default is the Default Switch when present")] string? switchName = null, + [Description("deploy: absolute folder for the VM (default: the host's virtual machine path)")] string? vmPath = null, + [Description("deploy: start the VM after configuring it")] bool start = false, + CancellationToken ct = default) => Run(async () => action switch + { + "list" => await service.Images.ListAsync(ct), + "fetch" => await service.Images.FetchAsync(imageName, catalog, keepArchive, waitSeconds, ct), + "register" => await service.Images.RegisterAsync(path, name, osFamily, kind, secureBoot, notes, ct), + "remove" => await service.Images.RemoveAsync(name ?? imageName, deleteFile, ct), + "catalogs" => await service.Images.CatalogsAsync(catalogAction, catalog, location, ct), + "deploy" => await service.Images.DeployAsync(new ImageDeployRequest(imageName ?? "", vmName ?? "", linked, memoryMb, processors, switchName, notes, start, vmPath), + service.Lifecycle, ct), + _ => throw new ArgumentException("Use list, fetch, register, remove, catalogs or deploy.") + }); } diff --git a/src/HyperVControl/Services/ImageCatalogFeed.cs b/src/HyperVControl/Services/ImageCatalogFeed.cs new file mode 100644 index 0000000..446acd1 --- /dev/null +++ b/src/HyperVControl/Services/ImageCatalogFeed.cs @@ -0,0 +1,272 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace HyperVControl.Services; + +/// A name/value pair from a feed entry's details list. +internal sealed record CatalogDetail(string Name, string Value); + +/// +/// One image from a Quick Create gallery feed (Microsoft's or a custom catalog), normalized leniently: +/// secureBoot may be a string or a bool, keeps only the hex after the hash +/// prefix (always SHA-256, as VMCreate treats it), and warnings, age and OS family are derived at parse time. +/// +internal sealed record CatalogImage( + string Catalog, string Name, string? Publisher, string? Version, DateTimeOffset? LastUpdated, string? Locale, + string Description, string DiskUri, string? Hash, string? Sha256, string? ArchiveRelativePath, + bool? SecureBoot, string? EnhancedSessionTransportType, string? MinHostVersion, string? MaxHostVersion, + IReadOnlyList Details, string OsFamily, IReadOnlyList Warnings, + bool Expired, bool Stale, bool EndOfLife, bool Fetchable) +{ + /// The disk type implied by the URI path before redirects: ".zip", ".vhdx", ".iso", another extension, or "". + public string DiskExtension => ImageCatalogFeed.ExtensionOf(DiskUri); +} + +/// A parsed feed: compatible images, the number hidden by the host version filter, and skipped entries. +internal sealed record CatalogFeed(string Catalog, IReadOnlyList Images, int Hidden, IReadOnlyList Skipped); + +/// +/// Parses Quick Create gallery feeds the way VMCreate accepts them: UTF-16 or UTF-8 with or without a BOM, +/// trailing commas, comments, case-insensitive property names, and string-or-bool settings. Listing never +/// touches disk URLs; everything here is derived from the feed text alone. +/// +internal static class ImageCatalogFeed +{ + public const string MicrosoftName = "microsoft"; + public const string MicrosoftLocation = "https://go.microsoft.com/fwlink/?linkid=851584"; + public static readonly string[] DiskExtensions = [".zip", ".vhdx", ".iso"]; + private const int MaxDescription = 600; + + private static readonly JsonDocumentOptions Lenient = new() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip, MaxDepth = 64 }; + private static readonly Regex WinDev = new(@"WinDev(?\d{2})(?\d{2})Eval", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex Ubuntu = new(@"\bUbuntu\s+(?\d{2}\.\d{2})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex WindowsWord = new(@"\bwindows\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex LinuxWord = new(@"\b(ubuntu|debian|fedora|centos|red hat|rhel|suse|linux|almalinux|rocky|kali|mint|canonical|alpine)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // End of standard support for Ubuntu LTS releases (https://ubuntu.com/about/release-cycle), by month. + private static readonly Dictionary UbuntuSupportEnd = new() + { + ["16.04"] = (new(2021, 5, 1, 0, 0, 0, TimeSpan.Zero), "April 2021"), + ["18.04"] = (new(2023, 6, 1, 0, 0, 0, TimeSpan.Zero), "May 2023"), + ["20.04"] = (new(2025, 6, 1, 0, 0, 0, TimeSpan.Zero), "May 2025"), + ["22.04"] = (new(2027, 6, 1, 0, 0, 0, TimeSpan.Zero), "May 2027"), + }; + + private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF]; + private static ReadOnlySpan Utf32LeBom => [0xFF, 0xFE, 0x00, 0x00]; + private static ReadOnlySpan Utf32BeBom => [0x00, 0x00, 0xFE, 0xFF]; + private static ReadOnlySpan Utf16LeBom => [0xFF, 0xFE]; + private static ReadOnlySpan Utf16BeBom => [0xFE, 0xFF]; + + /// Decodes feed bytes by BOM (UTF-8, UTF-16 LE/BE, UTF-32), detecting BOM-less UTF-16; UTF-8 otherwise. + public static string Decode(ReadOnlySpan bytes) + { + if (bytes.StartsWith(Utf8Bom)) return Encoding.UTF8.GetString(bytes[3..]); + if (bytes.StartsWith(Utf32LeBom)) return new UTF32Encoding(false, false).GetString(bytes[4..]); + if (bytes.StartsWith(Utf32BeBom)) return new UTF32Encoding(true, false).GetString(bytes[4..]); + if (bytes.StartsWith(Utf16LeBom)) return Encoding.Unicode.GetString(bytes[2..]); + if (bytes.StartsWith(Utf16BeBom)) return Encoding.BigEndianUnicode.GetString(bytes[2..]); + // BOM-less UTF-16 puts a zero byte beside every ASCII character. + if (bytes.Length >= 4 && bytes[0] != 0 && bytes[1] == 0 && bytes[3] == 0) return Encoding.Unicode.GetString(bytes); + if (bytes.Length >= 4 && bytes[0] == 0 && bytes[1] != 0 && bytes[2] == 0) return Encoding.BigEndianUnicode.GetString(bytes); + return Encoding.UTF8.GetString(bytes); + } + + /// An http(s) URL, a file: URI, or an absolute local or UNC path (which become file: URIs). + public static Uri ParseLocation(string location) + { + if (string.IsNullOrWhiteSpace(location)) throw new ArgumentException("A location is required."); + if (Uri.TryCreate(location.Trim(), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp || (uri.IsFile && Path.IsPathFullyQualified(uri.LocalPath)))) + return uri; + throw new ArgumentException($"'{location}' is not an http(s) URL, a file: URI, or an absolute local or UNC path."); + } + + public static string ExtensionOf(string uriOrPath) + { + try + { + var path = Uri.TryCreate(uriOrPath, UriKind.Absolute, out var uri) ? (uri.IsFile ? uri.LocalPath : uri.AbsolutePath) : uriOrPath; + return Path.GetExtension(path).ToLowerInvariant(); + } + catch (ArgumentException) { return ""; } + } + + /// The hex digest VMCreate compares: the text after the first ':' (any prefix, always SHA-256), or a bare digest. + public static string? Sha256Of(string? hash) + { + if (string.IsNullOrWhiteSpace(hash)) return null; + var colon = hash.IndexOf(':'); + var hex = (colon >= 0 ? hash[(colon + 1)..] : hash).Trim(); + return hex.Length == 64 && hex.All(Uri.IsHexDigit) ? hex.ToLowerInvariant() : null; + } + + /// Parses one feed. Malformed JSON throws; malformed entries are skipped and named in . + public static CatalogFeed Parse(string catalog, string json, Version host, DateTimeOffset now) + { + using var document = JsonDocument.Parse(json, Lenient); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || Child(root, "images") is not { ValueKind: JsonValueKind.Array } list) + throw new InvalidDataException("A gallery feed is a JSON object with an \"images\" array."); + var images = new List(); + var skipped = new List(); + var hidden = 0; + var index = 0; + foreach (var item in list.EnumerateArray()) + { + index++; + if (item.ValueKind != JsonValueKind.Object) { skipped.Add($"entry {index}: not a JSON object"); continue; } + var name = Text(Child(item, "name"))?.Trim(); + var disk = Child(item, "disk"); + var uri = disk is { ValueKind: JsonValueKind.Object } diskObject ? Text(Child(diskObject, "uri"))?.Trim() : null; + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(uri)) { skipped.Add($"entry {index}: missing name or disk.uri"); continue; } + var min = Text(Child(item, "minHostVersion"))?.Trim(); + var max = Text(Child(item, "maxHostVersion"))?.Trim(); + if (!HostAllows(host, min, max)) { hidden++; continue; } + images.Add(Build(catalog, name, item, disk!.Value, uri, min, max, now)); + } + return new(catalog, images, hidden, skipped); + } + + private static CatalogImage Build(string catalog, string name, JsonElement item, JsonElement disk, string uri, string? min, string? max, DateTimeOffset now) + { + var publisher = Text(Child(item, "publisher"))?.Trim(); + var version = Text(Child(item, "version"))?.Trim(); + var updatedText = Text(Child(item, "lastUpdated"))?.Trim(); + DateTimeOffset? updated = DateTimeOffset.TryParse(updatedText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : null; + var hash = Text(Child(disk, "hash"))?.Trim(); + var archive = Text(Child(disk, "archiveRelativePath"))?.Trim(); + if (string.IsNullOrEmpty(archive)) archive = null; + var config = Child(item, "config"); + bool? secureBoot = config is { ValueKind: JsonValueKind.Object } c1 ? Bool(Child(c1, "secureBoot")) : null; + var transportText = config is { ValueKind: JsonValueKind.Object } c2 ? Text(Child(c2, "enhancedSessionTransportType"))?.Trim() : null; + string? transport = transportText?.ToLowerInvariant() switch { "hvsocket" => "HvSocket", "vmbus" => "VMBus", _ => null }; + var details = new List(); + if (Child(item, "details") is { ValueKind: JsonValueKind.Array } detailList) + foreach (var detail in detailList.EnumerateArray()) + if (detail.ValueKind == JsonValueKind.Object && Text(Child(detail, "value")) is { } value) + details.Add(new(Text(Child(detail, "name")) ?? "", value)); + var description = Describe(Child(item, "description")); + var family = InferOsFamily(name, publisher, details); + var sha256 = Sha256Of(hash); + + var warnings = new List(); + var expired = false; + var windev = WinDev.Match(name + " " + uri + " " + archive); + if (windev.Success) + { + var built = updated ?? new DateTimeOffset(2000 + int.Parse(windev.Groups["yy"].Value, CultureInfo.InvariantCulture), + Math.Clamp(int.Parse(windev.Groups["mm"].Value, CultureInfo.InvariantCulture), 1, 12), 1, 0, 0, 0, TimeSpan.Zero); + // Windows development environment evaluations expire 90 days after their build (2407: 2024-07-25 to 2024-10-23). + var expiry = built.AddDays(90); + if (now >= expiry) + { + expired = true; + warnings.Add($"Expired evaluation: this is Microsoft's {windev.Value} Windows development environment (version {version ?? "unknown"}, built {built:yyyy-MM-dd}); " + + $"its evaluation license expired on {expiry:yyyy-MM-dd}. It boots to a black desktop with a 'not genuine' notice and shuts down every hour, " + + "and Microsoft discontinued these images. Build a Windows image from a current evaluation or licensed ISO instead."); + } + else warnings.Add($"Time-limited evaluation: it expires around {expiry:yyyy-MM-dd}, 90 days after its build."); + } + else if (family == "windows" && IsEvaluation(name, description, details) && updated is { } evalUpdated && now >= evalUpdated.AddDays(90)) + { + expired = true; + warnings.Add($"Likely expired evaluation: this Windows evaluation image was last updated on {evalUpdated:yyyy-MM-dd}. " + + "Windows evaluations run for about 90 days and the feed publishes no expiry date, so it has almost certainly expired."); + } + + var stale = updated is { } age && age < now.AddYears(-3); + if (stale) warnings.Add($"Stale image: last updated {updated:yyyy-MM-dd}, more than 3 years ago; expect outdated software and a long first update."); + else if (updated is null) warnings.Add("No lastUpdated date: the image's age is unknown."); + + var endOfLife = false; + var ubuntu = Ubuntu.Match(name + " " + version); + if (ubuntu.Success && UbuntuSupportEnd.TryGetValue(ubuntu.Groups["release"].Value, out var support) && now >= support.After) + { + endOfLife = true; + warnings.Add($"Ubuntu {ubuntu.Groups["release"].Value} left standard support in {support.Month}; it receives no security updates without Ubuntu Pro."); + } + + var fetchable = true; + if (hash is null) { fetchable = false; warnings.Add("No hash: the entry has no SHA-256, so its download cannot be verified and fetch refuses it."); } + else if (sha256 is null) { fetchable = false; warnings.Add($"Invalid hash '{hash}': expected ':<64 hex digits>' (always SHA-256); fetch refuses it."); } + var extension = ExtensionOf(uri); + if (extension.Length > 0 && !DiskExtensions.Contains(extension)) + { + fetchable = false; + warnings.Add($"Unsupported disk type '{extension}': Quick Create images are a .zip containing a .vhdx, a .vhdx or an .iso."); + } + else if (extension == ".zip" && archive is null) + { + fetchable = false; + warnings.Add("The disk is a .zip without archiveRelativePath, so there is no entry to extract; fetch refuses it."); + } + if (archive is not null && Path.GetExtension(archive).ToLowerInvariant() is not (".vhdx" or ".iso")) + { + fetchable = false; + warnings.Add($"The archive entry '{archive}' is not a .vhdx or .iso; fetch refuses it."); + } + if (Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri) && parsedUri.Scheme == Uri.UriSchemeHttp) + warnings.Add("The disk is served over plain HTTP; only its SHA-256 protects the download."); + + return new(catalog, name, publisher, version, updated, Text(Child(item, "locale")), description, uri, hash, sha256, archive, + secureBoot, transport, min, max, details, family, warnings, expired, stale, endOfLife, fetchable); + } + + /// VMCreate hides images whose minHostVersion/maxHostVersion exclude the host; unparseable bounds are ignored. + public static bool HostAllows(Version host, string? min, string? max) => + !(Version.TryParse(min, out var low) && host < low) && !(Version.TryParse(max, out var high) && host > high); + + public static string InferOsFamily(string name, string? publisher, IEnumerable details) + { + var title = name + " " + publisher; + if (WindowsWord.IsMatch(title)) return "windows"; + var text = title + " " + string.Join(' ', details.Select(d => d.Value)); + if (LinuxWord.IsMatch(text)) return "linux"; + return WindowsWord.IsMatch(text) ? "windows" : "other"; + } + + private static bool IsEvaluation(string name, string description, IReadOnlyList details) => + name.Contains("eval", StringComparison.OrdinalIgnoreCase) + || description.Contains("evaluation", StringComparison.OrdinalIgnoreCase) + || details.Any(d => d.Value.Contains("evaluation", StringComparison.OrdinalIgnoreCase)); + + private static string Describe(JsonElement? element) + { + var text = element switch + { + { ValueKind: JsonValueKind.Array } array => string.Concat(array.EnumerateArray().Select(e => Text(e) ?? "")), + { } single => Text(single) ?? "", + null => "" + }; + text = Regex.Replace(text, @"\s+", " ").Trim(); + return text.Length <= MaxDescription ? text : text[..(MaxDescription - 1)] + "…"; + } + + private static JsonElement? Child(JsonElement element, string name) + { + if (element.ValueKind != JsonValueKind.Object) return null; + foreach (var property in element.EnumerateObject()) + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) return property.Value; + return null; + } + + private static string? Text(JsonElement? element) => element?.ValueKind switch + { + JsonValueKind.String => element.Value.GetString(), + JsonValueKind.Number => element.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null + }; + + private static bool? Bool(JsonElement? element) => element?.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => element.Value.GetString()?.Trim().ToLowerInvariant() switch { "true" => true, "false" => false, _ => null }, + _ => null + }; +} diff --git a/src/HyperVControl/Services/ImageCatalogService.cs b/src/HyperVControl/Services/ImageCatalogService.cs index 4bdeefb..875bb65 100644 --- a/src/HyperVControl/Services/ImageCatalogService.cs +++ b/src/HyperVControl/Services/ImageCatalogService.cs @@ -1,11 +1,836 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Text.Json; + namespace HyperVControl.Services; +/// Arguments of : a VM created from a library entry with the lab defaults. +internal sealed record ImageDeployRequest(string ImageName, string VmName, bool Linked = true, int MemoryMb = 4096, int Processors = 2, + string? SwitchName = null, string? Notes = null, bool Start = false, string? VmPath = null); + /// -/// Image catalogs (Microsoft's Quick Create gallery feed plus custom catalogs), the local image -/// library and templates. Implemented in phase 3; this scaffold only fixes the composition point. +/// Image catalogs (Microsoft's Quick Create gallery feed plus custom catalogs), the local image library, fetch +/// jobs and deployment. Downloads, hashing, extraction and disk-space checks are C#; host work (default paths, +/// the in-use guard, the per-VM disk, lab defaults) runs as one per step. /// internal sealed class ImageCatalogService(PowerShellRunner runner, JobRunner jobs) { + /// Overrides the library root (default <Get-VMHost VirtualHardDiskPath>\HyperVControl\Images). + public const string RootVariable = "HYPERV_CONTROL_IMAGE_ROOT"; + /// Microsoft's fixed ID of the Default Switch; its name is localized. + internal const string DefaultSwitchId = "c08cb7b8-9b3c-408e-8e30-5e16a3aeb444"; + private static readonly string[] OsFamilies = ["windows", "linux", "other"]; + private static readonly string[] Kinds = ["image", "template"]; + private static readonly TimeSpan FeedLifetime = TimeSpan.FromMinutes(5); + private const int MaxFeedBytes = 16 << 20; + private static readonly HttpClient Http = CreateHttpClient(); + internal PowerShellRunner Runner { get; } = runner; internal JobRunner Jobs { get; } = jobs; + /// How long a download may receive nothing before it counts as interrupted (tests shorten it). + internal TimeSpan StallTimeout { get; set; } = TimeSpan.FromSeconds(60); + internal Func Clock { get; set; } = () => DateTimeOffset.UtcNow; + internal Version HostVersion { get; set; } = Environment.OSVersion.Version; + + private readonly SemaphoreSlim hostGate = new(1, 1); + private readonly SemaphoreSlim fetchGate = new(1, 1); + private readonly ConcurrentDictionary activeFetches = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary feeds = new(StringComparer.OrdinalIgnoreCase); + private HostPaths? hostPaths; + + private sealed record HostPaths(string VirtualHardDiskPath, string VirtualMachinePath); + private sealed record Recommendation(CatalogImage? Linux, CatalogImage? Windows, ImageLibraryEntry? Deploy, object Summary); + + private const string VmProjection = "($vm | Select-Object Id,Name,@{Name='State';Expression={[string]$_.State}},Generation,ProcessorCount,MemoryStartup,DynamicMemoryEnabled," + + "@{Name='CheckpointType';Expression={[string]$_.CheckpointType}},AutomaticCheckpointsEnabled," + + "@{Name='EnhancedSessionTransportType';Expression={[string]$_.EnhancedSessionTransportType}},Path,Notes)"; + + // Walks each disk's parent chain (current disks, checkpoint disks, recorded children) looking for $target. + private const string InUseGuard = """ + function Test-ControlChain([string]$Start) { + $current = $Start + for ($depth = 0; $current -and $depth -lt 64; $depth++) { + try { + if ([string]::Equals([System.IO.Path]::GetFullPath($current), $target, [System.StringComparison]::OrdinalIgnoreCase)) { return $depth } + $current = [string](Get-VHD -Path $current -ErrorAction Stop).ParentPath + } catch { return -1 } + } + return -1 + } + $references = New-Object System.Collections.ArrayList + foreach ($machine in @(Get-VM)) { + $paths = @(Get-VMHardDiskDrive -VM $machine | ForEach-Object { [string]$_.Path }) + foreach ($snapshot in @(Get-VMSnapshot -VM $machine)) { $paths += @(Get-VMHardDiskDrive -VMSnapshot $snapshot | ForEach-Object { [string]$_.Path }) } + foreach ($path in @($paths | Where-Object { $_ } | Select-Object -Unique)) { + $depth = Test-ControlChain $path + if ($depth -ge 0) { [void]$references.Add([ordered]@{ vm = $machine.Name; vmId = [string]$machine.Id; disk = $path; relation = $(if ($depth -eq 0) { 'attached' } else { 'differencingParent' }) }) } + } + foreach ($dvd in @(Get-VMDvdDrive -VM $machine)) { + $same = $false + try { $same = $dvd.Path -and [string]::Equals([System.IO.Path]::GetFullPath([string]$dvd.Path), $target, [System.StringComparison]::OrdinalIgnoreCase) } catch { } + if ($same) { [void]$references.Add([ordered]@{ vm = $machine.Name; vmId = [string]$machine.Id; disk = [string]$dvd.Path; relation = 'attachedDvd' }) } + } + } + foreach ($child in $children) { + $depth = Test-ControlChain $child + if ($depth -gt 0) { [void]$references.Add([ordered]@{ vm = $null; vmId = $null; disk = $child; relation = 'differencingParent' }) } + } + """; + + private static HttpClient CreateHttpClient() + { + var client = new HttpClient(new SocketsHttpHandler + { + AllowAutoRedirect = true, MaxAutomaticRedirections = 10, AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(30), PooledConnectionLifetime = TimeSpan.FromMinutes(10) + }) { Timeout = Timeout.InfiniteTimeSpan }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("HyperVControl/" + Program.Version); + return client; + } + + private ImageDownloader Downloader => new(Http, StallTimeout); + + /// The host's default VHD and VM folders, read once through one script and cached. + private async Task HostPathsAsync(CancellationToken ct) + { + if (hostPaths is { } known) return known; + await hostGate.WaitAsync(ct); + try + { + if (hostPaths is { } again) return again; + var data = await Runner.InvokeAsync(new HyperVScript().Returns("(Get-VMHost | Select-Object VirtualHardDiskPath, VirtualMachinePath)"), ct); + var vhd = data.TryGetProperty("VirtualHardDiskPath", out var v) ? v.GetString() : null; + var vm = data.TryGetProperty("VirtualMachinePath", out var m) ? m.GetString() : null; + if (string.IsNullOrWhiteSpace(vhd) || string.IsNullOrWhiteSpace(vm)) + throw new HyperVOperationException("Get-VMHost returned no default virtual hard disk or virtual machine path.", "InvalidResponse"); + return hostPaths = new(vhd, vm); + } + finally { hostGate.Release(); } + } + + /// The library root: when set, else <VirtualHardDiskPath>\HyperVControl\Images. + internal async Task StoreAsync(CancellationToken ct) + { + var configured = Environment.GetEnvironmentVariable(RootVariable)?.Trim(); + string root; + if (!string.IsNullOrEmpty(configured)) + { + if (!Path.IsPathFullyQualified(configured) || configured.Split('\\', '/').Contains("..")) + throw new ArgumentException($"{RootVariable} must be an absolute path without '..' components."); + root = configured; + } + else root = Path.Combine((await HostPathsAsync(ct)).VirtualHardDiskPath, "HyperVControl", "Images"); + Directory.CreateDirectory(root); + return new ImageLibraryStore(root); + } + + // ---- list and catalogs ---------------------------------------------------------------------------- + + /// Catalog images and library entries with warnings and a recommendation. Reads feeds only; never touches disk URLs. + public async Task ListAsync(CancellationToken ct) + { + var store = await StoreAsync(ct); + var catalogs = await store.CatalogsAsync(ct); + var library = await store.LibraryAsync(ct); + var loads = await Task.WhenAll(catalogs.Catalogs.Select(async source => + { + if (!source.Enabled) return (Source: source, Feed: (CatalogFeed?)null, Error: (string?)null); + try { return (Source: source, Feed: await LoadFeedAsync(source, ct), Error: null); } + catch (Exception ex) when (ex is not OperationCanceledException) { return (Source: source, Feed: null, Error: ex.Message); } + })); + var images = loads.Where(l => l.Feed is not null).SelectMany(l => l.Feed!.Images).ToList(); + var recommendation = Recommend(images, library); + return new + { + root = store.Root, + freeBytes = ImageFiles.FreeBytes(store.Root), + recommended = recommendation.Summary, + library = library.Entries.Select(e => ProjectEntry(e, ReferenceEquals(e, recommendation.Deploy))).ToList(), + catalogs = loads.Select(l => new + { + l.Source.Name, l.Source.Location, l.Source.Enabled, l.Source.BuiltIn, + status = !l.Source.Enabled ? "disabled" : l.Feed is not null ? "ok" : "error", + images = l.Feed?.Images.Count, hidden = l.Feed?.Hidden, skipped = l.Feed?.Skipped, error = l.Error + }).ToList(), + images = images.Select(i => ProjectImage(i, library, recommendation)).ToList(), + cache = CacheFiles(store) + }; + } + + public async Task CatalogsAsync(string action, string? name, string? location, CancellationToken ct) + { + var store = await StoreAsync(ct); + switch (action) + { + case "list": + return new { file = store.CatalogsFile, catalogs = (await store.CatalogsAsync(ct)).Catalogs }; + case "add": + { + if (string.IsNullOrWhiteSpace(location)) throw new ArgumentException("location required: an http(s) URL, a file: URI, or an absolute local or UNC path to a gallery JSON."); + location = location.Trim(); + var uri = ImageCatalogFeed.ParseLocation(location); + var index = await store.CatalogsAsync(ct); + var existing = index.Catalogs.FirstOrDefault(c => SameText(c.Location, location)); + var chosen = name?.Trim() is { Length: > 0 } given ? given : existing?.Name ?? DeriveCatalogName(uri, index); + if (existing is null && index.Catalogs.Any(c => SameText(c.Name, chosen))) + throw new HyperVOperationException($"A catalog named '{chosen}' already exists; pass another catalog name.", "AlreadyExists"); + // Read and parse it now, so a bad location fails here instead of at the next list. + var feed = ImageCatalogFeed.Parse(chosen, ImageCatalogFeed.Decode(await ReadLocationAsync(location, ct)), HostVersion, Clock()); + var saved = await store.UpdateCatalogsAsync(current => + { + var match = current.Catalogs.FirstOrDefault(c => SameText(c.Location, location)); + if (match is null) + { + var added = new ImageCatalogSource(chosen, location); + return (current with { Catalogs = [.. current.Catalogs, added] }, added); + } + var enabled = match with { Enabled = true }; + return (current with { Catalogs = current.Catalogs.Select(c => ReferenceEquals(c, match) ? enabled : c).ToList() }, enabled); + }, ct); + return new + { + added = existing is null, reenabled = existing is { Enabled: false }, catalog = saved, + images = feed.Images.Select(i => new { i.Name, i.Version, i.Fetchable, i.Warnings }).ToList(), hidden = feed.Hidden, skipped = feed.Skipped + }; + } + case "remove": + { + if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(location)) throw new ArgumentException("catalog (its name) or location required."); + var target = await store.UpdateCatalogsAsync(current => + { + var match = current.Catalogs.FirstOrDefault(c => (name is not null && SameText(c.Name, name.Trim())) || (location is not null && SameText(c.Location, location.Trim()))) + ?? throw new HyperVOperationException($"No catalog named '{name ?? location}'. Use catalogAction=list.", "NotFound"); + var catalogs = match.BuiltIn + ? current.Catalogs.Select(c => ReferenceEquals(c, match) ? c with { Enabled = false } : c).ToList() + : current.Catalogs.Where(c => !ReferenceEquals(c, match)).ToList(); + return (current with { Catalogs = catalogs }, match); + }, ct); + return new + { + catalog = target.Name, removed = !target.BuiltIn, disabled = target.BuiltIn, + note = target.BuiltIn ? "The built-in Microsoft feed is disabled, not deleted; add its location again to re-enable it." : null + }; + } + default: + throw new ArgumentException("catalogAction must be list, add or remove."); + } + } + + private async Task LoadFeedAsync(ImageCatalogSource source, CancellationToken ct) + { + var key = source.Name + "|" + source.Location; + if (feeds.TryGetValue(key, out var cached) && Clock() - cached.At < FeedLifetime) return cached.Feed; + var feed = ImageCatalogFeed.Parse(source.Name, ImageCatalogFeed.Decode(await ReadLocationAsync(source.Location, ct)), HostVersion, Clock()); + feeds[key] = (Clock(), feed); + return feed; + } + + /// Reads a catalog from an http(s) URL, a file: URI, or a local or UNC path (16 MB at most). + internal static async Task ReadLocationAsync(string location, CancellationToken ct) + { + var uri = ImageCatalogFeed.ParseLocation(location); + if (uri.IsFile) + { + var file = new FileInfo(uri.LocalPath); + if (!file.Exists) throw new HyperVOperationException($"Catalog file not found: {file.FullName}.", "NotFound"); + if (file.Length > MaxFeedBytes) throw new InvalidDataException($"Catalog {file.FullName} is larger than 16 MB."); + return await File.ReadAllBytesAsync(file.FullName, ct); + } + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + try + { + using var response = await Http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + if (!response.IsSuccessStatusCode) + throw new HyperVOperationException($"Catalog {uri} returned HTTP {(int)response.StatusCode} {response.ReasonPhrase}.", "DownloadFailed"); + if (response.Content.Headers.ContentLength > MaxFeedBytes) throw new InvalidDataException($"Catalog {uri} is larger than 16 MB."); + var bytes = await response.Content.ReadAsByteArrayAsync(timeout.Token); + return bytes.Length <= MaxFeedBytes ? bytes : throw new InvalidDataException($"Catalog {uri} is larger than 16 MB."); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new HyperVOperationException($"Catalog {uri} did not answer within 60 s.", "DownloadFailed"); + } + catch (HttpRequestException ex) + { + throw new HyperVOperationException($"Cannot read catalog {uri}: {ex.Message}", "DownloadFailed"); + } + } + + private static string DeriveCatalogName(Uri uri, ImageCatalogIndex index) + { + var stem = uri.IsFile ? Path.GetFileNameWithoutExtension(uri.LocalPath) : uri.Host + "-" + Path.GetFileNameWithoutExtension(uri.AbsolutePath); + var name = ImageFiles.SafeName(stem).ToLowerInvariant(); + var candidate = name; + for (var n = 2; index.Catalogs.Any(c => SameText(c.Name, candidate)); n++) candidate = name + "-" + n; + return candidate; + } + + private async Task<(CatalogImage Image, ImageCatalogSource Source)> FindImageAsync(ImageLibraryStore store, string imageName, string? catalog, CancellationToken ct) + { + var sources = (await store.CatalogsAsync(ct)).Catalogs.Where(c => c.Enabled && (catalog is null || SameText(c.Name, catalog))).ToList(); + if (catalog is not null && sources.Count == 0) throw new HyperVOperationException($"No enabled catalog named '{catalog}'.", "NotFound"); + var matches = new List<(CatalogImage Image, ImageCatalogSource Source)>(); + var notes = new List(); + foreach (var source in sources) + { + try + { + var feed = await LoadFeedAsync(source, ct); + matches.AddRange(feed.Images.Where(i => SameText(i.Name, imageName)).Select(i => (i, source))); + if (feed.Hidden > 0) notes.Add($"{feed.Hidden} image(s) in '{source.Name}' are hidden by their host version limits"); + } + catch (Exception ex) when (ex is not OperationCanceledException) { notes.Add($"catalog '{source.Name}' is unreadable: {ex.Message}"); } + } + return matches.Count switch + { + 1 => matches[0], + 0 => throw new HyperVOperationException($"No catalog image named '{imageName}'{(notes.Count > 0 ? " (" + string.Join("; ", notes) + ")" : "")}. " + + "Call hyperv_image list for exact names.", "NotFound"), + _ => throw new HyperVOperationException($"'{imageName}' is in catalogs {string.Join(", ", matches.Select(m => m.Source.Name))}; pass catalog.", "AmbiguousName") + }; + } + + private static Recommendation Recommend(IReadOnlyList images, ImageLibraryIndex library) + { + var usable = images.Where(i => i.Fetchable && !i.Expired && !i.EndOfLife).ToList(); + CatalogImage? Pick(string family) => usable.Where(i => i.OsFamily == family) + .OrderByDescending(i => i.LastUpdated ?? DateTimeOffset.MinValue).ThenByDescending(i => i.Version, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + var linux = Pick("linux"); + var windows = Pick("windows"); + var deploy = library.Entries.Where(e => e.Format == "vhdx" && !e.Expired && File.Exists(e.Path)) + .OrderByDescending(e => e.Kind == "template").ThenByDescending(e => e.RegisteredAt).FirstOrDefault(); + var hints = new List(); + if (deploy is not null) hints.Add($"Deploy library {deploy.Kind} '{deploy.Name}' with action=deploy; a linked clone is ready in seconds."); + if (linux is not null) hints.Add($"For Linux, fetch '{linux.Name}' from catalog '{linux.Catalog}' once, then deploy it."); + if (windows is not null) hints.Add($"For Windows, fetch '{windows.Name}' from catalog '{windows.Catalog}' once, then deploy it."); + else hints.Add("No catalog offers a current Windows image" + + (images.Any(i => i.OsFamily == "windows" && i.Expired) ? " (its Windows evaluation images have expired)" : "") + + ": build a golden image from a Windows ISO, or register your own VHDX with action=register."); + return new(linux, windows, deploy, new + { + deploy = deploy?.Name, + linux = linux is null ? null : new { linux.Catalog, linux.Name }, + windows = windows is null ? null : new { windows.Catalog, windows.Name }, + hint = string.Join(" ", hints) + }); + } + + private static object ProjectImage(CatalogImage image, ImageLibraryIndex library, Recommendation recommendation) => new + { + image.Catalog, image.Name, image.Publisher, image.Version, image.LastUpdated, image.OsFamily, image.Description, + disk = new { uri = image.DiskUri, type = image.DiskExtension.TrimStart('.'), sha256 = image.Sha256, image.ArchiveRelativePath }, + config = new { image.SecureBoot, image.EnhancedSessionTransportType }, + image.Fetchable, image.Expired, image.Stale, image.EndOfLife, + recommended = ReferenceEquals(image, recommendation.Linux) || ReferenceEquals(image, recommendation.Windows), + inLibrary = image.Sha256 is null ? null : library.Entries.FirstOrDefault(e => SameText(e.Source.Sha256, image.Sha256))?.Name, + image.Warnings + }; + + internal static object ProjectEntry(ImageLibraryEntry entry, bool recommended = false) + { + var exists = File.Exists(entry.Path); + var warnings = new List(entry.Warnings ?? []); + if (!exists) warnings.Add($"The image file is missing: {entry.Path}. Remove the entry or restore the file."); + return new + { + entry.Name, entry.Kind, entry.OsFamily, entry.Path, entry.Format, exists, + fileBytes = exists ? new FileInfo(entry.Path).Length : (long?)null, + entry.ReadOnly, entry.Expired, entry.Config, entry.Source, entry.Sizes, entry.RegisteredAt, entry.Notes, entry.ArchivePath, + deployments = entry.Deployments ?? [], recommended, warnings + }; + } + + private static IReadOnlyList CacheFiles(ImageLibraryStore store) => !Directory.Exists(store.CacheDirectory) ? [] : + new DirectoryInfo(store.CacheDirectory).GetFiles() + .Select(f => (object)new { f.Name, bytes = f.Length, partial = f.Name.EndsWith(".partial", StringComparison.OrdinalIgnoreCase), modified = f.LastWriteTimeUtc }) + .ToList(); + + // ---- fetch ---------------------------------------------------------------------------------------- + + /// + /// Starts (or joins) the fetch job for one catalog image and optionally waits for it. Returns the job snapshot, + /// an ok:false envelope around it when it failed within the wait, or the existing entry when the image + /// is already in the library. + /// + public async Task FetchAsync(string? imageName, string? catalog, bool keepArchive, int waitSeconds, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(imageName)) throw new ArgumentException("imageName required: a catalog image name from hyperv_image list."); + if (waitSeconds is < 0 or > 3600) throw new ArgumentException("waitSeconds must be between 0 and 3600."); + var store = await StoreAsync(ct); + var (image, source) = await FindImageAsync(store, imageName.Trim(), catalog?.Trim(), ct); + if (image.Sha256 is null) + throw new HyperVOperationException($"'{image.Name}' has no valid SHA-256 in catalog '{image.Catalog}'; unverifiable images are never fetched.", "UnsupportedImage"); + if (!image.Fetchable) + throw new HyperVOperationException($"'{image.Name}' cannot be fetched: {string.Join(" ", image.Warnings)}", "UnsupportedImage"); + JobInfo job; + await fetchGate.WaitAsync(ct); + try + { + var library = await store.LibraryAsync(ct); + var same = library.Entries.FirstOrDefault(e => SameText(e.Source.Sha256, image.Sha256)); + if (same is not null && File.Exists(same.Path)) + return new { alreadyInLibrary = true, entry = ProjectEntry(same), next = $"Deploy it with action=deploy imageName='{same.Name}'." }; + if (library.Find(image.Name) is { } named) + throw new HyperVOperationException($"The library already has an entry named '{named.Name}'" + (File.Exists(named.Path) ? " with different content" : " whose file is missing") + + "; remove it first (action=remove).", "AlreadyExists"); + if (activeFetches.TryGetValue(image.Sha256, out var activeId) && Jobs.Get(activeId) is { Status: JobStatus.Queued or JobStatus.Running } active) + return new { alreadyRunning = true, job = active }; + job = Jobs.Start("fetch " + image.Name, (progress, token) => RunFetchAsync(store, image, source.Location, keepArchive, progress, token)); + activeFetches[image.Sha256] = job.Id; + } + finally { fetchGate.Release(); } + return waitSeconds == 0 ? job : JobOutcome(await Jobs.WaitAsync(job.Id, TimeSpan.FromSeconds(waitSeconds), ct)); + } + + /// A job snapshot, wrapped in the failure envelope when the job failed or was cancelled. + internal static object JobOutcome(JobInfo job) => job.Status is JobStatus.Failed or JobStatus.Cancelled + ? new { ok = false, error = job.Error, errorType = job.ErrorType, job } + : job; + + private async Task RunFetchAsync(ImageLibraryStore store, CatalogImage image, string catalogLocation, bool keepArchive, IProgress progress, CancellationToken ct) + { + var clock = Stopwatch.StartNew(); + var sha = image.Sha256!; + var report = Throttle(progress); + string? partial = null; // a partial download this job owns + string? downloaded = null; // a complete, verified archive this job downloaded + var outputs = new List(); // extraction or copy output this job owns + try + { + Directory.CreateDirectory(store.CacheDirectory); + var uri = ImageCatalogFeed.ParseLocation(image.DiskUri); + string source, extension; + long downloadedBytes = 0, resumedFrom = 0; + bool inCache; + if (uri.IsFile) + { + // A local or UNC disk is verified in place and never copied to the cache. + source = uri.LocalPath; + extension = Path.GetExtension(source).ToLowerInvariant(); + RequireDiskType(extension, image); + if (!File.Exists(source)) throw new HyperVOperationException($"The catalog's disk file does not exist: {source}.", "NotFound"); + var length = new FileInfo(source).Length; + var actual = await ImageFiles.Sha256Async(source, done => report("verifying " + Path.GetFileName(source), done, length), ct); + if (!SameText(actual, sha)) + throw new HyperVOperationException($"SHA-256 mismatch: the catalog lists {sha} but {source} hashes to {actual}. The source file was left untouched.", "HashMismatch"); + inCache = false; + } + else + { + report("resolving " + uri.Host, 0, null); + var probe = await Downloader.ProbeAsync(uri, ct); + extension = probe.Extension; + RequireDiskType(extension, image); + var cached = Path.Combine(store.CacheDirectory, sha + extension); + if (File.Exists(cached) && SameText(await ImageFiles.Sha256Async(cached, done => report("verifying the cached download", done, new FileInfo(cached).Length), ct), sha)) + source = cached; + else + { + ImageFiles.TryDelete(cached); + partial = cached + ".partial"; + var have = File.Exists(partial) ? new FileInfo(partial).Length : 0; + long extract = 0; + if (extension == ".zip" && probe.ZipEntries is { } entries) + extract = (entries.FirstOrDefault(e => ImageFiles.SameEntry(e.FullName, image.ArchiveRelativePath!)) + ?? throw new HyperVOperationException($"The zip has no entry '{image.ArchiveRelativePath}' (entries: {string.Join(", ", entries.Take(20).Select(e => e.FullName))}); nothing was downloaded.", "UnsupportedImage")).UncompressedSize; + if (probe.Length is long total) + ImageFiles.EnsureSpace(store.Root, Math.Max(0, total - have) + extract, $"downloading {image.Name}" + (extract > 0 ? " and extracting it" : "")); + var result = await Downloader.DownloadAsync(probe.FinalUri, partial, probe.Length, sha, report, ct); + downloadedBytes = result.Downloaded; + resumedFrom = result.ResumedFrom; + File.Move(partial, cached, overwrite: true); + partial = null; + downloaded = source = cached; + } + inCache = true; + } + if (!ImageFiles.HasMagic(source, extension)) + { + if (downloaded is not null) { ImageFiles.TryDelete(downloaded); downloaded = null; } + throw new HyperVOperationException($"The {(inCache ? "download" : "file")} is not a valid {extension} (its signature does not match)" + (inCache ? "; it was deleted." : "."), "UnsupportedImage"); + } + + string target; + if (extension == ".zip") + { + var entryExtension = Path.GetExtension(image.ArchiveRelativePath!).ToLowerInvariant(); + target = UniqueLibraryPath(store.Root, image.Name, entryExtension); + var temporary = target + ".partial"; + outputs.Add(temporary); + await ImageFiles.ExtractAsync(source, image.ArchiveRelativePath!, temporary, (done, total) => report("extracting " + image.ArchiveRelativePath, done, total), ct); + if (!ImageFiles.HasMagic(temporary, entryExtension)) + throw new HyperVOperationException($"The extracted '{image.ArchiveRelativePath}' is not a valid {entryExtension} (its signature does not match); it was deleted.", "UnsupportedImage"); + File.Move(temporary, target); + outputs.Add(target); + } + else if (inCache) + { + // A verified .vhdx or .iso download becomes the library file itself. + target = UniqueLibraryPath(store.Root, image.Name, extension); + File.Move(source, target); + downloaded = null; + outputs.Add(target); + } + else + { + target = UniqueLibraryPath(store.Root, image.Name, extension); + var temporary = target + ".partial"; + outputs.Add(temporary); + ImageFiles.EnsureSpace(store.Root, new FileInfo(source).Length, "copying " + image.Name); + await ImageFiles.CopyAsync(source, temporary, (done, total) => report("copying " + Path.GetFileName(source), done, total), ct); + File.Move(temporary, target); + outputs.Add(target); + } + + var archiveKept = extension == ".zip" && inCache && keepArchive ? source : null; + var entry = new ImageLibraryEntry(image.Name, "image", image.OsFamily, target, Path.GetExtension(target).TrimStart('.').ToLowerInvariant(), + new ImageSource(image.Catalog, catalogLocation, image.Name, image.Version, image.LastUpdated, image.DiskUri, sha, image.ArchiveRelativePath), + new ImageSizes(new FileInfo(target).Length, extension == ".zip" && inCache ? new FileInfo(source).Length : null), + new ImageConfig(image.SecureBoot, image.EnhancedSessionTransportType), Clock(), + Expired: image.Expired, ArchivePath: archiveKept, Warnings: image.Warnings.Count > 0 ? image.Warnings : null); + report("registering " + image.Name, 0, null); + await store.UpdateLibraryAsync(index => index.Find(entry.Name) is null + ? (index with { Entries = [.. index.Entries, entry] }, entry) + : throw new HyperVOperationException($"A library entry named '{entry.Name}' appeared while fetching; the extracted file was deleted.", "AlreadyExists"), ct); + outputs.Clear(); + if (extension == ".zip" && inCache && !keepArchive) ImageFiles.TryDelete(source); + downloaded = null; + progress.Report(new JobProgress(100, null, null, "done")); + return new + { + entry = ProjectEntry(entry), archive = archiveKept, downloadedBytes, resumedFromBytes = resumedFrom, sha256 = sha, + seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), + next = $"Deploy it with action=deploy imageName='{entry.Name}' vmName=." + }; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Cancellation removes everything this job produced: the partial download and any extraction output. + ImageFiles.TryDelete(partial); + foreach (var output in outputs) ImageFiles.TryDelete(output); + if (downloaded is not null && !keepArchive) ImageFiles.TryDelete(downloaded); + throw; + } + catch (Exception ex) + { + // Failures never leave extraction output behind. An interrupted download keeps its partial file for + // resume (a hash mismatch has already deleted it), and a verified archive is kept for a quick retry. + foreach (var output in outputs) ImageFiles.TryDelete(output); + if (downloaded is not null) + throw new HyperVOperationException(ex.Message + $" The verified download is kept at {downloaded}; fetch again to reuse it.", + ex is HyperVOperationException known ? known.ErrorType : "ExtractionFailed"); + throw; + } + finally + { + activeFetches.TryRemove(sha, out _); + } + } + + private static void RequireDiskType(string extension, CatalogImage image) + { + if (!ImageCatalogFeed.DiskExtensions.Contains(extension)) + throw new HyperVOperationException($"'{image.Name}' resolves to a '{extension}' file; only a .zip containing a .vhdx, a .vhdx or an .iso is supported.", "UnsupportedImage"); + if (extension == ".zip" && image.ArchiveRelativePath is null) + throw new HyperVOperationException($"'{image.Name}' is a .zip without archiveRelativePath.", "UnsupportedImage"); + } + + private static string UniqueLibraryPath(string root, string name, string extension) + { + var stem = ImageFiles.SafeName(name); + var path = Path.Combine(root, stem + extension); + for (var n = 2; File.Exists(path) || File.Exists(path + ".partial"); n++) path = Path.Combine(root, $"{stem}-{n}{extension}"); + return path; + } + + /// Progress at most four times a second per stage, plus every stage change and completion. + private static Action Throttle(IProgress progress) + { + var clock = Stopwatch.StartNew(); + var last = TimeSpan.MinValue; + string? stage = null; + return (current, done, total) => + { + if (current == stage && clock.Elapsed - last < TimeSpan.FromMilliseconds(250) && done != total) return; + last = clock.Elapsed; + stage = current; + progress.Report(new JobProgress(total is > 0 ? Math.Round(100.0 * done / total.Value, 1) : null, done, total, current)); + }; + } + + // ---- register and remove -------------------------------------------------------------------------- + + /// Adopts a local .vhdx or .iso in place. A template is made read-only, because it exists to be a differencing parent. + public async Task RegisterAsync(string? path, string? name, string? osFamily, string? kind, bool? secureBoot, string? notes, CancellationToken ct) + { + var file = ControlService.FullPath(path ?? throw new ArgumentException("path required: an absolute path to a local .vhdx or .iso.")); + if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 100) throw new ArgumentException("name required (1-100 characters)."); + var entryName = name.Trim(); + var format = Path.GetExtension(file).ToLowerInvariant() switch + { + ".vhdx" => "vhdx", + ".iso" => "iso", + _ => throw new ArgumentException("register accepts a .vhdx or an .iso file.") + }; + if (!File.Exists(file)) throw new HyperVOperationException($"File not found: {file}.", "NotFound"); + if (!ImageFiles.HasMagic(file, "." + format)) throw new HyperVOperationException($"{file} is not a valid .{format} (its signature does not match).", "UnsupportedImage"); + var entryKind = (kind ?? "image").Trim().ToLowerInvariant(); + if (!Kinds.Contains(entryKind)) throw new ArgumentException("kind must be image or template."); + if (format == "iso" && entryKind == "template") throw new ArgumentException("An ISO is installation media; register it as kind=image."); + var warnings = new List(); + var family = osFamily?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(family)) + { + family = ImageCatalogFeed.InferOsFamily(entryName + " " + Path.GetFileNameWithoutExtension(file), null, []); + warnings.Add($"osFamily was not given; '{family}' was inferred from the name. Remove and register again with osFamily if that is wrong."); + } + else if (!OsFamilies.Contains(family)) throw new ArgumentException("osFamily must be windows, linux or other."); + var store = await StoreAsync(ct); + var entry = await store.UpdateLibraryAsync(index => + { + if (index.Find(entryName) is not null) throw new HyperVOperationException($"A library entry named '{entryName}' already exists.", "AlreadyExists"); + if (index.Entries.FirstOrDefault(e => SamePath(e.Path, file)) is { } same) throw new HyperVOperationException($"{file} is already registered as '{same.Name}'.", "AlreadyExists"); + var template = entryKind == "template" && format == "vhdx"; + if (template) MakeReadOnly(file); + var added = new ImageLibraryEntry(entryName, entryKind, family, file, format, new ImageSource(RegisteredFrom: file), + new ImageSizes(new FileInfo(file).Length), new ImageConfig(secureBoot), Clock(), ReadOnly: template, Notes: notes); + return (index with { Entries = [.. index.Entries, added] }, added); + }, ct); + return new { registered = true, entry = ProjectEntry(entry), warnings }; + } + + /// + /// Removes a library entry (and with its file and kept archive). Refuses with + /// InUse while any VM attaches the file, any disk chain (current, checkpoint or recorded child) has it as + /// a differencing parent, or a VM's DVD drive holds it. + /// + public async Task RemoveAsync(string? name, bool deleteFile, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required: the library entry to remove."); + var store = await StoreAsync(ct); + var entry = (await store.LibraryAsync(ct)).Find(name.Trim()) + ?? throw new HyperVOperationException($"No library entry named '{name}'. Call hyperv_image list.", "NotFound"); + var references = await ReferencesAsync(entry, ct); + if (references.Count > 0) + { + var summary = string.Join("; ", references.Select(r => + $"{(r.TryGetProperty("vm", out var vm) && vm.ValueKind == JsonValueKind.String ? "VM '" + vm.GetString() + "'" : "an unattached disk")} " + + $"{r.GetProperty("disk").GetString()} ({r.GetProperty("relation").GetString()})")); + return new + { + ok = false, errorType = "InUse", references, + error = $"'{entry.Name}' is in use: {summary}. Remove those VMs and child disks first; removing a differencing parent breaks its children." + }; + } + var deleted = new List(); + if (deleteFile) + foreach (var path in new[] { entry.Path, entry.ArchivePath }) + if (path is not null && File.Exists(path)) + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + deleted.Add(path); + } + var removed = await store.UpdateLibraryAsync(index => + { + var current = index.Find(entry.Name) ?? throw new HyperVOperationException($"No library entry named '{entry.Name}'.", "NotFound"); + return (index with { Entries = index.Entries.Where(e => !ReferenceEquals(e, current)).ToList() }, current); + }, ct); + return new + { + removed = removed.Name, deleted, + kept = deleteFile ? null : removed.Path, + note = !deleteFile && removed.ReadOnly ? "The file was kept with its read-only attribute." : null + }; + } + + private async Task> ReferencesAsync(ImageLibraryEntry entry, CancellationToken ct) + { + var children = (entry.Deployments ?? []).Select(d => d.DiskPath).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var script = new HyperVScript() + .Line("$target = [System.IO.Path]::GetFullPath(" + PowerShellRunner.Literal(entry.Path) + ")") + .Line("$children = @(" + string.Join(", ", children.Select(PowerShellRunner.Literal)) + ")") + .Line(InUseGuard) + .Returns("@($references)"); + var data = await Runner.InvokeAsync(script, ct, 300); + return data.ValueKind == JsonValueKind.Array ? data.EnumerateArray().Select(e => e.Clone()).ToList() : []; + } + + private static void MakeReadOnly(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) == 0) File.SetAttributes(path, attributes | FileAttributes.ReadOnly); + } + + // ---- deploy --------------------------------------------------------------------------------------- + + /// + /// Creates a VM from a library entry: a differencing child of the entry (linked, the default) or a full copy under + /// the VM's folder, the VM through , then the lab defaults in one script: + /// static memory, automatic checkpoints off, Standard checkpoints, Secure Boot per OS family (off when the image + /// says so), HvSocket enhanced session for Linux, and a vTPM for Windows (after the template, which the vTPM locks). + /// Any failure removes the half-created VM and its new disk. + /// + public async Task DeployAsync(ImageDeployRequest request, VmLifecycleService lifecycle, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.ImageName)) throw new ArgumentException("imageName required: a library entry name from hyperv_image list."); + var vmName = request.VmName?.Trim(); + if (string.IsNullOrEmpty(vmName) || vmName.Length > 100) throw new ArgumentException("vmName required (1-100 characters)."); + if (request.MemoryMb is < 32 or > 1048576 || request.Processors is < 1 or > 2048) throw new ArgumentException("Invalid memoryMb or processors."); + var store = await StoreAsync(ct); + var entry = (await store.LibraryAsync(ct)).Find(request.ImageName.Trim()) + ?? throw new HyperVOperationException($"No library entry named '{request.ImageName}'. Fetch or register it first; hyperv_image list shows the library.", "NotFound"); + if (entry.Format != "vhdx") + throw new ArgumentException($"'{entry.Name}' is an ISO (installation media), not a bootable disk. Create a VM with a new disk and attach the ISO instead."); + if (!File.Exists(entry.Path)) throw new HyperVOperationException($"The file of '{entry.Name}' is missing: {entry.Path}.", "NotFound"); + var vmRoot = request.VmPath is null ? (await HostPathsAsync(ct)).VirtualMachinePath : ControlService.FullPath(request.VmPath); + var folder = Path.Combine(vmRoot, ImageFiles.SafeDirectoryName(vmName)); + var directory = Path.Combine(folder, "Virtual Hard Disks"); + var disk = Path.Combine(directory, ImageFiles.SafeDirectoryName(vmName) + ".vhdx"); + if (File.Exists(disk)) throw new HyperVOperationException($"{disk} already exists; choose another vmName or vmPath.", "AlreadyExists"); + var warnings = new List(entry.Warnings ?? []); + var createdFolder = !Directory.Exists(folder); + Directory.CreateDirectory(directory); + var ownsDisk = false; + string? vmId = null; + try + { + // 1. One script: refuse a duplicate VM name, resolve the switch, and create the differencing child. + var prepare = new HyperVScript() + .Line("$name = " + PowerShellRunner.Literal(vmName)) + .Line("if (@(Get-VM | Where-Object { $_.Name -eq $name }).Count -gt 0) { Stop-Control 'InvalidArgument' (\"A VM named '\" + $name + \"' already exists. Choose another vmName.\") }"); + if (request.SwitchName is { } requested) + prepare.Line("$switchName = " + PowerShellRunner.Literal(requested)) + .Line("if (@(Get-VMSwitch | Where-Object { $_.Name -eq $switchName }).Count -eq 0) { Stop-Control 'NotFound' (\"Switch '\" + $switchName + \"' not found. Call hyperv_network list.\") }"); + else + prepare.Line("$switchName = $null") + .Line("try { $default = Get-VMSwitch -Id " + PsValue.Of(Guid.Parse(DefaultSwitchId)).Text + " -ErrorAction Stop; if ($default) { $switchName = [string]@($default)[0].Name } } catch { }"); + if (request.Linked) + prepare.Cmd("$child = New-VHD", ("Path", PsValue.Of(disk)), ("ParentPath", PsValue.Of(entry.Path)), ("Differencing", PsValue.Of(true))); + ownsDisk = request.Linked; + var prepared = await Runner.InvokeAsync(prepare.Returns("@{ switchName = $switchName }"), ct, 300); + var switchName = prepared.TryGetProperty("switchName", out var chosen) && chosen.ValueKind == JsonValueKind.String ? chosen.GetString() : null; + if (switchName is null) warnings.Add("No switchName was given and this host has no Default Switch, so the VM's network adapter is not connected."); + if (request.Linked) entry = await ProtectParentAsync(store, entry, ct); + else + { + ImageFiles.EnsureSpace(directory, new FileInfo(entry.Path).Length, "copying " + entry.Name); + ownsDisk = true; + await ImageFiles.CopyAsync(entry.Path, disk, null, ct); + } + + // 2. The VM itself; this is the only call into the lifecycle service. + var created = await lifecycle.CreateAsync(vmName, vmRoot, disk, 2, request.MemoryMb, request.Processors, 0, switchName, ct); + vmId = created.GetProperty("Id").GetString() ?? throw new HyperVOperationException("New-VM returned no VM ID.", "InvalidResponse"); + + // 3. Lab defaults, then the optional start. + var configured = await Runner.InvokeAsync(LabDefaults(vmId, entry, request, warnings), ct, 300); + if (configured.TryGetProperty("startError", out var startError) && startError.ValueKind == JsonValueKind.String) + warnings.Add("The VM was created and configured but did not start: " + startError.GetString()); + var deployment = new ImageDeployment(vmName, vmId, disk, request.Linked, Clock()); + await store.UpdateLibraryAsync(index => index.Find(entry.Name) is { } current + ? (Replace(index, current, current with { Deployments = [.. current.Deployments ?? [], deployment] }), true) + : (index, false), ct); + return new + { + vm = configured.GetProperty("vm"), + disk = new { path = disk, type = request.Linked ? "differencing" : "copy", parentPath = request.Linked ? entry.Path : null }, + parent = new { entry.Name, entry.Path, entry.Kind, entry.OsFamily, entry.ReadOnly }, + switchName, + firmware = configured.TryGetProperty("firmware", out var firmware) ? firmware : default, + security = configured.TryGetProperty("security", out var security) ? security : default, + started = request.Start && !(startError.ValueKind == JsonValueKind.String), + warnings + }; + } + catch + { + await RollbackAsync(vmId, ownsDisk ? disk : null, createdFolder ? folder : directory); + throw; + } + } + + private static HyperVScript LabDefaults(string vmId, ImageLibraryEntry entry, ImageDeployRequest request, List warnings) + { + var vm = PsValue.Raw("$vm"); + var windows = entry.OsFamily == "windows"; + var linux = entry.OsFamily == "linux"; + var transport = entry.Config.EnhancedSessionTransportType ?? (linux ? "HvSocket" : null); + var script = new HyperVScript().Vm(vmId).RequireOff() + .Cmd("Set-VM", ("VM", vm), ("StaticMemory", PsValue.Of(true)), ("AutomaticCheckpointsEnabled", PsValue.Of(false)), + ("CheckpointType", PsValue.Token("Standard", "Standard")), + ("EnhancedSessionTransportType", transport is null ? null : PsValue.Token(transport, "HvSocket", "VMBus")), + ("Notes", request.Notes is null ? null : PsValue.Of(request.Notes))); + // Secure Boot follows the image (off when it says so), else the template that matches the OS family. + var template = entry.Config.SecureBoot == false ? null + : windows ? "MicrosoftWindows" + : linux || entry.Config.SecureBoot == true ? "MicrosoftUEFICertificateAuthority" + : null; + if (template is null) + { + script.Cmd("Set-VMFirmware", ("VM", vm), ("EnableSecureBoot", PsValue.Token("Off", "On", "Off"))); + if (entry.Config.SecureBoot is null) + warnings.Add("Secure Boot is off because the image's OS family is 'other'; enable it with hyperv_firmware if the guest supports it."); + } + else + script.Cmd("Set-VMFirmware", ("VM", vm), ("EnableSecureBoot", PsValue.Token("On", "On", "Off")), + ("SecureBootTemplate", PsValue.Token(template, "MicrosoftWindows", "MicrosoftUEFICertificateAuthority"))); + // The vTPM comes after the template: initializing it locks the Secure Boot template. + if (windows) script.Cmd("Set-VMKeyProtector", ("VM", vm), ("NewLocalKeyProtector", PsValue.Of(true))).Cmd("Enable-VMTPM", ("VM", vm)); + script.Line("$startError = $null"); + if (request.Start) script.Line("try { Start-VM -VM $vm } catch { $startError = [string]$_.Exception.Message }"); + return script.Line("$vm = Get-VM -Id $vm.Id -ErrorAction Stop").Returns( + "@{ vm = " + VmProjection + + "; firmware = (Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={[string]$_.SecureBoot}},SecureBootTemplate)" + + "; security = (Get-VMSecurity -VM $vm | Select-Object TpmEnabled); startError = $startError }"); + } + + private static async Task ProtectParentAsync(ImageLibraryStore store, ImageLibraryEntry entry, CancellationToken ct) => + await store.UpdateLibraryAsync(index => + { + var current = index.Find(entry.Name) ?? entry; + MakeReadOnly(current.Path); + if (current.ReadOnly || !index.Entries.Contains(current)) return (index, current with { ReadOnly = true }); + var updated = current with { ReadOnly = true }; + return (Replace(index, current, updated), updated); + }, ct); + + /// Best effort: removes a half-created VM, the disk this deploy created and the folders it left empty. + private async Task RollbackAsync(string? vmId, string? disk, string folder) + { + if (vmId is not null && Guid.TryParse(vmId, out var id)) + { + try + { + await Runner.InvokeAsync(new HyperVScript() + .Line("$vm = $null; try { $vm = Get-VM -Id " + PsValue.Of(id).Text + " -ErrorAction Stop } catch { }") + .Line("if ($vm) { if ([string]$vm.State -ne 'Off') { Stop-VM -VM $vm -TurnOff -Force }; Remove-VM -VM $vm -Force }") + .Returns("$true"), CancellationToken.None, 120); + } + catch (Exception) { /* the original failure is the one reported */ } + } + ImageFiles.TryDelete(disk); + DeleteEmptyFolders(folder); + } + + private static void DeleteEmptyFolders(string folder) + { + try + { + if (!Directory.Exists(folder)) return; + foreach (var child in Directory.GetDirectories(folder)) DeleteEmptyFolders(child); + if (!Directory.EnumerateFileSystemEntries(folder).Any()) Directory.Delete(folder); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static ImageLibraryIndex Replace(ImageLibraryIndex index, ImageLibraryEntry current, ImageLibraryEntry updated) => + index with { Entries = index.Entries.Select(e => ReferenceEquals(e, current) ? updated : e).ToList() }; + + private static bool SameText(string? a, string? b) => a is not null && b is not null && string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool SamePath(string a, string b) => string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); } diff --git a/src/HyperVControl/Services/ImageDownloader.cs b/src/HyperVControl/Services/ImageDownloader.cs new file mode 100644 index 0000000..3741be1 --- /dev/null +++ b/src/HyperVControl/Services/ImageDownloader.cs @@ -0,0 +1,188 @@ +using System.Buffers; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; + +namespace HyperVControl.Services; + +/// Where a catalog disk URI resolved to: the final URL after redirects, its type and size, and, +/// for a zip whose server honors range requests, the central directory read from the archive's tail. +internal sealed record ImageProbe(Uri FinalUri, string Extension, long? Length, bool AcceptsRanges, IReadOnlyList? ZipEntries, string? Note); + +/// One finished download: its length, the bytes received now, and the bytes reused from an earlier partial file. +internal sealed record ImageDownload(long Length, long Downloaded, long ResumedFrom, string Sha256); + +/// +/// The HTTP side of a fetch. One suffix-range GET resolves redirects, the size and (for zips) the central +/// directory without downloading the payload. The download resumes an existing partial file with a Range +/// request, hashes while it writes, and reports a dropped or stalled connection as DownloadInterrupted, +/// keeping the partial file so the next fetch resumes it. A hash mismatch deletes the file. +/// +internal sealed class ImageDownloader(HttpClient http, TimeSpan stallTimeout) +{ + internal const int TailBytes = 64 * 1024; + private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(60); + + public async Task ProbeAsync(Uri uri, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Range = new RangeHeaderValue(null, TailBytes); + using var response = await SendAsync(request, ct); + var final = response.RequestMessage?.RequestUri ?? uri; + var extension = ImageCatalogFeed.ExtensionOf(final.AbsoluteUri); + if (response.StatusCode == HttpStatusCode.PartialContent && response.Content.Headers.ContentRange is { Length: long length }) + { + var tail = await ReadBodyAsync(response, TailBytes, ct); + if (extension != ".zip") return new(final, extension, length, true, null, null); + try + { + var entries = await ImageZip.ReadCentralDirectoryAsync(length, tail, (offset, count, token) => RangeAsync(final, offset, count, token), ct); + return new(final, extension, length, true, entries, null); + } + catch (Exception ex) when (ex is InvalidDataException or HyperVOperationException or IOException) + { + return new(final, extension, length, true, null, "The zip's central directory could not be read before downloading (" + ex.Message + "); it is checked after the download."); + } + } + if (response.StatusCode == HttpStatusCode.OK) + return new(final, extension, response.Content.Headers.ContentLength, false, null, + "The server ignores range requests: an interrupted download restarts, and the archive is inspected after it completes."); + throw Failure(response, final); + } + + private async Task RangeAsync(Uri uri, long offset, int count, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Range = new RangeHeaderValue(offset, offset + count - 1); + using var response = await SendAsync(request, ct); + if (response.StatusCode != HttpStatusCode.PartialContent) throw new InvalidDataException($"The server answered a range request with HTTP {(int)response.StatusCode}."); + var bytes = await ReadBodyAsync(response, count, ct); + return bytes.Length == count ? bytes : throw new InvalidDataException($"A range request returned {bytes.Length} of {count} bytes."); + } + + /// + /// Downloads into , resuming from its current length, and verifies + /// the SHA-256 of the whole file. The caller renames the verified file; on a mismatch the file is deleted here. + /// + public async Task DownloadAsync(Uri uri, string partial, long? length, string expectedSha256, Action progress, CancellationToken ct) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + long existing = File.Exists(partial) ? new FileInfo(partial).Length : 0; + if (length is long known && existing > known) { File.Delete(partial); existing = 0; } + if (existing > 0) + { + await using var previous = ImageFiles.OpenRead(partial); + var have = existing; + existing = await ImageFiles.HashStreamAsync(previous, hash, existing, done => progress("verifying the partial download", done, have), ct); + } + var resumedFrom = existing; + long received = 0; + if (length is not long complete || existing != complete) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (existing > 0) request.Headers.Range = new RangeHeaderValue(existing, null); + using var response = await SendAsync(request, ct); + var mode = FileMode.Create; + var satisfied = false; + if (existing > 0 && response.StatusCode == HttpStatusCode.PartialContent) + { + var range = response.Content.Headers.ContentRange; + if (range?.From != existing) + throw new HyperVOperationException($"The server answered the resume request from byte {existing} with range '{range}'.", "DownloadFailed"); + length ??= range.Length; + mode = FileMode.Append; + } + else if (response.StatusCode == HttpStatusCode.OK) + { + if (existing > 0) { hash.GetHashAndReset(); resumedFrom = 0; existing = 0; } + length ??= response.Content.Headers.ContentLength; + } + else if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && existing > 0 && response.Content.Headers.ContentRange?.Length == existing) + { + length = existing; + satisfied = true; + } + else throw Failure(response, uri); + + if (!satisfied) + { + await using var source = await response.Content.ReadAsStreamAsync(ct); + await using var target = new FileStream(partial, mode, FileAccess.Write, FileShare.Read, ImageFiles.BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + received = await ReceiveAsync(source, target, hash, existing, length, progress, ct); + } + } + var total = resumedFrom + received; + if (length is long expected && total != expected) + throw new HyperVOperationException($"The download ended after {total} of {expected} bytes. The partial file is kept; fetch again to resume.", "DownloadInterrupted"); + var actual = Convert.ToHexStringLower(hash.GetHashAndReset()); + if (!string.Equals(actual, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + ImageFiles.TryDelete(partial); + throw new HyperVOperationException($"SHA-256 mismatch: the catalog lists {expectedSha256} but the download hashes to {actual}. The download was deleted.", "HashMismatch"); + } + return new(total, received, resumedFrom, actual); + } + + private async Task ReceiveAsync(Stream source, FileStream target, IncrementalHash hash, long offset, long? length, Action progress, CancellationToken ct) + { + var buffer = ArrayPool.Shared.Rent(ImageFiles.BufferSize); + using var stall = CancellationTokenSource.CreateLinkedTokenSource(ct); + long received = 0; + try + { + while (true) + { + stall.CancelAfter(stallTimeout); + int read; + try { read = await source.ReadAsync(buffer.AsMemory(0, ImageFiles.BufferSize), stall.Token); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new HyperVOperationException($"The download stalled: no data for {stallTimeout.TotalSeconds:0} s after {offset + received} bytes. " + + "The partial file is kept; fetch again to resume.", "DownloadInterrupted"); + } + catch (Exception ex) when ((ex is IOException || ex is HttpRequestException) && !ct.IsCancellationRequested) + { + throw new HyperVOperationException($"The download was interrupted after {offset + received} bytes ({ex.Message}). " + + "The partial file is kept; fetch again to resume.", "DownloadInterrupted"); + } + if (read == 0) break; + await target.WriteAsync(buffer.AsMemory(0, read), ct); + hash.AppendData(buffer, 0, read); + received += read; + progress("downloading", offset + received, length); + } + await target.FlushAsync(ct); + return received; + } + finally { ArrayPool.Shared.Return(buffer); } + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct); + headers.CancelAfter(HeaderTimeout); + try { return await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, headers.Token); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new HyperVOperationException($"No response from {request.RequestUri?.Host} within {HeaderTimeout.TotalSeconds:0} s.", "DownloadInterrupted"); + } + catch (HttpRequestException ex) + { + throw new HyperVOperationException($"Cannot download {request.RequestUri}: {ex.Message}", "DownloadFailed"); + } + } + + private static async Task ReadBodyAsync(HttpResponseMessage response, int limit, CancellationToken ct) + { + await using var stream = await response.Content.ReadAsStreamAsync(ct); + using var memory = new MemoryStream(); + var buffer = new byte[16384]; + int read; + while (memory.Length < limit && (read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, limit - memory.Length)), ct)) > 0) + memory.Write(buffer, 0, read); + return memory.ToArray(); + } + + private static HyperVOperationException Failure(HttpResponseMessage response, Uri uri) => + new($"Download failed: HTTP {(int)response.StatusCode} {response.ReasonPhrase} from {uri}.", response.StatusCode == HttpStatusCode.NotFound ? "NotFound" : "DownloadFailed"); +} diff --git a/src/HyperVControl/Services/ImageFiles.cs b/src/HyperVControl/Services/ImageFiles.cs new file mode 100644 index 0000000..260e4ae --- /dev/null +++ b/src/HyperVControl/Services/ImageFiles.cs @@ -0,0 +1,293 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Globalization; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace HyperVControl.Services; + +/// One entry of a zip central directory, with Zip64 sizes already applied. +internal sealed record ZipEntryInfo(string FullName, long CompressedSize, long UncompressedSize, int Method); + +/// +/// File-level image work, all in C#: the magic bytes VMCreate checks, streaming SHA-256, free-space +/// preflight, safe file names, and cancellation-aware copies and zip extraction. +/// +internal static class ImageFiles +{ + /// Headroom kept free on the target volume beyond what a download or extraction needs. + public const long SpaceMargin = 1L << 30; + internal const int BufferSize = 1 << 20; + + private static ReadOnlySpan ZipMagic => [0x50, 0x4B, 0x03, 0x04]; + private static ReadOnlySpan VhdxMagic => "vhdxfile"u8; + private static ReadOnlySpan IsoMagic => "CD001"u8; + private static readonly long[] IsoOffsets = [0x8001, 0x8801, 0x9001]; + private static readonly Regex ReservedName = new(@"^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// True when the file carries the signature VMCreate expects for its extension + /// (zip PK\x03\x04, vhdx vhdxfile, iso CD001 at 0x8001, 0x8801 or 0x9001). + public static bool HasMagic(string path, string extension) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1); + switch (extension.ToLowerInvariant()) + { + case ".zip": return Matches(stream, 0, ZipMagic); + case ".vhdx": return Matches(stream, 0, VhdxMagic); + case ".iso": + foreach (var offset in IsoOffsets) + if (Matches(stream, offset, IsoMagic)) return true; + return false; + default: return false; + } + } + + private static bool Matches(FileStream stream, long offset, ReadOnlySpan magic) + { + if (stream.Length < offset + magic.Length) return false; + Span buffer = stackalloc byte[magic.Length]; + stream.Position = offset; + stream.ReadExactly(buffer); + return buffer.SequenceEqual(magic); + } + + /// A conservative file name: ASCII letters, digits, '.', '_' and '-' only, at most 80 characters. + public static string SafeName(string name) + { + var builder = new StringBuilder(name.Length); + foreach (var character in name.Normalize(NormalizationForm.FormKC)) + builder.Append(char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-' ? character : '-'); + var safe = Regex.Replace(builder.ToString(), "-{2,}", "-").Trim('-', '.', '_'); + if (safe.Length > 80) safe = safe[..80].TrimEnd('-', '.', '_'); + if (safe.Length == 0) safe = "image"; + return ReservedName.IsMatch(safe) ? "image-" + safe : safe; + } + + /// A directory name that keeps the VM name readable: only characters Windows rejects are replaced. + public static string SafeDirectoryName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var safe = new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim().TrimEnd('.'); + if (safe.Length == 0) safe = "vm"; + return ReservedName.IsMatch(safe) ? "vm-" + safe : safe; + } + + public static bool SameEntry(string entryName, string relativePath) => + string.Equals(entryName.Replace('\\', '/').TrimStart('/'), relativePath.Replace('\\', '/').TrimStart('/'), StringComparison.OrdinalIgnoreCase); + + [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetDiskFreeSpaceExW(string directory, out ulong freeBytesAvailable, out ulong totalBytes, out ulong totalFreeBytes); + + /// Bytes available to this user on the volume holding (mount points and UNC paths included). + public static long FreeBytes(string directory) + { + if (!GetDiskFreeSpaceExW(Path.TrimEndingDirectorySeparator(directory) + Path.DirectorySeparatorChar, out var available, out _, out _)) + throw new IOException($"Cannot read the free space of {directory} (Win32 error {Marshal.GetLastPInvokeError()})."); + return (long)Math.Min(available, long.MaxValue); + } + + /// Fails with InsufficientDiskSpace unless bytes plus a 1 GB margin are free. + public static void EnsureSpace(string directory, long needed, string purpose) + { + var free = FreeBytes(directory); + var required = Math.Max(0, needed) + SpaceMargin; + if (free < required) + throw new HyperVOperationException($"Not enough free space for {purpose} in {directory}: {Gb(required)} needed (including a 1 GB margin), {Gb(free)} free. " + + $"Free space or set {ImageCatalogService.RootVariable} to a larger volume.", "InsufficientDiskSpace"); + } + + public static string Gb(long bytes) => (bytes / 1e9).ToString("0.0#", CultureInfo.InvariantCulture) + " GB"; + + public static FileStream OpenRead(string path) => + new(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + + /// Feeds up to bytes of into ; returns the bytes read. + public static async Task HashStreamAsync(Stream stream, IncrementalHash hash, long limit, Action? progress, CancellationToken ct) + { + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + long done = 0; + while (done < limit) + { + var read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, limit - done)), ct); + if (read == 0) break; + hash.AppendData(buffer, 0, read); + done += read; + progress?.Invoke(done); + } + return done; + } + finally { ArrayPool.Shared.Return(buffer); } + } + + public static async Task Sha256Async(string path, Action? progress, CancellationToken ct) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + await using var stream = OpenRead(path); + await HashStreamAsync(stream, hash, long.MaxValue, progress, ct); + return Convert.ToHexStringLower(hash.GetHashAndReset()); + } + + /// Copies a file to a new path with cancellation and progress; the target is pre-sized and never overwritten. + public static async Task CopyAsync(string source, string target, Action? progress, CancellationToken ct) + { + await using var input = OpenRead(source); + await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + var length = input.Length; + output.SetLength(length); + var done = await PumpAsync(input, output, length, progress, ct); + if (done != length) throw new IOException($"Copied {done} of {length} bytes from {source}."); + } + + /// + /// Streams one entry, matched case-insensitively against as VMCreate does, + /// to . The archive reader handles Zip64; free space is checked against the entry size first. + /// + public static async Task ExtractAsync(string archivePath, string relativePath, string target, Action? progress, CancellationToken ct) + { + using var archive = ZipFile.OpenRead(archivePath); + var entry = archive.Entries.FirstOrDefault(e => SameEntry(e.FullName, relativePath)) + ?? throw new HyperVOperationException($"The archive has no entry '{relativePath}'. Entries: {string.Join(", ", archive.Entries.Take(20).Select(e => e.FullName))}.", "UnsupportedImage"); + EnsureSpace(Path.GetDirectoryName(target)!, entry.Length, "extracting " + entry.FullName); + await using var input = entry.Open(); + await using var output = new FileStream(target, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + output.SetLength(entry.Length); + var done = await PumpAsync(input, output, entry.Length, progress, ct); + if (done != entry.Length) throw new InvalidDataException($"The entry '{entry.FullName}' produced {done} of {entry.Length} bytes."); + return done; + } + + private static async Task PumpAsync(Stream input, Stream output, long total, Action? progress, CancellationToken ct) + { + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + long done = 0; + int read; + while ((read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), ct)) > 0) + { + await output.WriteAsync(buffer.AsMemory(0, read), ct); + done += read; + progress?.Invoke(done, total); + } + await output.FlushAsync(ct); + return done; + } + finally { ArrayPool.Shared.Return(buffer); } + } + + public static void TryDelete(string? path) + { + if (path is null) return; + try + { + if (!File.Exists(path)) return; + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} + +/// +/// Reads a zip central directory from the archive's tail, so a remote archive's entry sizes are known from a +/// 64 KB range request before downloading it. Handles Zip64 (EOCD locator, Zip64 EOCD record and the +/// extended-information extra field); further ranges are read through a caller-supplied reader. +/// +internal static class ImageZip +{ + private const uint EndOfCentralDirectory = 0x06054b50, Zip64Locator = 0x07064b50, Zip64EndOfCentralDirectory = 0x06064b50, CentralHeader = 0x02014b50; + private const int MaxCentralDirectory = 64 << 20; + + public delegate Task RangeReader(long offset, int count, CancellationToken ct); + + /// The archive length. + /// The last tail.Length bytes of the archive. + /// Reads bytes outside the tail (another range request, or a file read). + public static async Task> ReadCentralDirectoryAsync(long length, byte[] tail, RangeReader read, CancellationToken ct) + { + var tailStart = length - tail.Length; + var end = -1; + for (var i = tail.Length - 22; i >= 0; i--) + if (BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(i)) == EndOfCentralDirectory) { end = i; break; } + if (end < 0) throw new InvalidDataException("No zip end-of-central-directory record in the archive's last " + tail.Length + " bytes."); + ulong entries = BinaryPrimitives.ReadUInt16LittleEndian(tail.AsSpan(end + 10)); + ulong size = BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(end + 12)); + ulong offset = BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(end + 16)); + if (entries == 0xFFFF || size == 0xFFFFFFFF || offset == 0xFFFFFFFF) + { + var locator = await SliceAsync(tailStart + end - 20, 20); + if (BinaryPrimitives.ReadUInt32LittleEndian(locator) != Zip64Locator) throw new InvalidDataException("Zip64 end-of-central-directory locator missing."); + var recordOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(locator.AsSpan(8)); + var record = await SliceAsync(recordOffset, 56); + if (BinaryPrimitives.ReadUInt32LittleEndian(record) != Zip64EndOfCentralDirectory) throw new InvalidDataException("Zip64 end-of-central-directory record missing."); + entries = BinaryPrimitives.ReadUInt64LittleEndian(record.AsSpan(32)); + size = BinaryPrimitives.ReadUInt64LittleEndian(record.AsSpan(40)); + offset = BinaryPrimitives.ReadUInt64LittleEndian(record.AsSpan(48)); + } + if (size > MaxCentralDirectory || offset + size > (ulong)length) throw new InvalidDataException("The zip central directory is out of range."); + var directory = await SliceAsync((long)offset, (int)size); + var result = new List(); + var position = 0; + for (ulong n = 0; n < entries && position + 46 <= directory.Length; n++) + { + var header = directory.AsSpan(position); + if (BinaryPrimitives.ReadUInt32LittleEndian(header) != CentralHeader) throw new InvalidDataException("Corrupt zip central directory header."); + var flags = BinaryPrimitives.ReadUInt16LittleEndian(header[8..]); + var method = BinaryPrimitives.ReadUInt16LittleEndian(header[10..]); + long compressed = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + long uncompressed = BinaryPrimitives.ReadUInt32LittleEndian(header[24..]); + var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(header[28..]); + var extraLength = BinaryPrimitives.ReadUInt16LittleEndian(header[30..]); + var commentLength = BinaryPrimitives.ReadUInt16LittleEndian(header[32..]); + if (46 + nameLength + extraLength > header.Length) throw new InvalidDataException("Truncated zip central directory entry."); + var name = ((flags & 0x0800) != 0 ? Encoding.UTF8 : Encoding.Latin1).GetString(header.Slice(46, nameLength)); + var extra = header.Slice(46 + nameLength, extraLength); + while (extra.Length >= 4) + { + var id = BinaryPrimitives.ReadUInt16LittleEndian(extra); + var dataLength = BinaryPrimitives.ReadUInt16LittleEndian(extra[2..]); + if (4 + dataLength > extra.Length) break; + if (id == 0x0001) + { + // Zip64 extended information: only the fields whose 32-bit value is saturated, in this order. + var data = extra.Slice(4, dataLength); + if (uncompressed == 0xFFFFFFFF && data.Length >= 8) { uncompressed = (long)BinaryPrimitives.ReadUInt64LittleEndian(data); data = data[8..]; } + if (compressed == 0xFFFFFFFF && data.Length >= 8) compressed = (long)BinaryPrimitives.ReadUInt64LittleEndian(data); + } + extra = extra[(4 + dataLength)..]; + } + result.Add(new(name, compressed, uncompressed, method)); + position += 46 + nameLength + extraLength + commentLength; + } + return result; + + Task SliceAsync(long start, int count) + { + if (start < 0 || start + count > length) throw new InvalidDataException("A zip record points outside the archive."); + return start >= tailStart ? Task.FromResult(tail[(int)(start - tailStart)..(int)(start - tailStart + count)]) : read(start, count, ct); + } + } + + /// Reads the central directory of a local archive through the same parser (tests and diagnostics). + public static async Task> ReadCentralDirectoryAsync(string path, CancellationToken ct) + { + await using var stream = ImageFiles.OpenRead(path); + var tail = new byte[(int)Math.Min(stream.Length, ImageDownloader.TailBytes)]; + stream.Position = stream.Length - tail.Length; + await stream.ReadExactlyAsync(tail, ct); + return await ReadCentralDirectoryAsync(stream.Length, tail, async (offset, count, token) => + { + var bytes = new byte[count]; + stream.Position = offset; + await stream.ReadExactlyAsync(bytes, token); + return bytes; + }, ct); + } +} diff --git a/src/HyperVControl/Services/ImageLibrary.cs b/src/HyperVControl/Services/ImageLibrary.cs new file mode 100644 index 0000000..81c0711 --- /dev/null +++ b/src/HyperVControl/Services/ImageLibrary.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HyperVControl.Services; + +/// Where a library file came from: a catalog image (with its verified SHA-256) or a registered local file. +internal sealed record ImageSource( + string? Catalog = null, string? CatalogLocation = null, string? Image = null, string? Version = null, DateTimeOffset? LastUpdated = null, + string? Uri = null, string? Sha256 = null, string? ArchiveRelativePath = null, string? RegisteredFrom = null); + +/// Firmware and session settings applied at deploy; null means "use the OS family default". +internal sealed record ImageConfig(bool? SecureBoot = null, string? EnhancedSessionTransportType = null); + +internal sealed record ImageSizes(long? FileBytes = null, long? ArchiveBytes = null); + +/// A VM disk created from a library entry: a differencing child (linked) or a full copy. +internal sealed record ImageDeployment(string VmName, string? VmId, string DiskPath, bool Linked, DateTimeOffset CreatedAt); + +/// +/// One library entry: a .vhdx or .iso on the host, either fetched from a catalog (the library owns the file) +/// or registered in place. records that the library set the file's read-only attribute +/// because it is (or is meant to be) a differencing parent. +/// +internal sealed record ImageLibraryEntry( + string Name, string Kind, string OsFamily, string Path, string Format, ImageSource Source, ImageSizes Sizes, ImageConfig Config, + DateTimeOffset RegisteredAt, bool ReadOnly = false, bool Expired = false, string? Notes = null, string? ArchivePath = null, + IReadOnlyList? Warnings = null, IReadOnlyList? Deployments = null); + +internal sealed record ImageLibraryIndex(int Version, IReadOnlyList Entries) +{ + public ImageLibraryEntry? Find(string name) => Entries.FirstOrDefault(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); +} + +/// A catalog location: an http(s) URL, a file: URI, or an absolute local or UNC path to a gallery JSON. +internal sealed record ImageCatalogSource(string Name, string Location, bool Enabled = true, bool BuiltIn = false); + +internal sealed record ImageCatalogIndex(int Version, IReadOnlyList Catalogs) +{ + public static ImageCatalogIndex Default => new(1, [new(ImageCatalogFeed.MicrosoftName, ImageCatalogFeed.MicrosoftLocation, true, true)]); +} + +/// +/// The library root on disk: library.json, catalogs.json, the cache directory for downloads, +/// and the image files. Updates are read-modify-write under an in-process gate plus a lock file shared with other +/// MCP processes, and every write replaces the file atomically, so readers never see a partial index. +/// +internal sealed class ImageLibraryStore(string root) +{ + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private static readonly SemaphoreSlim Gate = new(1, 1); + + public string Root { get; } = root; + public string LibraryFile => Path.Combine(Root, "library.json"); + public string CatalogsFile => Path.Combine(Root, "catalogs.json"); + public string CacheDirectory => Path.Combine(Root, "cache"); + + public Task LibraryAsync(CancellationToken ct) => + Task.Run(() => Read(LibraryFile) ?? new(1, []), ct); + + /// The catalog list; the first read writes the default (Microsoft's feed) so users can see and edit it. + public async Task CatalogsAsync(CancellationToken ct) => + Read(CatalogsFile) ?? await UpdateCatalogsAsync(index => (index, index), ct); + + public Task UpdateLibraryAsync(Func update, CancellationToken ct) => + UpdateAsync(LibraryFile, () => Read(LibraryFile) ?? new(1, []), update, ct); + + public Task UpdateCatalogsAsync(Func update, CancellationToken ct) => + UpdateAsync(CatalogsFile, () => Read(CatalogsFile) ?? ImageCatalogIndex.Default, update, ct); + + private async Task UpdateAsync(string file, Func read, Func update, CancellationToken ct) + { + await Gate.WaitAsync(ct); + try + { + await using var crossProcess = await LockAsync(ct); + var exists = File.Exists(file); + var current = read(); + var (next, result) = update(current); + if (!exists || !ReferenceEquals(next, current)) WriteAtomic(file, next!); + return result; + } + finally { Gate.Release(); } + } + + private async Task LockAsync(CancellationToken ct) + { + Directory.CreateDirectory(Root); + var path = Path.Combine(Root, ".library.lock"); + var clock = Stopwatch.StartNew(); + while (true) + { + try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.DeleteOnClose); } + catch (Exception ex) when ((ex is IOException || ex is UnauthorizedAccessException) && clock.Elapsed < TimeSpan.FromSeconds(30)) + { + await Task.Delay(100, ct); + } + } + } + + private static void WriteAtomic(string path, object value) + { + var temporary = path + "." + Guid.NewGuid().ToString("N")[..8] + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(value, value.GetType(), Json) + "\n", new UTF8Encoding(false)); + File.Move(temporary, path, overwrite: true); + } + + private static T? Read(string path) where T : class + { + for (var attempt = 0; ; attempt++) + { + try + { + if (!File.Exists(path)) return null; + return JsonSerializer.Deserialize(ImageCatalogFeed.Decode(File.ReadAllBytes(path)), Json) + ?? throw new HyperVOperationException($"{path} is empty. Fix or delete it; the library never rewrites an unreadable index.", "InvalidLibrary"); + } + catch (IOException) when (attempt < 20) { Thread.Sleep(50); } + catch (JsonException ex) + { + throw new HyperVOperationException($"{path} is not a valid index ({ex.Message}). Fix or delete it; the library never rewrites an unreadable index.", "InvalidLibrary"); + } + } + } +} diff --git a/tests/HyperVControl.Tests/ImageLiveTests.cs b/tests/HyperVControl.Tests/ImageLiveTests.cs new file mode 100644 index 0000000..087cf25 --- /dev/null +++ b/tests/HyperVControl.Tests/ImageLiveTests.cs @@ -0,0 +1,223 @@ +using System.Diagnostics; +using System.Text.Json; +using HyperVControl.Services; + +namespace HyperVControl.Tests; + +/// +/// Live validation of the image workflow (phase images-live), authorized for one Ubuntu 22.04 fetch from +/// Microsoft's gallery (2.5 GB zip, 6.4 GB VHDX) and throwaway VMs: list, fetch as a polled job, the in-use guard +/// on the real module, a linked-clone deploy that boots to the first-boot wizard (heartbeat plus a basic-console +/// screenshot), then removal of the VM, its disk, the library entry and every downloaded byte, even on failure. +/// Run it from an elevated shell with HYPERV_CONTROL_IMAGE_ROOT set: a worker started by --elevate does not inherit +/// process environment variables. Only VMs named HyperVControl-validation-images-* are ever operated on. +/// +internal static class ImageLiveTests +{ + private const string ImageName = "Ubuntu 22.04 LTS"; + private const string Prefix = "HyperVControl-validation-images-"; + private const string JammySha256 = "eb6b904aa40f7214bdeade25e7b189db8c25a021b711677c1049d61867ff20cf"; + + internal static async Task RunAsync(ToolCall call, Action check, string results) + { + var root = Environment.GetEnvironmentVariable(ImageCatalogService.RootVariable) + ?? throw new InvalidOperationException($"Set {ImageCatalogService.RootVariable} (for example D:\\HyperVControl-validation\\images) for this phase."); + if (!ElevationBridge.IsAdministrator) + throw new InvalidOperationException("Run images-live from an elevated shell: the --elevate worker would not inherit " + ImageCatalogService.RootVariable + "."); + root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + var vmRoot = root + "-vms"; + var vmName = Prefix + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var clock = Stopwatch.StartNew(); + var steps = new List(); + var progress = new List(); + var screenshots = new List(); + var evidence = new Dictionary { ["root"] = root, ["vmRoot"] = vmRoot, ["vmName"] = vmName, ["image"] = ImageName, ["startedAt"] = DateTimeOffset.UtcNow }; + void Step(string name, object? detail = null) + { + steps.Add(new { name, seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), detail }); + Console.WriteLine($"STEP {clock.Elapsed.TotalSeconds,6:0}s {name}"); + } + void Pass(bool condition, string message) + { + check(condition, message); + steps.Add(new { name = "PASS " + message, seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), detail = (object?)null }); + } + + string? vmId = null; + Exception? failure = null; + try + { + var before = await call("hyperv_list", label: "images-live-inventory-before"); + var existing = before.GetProperty("machines").EnumerateArray().Select(m => m.GetProperty("name").GetString()).ToList(); + evidence["vmsBefore"] = existing; + Pass(!existing.Contains(vmName), "The throwaway VM name is unused"); + + // 1. List: Microsoft's feed, warnings and the recommendation; no disk URL is touched. + var list = await call("hyperv_image", new { action = "list" }, label: "images-live-list"); + var images = list.GetProperty("images").EnumerateArray().ToList(); + var jammy = images.Single(i => i.GetProperty("catalog").GetString() == "microsoft" && i.GetProperty("name").GetString() == ImageName); + var windev = images.Single(i => i.GetProperty("name").GetString() == "Windows 11 dev environment"); + evidence["list"] = new + { + catalogs = list.GetProperty("catalogs"), recommended = list.GetProperty("recommended"), + images = images.Select(i => new { name = i.GetProperty("name").GetString(), expired = i.GetProperty("expired").GetBoolean(), warnings = i.GetProperty("warnings") }).ToList() + }; + Pass(jammy.GetProperty("fetchable").GetBoolean() && jammy.GetProperty("disk").GetProperty("sha256").GetString() == JammySha256 + && windev.GetProperty("expired").GetBoolean() && list.GetProperty("recommended").GetProperty("linux").GetProperty("name").GetString() == ImageName, + "list reads Microsoft's live feed, flags the expired Windows image and recommends Ubuntu 22.04"); + + // 2. Fetch as a job and poll it. + var started = await call("hyperv_image", new { action = "fetch", imageName = ImageName, catalog = "microsoft" }, label: "images-live-fetch-start"); + JsonElement job; + if (started.TryGetProperty("alreadyInLibrary", out _)) throw new InvalidOperationException($"{root} already holds {ImageName}; clean the validation root before this phase."); + var jobId = started.GetProperty("id").GetString()!; + Step("fetch started", jobId); + var sawDownloading = false; + do + { + job = await call("hyperv_jobs", new { action = "wait", jobId, waitSeconds = 20 }, label: "images-live-fetch-poll"); + var p = job.GetProperty("progress"); + sawDownloading |= p.GetProperty("message").GetString() == "downloading"; + progress.Add(new + { + seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), status = job.GetProperty("status").GetString(), message = p.GetProperty("message").GetString(), + percent = p.GetProperty("percent").ValueKind == JsonValueKind.Number ? p.GetProperty("percent").GetDouble() : (double?)null, + bytesDone = p.GetProperty("bytesDone").ValueKind == JsonValueKind.Number ? p.GetProperty("bytesDone").GetInt64() : (long?)null, + bytesTotal = p.GetProperty("bytesTotal").ValueKind == JsonValueKind.Number ? p.GetProperty("bytesTotal").GetInt64() : (long?)null + }); + } + while (job.GetProperty("status").GetString() is "queued" or "running"); + evidence["fetch"] = job; + Pass(job.GetProperty("status").GetString() == "succeeded", "The fetch job succeeds: " + job.GetProperty("error")); + var entry = job.GetProperty("result").GetProperty("entry"); + var imagePath = entry.GetProperty("path").GetString()!; + var cache = Path.Combine(root, "cache"); + Pass(entry.GetProperty("format").GetString() == "vhdx" && imagePath.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase) + && new FileInfo(imagePath).Length > 6_000_000_000L && ImageFiles.HasMagic(imagePath, ".vhdx") + && entry.GetProperty("source").GetProperty("sha256").GetString() == JammySha256 + && entry.GetProperty("config").GetProperty("secureBoot").GetBoolean() == false + && (!Directory.Exists(cache) || Directory.GetFiles(cache).Length == 0) && sawDownloading, + "Fetched, verified and extracted a 6+ GB VHDX into the library; the archive was deleted; progress was observable"); + + // 3. Deploy a linked clone with the lab defaults and start it. + var deployed = await call("hyperv_image", new + { + action = "deploy", imageName = ImageName, vmName, vmPath = vmRoot, memoryMb = 4096, processors = 2, start = true, + notes = "HyperVControl images-live validation VM; safe to delete." + }, label: "images-live-deploy"); + vmId = deployed.GetProperty("vm").GetProperty("Id").GetString(); + evidence["deploy"] = deployed; + var vm = deployed.GetProperty("vm"); + Pass(deployed.GetProperty("disk").GetProperty("type").GetString() == "differencing" + && string.Equals(deployed.GetProperty("disk").GetProperty("parentPath").GetString(), imagePath, StringComparison.OrdinalIgnoreCase) + && deployed.GetProperty("firmware").GetProperty("SecureBoot").GetString() == "Off" + && vm.GetProperty("EnhancedSessionTransportType").GetString() == "HvSocket" && !vm.GetProperty("DynamicMemoryEnabled").GetBoolean() + && !vm.GetProperty("AutomaticCheckpointsEnabled").GetBoolean() && vm.GetProperty("CheckpointType").GetString() == "Standard" + && deployed.GetProperty("started").GetBoolean() && File.GetAttributes(imagePath).HasFlag(FileAttributes.ReadOnly), + "Deploy creates a differencing child, applies the lab defaults (Secure Boot off per the feed, HvSocket, static memory, Standard checkpoints) and starts the VM"); + + var guarded = await call("hyperv_image", new { action = "remove", name = ImageName, deleteFile = true }, allowError: true, label: "images-live-remove-guarded"); + Pass(guarded.GetProperty("errorType").GetString() == "InUse" && File.Exists(imagePath) + && guarded.GetProperty("references").EnumerateArray().Any(r => r.GetProperty("relation").GetString() == "differencingParent"), + "The real module's parent chain makes remove refuse the image while the clone exists"); + + // 4. Boot evidence: heartbeat from the guest kernel and the first-boot wizard on the basic console. + await call("hyperv_console", new { action = "open", machine = vmId, session = "basic" }, label: "images-live-console-open"); + var heartbeat = ""; + long largest = 0; + for (var attempt = 1; attempt <= 16; attempt++) + { + await Task.Delay(TimeSpan.FromSeconds(15)); + var label = $"images-live-screenshot-{attempt:00}"; + await call("hyperv_screenshot", new { machine = vmId }, label: label); + var png = Path.Combine(results, label + ".png"); + var size = File.Exists(png) ? new FileInfo(png).Length : 0; + largest = Math.Max(largest, size); + var info = await call("hyperv_vm_info", new { machine = vmId }, label: "images-live-vm-info"); + heartbeat = info.GetProperty("integration").EnumerateArray() + .Where(s => s.GetProperty("Name").GetString() == "Heartbeat").Select(s => s.GetProperty("PrimaryStatusDescription").GetString()).FirstOrDefault() ?? ""; + screenshots.Add(new { attempt, seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), png = label + ".png", bytes = size, heartbeat }); + if (heartbeat == "OK" && size > 40_000 && attempt >= 6) break; + } + evidence["screenshots"] = screenshots; + Pass(heartbeat == "OK" && largest > 40_000, $"The clone boots: heartbeat {heartbeat}, console framebuffer {largest} bytes of PNG (see the saved screenshots)"); + await call("hyperv_console", new { action = "close", machine = vmId }, allowError: true, label: "images-live-console-close"); + + // 5. Stop, remove the VM and its disk, then the library entry and the image file. + await call("hyperv_power", new { machine = vmId, action = "turn_off" }, label: "images-live-turn-off"); + await call("hyperv_vm_remove", new { machineId = vmId }, label: "images-live-vm-remove"); + var removedId = vmId; + vmId = null; + await DeleteVmFolderAsync(vmRoot, vmName); + var after = await call("hyperv_list", label: "images-live-inventory-after"); + Pass(!after.GetProperty("machines").EnumerateArray().Any(m => m.GetProperty("id").GetString() == removedId) && !Directory.Exists(Path.Combine(vmRoot, vmName)), + "The throwaway VM and its differencing disk are gone"); + var removed = await call("hyperv_image", new { action = "remove", name = ImageName, deleteFile = true }, label: "images-live-remove"); + Pass(!File.Exists(imagePath) && removed.GetProperty("deleted").GetArrayLength() == 1, "The library entry and its image file are removed"); + Step("done"); + } + catch (Exception ex) + { + failure = ex; + evidence["failure"] = ex.ToString(); + } + finally + { + // Cleanup runs whether or not the checks passed, and touches only this phase's VM and folders. + try { await CleanupAsync(call, vmId, vmName, vmRoot, root, steps); } + catch (Exception ex) { evidence["cleanupFailure"] = ex.ToString(); failure ??= ex; } + evidence["steps"] = steps; + evidence["progress"] = progress; + evidence["screenshots"] = screenshots; + evidence["seconds"] = Math.Round(clock.Elapsed.TotalSeconds, 1); + evidence["leftovers"] = new[] { root, vmRoot }.Where(Directory.Exists).SelectMany(d => Directory.EnumerateFileSystemEntries(d, "*", SearchOption.AllDirectories)).ToList(); + await File.WriteAllTextAsync(Path.Combine(results, "images-live-evidence.json"), + JsonSerializer.Serialize(evidence, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true })); + } + if (failure is not null) throw new Exception("images-live failed; cleanup ran. " + failure.Message, failure); + } + + private static async Task CleanupAsync(ToolCall call, string? vmId, string vmName, string vmRoot, string root, List steps) + { + // Find this phase's VM by its unique name if the deploy result was lost; never act on any other VM. + var inventory = await call("hyperv_list", allowError: true, label: "images-live-cleanup-inventory"); + if (inventory.TryGetProperty("machines", out var machines)) + { + foreach (var machine in machines.EnumerateArray()) + { + var name = machine.GetProperty("name").GetString() ?? ""; + var id = machine.GetProperty("id").GetString(); + if (!name.StartsWith(Prefix, StringComparison.Ordinal) || (name != vmName && id != vmId)) continue; + await call("hyperv_console", new { action = "close", machine = id }, allowError: true, label: "images-live-cleanup-console"); + await call("hyperv_power", new { machine = id, action = "turn_off" }, allowError: true, label: "images-live-cleanup-turn-off"); + await call("hyperv_vm_remove", new { machineId = id }, allowError: true, label: "images-live-cleanup-vm-remove"); + steps.Add(new { name = "cleanup removed VM " + id, seconds = 0.0, detail = (object?)null }); + } + } + await DeleteVmFolderAsync(vmRoot, vmName); + await call("hyperv_image", new { action = "remove", name = ImageName, deleteFile = true }, allowError: true, label: "images-live-cleanup-remove"); + // The validation root and VM folder belong to this phase only; drop everything left in them (partials, indexes). + foreach (var directory in new[] { root, vmRoot }) + { + if (!Directory.Exists(directory)) continue; + foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(directory, true); + } + } + + /// Deletes this phase's VM folder; VMMS can hold the disk briefly after Remove-VM, so it retries. + private static async Task DeleteVmFolderAsync(string vmRoot, string vmName) + { + var folder = Path.Combine(vmRoot, vmName); + if (!vmName.StartsWith(Prefix, StringComparison.Ordinal)) return; + for (var attempt = 1; Directory.Exists(folder); attempt++) + { + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(folder, true); + } + catch (Exception ex) when ((ex is IOException || ex is UnauthorizedAccessException) && attempt < 10) { await Task.Delay(3000); } + } + } +} diff --git a/tests/HyperVControl.Tests/ImageTests.cs b/tests/HyperVControl.Tests/ImageTests.cs new file mode 100644 index 0000000..80c89fe --- /dev/null +++ b/tests/HyperVControl.Tests/ImageTests.cs @@ -0,0 +1,621 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; + +namespace HyperVControl.Tests; + +/// +/// Image catalog, library, fetch jobs and deploy against the recording mock module and a loopback HTTP +/// server: the saved live gallery feed, a UTF-8 custom catalog, Zip64 central directories, resume after an +/// interrupted download, cancellation cleanup, hash mismatches, the in-use guard and deploy rendering. +/// +internal static class ImageTests +{ + private static readonly DateTimeOffset FeedDay = new(2026, 10, 13, 0, 0, 0, TimeSpan.Zero); + private static readonly Version Host = new(10, 0, 26200, 0); + private static readonly string[] LiveFeedNames = + ["Windows 10 MSIX packaging environment", "Ubuntu 18.04 LTS", "Ubuntu 20.04 LTS", "Ubuntu 22.04 LTS", "Windows 11 dev environment"]; + + internal static async Task RunAsync(Action check) + { + var temp = Path.Combine(Path.GetTempPath(), "HyperVControl-images-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temp); + var previousRoot = Environment.GetEnvironmentVariable(ImageCatalogService.RootVariable); + try + { + ParseFeeds(check); + await ZipDirectories(check, temp); + await using var server = new FixtureServer(); + await LibraryScenarios(check, temp, server); + } + finally + { + Environment.SetEnvironmentVariable(ImageCatalogService.RootVariable, previousRoot); + foreach (var file in Directory.EnumerateFiles(temp, "*", SearchOption.AllDirectories)) File.SetAttributes(file, FileAttributes.Normal); + try { Directory.Delete(temp, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + private static void ParseFeeds(Action check) + { + var bytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "MockHyperV", "Fixtures", "GalleryHyperV.json")); + check(bytes is [0xFF, 0xFE, ..] && Encoding.Unicode.GetString(bytes.AsSpan(2)).TrimEnd().EndsWith("}\r\n,\r\n]\r\n}"), + "Fixture is the saved live feed: UTF-16 LE with a BOM and trailing commas"); + var feed = ImageCatalogFeed.Parse("microsoft", ImageCatalogFeed.Decode(bytes), Host, FeedDay); + check(feed.Images.Select(i => i.Name).SequenceEqual(LiveFeedNames) && feed.Hidden == 0 && feed.Skipped.Count == 0, + "The live feed parses leniently into its 5 images"); + var jammy = feed.Images.Single(i => i.Name == "Ubuntu 22.04 LTS"); + check(jammy.SecureBoot == false && jammy.EnhancedSessionTransportType == "HvSocket" && jammy.OsFamily == "linux" + && jammy.Sha256 == "eb6b904aa40f7214bdeade25e7b189db8c25a021b711677c1049d61867ff20cf" && jammy.ArchiveRelativePath == "livecd.ubuntu-desktop-hyperv.vhdx" + && jammy.DiskExtension == ".zip" && jammy.Fetchable && jammy.Stale && !jammy.Expired && !jammy.EndOfLife, + "Ubuntu 22.04: string secureBoot, HvSocket, SHA-256 from the prefixed hash, stale but still supported"); + var windev = feed.Images.Single(i => i.Name == "Windows 11 dev environment"); + check(windev.Expired && windev.OsFamily == "windows" && windev.SecureBoot == true + && windev.Warnings.Any(w => w.StartsWith("Expired evaluation") && w.Contains("WinDev2407Eval") && w.Contains("2024-10-23")), + "The Windows 11 dev environment is flagged as the WinDev2407Eval evaluation that expired on 2024-10-23"); + var msix = feed.Images.Single(i => i.Name.StartsWith("Windows 10 MSIX")); + check(msix.Expired && msix.DiskExtension == ".zip" && msix.Warnings.Any(w => w.StartsWith("Likely expired evaluation")), + "The Windows 10 MSIX evaluation is flagged as likely expired, and its URI with spaces still parses"); + var bionic = feed.Images.Single(i => i.Name == "Ubuntu 18.04 LTS"); + check(bionic.EndOfLife && bionic.Stale && bionic.Warnings.Any(w => w.Contains("May 2023")), "Ubuntu 18.04 is flagged out of standard support"); + + var custom = """ + // Comments, trailing commas and bool settings are accepted. + { "Images": [ + { "name": "Bounded", "minHostVersion": "10.0.17763.0", "maxHostVersion": "10.0.99999.0", "lastUpdated": "2026-09-01T00:00:00Z", + "config": { "secureBoot": true, "enhancedSessionTransportType": "vmbus", }, + "disk": { "uri": "C:\\gallery\\bounded.vhdx", "hash": "sha256:AA" }, }, + { "name": "Future host", "minHostVersion": "99.0.0.0", "disk": { "uri": "https://example.invalid/a.zip", "archiveRelativePath": "a.vhdx" } }, + { "name": "Old host", "maxHostVersion": "6.3.9600.0", "disk": { "uri": "https://example.invalid/b.zip", "archiveRelativePath": "b.vhdx" } }, + { "name": "Legacy VHD", "disk": { "uri": "https://example.invalid/legacy.vhd", "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" } }, + { "publisher": "no name" }, + ], } + """; + var parsed = ImageCatalogFeed.Parse("custom", ImageCatalogFeed.Decode([.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(custom)]), Host, FeedDay); + var bounded = parsed.Images.Single(i => i.Name == "Bounded"); + var legacy = parsed.Images.Single(i => i.Name == "Legacy VHD"); + check(parsed.Images.Count == 2 && parsed.Hidden == 2 && parsed.Skipped.Count == 1 + && bounded.SecureBoot == true && bounded.EnhancedSessionTransportType == "VMBus" && !bounded.Fetchable && bounded.Warnings.Any(w => w.StartsWith("Invalid hash")) + && !legacy.Fetchable && legacy.Warnings.Any(w => w.Contains("'.vhd'")), + "A UTF-8 custom catalog: comments, trailing commas, bool settings, the host version filter, invalid hashes and .vhd disks"); + } + + private static async Task ZipDirectories(Action check, string temp) + { + var payload = Payload(1, 300_000); + var zip = Path.Combine(temp, "two.zip"); + await File.WriteAllBytesAsync(zip, Zip(("notes/readme.txt", "hello"u8.ToArray()), ("Disk/Test.vhdx", payload))); + var entries = await ImageZip.ReadCentralDirectoryAsync(zip, default); + check(entries.Count == 2 && entries.Single(e => e.FullName == "Disk/Test.vhdx").UncompressedSize == payload.Length, + "The tail-only central directory reader lists entries and uncompressed sizes"); + + var zip64 = Path.Combine(temp, "zip64.zip"); + await File.WriteAllBytesAsync(zip64, Zip64("big/disk.vhdx", payload)); + var entries64 = await ImageZip.ReadCentralDirectoryAsync(zip64, default); + check(entries64.Count == 1 && entries64[0].UncompressedSize == payload.Length && entries64[0].CompressedSize == payload.Length, + "Zip64: sizes come from the extended-information field through the Zip64 end records"); + var extracted = Path.Combine(temp, "zip64.vhdx"); + await ImageFiles.ExtractAsync(zip64, @"BIG\Disk.VHDX", extracted, null, default); + check(File.ReadAllBytes(extracted).AsSpan().SequenceEqual(payload) && ImageFiles.HasMagic(extracted, ".vhdx"), + "Extraction matches the entry case-insensitively with either slash and streams a Zip64 archive"); + var iso = Path.Combine(temp, "magic.iso"); + var isoBytes = new byte[0x9000]; + "CD001"u8.CopyTo(isoBytes.AsSpan(0x8801)); + await File.WriteAllBytesAsync(iso, isoBytes); + check(ImageFiles.HasMagic(iso, ".iso") && !ImageFiles.HasMagic(iso, ".vhdx") && ImageFiles.HasMagic(zip, ".zip"), "Magic bytes: CD001 at 0x8801, PK zip header"); + } + + private static async Task LibraryScenarios(Action check, string temp, FixtureServer server) + { + var root = Path.Combine(temp, "library"); + var user = Directory.CreateDirectory(Path.Combine(temp, "user")).FullName; + var vms = Path.Combine(temp, "vms"); + + // Fixtures: distinct payloads so every archive has its own hash. + var linux = Payload(2, 2_000_000); + var testZip = Zip(("Disk/Test-Image.vhdx", linux)); + var interrupted = Payload(3, 3_000_000); + var interruptZip = Zip(("disk.vhdx", interrupted)); + var stallZip = Zip(("disk.vhdx", Payload(4, 3_000_000))); + var badZip = Zip(("disk.vhdx", Payload(5, 500_000))); + var plain = Payload(6, 1_000_000); + server.Add("/feeds/gallery.json", File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "MockHyperV", "Fixtures", "GalleryHyperV.json"))); + server.Add("/images/test.zip", testZip); + server.Add("/images/interrupt.zip", interruptZip); + server.Add("/images/stall.zip", stallZip); + server.Add("/images/bad.zip", badZip); + server.Add("/images/plain.vhdx", plain); + server.Redirect("/go/test", "/images/test.zip"); + server.DropOnce("/images/interrupt.zip", 1_000_000); + server.Stall("/images/stall.zip", 600_000); + var recent = DateTimeOffset.UtcNow.AddDays(-10).ToString("yyyy-MM-ddTHH:mm:ssZ"); + var catalog = $$""" + // HyperVControl test catalog + { "images": [ + { "name": "Test Linux", "publisher": "Canonical Group Ltd", "version": "1.0", "lastUpdated": "{{recent}}", + "description": ["Synthetic ", "test image", ], + "config": { "secureBoot": false, "enhancedSessionTransportType": "HvSocket", }, + "disk": { "uri": "{{server.BaseUrl}}go/test", "hash": "sha256:{{Sha(testZip).ToUpperInvariant()}}", "archiveRelativePath": "disk/TEST-image.vhdx", }, }, + { "name": "Test Bad Hash", "publisher": "Contoso", "lastUpdated": "{{recent}}", + "disk": { "uri": "{{server.BaseUrl}}images/bad.zip", "hash": "sha256:{{Sha("not the served archive"u8.ToArray())}}", "archiveRelativePath": "disk.vhdx" } }, + { "name": "Test No Hash", "publisher": "Contoso", "lastUpdated": "{{recent}}", + "disk": { "uri": "{{server.BaseUrl}}images/test.zip", "archiveRelativePath": "disk.vhdx" } }, + { "name": "Test Future Host", "minHostVersion": "99.0.0.0", + "disk": { "uri": "{{server.BaseUrl}}images/test.zip", "hash": "sha256:{{Sha(testZip)}}", "archiveRelativePath": "disk.vhdx" } }, + { "name": "Test Interrupt", "publisher": "Contoso", "lastUpdated": "{{recent}}", + "disk": { "uri": "{{server.BaseUrl}}images/interrupt.zip", "hash": "sha256:{{Sha(interruptZip)}}", "archiveRelativePath": "disk.vhdx" } }, + { "name": "Test Stall", "publisher": "Contoso", "lastUpdated": "{{recent}}", + "disk": { "uri": "{{server.BaseUrl}}images/stall.zip", "hash": "sha256:{{Sha(stallZip)}}", "archiveRelativePath": "disk.vhdx" } }, + { "name": "Test Direct VHDX", "publisher": "Contoso", "lastUpdated": "{{recent}}", + "disk": { "uri": "{{server.BaseUrl}}images/plain.vhdx", "hash": "sha256:{{Sha(plain)}}" } }, + ], } + """; + var customFile = Path.Combine(temp, "custom catalog.json"); + await File.WriteAllTextAsync(customFile, catalog, new UTF8Encoding(true)); + + var defaultSwitch = Guid.Parse(ImageCatalogService.DefaultSwitchId); + using var mock = new MockHyperV(new + { + vms = Array.Empty(), + switches = new object[] { new { id = defaultSwitch, name = "Default Switch", type = "Internal" }, new { id = Guid.NewGuid(), name = "Lab Private", type = "Private" } }, + host = new { virtualHardDiskPath = Path.Combine(temp, "host-vhds"), virtualMachinePath = Path.Combine(temp, "host-vms") }, + vhds = Array.Empty() + }); + using var service = new ControlService(); + service.Images.StallTimeout = TimeSpan.FromSeconds(10); + var tools = new HyperVTools(service); + + Environment.SetEnvironmentVariable(ImageCatalogService.RootVariable, null); + var defaultStore = await service.Images.StoreAsync(default); + check(defaultStore.Root == Path.Combine(temp, "host-vhds", "HyperVControl", "Images") && mock.Called("Get-VMHost"), + "Without an override the library root is \\HyperVControl\\Images"); + Environment.SetEnvironmentVariable(ImageCatalogService.RootVariable, root); + mock.ClearCalls(); + check((await service.Images.StoreAsync(default)).Root == root && !mock.Called("Get-VMHost"), "HYPERV_CONTROL_IMAGE_ROOT overrides the root without a host query"); + + // Catalogs: Microsoft's feed is the default; the tests disable it so nothing leaves the machine. + var (_, catalogs) = await Call(tools.ImageTool("catalogs")); + var builtIn = catalogs.GetProperty("catalogs")[0]; + check(catalogs.GetProperty("catalogs").GetArrayLength() == 1 && builtIn.GetProperty("name").GetString() == "microsoft" + && builtIn.GetProperty("location").GetString() == ImageCatalogFeed.MicrosoftLocation && builtIn.GetProperty("enabled").GetBoolean() + && File.Exists(Path.Combine(root, "catalogs.json")), "catalogs.json starts with Microsoft's Quick Create feed"); + var (_, disabled) = await Call(tools.ImageTool("catalogs", catalogAction: "remove", catalog: "microsoft")); + var (_, mirror) = await Call(tools.ImageTool("catalogs", catalogAction: "add", catalog: "mirror", location: server.BaseUrl + "feeds/gallery.json")); + var (_, added) = await Call(tools.ImageTool("catalogs", catalogAction: "add", location: customFile)); + check(disabled.GetProperty("disabled").GetBoolean() && mirror.GetProperty("images").GetArrayLength() == 5 + && added.GetProperty("catalog").GetProperty("name").GetString() == "custom-catalog" && added.GetProperty("images").GetArrayLength() == 6 + && added.GetProperty("hidden").GetInt32() == 1, "Catalogs: the built-in feed is disabled, an http feed and a local UTF-8 file are added after parsing"); + var (badAdd, _) = await Call(tools.ImageTool("catalogs", catalogAction: "add", location: "relative\\gallery.json")); + check(badAdd, "A relative catalog location is refused"); + + var (_, list) = await Call(tools.ImageTool("list")); + var images = list.GetProperty("images").EnumerateArray().ToList(); + var noHash = images.Single(i => i.GetProperty("name").GetString() == "Test No Hash"); + check(images.Count == 11 && list.GetProperty("catalogs").EnumerateArray().Count(c => c.GetProperty("status").GetString() == "ok") == 2 + && !noHash.GetProperty("fetchable").GetBoolean() && noHash.GetProperty("warnings").EnumerateArray().Any(w => w.GetString()!.StartsWith("No hash")) + && server.Requests.All(r => r.Path.StartsWith("/feeds/")), + "list merges catalogs with per-image warnings and reads only the feeds, never a disk URL"); + var recommended = list.GetProperty("recommended"); + check(recommended.GetProperty("linux").GetProperty("name").GetString() == "Test Linux" && recommended.GetProperty("windows").ValueKind == JsonValueKind.Null + && recommended.GetProperty("hint").GetString()!.Contains("expired") + && images.Single(i => i.GetProperty("name").GetString() == "Windows 11 dev environment").GetProperty("expired").GetBoolean(), + "The recommendation picks the newest supported Linux image and steers Windows away from the expired evaluations"); + + // Fetch end to end: redirect, tail probe, download, hash, magic, extraction, index, cache cleanup. + var (fetchFailed, fetched) = await Call(tools.ImageTool("fetch", imageName: "Test Linux", waitSeconds: 60)); + var entry = fetched.GetProperty("result").GetProperty("entry"); + var linuxPath = Path.Combine(root, "Test-Linux.vhdx"); + check(!fetchFailed && fetched.GetProperty("status").GetString() == "succeeded" && entry.GetProperty("path").GetString() == linuxPath + && File.ReadAllBytes(linuxPath).AsSpan().SequenceEqual(linux) && entry.GetProperty("osFamily").GetString() == "linux" + && entry.GetProperty("config").GetProperty("secureBoot").GetBoolean() == false && entry.GetProperty("source").GetProperty("sha256").GetString() == Sha(testZip) + && Directory.GetFiles(Path.Combine(root, "cache")).Length == 0, + "fetch runs as a job: verified download, extraction to \\.vhdx, library entry, archive deleted"); + check(server.Requests.Any(r => r.Path == "/go/test" && r.Range == "bytes=-65536") && server.Requests.Any(r => r.Path == "/images/test.zip" && r.Range is null), + "The probe resolves the redirect with one 64 KB tail read before the full download"); + var library = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "library.json")))!; + check(library["entries"]!.AsArray().Single()!["name"]!.GetValue() == "Test Linux", "library.json records the entry"); + var (_, again) = await Call(tools.ImageTool("fetch", imageName: "test linux")); + check(again.GetProperty("alreadyInLibrary").GetBoolean(), "A second fetch of the same image returns the library entry without downloading"); + + // An interrupted download keeps its partial file; the next fetch resumes it with a Range request. + var (interruptFailed, firstTry) = await Call(tools.ImageTool("fetch", imageName: "Test Interrupt", waitSeconds: 60)); + var partial = Path.Combine(root, "cache", Sha(interruptZip) + ".zip.partial"); + var kept = File.Exists(partial) ? new FileInfo(partial).Length : 0; + check(interruptFailed && firstTry.GetProperty("errorType").GetString() == "DownloadInterrupted" && kept is > 0 and < 3_000_000, + $"A dropped connection fails the job with DownloadInterrupted and keeps the partial download ({kept} bytes)"); + var (resumeFailed, resumed) = await Call(tools.ImageTool("fetch", imageName: "Test Interrupt", waitSeconds: 60)); + check(!resumeFailed && resumed.GetProperty("result").GetProperty("resumedFromBytes").GetInt64() == kept + && server.Requests.Any(r => r.Path == "/images/interrupt.zip" && r.Range == $"bytes={kept}-") + && File.ReadAllBytes(Path.Combine(root, "Test-Interrupt.vhdx")).AsSpan().SequenceEqual(interrupted) && !File.Exists(partial), + $"The next fetch resumes with Range: bytes={kept}- and produces the verified image"); + + // Cancelling a running fetch deletes its partial download and extraction output. + var (_, stalling) = await Call(tools.ImageTool("fetch", imageName: "Test Stall")); + var stallJob = stalling.GetProperty("id").GetString()!; + await server.StallReached.Task.WaitAsync(TimeSpan.FromSeconds(30)); + var stallPartial = Path.Combine(root, "cache", Sha(stallZip) + ".zip.partial"); + for (var i = 0; i < 200 && !(service.Jobs.Get(stallJob)?.Progress.BytesDone > 0); i++) await Task.Delay(25); + var hadPartial = File.Exists(stallPartial) && service.Jobs.Get(stallJob)?.Progress.Message == "downloading"; + var (_, cancelled) = await Call(tools.JobsTool("cancel", stallJob, 30)); + server.ReleaseStall(); + check(hadPartial && cancelled.GetProperty("status").GetString() == "cancelled" && !File.Exists(stallPartial) + && !Directory.EnumerateFiles(root, "Test-Stall*").Any() && !File.ReadAllText(Path.Combine(root, "library.json")).Contains("Test Stall"), + "hyperv_jobs cancel stops the download and deletes the partial file; nothing is registered"); + + var (mismatchFailed, mismatch) = await Call(tools.ImageTool("fetch", imageName: "Test Bad Hash", waitSeconds: 60)); + check(mismatchFailed && mismatch.GetProperty("errorType").GetString() == "HashMismatch" + && Directory.GetFiles(Path.Combine(root, "cache")).Length == 0 && !Directory.EnumerateFiles(root, "Test-Bad-Hash*").Any(), + "A SHA-256 mismatch fails the job and deletes the download"); + var (noHashFailed, refused) = await Call(tools.ImageTool("fetch", imageName: "Test No Hash")); + var (hiddenFailed, hidden) = await Call(tools.ImageTool("fetch", imageName: "Test Future Host")); + check(noHashFailed && refused.GetProperty("errorType").GetString() == "UnsupportedImage" + && hiddenFailed && hidden.GetProperty("error").GetString()!.Contains("hidden by their host version limits"), + "Unverifiable and host-incompatible images are refused before any job starts"); + var (directFailed, direct) = await Call(tools.ImageTool("fetch", imageName: "Test Direct VHDX", waitSeconds: 60)); + var directPath = Path.Combine(root, "Test-Direct-VHDX.vhdx"); + check(!directFailed && File.ReadAllBytes(directPath).AsSpan().SequenceEqual(plain) && direct.GetProperty("result").GetProperty("entry").GetProperty("osFamily").GetString() == "other", + "A .vhdx disk is verified and moved into the library without a copy"); + + // Register adopts files in place; templates become read-only; guards refuse bad input. + var template = Path.Combine(user, "Win Template.vhdx"); + await File.WriteAllBytesAsync(template, Payload(7, 200_000)); + var (registerFailed, registered) = await Call(tools.ImageTool("register", path: template, name: "Win Template", osFamily: "windows", kind: "template")); + check(!registerFailed && registered.GetProperty("entry").GetProperty("readOnly").GetBoolean() && File.GetAttributes(template).HasFlag(FileAttributes.ReadOnly) + && registered.GetProperty("entry").GetProperty("path").GetString() == template, "register adopts a .vhdx in place and makes a template read-only"); + var notVhdx = Path.Combine(user, "not.vhdx"); + await File.WriteAllTextAsync(notVhdx, "plain text"); + var linuxPlain = Path.Combine(user, "linux.vhdx"); + await File.WriteAllBytesAsync(linuxPlain, Payload(8, 100_000)); + var (badMagic, badMagicResult) = await Call(tools.ImageTool("register", path: notVhdx, name: "Not a disk", osFamily: "linux")); + var (duplicate, duplicateResult) = await Call(tools.ImageTool("register", path: linuxPlain, name: "win template", osFamily: "linux")); + var (samePath, samePathResult) = await Call(tools.ImageTool("register", path: template, name: "Another name", osFamily: "windows")); + var (relative, _) = await Call(tools.ImageTool("register", path: "disk.vhdx", name: "Relative", osFamily: "linux")); + check(badMagic && badMagicResult.GetProperty("errorType").GetString() == "UnsupportedImage" + && duplicate && duplicateResult.GetProperty("errorType").GetString() == "AlreadyExists" + && samePath && samePathResult.GetProperty("errorType").GetString() == "AlreadyExists" && relative, + "register refuses files without the VHDX signature, duplicate names or files, and relative paths"); + var (_, inferred) = await Call(tools.ImageTool("register", path: linuxPlain, name: "Ubuntu Plain")); + check(inferred.GetProperty("entry").GetProperty("osFamily").GetString() == "linux" && inferred.GetProperty("warnings").GetArrayLength() == 1, + "register infers a missing osFamily from the name and says so"); + var iso = Path.Combine(user, "setup.iso"); + var isoBytes = new byte[0x9000]; + "CD001"u8.CopyTo(isoBytes.AsSpan(0x8001)); + await File.WriteAllBytesAsync(iso, isoBytes); + var (_, isoEntry) = await Call(tools.ImageTool("register", path: iso, name: "Setup ISO", osFamily: "windows")); + check(isoEntry.GetProperty("entry").GetProperty("format").GetString() == "iso", "register adopts an ISO as installation media"); + + // The in-use guard: attached disks, DVDs and differencing parents (also through checkpoints) block removal. + var child = Path.Combine(user, "child.avhdx"); + var attachedId = Guid.NewGuid(); + var parentId = Guid.NewGuid(); + EditState(mock, state => + { + state["vms"]!.AsArray().Add(Vm(attachedId, "Lab Attached", [template])); + var parentVm = Vm(parentId, "Lab Checkpointed", []); + parentVm["snapshots"] = new JsonArray(new JsonObject { ["id"] = Guid.NewGuid().ToString(), ["name"] = "base", ["creationTime"] = "2026-01-01T00:00:00Z", ["disks"] = new JsonArray(child) }); + parentVm["dvd"] = iso; + state["vms"]!.AsArray().Add(parentVm); + state["vhds"] = new JsonArray(new JsonObject { ["path"] = child, ["parentPath"] = linuxPlain }); + }); + var (attachedFailed, attached) = await Call(tools.ImageTool("remove", name: "Win Template")); + var (parentFailed, parent) = await Call(tools.ImageTool("remove", name: "Ubuntu Plain", deleteFile: true)); + var (dvdFailed, dvd) = await Call(tools.ImageTool("remove", name: "Setup ISO", deleteFile: true)); + check(attachedFailed && attached.GetProperty("errorType").GetString() == "InUse" && Relation(attached) == "attached" + && parentFailed && Relation(parent) == "differencingParent" && dvdFailed && Relation(dvd) == "attachedDvd" + && File.Exists(linuxPlain) && File.Exists(iso), + "remove refuses an attached disk, a differencing parent reached through a checkpoint, and a mounted ISO"); + EditState(mock, state => + { + state["vms"] = new JsonArray(); + state["vhds"] = new JsonArray(); + }); + var (_, removedIso) = await Call(tools.ImageTool("remove", name: "Setup ISO", deleteFile: true)); + var spare = Path.Combine(user, "spare.vhdx"); + await File.WriteAllBytesAsync(spare, Payload(9, 50_000)); + await Call(tools.ImageTool("register", path: spare, name: "Spare Template", osFamily: "windows", kind: "template")); + var (_, removedSpare) = await Call(tools.ImageTool("remove", name: "Spare Template", deleteFile: true)); + check(!File.Exists(iso) && removedIso.GetProperty("deleted").GetArrayLength() == 1 && !File.Exists(spare) && removedSpare.GetProperty("deleted").GetArrayLength() == 1, + "remove with deleteFile deletes an unused image, clearing a template's read-only attribute first"); + var media = Path.Combine(user, "media.iso"); + await File.WriteAllBytesAsync(media, isoBytes); + await Call(tools.ImageTool("register", path: media, name: "Install Media", osFamily: "linux")); + var (isoDeploy, isoDeployResult) = await Call(tools.ImageTool("deploy", imageName: "Install Media", vmName: "Lab Media", vmPath: vms)); + check(isoDeploy && isoDeployResult.GetProperty("error").GetString()!.Contains("installation media") && !Directory.Exists(Path.Combine(vms, "Lab Media")), + "deploy refuses an ISO entry before touching the host"); + + // Deploy: per-VM disk, VM creation and lab defaults, rendered against the mock. + mock.ClearCalls(); + var (winFailed, win) = await Call(tools.ImageTool("deploy", imageName: "Win Template", vmName: "Lab Win", vmPath: vms, notes: "lab notes", memoryMb: 6144, processors: 3)); + var calls = mock.Calls(); + var winDisk = Path.Combine(vms, "Lab Win", "Virtual Hard Disks", "Lab Win.vhdx"); + var newVhd = calls.Single(c => c.Cmdlet == "New-VHD").Parameters; + var newVm = calls.Single(c => c.Cmdlet == "New-VM").Parameters; + var setVm = calls.Single(c => c.Cmdlet == "Set-VM").Parameters; + var firmware = calls.Single(c => c.Cmdlet == "Set-VMFirmware").Parameters; + check(!winFailed && newVhd.GetProperty("Path").GetString() == winDisk && newVhd.GetProperty("ParentPath").GetString() == template && newVhd.GetProperty("Differencing").GetBoolean() + && newVm.GetProperty("VHDPath").GetString() == winDisk && newVm.GetProperty("Path").GetString() == vms && newVm.GetProperty("Generation").GetInt32() == 2 + && newVm.GetProperty("MemoryStartupBytes").GetInt64() == 6144L << 20 && newVm.GetProperty("SwitchName").GetString() == "Default Switch" + && calls.Any(c => c.Cmdlet == "Set-VMProcessor" && c.Parameters.GetProperty("Count").GetInt32() == 3), + "Linked deploy creates a differencing child under the VM folder and the VM on the Default Switch (resolved by ID)"); + check(setVm.GetProperty("StaticMemory").GetBoolean() && !setVm.GetProperty("AutomaticCheckpointsEnabled").GetBoolean() + && setVm.GetProperty("CheckpointType").GetString() == "Standard" && setVm.GetProperty("Notes").GetString() == "lab notes" && !setVm.TryGetProperty("EnhancedSessionTransportType", out _) + && firmware.GetProperty("EnableSecureBoot").GetString() == "On" && firmware.GetProperty("SecureBootTemplate").GetString() == "MicrosoftWindows" + && calls.Any(c => c.Cmdlet == "Set-VMKeyProtector" && c.Parameters.GetProperty("NewLocalKeyProtector").GetBoolean()) && !mock.Called("Start-VM"), + "Windows lab defaults: static memory, no automatic checkpoints, Standard checkpoints, MicrosoftWindows Secure Boot, a local key protector"); + int Index(string cmdlet) => calls.ToList().FindIndex(c => c.Cmdlet == cmdlet); + check(Index("New-VHD") < Index("New-VM") && Index("New-VM") < Index("Set-VM") && Index("Set-VM") < Index("Set-VMFirmware") + && Index("Set-VMFirmware") < Index("Set-VMKeyProtector") && Index("Set-VMKeyProtector") < Index("Enable-VMTPM"), + "Order: New-VHD, New-VM, Set-VM, Set-VMFirmware, Set-VMKeyProtector, Enable-VMTPM (template before the vTPM locks it)"); + var winLibrary = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "library.json")))!["entries"]!.AsArray().Single(e => e!["name"]!.GetValue() == "Win Template")!; + check(win.GetProperty("disk").GetProperty("type").GetString() == "differencing" && win.GetProperty("disk").GetProperty("parentPath").GetString() == template + && win.GetProperty("vm").GetProperty("Name").GetString() == "Lab Win" && winLibrary["deployments"]!.AsArray().Count == 1 + && winLibrary["deployments"]![0]!["diskPath"]!.GetValue() == winDisk, + "Deploy returns the VM, the child disk and its parent, and records the deployment in library.json"); + + mock.ClearCalls(); + var (linuxFailed, linuxVm) = await Call(tools.ImageTool("deploy", imageName: "Test Linux", vmName: "Lab Linux", vmPath: vms, start: true, switchName: "Lab Private")); + calls = mock.Calls(); + firmware = calls.Single(c => c.Cmdlet == "Set-VMFirmware").Parameters; + check(!linuxFailed && firmware.GetProperty("EnableSecureBoot").GetString() == "Off" && !firmware.TryGetProperty("SecureBootTemplate", out _) + && calls.Single(c => c.Cmdlet == "Set-VM").Parameters.GetProperty("EnhancedSessionTransportType").GetString() == "HvSocket" + && !mock.Called("Enable-VMTPM") && !mock.Called("Set-VMKeyProtector") && mock.Called("Start-VM") && linuxVm.GetProperty("started").GetBoolean() + && calls.Single(c => c.Cmdlet == "New-VM").Parameters.GetProperty("SwitchName").GetString() == "Lab Private" + && File.GetAttributes(linuxPath).HasFlag(FileAttributes.ReadOnly), + "Linux lab defaults follow the catalog: Secure Boot off, HvSocket, no vTPM, start on request; the parent becomes read-only"); + + mock.ClearCalls(); + var (_, plainVm) = await Call(tools.ImageTool("deploy", imageName: "Ubuntu Plain", vmName: "Lab Plain", vmPath: vms)); + firmware = mock.Calls().Single(c => c.Cmdlet == "Set-VMFirmware").Parameters; + check(firmware.GetProperty("EnableSecureBoot").GetString() == "On" && firmware.GetProperty("SecureBootTemplate").GetString() == "MicrosoftUEFICertificateAuthority" + && plainVm.GetProperty("disk").GetProperty("type").GetString() == "differencing", + "Linux without a catalog setting keeps Secure Boot on with the Microsoft UEFI CA template"); + + mock.ClearCalls(); + var (copyFailed, copied) = await Call(tools.ImageTool("deploy", imageName: "Test Direct VHDX", vmName: "Lab Copy", vmPath: vms, linked: false)); + var copyDisk = Path.Combine(vms, "Lab Copy", "Virtual Hard Disks", "Lab Copy.vhdx"); + check(!copyFailed && !mock.Called("New-VHD") && File.ReadAllBytes(copyDisk).AsSpan().SequenceEqual(plain) && !File.GetAttributes(copyDisk).HasFlag(FileAttributes.ReadOnly) + && mock.Calls().Single(c => c.Cmdlet == "Set-VMFirmware").Parameters.GetProperty("EnableSecureBoot").GetString() == "Off" + && copied.GetProperty("warnings").EnumerateArray().Any(w => w.GetString()!.Contains("'other'")), + "A full-copy deploy copies the image into the VM folder; an unknown OS family gets Secure Boot off with a warning"); + + mock.ClearCalls(); + var (duplicateVm, duplicateVmResult) = await Call(tools.ImageTool("deploy", imageName: "Win Template", vmName: "Lab Win", vmPath: Path.Combine(temp, "elsewhere"))); + check(duplicateVm && duplicateVmResult.GetProperty("errorType").GetString() == "InvalidArgument" && !mock.Called("New-VHD") && !mock.Called("New-VM") + && !Directory.Exists(Path.Combine(temp, "elsewhere", "Lab Win")), + "A duplicate VM name is refused before any disk is created, and the empty folder is removed"); + + var (inUse, inUseResult) = await Call(tools.ImageTool("remove", name: "Win Template", deleteFile: true)); + check(inUse && Relation(inUseResult) is "differencingParent" or "attached" && File.Exists(template), + "A template with a deployed linked clone cannot be removed"); + + // hyperv_jobs: list, status, wait, unknown IDs. + var (_, jobs) = await Call(tools.JobsTool()); + var statuses = jobs.GetProperty("jobs").EnumerateArray().Select(j => j.GetProperty("status").GetString()).ToList(); + var (_, status) = await Call(tools.JobsTool("status", stallJob)); + var (_, waited) = await Call(tools.JobsTool("wait", fetched.GetProperty("id").GetString(), 1)); + var (unknown, unknownResult) = await Call(tools.JobsTool("status", "nope")); + var (badWait, _) = await Call(tools.JobsTool("wait", stallJob, 7200)); + check(statuses.Count >= 6 && statuses.Contains("succeeded") && statuses.Contains("failed") && statuses.Contains("cancelled") + && status.GetProperty("status").GetString() == "cancelled" && status.GetProperty("errorType").GetString() == "Cancelled" + && waited.GetProperty("status").GetString() == "succeeded" && waited.GetProperty("progress").GetProperty("percent").GetDouble() == 100 + && unknown && unknownResult.GetProperty("errorType").GetString() == "ArgumentException" && badWait, + "hyperv_jobs lists, reports and waits on jobs with camelCase statuses and rejects unknown IDs and unbounded waits"); + } + + private static string? Relation(JsonElement result) => + result.TryGetProperty("references", out var references) && references.GetArrayLength() > 0 ? references[0].GetProperty("relation").GetString() : null; + + private static JsonObject Vm(Guid id, string name, string[] disks) => new() + { + ["id"] = id.ToString(), ["name"] = name, ["state"] = "Off", ["generation"] = 2, ["processorCount"] = 2, ["memoryStartup"] = 4294967296L, + ["dynamicMemory"] = false, ["disks"] = new JsonArray(disks.Select(d => (JsonNode)JsonValue.Create(d)!).ToArray()), ["snapshots"] = new JsonArray() + }; + + private static void EditState(MockHyperV mock, Action edit) + { + var state = JsonNode.Parse(File.ReadAllText(mock.StatePath))!.AsObject(); + edit(state); + File.WriteAllText(mock.StatePath, state.ToJsonString(), new UTF8Encoding(false)); + } + + private static async Task<(bool IsError, JsonElement Json)> Call(Task call) + { + var result = await call; + using var document = JsonDocument.Parse(result.Content.OfType().Single().Text); + return (result.IsError == true, document.RootElement.Clone()); + } + + /// Deterministic, incompressible bytes that start with the VHDX signature. + private static byte[] Payload(int seed, int length) + { + var bytes = new byte[length]; + new Random(seed).NextBytes(bytes); + "vhdxfile"u8.CopyTo(bytes); + return bytes; + } + + private static string Sha(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); + + private static byte[] Zip(params (string Name, byte[] Data)[] entries) + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, true)) + foreach (var (name, data) in entries) + { + using var stream = archive.CreateEntry(name, CompressionLevel.Fastest).Open(); + stream.Write(data); + } + return memory.ToArray(); + } + + /// A stored single-entry archive whose sizes and offset exist only in Zip64 records, as in multi-GB images. + private static byte[] Zip64(string name, byte[] data) + { + var nameBytes = Encoding.UTF8.GetBytes(name); + var crc = Crc32(data); + using var memory = new MemoryStream(); + using var writer = new BinaryWriter(memory); + writer.Write(0x04034b50u); writer.Write((ushort)45); writer.Write((ushort)0x0800); writer.Write((ushort)0); writer.Write(0u); + writer.Write(crc); writer.Write(0xFFFFFFFFu); writer.Write(0xFFFFFFFFu); writer.Write((ushort)nameBytes.Length); writer.Write((ushort)20); + writer.Write(nameBytes); writer.Write((ushort)1); writer.Write((ushort)16); writer.Write((long)data.Length); writer.Write((long)data.Length); + writer.Write(data); + var central = memory.Position; + writer.Write(0x02014b50u); writer.Write((ushort)45); writer.Write((ushort)45); writer.Write((ushort)0x0800); writer.Write((ushort)0); writer.Write(0u); + writer.Write(crc); writer.Write(0xFFFFFFFFu); writer.Write(0xFFFFFFFFu); writer.Write((ushort)nameBytes.Length); writer.Write((ushort)28); writer.Write((ushort)0); + writer.Write((ushort)0); writer.Write((ushort)0); writer.Write(0u); writer.Write(0xFFFFFFFFu); + writer.Write(nameBytes); writer.Write((ushort)1); writer.Write((ushort)24); writer.Write((long)data.Length); writer.Write((long)data.Length); writer.Write(0L); + var centralSize = memory.Position - central; + var record = memory.Position; + writer.Write(0x06064b50u); writer.Write(44L); writer.Write((ushort)45); writer.Write((ushort)45); writer.Write(0u); writer.Write(0u); + writer.Write(1L); writer.Write(1L); writer.Write(centralSize); writer.Write(central); + writer.Write(0x07064b50u); writer.Write(0u); writer.Write(record); writer.Write(1u); + writer.Write(0x06054b50u); writer.Write((ushort)0); writer.Write((ushort)0); writer.Write((ushort)0xFFFF); writer.Write((ushort)0xFFFF); + writer.Write(0xFFFFFFFFu); writer.Write(0xFFFFFFFFu); writer.Write((ushort)0); + writer.Flush(); + return memory.ToArray(); + } + + private static uint Crc32(ReadOnlySpan data) + { + var crc = 0xFFFFFFFFu; + foreach (var b in data) + { + crc ^= b; + for (var bit = 0; bit < 8; bit++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1; + } + return ~crc; + } +} + +/// +/// A loopback HTTP server for image fixtures: byte ranges (suffix, open and closed), redirects, a connection +/// dropped once mid-download, and a download that stalls until released. It logs every request's path and Range. +/// +internal sealed class FixtureServer : IAsyncDisposable +{ + private readonly HttpListener listener = new(); + private readonly CancellationTokenSource stop = new(); + private readonly ConcurrentDictionary files = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary redirects = new(StringComparer.OrdinalIgnoreCase); + private readonly TaskCompletionSource stallRelease = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task loop; + private string? dropPath, stallPath; + private long dropAfter, stallAfter; + private int dropped; + + public ConcurrentQueue<(string Path, string? Range)> Requests { get; } = new(); + public TaskCompletionSource StallReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public string BaseUrl { get; } + + public FixtureServer() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + BaseUrl = $"http://127.0.0.1:{port}/"; + listener.Prefixes.Add(BaseUrl); + listener.Start(); + loop = Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + HttpListenerContext context; + try { context = await listener.GetContextAsync(); } + catch (Exception) { break; } + _ = Task.Run(() => HandleAsync(context)); + } + }); + } + + public void Add(string path, byte[] bytes) => files[path] = bytes; + public void Redirect(string path, string target) => redirects[path] = target; + public void DropOnce(string path, long after) { dropPath = path; dropAfter = after; } + public void Stall(string path, long after) { stallPath = path; stallAfter = after; } + public void ReleaseStall() => stallRelease.TrySetResult(); + + private async Task HandleAsync(HttpListenerContext context) + { + var response = context.Response; + try + { + var path = context.Request.Url!.AbsolutePath; + var range = context.Request.Headers["Range"]; + Requests.Enqueue((path, range)); + if (redirects.TryGetValue(path, out var target)) + { + response.StatusCode = 302; + response.RedirectLocation = target; + response.Close(); + return; + } + if (!files.TryGetValue(path, out var bytes)) { response.StatusCode = 404; response.Close(); return; } + long start = 0, end = bytes.Length - 1; + if (range is not null && range.StartsWith("bytes=", StringComparison.Ordinal)) + { + var spec = range[6..]; + var dash = spec.IndexOf('-'); + if (dash == 0) start = Math.Max(0, bytes.Length - long.Parse(spec[1..])); + else + { + start = long.Parse(spec[..dash]); + if (dash < spec.Length - 1) end = Math.Min(end, long.Parse(spec[(dash + 1)..])); + } + if (start >= bytes.Length) + { + response.StatusCode = 416; + response.AddHeader("Content-Range", $"bytes */{bytes.Length}"); + response.Close(); + return; + } + response.StatusCode = 206; + response.AddHeader("Content-Range", $"bytes {start}-{end}/{bytes.Length}"); + } + response.AddHeader("Accept-Ranges", "bytes"); + response.ContentType = "application/octet-stream"; + response.ContentLength64 = end - start + 1; + var output = response.OutputStream; + var download = range is null || (range.EndsWith('-') && !range.StartsWith("bytes=-", StringComparison.Ordinal)); + if (download && path == dropPath && Interlocked.Exchange(ref dropped, 1) == 0) + { + await output.WriteAsync(bytes.AsMemory((int)start, (int)Math.Min(dropAfter, end - start + 1))); + await output.FlushAsync(); + await Task.Delay(300); + response.Abort(); + return; + } + if (download && path == stallPath) + { + await output.WriteAsync(bytes.AsMemory((int)start, (int)Math.Min(stallAfter, end - start + 1))); + await output.FlushAsync(); + StallReached.TrySetResult(); + await stallRelease.Task.WaitAsync(stop.Token); + response.Abort(); + return; + } + await output.WriteAsync(bytes.AsMemory((int)start, (int)(end - start + 1))); + response.Close(); + } + catch (Exception) + { + try { response.Abort(); } catch (Exception) { } + } + } + + public async ValueTask DisposeAsync() + { + stop.Cancel(); + stallRelease.TrySetResult(); + try { listener.Stop(); listener.Close(); } catch (Exception) { } + try { await loop; } catch (Exception) { } + stop.Dispose(); + } +} diff --git a/tests/HyperVControl.Tests/MockHyperV/Fixtures/GalleryHyperV.json b/tests/HyperVControl.Tests/MockHyperV/Fixtures/GalleryHyperV.json new file mode 100644 index 0000000000000000000000000000000000000000..968448516c61b9ed42607cd865a340022865ce44 GIT binary patch literal 24744 zcmeI4ZEqvD5y$zuK)-{aS1lS_)`Kl6?<`qvE^vV$cG9BB!I0#Kb85@3d zAIJ2rB(5x-EZcWoELOWqF1h5)aEAPgGynblS66UFcjhi!*PXg!H`3GBO8ZIYAKf4H z8;)Nmo+&p_sSA~|GNk-nbq(B+yL5M|tKiCRL%)A>`|h3lhwiRaqVN9Yzs8%VN;}r| zR^^5&HSqNf+_mcZYU+!$rKmb?T+f}W#K?V6>EXloi~7!)YNwu&&J)#AaBbZURfgZL zQqFWYm{I$=-Wut>A5`YZmpInddRTN7^{1j^P1jBRmUSHHtcYqGI_&qKGwQ$fEgn6# z18UPND7Eg-vTL}i+tQg$gLCzwuhyNXsBiT^%{8@te@5vel^S_UovD`zO^oy7d=LB& z*P{9h)ze)4PA!?_>VaB#srDIvyPjiwwczfRexvKPt_9sCbmrZUp0BjYq{AetDhe0$ zl`9=^p1y~67yP^M40=G3q5qE6e?8UO6Mc8+CH`6zFNjjeKlR@03I5;c+kKUrzmF%X z@lv%EyzF%KgzPak2C2R>!brEa@(!{^8?rbZsLmTdtJa@YU%1Xw1}@O2WA$pNvb2fb z!#{WeCumU^p8>)6>Yy@alS>B{T0MHlnCL89&ctT}*GsY!?#MSDzUREtR+wU4xdtR_cw? zW8eKP`b}#2x5{9}jzw8y_F8la--kBPaHR3HFK$5jU^mhIb#FbeAa)d8{5zqJP;X3E z7k#hJ9(yw>GbSGc@$J&{meD~6~ zShj0TIdzrht|?7Z)BT#>-_qICeP6xq>j`VuR82tx72IrVf}Nxcmbk2v>+U~l{Z*7# zrlUTnP0SPmce(GXJZ-^VH#JAt)V1ns-BC-M${}sX?drX@e;b$5HCJvwjwTtNY1Iw*ObByFMTCZ zx}?6q%c|#DRWx1GX`Dj}J6>K$>*$>g<+gP|`?`40(0xax8!Aznn7E-7P9 z64MIvNZPKxz3ru8S6@c*;R4dK>m_+xsZ}pq$kmSC*wqtqRMitR&5o`}A+lN1-L}fN zbt047>On>C?5Ipt*M{C9eN#C|X?!*r=kG{*|H97>&i_w%yr1gvvV5*-)b&L1uIPTO zYeRYwozc@BTAy)>W?)WMQ))$5>~B*~TRO1Y*xa5{I5zZ58T1o8?kRUe?;a@)J%euT ziQ^moJH4oU#`%o?xbd83C5I({?Gato7_9ga&5XY;>Bg><{g_3L8QV1-ZS@?xvZG$_ z>TXBxZYviWb#yWsuse)@u>jYR zuVm(C9RDf0*0x!G!A;MfLXPRZaU7tiR;K5RS^6^6QIr*8PH8hP)}UE9LdValn8{x0 zWHuF_qlKEe#w0(T&;6qP15>z?#-I(S8bjG=Mfg&zkJGgj+@GW&{;U?wk7Tg+*cSDe zlPB$A&BII-3Ni1ub>>r?SQWkYL?<+#t>d0*MT}O=VN6Dnbw83aIk$cO_0UtTjmg*h zvC;IKf0{(Ire!XMr~M$!4jRFl4K%GGJ%#QsxIN)8voM})l&mdE4kWUU9+HZMYkjxMXU2WoRub8Y4>U+dg*fALz)(6a0=A-7;T0kcc6via%2 zT=lh(vT^RAufV$)lSjJi`PDTx7|M@4SC0HUV3)SSh6429N>Q`%7F>?^Eu!L;~pK{?b7x6)Ua9pcZpg?F3oUeQiyr1ebM3fdA25b&aK&61LInFG<%sBEJn^a6TW@4*J3Bz*@h zf%;%cp$GTMqow29UD4vGlYP>>Cx{h>t(zG}$WiEjh%QUaP7>PPDm+;SW(N=q9E zqO}Z3WMvQ}5glTA;2PkqS6+6kF4OrVpOV<2p?~g6E@%Z52@>^ADF=FHbg*(=ot(tR zC6q9XEwl&uFuRJ5yi+@&x$%_|65b2+Mh+NH$vA`_2}*5vbbup$Yv11fx~`j|N+Xey&N=oTC4hNq=xbhF0r zd+~P?m3j_L4k-p{jL-OrN+0-<2y%}EK@&!v*^UGC3JQ-NDSjYsLfwF$d?ViNO9J^H zgZXhx-^6Tv(aX&;*x#0HAxBNNl9fVD-;RIjInY0_NB(&L^Bu}Qr~msyHlWhDe3{NH ziMmEHO5JK@#VAk4eaYL2P_pEyV*fk9GtfeGAY+W#OleFe*T%}OrM`5lZ(-|>G-s&k zJ}t>>6D=4omtb4VB81?9ti?fGdOASX&sLxG3w>rrv0%XJA2KnLXvyl)|;Hw>R_~U)f>xik_46BZOFD^iyA7AJ+-?p6}%hiPibR;t%gCLvKMLtLaI3lINIzS^pO27o`3uw9L!UW$cN7MaV zma8ECpwXZ|@DY@bbr5JTXb%54z*l(P_FO?TU;!$g$H)X40UXN^Fpy(31a*Q7!B;~O zk%L`*ujO+|$9_!P;J8I$_WFDX;9KSGbT8N4FS7ohj^#6U;3jxJ8XfD@5pV2&A2`K- zL{+81ZN?jXX45E+4~$J3y2N#0>f7f(asUFF!E#LGDpb3v%yhwge*tMl7G({A* zQo-Nz4{kTp#)q2T3Nki7>XNj4^1t%cA!C&<0vT&8jf~Atha4Hp_m1Uz$84WjzIP0r zU6f~avX_c|lZ%lD2ggE#!5S>A?K@)+8OR!?NCzKd z7K%@9*8Dq1#+H#(cEe}ya?Fvj92xucT}Ao6v6tPG{M2a8k+B>Zdn?$|x0(mo45{G0 z)rdmOcXsf#i*uR& zeWPBQAE5bp!STfJF4-l72OKehtWJxV6|dD~XWC~IUoxzTd5s}ki?^bA6>SYao5>lH!CB&rKQQoa;14F9Iuf*)f zx@=hR%KTB|V!2poEwz{E3~ha%{Z{xq;(K}Axn6U5`)iT@`bb@U&NU*&(pr%CC(I{MnS$p)JPDV)hZK>|@N^;u6A|)=e`b;QEJhyE#V$a6BJqa8qssiGK~V8e$-d1fq6XOd#%3f2G8p7 zc>H|0eK6JU<#`Tg@hv7w8CK3L1F_hMRcK=tO9H=%UD(#ixW_A!k#AOoNM=NO<0PIN z)&}2Ze4Nd*l63qlIT_#hylyQpwpo=e<^#Ft4zG^1DJ#4Xsc7~ZE#?;(Ur%;4|FgAI j9`P(tm)Y<3!>ZxSjd=F?t;wE38Djc4d$*hA82a}=6Wu$* literal 0 HcmV?d00001 diff --git a/tests/HyperVControl.Tests/MockHyperV/Hyper-V/images.functions.ps1 b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/images.functions.ps1 new file mode 100644 index 0000000..1e9f3a6 --- /dev/null +++ b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/images.functions.ps1 @@ -0,0 +1,79 @@ +# Image library additions to the recording mock (dot-sourced by Hyper-V.psm1; later definitions win). +# Optional state: +# host: { virtualHardDiskPath, virtualMachinePath } answers Get-VMHost, so tests never touch C:\ +# vhds: [ { path, parentPath, vhdType } ] answers Get-VHD, so tests can build differencing chains +# createVhdFiles: true New-VHD also writes a small placeholder file +# vms[].snapshots[].disks answers Get-VMHardDiskDrive -VMSnapshot +# New-VHD records every disk it creates in vhds, so a later Get-VHD walk sees the new parent link. + +function Get-VMHost { + [CmdletBinding()] param() + Write-MockCall 'Get-VMHost' $PSBoundParameters + $state = Read-MockState + $vhdPath = 'C:\MockVHDs' + $vmPath = 'C:\MockVMs' + if ($state.PSObject.Properties['host'] -and $state.host) { + if ($state.host.virtualHardDiskPath) { $vhdPath = [string]$state.host.virtualHardDiskPath } + if ($state.host.virtualMachinePath) { $vmPath = [string]$state.host.virtualMachinePath } + } + [pscustomobject]@{ VirtualHardDiskPath = $vhdPath; VirtualMachinePath = $vmPath; EnableEnhancedSessionMode = $true; LogicalProcessorCount = 8; MemoryCapacity = [long]34359738368 } +} + +function Get-MockVhdRow([string]$Path) { + $state = Read-MockState + if ($null -eq $state.PSObject.Properties['vhds']) { return $null } + @($state.vhds | Where-Object { $_ -and [string]::Equals([string]$_.path, $Path, [StringComparison]::OrdinalIgnoreCase) }) | Select-Object -First 1 +} + +function Get-VHD { + [CmdletBinding()] + param([Parameter(Position = 0, ValueFromPipeline)][string[]]$Path, [guid[]]$VMId) + process { + Write-MockCall 'Get-VHD' $PSBoundParameters + foreach ($p in $Path) { + $row = Get-MockVhdRow $p + $parent = if ($row -and $row.parentPath) { [string]$row.parentPath } else { $null } + $type = if ($row -and $row.vhdType) { [string]$row.vhdType } elseif ($parent) { 'Differencing' } else { 'Dynamic' } + [pscustomobject]@{ Path = $p; VhdFormat = 'VHDX'; VhdType = $type; ParentPath = $parent; Size = [long]68719476736; FileSize = [long]4194304; Attached = $false; DiskIdentifier = [guid]::NewGuid().ToString() } + } + } +} + +function New-VHD { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Path, [long]$SizeBytes, [switch]$Dynamic, [switch]$Fixed, [switch]$Differencing, [string]$ParentPath, [long]$BlockSizeBytes) + Write-MockCall 'New-VHD' $PSBoundParameters + $state = Read-MockState + $type = if ($Differencing) { 'Differencing' } elseif ($Fixed) { 'Fixed' } else { 'Dynamic' } + $row = [pscustomobject]@{ path = $Path; parentPath = $(if ($Differencing) { $ParentPath } else { $null }); vhdType = $type } + if ($null -eq $state.PSObject.Properties['vhds']) { $state | Add-Member -NotePropertyName vhds -NotePropertyValue @() } + $state.vhds = @(@($state.vhds) | Where-Object { $_ -and -not [string]::Equals([string]$_.path, $Path, [StringComparison]::OrdinalIgnoreCase) }) + $row + Write-MockState $state + if ($state.PSObject.Properties['createVhdFiles'] -and $state.createVhdFiles -and [IO.Directory]::Exists([IO.Path]::GetDirectoryName($Path))) { + [IO.File]::WriteAllBytes($Path, [Text.Encoding]::ASCII.GetBytes('vhdxfile mock ' + $type)) + } + [pscustomobject]@{ Path = $Path; VhdFormat = 'VHDX'; VhdType = $type; ParentPath = $row.parentPath; Size = $SizeBytes; FileSize = [long]4194304; Attached = $false } +} + +function Get-VMHardDiskDrive { + [CmdletBinding()] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, $VMSnapshot, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation) + process { + Write-MockCall 'Get-VMHardDiskDrive' $PSBoundParameters + if ($VMSnapshot) { + foreach ($snapshot in @($VMSnapshot)) { + $owner = @((Read-MockState).vms | Where-Object { [string]$_.id -eq [string]$snapshot.VMId }) | Select-Object -First 1 + $row = if ($owner) { @($owner.snapshots | Where-Object { $_ -and [string]$_.id -eq [string]$snapshot.Id }) | Select-Object -First 1 } else { $null } + $location = 0 + if ($row -and $row.PSObject.Properties['disks']) { + foreach ($disk in @($row.disks)) { if ($disk) { [pscustomobject]@{ VMId = $snapshot.VMId; Path = [string]$disk; ControllerType = 'SCSI'; ControllerNumber = 0; ControllerLocation = $location }; $location++ } } + } + } + return + } + $row = Get-MockRow $VM + if ($null -eq $row) { return } + $location = 0 + foreach ($disk in @($row.disks)) { if ($null -ne $disk) { [pscustomobject]@{ VMId = $VM.Id; Path = [string]$disk; ControllerType = 'SCSI'; ControllerNumber = 0; ControllerLocation = $location }; $location++ } } + } +} diff --git a/tests/HyperVControl.Tests/PhaseStubs.cs b/tests/HyperVControl.Tests/PhaseStubs.cs index 9f6d40c..97ea907 100644 --- a/tests/HyperVControl.Tests/PhaseStubs.cs +++ b/tests/HyperVControl.Tests/PhaseStubs.cs @@ -11,12 +11,6 @@ internal static class LifecycleV2Tests internal static Task RunAsync(Action check) => Task.CompletedTask; } -/// Image catalog, library and job unit tests (phase 3). -internal static class ImageTests -{ - internal static Task RunAsync(Action check) => Task.CompletedTask; -} - /// Golden image build unit tests (phase 3). internal static class ImageBuildTests { @@ -29,12 +23,6 @@ internal static class LifecycleLiveTests internal static Task RunAsync(ToolCall call, Action check, string results) => Task.CompletedTask; } -/// Live validation of image fetch, library and deploy (phase 3). -internal static class ImageLiveTests -{ - internal static Task RunAsync(ToolCall call, Action check, string results) => Task.CompletedTask; -} - /// Live validation of golden image builds (phase 3). internal static class ImageBuildLiveTests { From 71fec11dba8388191b44e575f43367f7db62929b Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:05:39 -0500 Subject: [PATCH 04/23] Document the image workflow and split the image service by concern Add references/images.md for agents: the list, fetch, deploy and boot workflow, catalog rules shared with Quick Create (encodings, trailing commas, hash prefix, disk types, host version filter, custom galleries), what each warning means (including the Windows 11 dev environment that expired on 2024-10-23), the library layout and its in-use guard, fetch semantics for interruption, cancellation and hash mismatches, disk-space expectations, the deploy lab defaults and the error types. SKILL.md does not link it yet. Split ImageCatalogService into partial files: the core (root, list, catalogs, recommendation), fetch, library (register, remove and the in-use guard) and deploy, so the deploy path lives in one file. The move changes no code. Free-space queries now work for a library root at a drive root, which used to produce a doubled separator. --- .../hyper-v-control/references/images.md | 101 ++++ .../Services/ImageCatalogService.Deploy.cs | 172 ++++++ .../Services/ImageCatalogService.Fetch.cs | 218 ++++++++ .../Services/ImageCatalogService.Library.cs | 144 +++++ .../Services/ImageCatalogService.cs | 521 +----------------- src/HyperVControl/Services/ImageFiles.cs | 3 +- tests/HyperVControl.Tests/ImageTests.cs | 1 + 7 files changed, 639 insertions(+), 521 deletions(-) create mode 100644 plugins/hyper-v-control/skills/hyper-v-control/references/images.md create mode 100644 src/HyperVControl/Services/ImageCatalogService.Deploy.cs create mode 100644 src/HyperVControl/Services/ImageCatalogService.Fetch.cs create mode 100644 src/HyperVControl/Services/ImageCatalogService.Library.cs diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/images.md b/plugins/hyper-v-control/skills/hyper-v-control/references/images.md new file mode 100644 index 0000000..d98f65a --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/images.md @@ -0,0 +1,101 @@ +# Images: catalogs, library and deploy + +`hyperv_image` turns gallery images and local disks into lab VMs; `hyperv_jobs` tracks the long-running part. Start with `hyperv_image action=list`. It reads every enabled catalog and the local library, flags problems per image, and returns a `recommended` block with a one-line `hint`. Listing reads only the catalog JSON files and never touches a disk URL. + +## Workflow + +1. `list`, then read `recommended`, each image's `warnings`, and the `library` entries. +2. When a library entry fits, `deploy` it. A linked clone (the default) is ready in seconds. +3. Otherwise `fetch` the catalog image once. It returns a job: poll `hyperv_jobs action=wait jobId= waitSeconds=30` until `status` is `succeeded`, or pass `waitSeconds` (at most 3600) to `fetch` to wait inline. Then `deploy` the new library entry. +4. Boot it: `hyperv_power start` (or `start=true` on deploy), then the Basic console. The Ubuntu desktop images open a first-boot setup wizard (user, locale, time zone) on the console. Enhanced Session works only after that setup finishes. + +For Windows there is no current catalog image: build a golden image from a Windows ISO, or `register` a VHDX you already have. + +## Catalogs + +A catalog is a Hyper-V Quick Create gallery JSON (`{"images":[...]}`), the same file Hyper-V Manager's Quick Create reads. `catalogs.json` starts with Microsoft's feed (`microsoft`, `https://go.microsoft.com/fwlink/?linkid=851584`). `catalogAction=add` takes a `location`, which can be an http(s) URL, a `file:` URI, or an absolute local or UNC path, plus an optional `catalog` name. The file is read and parsed before it is saved, so a broken location fails at once. `remove` deletes a custom catalog; Microsoft's built-in entry is only disabled, and adding its location again re-enables it. + +Parsing follows Quick Create's own rules: + +- UTF-16 or UTF-8, with or without a BOM. Trailing commas, comments and any property-name case are accepted. Microsoft's feed is UTF-16 with trailing commas. +- `config.secureBoot` may be a string or a bool; `enhancedSessionTransportType` is `HvSocket` or `VMBus`. +- `disk.hash` is `:<64 hex digits>`. The prefix is ignored: it is always SHA-256, compared case-insensitively. An entry without a valid hash is listed but never fetched. +- A disk must resolve, after redirects, to a `.zip` containing the `.vhdx` (or `.iso`) named by `archiveRelativePath`, a `.vhdx`, or an `.iso`. A `.vhd` is rejected. +- Images whose `minHostVersion`/`maxHostVersion` exclude this host are hidden; the catalog's `hidden` count says how many. +- `requirements.diskSpace` is ignored: Microsoft's values are far too small. + +To add an image Microsoft never published (Canonical's Ubuntu 24.04 Hyper-V desktop build, for example), write a one-entry catalog with its `name`, `publisher`, `version`, `lastUpdated`, `config` and `disk` (`uri`, `hash`, `archiveRelativePath`) and add it. Canonical maintains such definitions at ; Microsoft documents the schema at . The same file can also feed Quick Create through its `GalleryLocations` registry value. + +## Warnings + +Warnings explain why an image is or is not a good choice. Three flags drive the recommendation: `expired`, `endOfLife` and `fetchable`. + +| Warning | Meaning | +| --- | --- | +| Expired evaluation | Microsoft's **Windows 11 dev environment** (`WinDev2407Eval`, version 10.0.22621, built 2024-07-25) expired on **2024-10-23**. It still downloads (23.3 GB) and boots, but shows a black desktop and a "not genuine" notice and shuts down every hour. Microsoft discontinued these images. Do not use it for agent work. | +| Likely expired evaluation | Another Windows evaluation build more than 90 days old, such as the Windows 10 MSIX packaging environment. | +| Stale image | Last updated more than three years ago, as Microsoft's Ubuntu entries (2022) are. They still work, but expect a long first update. | +| Out of standard support | Ubuntu 18.04 (May 2023) and 20.04 (May 2025). | +| No hash / invalid hash / unsupported disk type | The image cannot be verified or used, so fetch refuses it. | + +Library entries keep the warnings of the catalog image they came from. + +## Library + +The library root is `\HyperVControl\Images`, or `HYPERV_CONTROL_IMAGE_ROOT` when it is set. It holds `library.json`, `catalogs.json`, the `cache\` download directory and the image files. The launcher's elevated worker does not inherit environment variables set only for the MCP client process; set the override as a user environment variable and restart the client. + +Each entry records `name`, `kind` (`image` or `template`), `osFamily` (`windows`, `linux` or `other`), `path`, `format` (`vhdx` or `iso`), `source` (catalog, image, URI and verified SHA-256, or the registered path), sizes, `config` (`secureBoot`, `enhancedSessionTransportType`), `registeredAt`, `readOnly`, `notes`, `warnings` and `deployments` (the clones and copies made from it). `list` adds `exists` and the current file size. + +- `register` adopts a local `.vhdx` or `.iso` in place, without copying or hashing it. It checks the file signature, refuses duplicate names and files, and infers a missing `osFamily` from the name (it says so in `warnings`). Pass `secureBoot=false` for guests that do not boot with Secure Boot. A `template` is made read-only at once. +- A differencing parent must never change, so the library sets the read-only attribute on any entry used for a linked clone. +- `remove` refuses with `errorType: InUse` while any VM attaches the file (`attached`, `attachedDvd`) or any disk chain uses it as a parent (`differencingParent`). The check covers current disks, checkpoint disks and recorded clones whose files still exist. `references` lists them. Remove those VMs and child disks first. `deleteFile=true` also deletes the image file and any kept archive; without it the file stays on disk. + +## Fetch + +A fetch job resolves the disk URL with one 64 KB range request, which gives the final URL, the size and, for a zip, the size of the entry inside. It then checks free space and downloads into `\cache\..partial`, hashing as it writes. It verifies the SHA-256 and the file signature, streams the `archiveRelativePath` entry to `\.vhdx`, re-checks the signature and records the entry. The archive is deleted unless `keepArchive=true`. A `.vhdx` or `.iso` download becomes the library file itself. Local and UNC disks are verified in place and extracted or copied without a cache copy. Progress `message` moves through `resolving`, `verifying the partial download`, `downloading`, `extracting`, `registering` and `done`, with `bytesDone`, `bytesTotal` and `percent`. + +| Event | Outcome | +| --- | --- | +| Connection dropped or stalled for 60 s | Job fails with `DownloadInterrupted`; the partial download is kept. Fetching again resumes it with an HTTP Range request. | +| `hyperv_jobs action=cancel` | Job ends `cancelled`; the partial download and any extraction output are deleted. | +| SHA-256 mismatch | Job fails with `HashMismatch`; the download is deleted. | +| Same image already in the library | `fetch` returns `alreadyInLibrary` with the entry; nothing is downloaded. | +| Same image already being fetched | `fetch` returns `alreadyRunning` with that job. | + +Jobs belong to the MCP process: they appear in `hyperv_jobs list` until it exits, and an exit stops them. A partial download survives an exit, and the next fetch resumes it. + +Disk space: the preflight needs the remaining download plus the extracted entry plus 1 GB. Microsoft's Ubuntu 22.04 entry needs about 10 GB at peak (2.5 GB zip, 6.4 GB VHDX) and leaves 6.4 GB. Canonical's 24.04 build needs about 13 GB (3.7 GB, 8.2 GB). The expired Windows image would need about 69 GB (23.3 GB, 44.8 GB). Point `HYPERV_CONTROL_IMAGE_ROOT` at a larger volume when the default VHD path is short of space. + +## Deploy + +`deploy` takes `imageName` (a library entry), `vmName`, `linked` (default `true`), `memoryMb` (4096), `processors` (2), `switchName`, `vmPath` (default: the host's VM path), `notes` and `start`. It refuses a VM name that already exists and refuses ISO entries, which are installation media rather than bootable disks. The new disk goes to `\\Virtual Hard Disks\.vhdx`: a differencing child of the image, or a full copy when `linked=false`. The VM is Generation 2 and joins the Default Switch, found by its fixed ID, when `switchName` is omitted; without one the adapter stays disconnected (a warning says so). Any failure removes the half-created VM and its disk. + +Lab defaults, which differ from Quick Create's on purpose: + +| Setting | Value | +| --- | --- | +| Memory | Static, `memoryMb` | +| Automatic checkpoints | Off (they would pile up and collide with checkpoint tools) | +| Checkpoint type | Standard, so restores include memory | +| Secure Boot | Off when the image says `secureBoot: false` (Microsoft's Ubuntu entries). Otherwise on, with `MicrosoftWindows` for Windows and `MicrosoftUEFICertificateAuthority` for Linux. An `other` OS family without a setting stays off and is reported. | +| Enhanced session transport | The image's setting; `HvSocket` for Linux by default | +| vTPM | Windows only: a local key protector and `Enable-VMTPM`, applied after the Secure Boot template because the vTPM locks it | + +The result has the VM (`State`, memory, checkpoint settings, `EnhancedSessionTransportType`), `disk` (`path`, `type`, `parentPath`), `parent`, `switchName`, `firmware`, `security` (`TpmEnabled`), `started` and `warnings`. Kernel debugging needs Secure Boot off; change it with `hyperv_firmware` while the VM is Off. + +## Errors + +Failures use the usual `{ ok: false, error, errorType }` envelope. A `fetch` whose job fails within `waitSeconds` returns that envelope with the `job` attached. `hyperv_jobs status` and `wait` return the job itself; read its `status`, `error` and `errorType`. + +| errorType | Cause | +| --- | --- | +| `NotFound` | No such catalog image, catalog, library entry or file, or an HTTP 404 | +| `AmbiguousName` | The image name exists in several catalogs; pass `catalog` | +| `AlreadyExists` | A library entry, catalog, file or disk with that name or path exists | +| `UnsupportedImage` | No valid hash, an unsupported disk type, a missing archive entry or a wrong file signature | +| `HashMismatch` | The download does not match the catalog's SHA-256 | +| `DownloadInterrupted` / `DownloadFailed` | The transfer broke (resumable) or the server refused it | +| `InsufficientDiskSpace` | Not enough free space for the download and extraction | +| `InUse` | `remove` found VMs or child disks still using the file | +| `InvalidLibrary` | `library.json` or `catalogs.json` is unreadable; it is never rewritten automatically | +| `Cancelled` | The job was cancelled | diff --git a/src/HyperVControl/Services/ImageCatalogService.Deploy.cs b/src/HyperVControl/Services/ImageCatalogService.Deploy.cs new file mode 100644 index 0000000..deaa29c --- /dev/null +++ b/src/HyperVControl/Services/ImageCatalogService.Deploy.cs @@ -0,0 +1,172 @@ +using System.Text.Json; + +namespace HyperVControl.Services; + +/// Deploying a VM from a library entry with the lab defaults. +internal sealed partial class ImageCatalogService +{ + private const string VmProjection = "($vm | Select-Object Id,Name,@{Name='State';Expression={[string]$_.State}},Generation,ProcessorCount,MemoryStartup,DynamicMemoryEnabled," + + "@{Name='CheckpointType';Expression={[string]$_.CheckpointType}},AutomaticCheckpointsEnabled," + + "@{Name='EnhancedSessionTransportType';Expression={[string]$_.EnhancedSessionTransportType}},Path,Notes)"; + + /// + /// Creates a VM from a library entry: a differencing child of the entry (linked, the default) or a full copy under + /// the VM's folder, the VM through , then the lab defaults in one script: + /// static memory, automatic checkpoints off, Standard checkpoints, Secure Boot per OS family (off when the image + /// says so), HvSocket enhanced session for Linux, and a vTPM for Windows (after the template, which the vTPM locks). + /// Any failure removes the half-created VM and its new disk. + /// + public async Task DeployAsync(ImageDeployRequest request, VmLifecycleService lifecycle, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.ImageName)) throw new ArgumentException("imageName required: a library entry name from hyperv_image list."); + var vmName = request.VmName?.Trim(); + if (string.IsNullOrEmpty(vmName) || vmName.Length > 100) throw new ArgumentException("vmName required (1-100 characters)."); + if (request.MemoryMb is < 32 or > 1048576 || request.Processors is < 1 or > 2048) throw new ArgumentException("Invalid memoryMb or processors."); + var store = await StoreAsync(ct); + var entry = (await store.LibraryAsync(ct)).Find(request.ImageName.Trim()) + ?? throw new HyperVOperationException($"No library entry named '{request.ImageName}'. Fetch or register it first; hyperv_image list shows the library.", "NotFound"); + if (entry.Format != "vhdx") + throw new ArgumentException($"'{entry.Name}' is an ISO (installation media), not a bootable disk. Create a VM with a new disk and attach the ISO instead."); + if (!File.Exists(entry.Path)) throw new HyperVOperationException($"The file of '{entry.Name}' is missing: {entry.Path}.", "NotFound"); + var vmRoot = request.VmPath is null ? (await HostPathsAsync(ct)).VirtualMachinePath : ControlService.FullPath(request.VmPath); + var folder = Path.Combine(vmRoot, ImageFiles.SafeDirectoryName(vmName)); + var directory = Path.Combine(folder, "Virtual Hard Disks"); + var disk = Path.Combine(directory, ImageFiles.SafeDirectoryName(vmName) + ".vhdx"); + if (File.Exists(disk)) throw new HyperVOperationException($"{disk} already exists; choose another vmName or vmPath.", "AlreadyExists"); + var warnings = new List(entry.Warnings ?? []); + var createdFolder = !Directory.Exists(folder); + Directory.CreateDirectory(directory); + var ownsDisk = false; + string? vmId = null; + try + { + // 1. One script: refuse a duplicate VM name, resolve the switch, and create the differencing child. + var prepare = new HyperVScript() + .Line("$name = " + PowerShellRunner.Literal(vmName)) + .Line("if (@(Get-VM | Where-Object { $_.Name -eq $name }).Count -gt 0) { Stop-Control 'InvalidArgument' (\"A VM named '\" + $name + \"' already exists. Choose another vmName.\") }"); + if (request.SwitchName is { } requested) + prepare.Line("$switchName = " + PowerShellRunner.Literal(requested)) + .Line("if (@(Get-VMSwitch | Where-Object { $_.Name -eq $switchName }).Count -eq 0) { Stop-Control 'NotFound' (\"Switch '\" + $switchName + \"' not found. Call hyperv_network list.\") }"); + else + prepare.Line("$switchName = $null") + .Line("try { $default = Get-VMSwitch -Id " + PsValue.Of(Guid.Parse(DefaultSwitchId)).Text + " -ErrorAction Stop; if ($default) { $switchName = [string]@($default)[0].Name } } catch { }"); + if (request.Linked) + prepare.Cmd("$child = New-VHD", ("Path", PsValue.Of(disk)), ("ParentPath", PsValue.Of(entry.Path)), ("Differencing", PsValue.Of(true))); + ownsDisk = request.Linked; + var prepared = await Runner.InvokeAsync(prepare.Returns("@{ switchName = $switchName }"), ct, 300); + var switchName = prepared.TryGetProperty("switchName", out var chosen) && chosen.ValueKind == JsonValueKind.String ? chosen.GetString() : null; + if (switchName is null) warnings.Add("No switchName was given and this host has no Default Switch, so the VM's network adapter is not connected."); + if (request.Linked) entry = await ProtectParentAsync(store, entry, ct); + else + { + ImageFiles.EnsureSpace(directory, new FileInfo(entry.Path).Length, "copying " + entry.Name); + ownsDisk = true; + await ImageFiles.CopyAsync(entry.Path, disk, null, ct); + } + + // 2. The VM itself; this is the only call into the lifecycle service. + var created = await lifecycle.CreateAsync(vmName, vmRoot, disk, 2, request.MemoryMb, request.Processors, 0, switchName, ct); + vmId = created.GetProperty("Id").GetString() ?? throw new HyperVOperationException("New-VM returned no VM ID.", "InvalidResponse"); + + // 3. Lab defaults, then the optional start. + var configured = await Runner.InvokeAsync(LabDefaults(vmId, entry, request, warnings), ct, 300); + if (configured.TryGetProperty("startError", out var startError) && startError.ValueKind == JsonValueKind.String) + warnings.Add("The VM was created and configured but did not start: " + startError.GetString()); + var deployment = new ImageDeployment(vmName, vmId, disk, request.Linked, Clock()); + await store.UpdateLibraryAsync(index => index.Find(entry.Name) is { } current + ? (Replace(index, current, current with { Deployments = [.. current.Deployments ?? [], deployment] }), true) + : (index, false), ct); + return new + { + vm = configured.GetProperty("vm"), + disk = new { path = disk, type = request.Linked ? "differencing" : "copy", parentPath = request.Linked ? entry.Path : null }, + parent = new { entry.Name, entry.Path, entry.Kind, entry.OsFamily, entry.ReadOnly }, + switchName, + firmware = configured.TryGetProperty("firmware", out var firmware) ? firmware : default, + security = configured.TryGetProperty("security", out var security) ? security : default, + started = request.Start && !(startError.ValueKind == JsonValueKind.String), + warnings + }; + } + catch + { + await RollbackAsync(vmId, ownsDisk ? disk : null, createdFolder ? folder : directory); + throw; + } + } + + private static HyperVScript LabDefaults(string vmId, ImageLibraryEntry entry, ImageDeployRequest request, List warnings) + { + var vm = PsValue.Raw("$vm"); + var windows = entry.OsFamily == "windows"; + var linux = entry.OsFamily == "linux"; + var transport = entry.Config.EnhancedSessionTransportType ?? (linux ? "HvSocket" : null); + var script = new HyperVScript().Vm(vmId).RequireOff() + .Cmd("Set-VM", ("VM", vm), ("StaticMemory", PsValue.Of(true)), ("AutomaticCheckpointsEnabled", PsValue.Of(false)), + ("CheckpointType", PsValue.Token("Standard", "Standard")), + ("EnhancedSessionTransportType", transport is null ? null : PsValue.Token(transport, "HvSocket", "VMBus")), + ("Notes", request.Notes is null ? null : PsValue.Of(request.Notes))); + // Secure Boot follows the image (off when it says so), else the template that matches the OS family. + var template = entry.Config.SecureBoot == false ? null + : windows ? "MicrosoftWindows" + : linux || entry.Config.SecureBoot == true ? "MicrosoftUEFICertificateAuthority" + : null; + if (template is null) + { + script.Cmd("Set-VMFirmware", ("VM", vm), ("EnableSecureBoot", PsValue.Token("Off", "On", "Off"))); + if (entry.Config.SecureBoot is null) + warnings.Add("Secure Boot is off because the image's OS family is 'other'; enable it with hyperv_firmware if the guest supports it."); + } + else + script.Cmd("Set-VMFirmware", ("VM", vm), ("EnableSecureBoot", PsValue.Token("On", "On", "Off")), + ("SecureBootTemplate", PsValue.Token(template, "MicrosoftWindows", "MicrosoftUEFICertificateAuthority"))); + // The vTPM comes after the template: initializing it locks the Secure Boot template. + if (windows) script.Cmd("Set-VMKeyProtector", ("VM", vm), ("NewLocalKeyProtector", PsValue.Of(true))).Cmd("Enable-VMTPM", ("VM", vm)); + script.Line("$startError = $null"); + if (request.Start) script.Line("try { Start-VM -VM $vm } catch { $startError = [string]$_.Exception.Message }"); + return script.Line("$vm = Get-VM -Id $vm.Id -ErrorAction Stop").Returns( + "@{ vm = " + VmProjection + + "; firmware = (Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={[string]$_.SecureBoot}},SecureBootTemplate)" + + "; security = (Get-VMSecurity -VM $vm | Select-Object TpmEnabled); startError = $startError }"); + } + + private static async Task ProtectParentAsync(ImageLibraryStore store, ImageLibraryEntry entry, CancellationToken ct) => + await store.UpdateLibraryAsync(index => + { + var current = index.Find(entry.Name) ?? entry; + MakeReadOnly(current.Path); + if (current.ReadOnly || !index.Entries.Contains(current)) return (index, current with { ReadOnly = true }); + var updated = current with { ReadOnly = true }; + return (Replace(index, current, updated), updated); + }, ct); + + /// Best effort: removes a half-created VM, the disk this deploy created and the folders it left empty. + private async Task RollbackAsync(string? vmId, string? disk, string folder) + { + if (vmId is not null && Guid.TryParse(vmId, out var id)) + { + try + { + await Runner.InvokeAsync(new HyperVScript() + .Line("$vm = $null; try { $vm = Get-VM -Id " + PsValue.Of(id).Text + " -ErrorAction Stop } catch { }") + .Line("if ($vm) { if ([string]$vm.State -ne 'Off') { Stop-VM -VM $vm -TurnOff -Force }; Remove-VM -VM $vm -Force }") + .Returns("$true"), CancellationToken.None, 120); + } + catch (Exception) { /* the original failure is the one reported */ } + } + ImageFiles.TryDelete(disk); + DeleteEmptyFolders(folder); + } + + private static void DeleteEmptyFolders(string folder) + { + try + { + if (!Directory.Exists(folder)) return; + foreach (var child in Directory.GetDirectories(folder)) DeleteEmptyFolders(child); + if (!Directory.EnumerateFileSystemEntries(folder).Any()) Directory.Delete(folder); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/src/HyperVControl/Services/ImageCatalogService.Fetch.cs b/src/HyperVControl/Services/ImageCatalogService.Fetch.cs new file mode 100644 index 0000000..65c8a74 --- /dev/null +++ b/src/HyperVControl/Services/ImageCatalogService.Fetch.cs @@ -0,0 +1,218 @@ +using System.Diagnostics; + +namespace HyperVControl.Services; + +/// Fetch jobs: download with resume, verification, extraction into the library, and cleanup on cancel or failure. +internal sealed partial class ImageCatalogService +{ + /// + /// Starts (or joins) the fetch job for one catalog image and optionally waits for it. Returns the job snapshot, + /// an ok:false envelope around it when it failed within the wait, or the existing entry when the image + /// is already in the library. + /// + public async Task FetchAsync(string? imageName, string? catalog, bool keepArchive, int waitSeconds, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(imageName)) throw new ArgumentException("imageName required: a catalog image name from hyperv_image list."); + if (waitSeconds is < 0 or > 3600) throw new ArgumentException("waitSeconds must be between 0 and 3600."); + var store = await StoreAsync(ct); + var (image, source) = await FindImageAsync(store, imageName.Trim(), catalog?.Trim(), ct); + if (image.Sha256 is null) + throw new HyperVOperationException($"'{image.Name}' has no valid SHA-256 in catalog '{image.Catalog}'; unverifiable images are never fetched.", "UnsupportedImage"); + if (!image.Fetchable) + throw new HyperVOperationException($"'{image.Name}' cannot be fetched: {string.Join(" ", image.Warnings)}", "UnsupportedImage"); + JobInfo job; + await fetchGate.WaitAsync(ct); + try + { + var library = await store.LibraryAsync(ct); + var same = library.Entries.FirstOrDefault(e => SameText(e.Source.Sha256, image.Sha256)); + if (same is not null && File.Exists(same.Path)) + return new { alreadyInLibrary = true, entry = ProjectEntry(same), next = $"Deploy it with action=deploy imageName='{same.Name}'." }; + if (library.Find(image.Name) is { } named) + throw new HyperVOperationException($"The library already has an entry named '{named.Name}'" + (File.Exists(named.Path) ? " with different content" : " whose file is missing") + + "; remove it first (action=remove).", "AlreadyExists"); + if (activeFetches.TryGetValue(image.Sha256, out var activeId) && Jobs.Get(activeId) is { Status: JobStatus.Queued or JobStatus.Running } active) + return new { alreadyRunning = true, job = active }; + job = Jobs.Start("fetch " + image.Name, (progress, token) => RunFetchAsync(store, image, source.Location, keepArchive, progress, token)); + activeFetches[image.Sha256] = job.Id; + } + finally { fetchGate.Release(); } + return waitSeconds == 0 ? job : JobOutcome(await Jobs.WaitAsync(job.Id, TimeSpan.FromSeconds(waitSeconds), ct)); + } + + /// A job snapshot, wrapped in the failure envelope when the job failed or was cancelled. + internal static object JobOutcome(JobInfo job) => job.Status is JobStatus.Failed or JobStatus.Cancelled + ? new { ok = false, error = job.Error, errorType = job.ErrorType, job } + : job; + + private async Task RunFetchAsync(ImageLibraryStore store, CatalogImage image, string catalogLocation, bool keepArchive, IProgress progress, CancellationToken ct) + { + var clock = Stopwatch.StartNew(); + var sha = image.Sha256!; + var report = Throttle(progress); + string? partial = null; // a partial download this job owns + string? downloaded = null; // a complete, verified archive this job downloaded + var outputs = new List(); // extraction or copy output this job owns + try + { + Directory.CreateDirectory(store.CacheDirectory); + var uri = ImageCatalogFeed.ParseLocation(image.DiskUri); + string source, extension; + long downloadedBytes = 0, resumedFrom = 0; + bool inCache; + if (uri.IsFile) + { + // A local or UNC disk is verified in place and never copied to the cache. + source = uri.LocalPath; + extension = Path.GetExtension(source).ToLowerInvariant(); + RequireDiskType(extension, image); + if (!File.Exists(source)) throw new HyperVOperationException($"The catalog's disk file does not exist: {source}.", "NotFound"); + var length = new FileInfo(source).Length; + var actual = await ImageFiles.Sha256Async(source, done => report("verifying " + Path.GetFileName(source), done, length), ct); + if (!SameText(actual, sha)) + throw new HyperVOperationException($"SHA-256 mismatch: the catalog lists {sha} but {source} hashes to {actual}. The source file was left untouched.", "HashMismatch"); + inCache = false; + } + else + { + report("resolving " + uri.Host, 0, null); + var probe = await Downloader.ProbeAsync(uri, ct); + extension = probe.Extension; + RequireDiskType(extension, image); + var cached = Path.Combine(store.CacheDirectory, sha + extension); + if (File.Exists(cached) && SameText(await ImageFiles.Sha256Async(cached, done => report("verifying the cached download", done, new FileInfo(cached).Length), ct), sha)) + source = cached; + else + { + ImageFiles.TryDelete(cached); + partial = cached + ".partial"; + var have = File.Exists(partial) ? new FileInfo(partial).Length : 0; + long extract = 0; + if (extension == ".zip" && probe.ZipEntries is { } entries) + extract = (entries.FirstOrDefault(e => ImageFiles.SameEntry(e.FullName, image.ArchiveRelativePath!)) + ?? throw new HyperVOperationException($"The zip has no entry '{image.ArchiveRelativePath}' (entries: {string.Join(", ", entries.Take(20).Select(e => e.FullName))}); nothing was downloaded.", "UnsupportedImage")).UncompressedSize; + if (probe.Length is long total) + ImageFiles.EnsureSpace(store.Root, Math.Max(0, total - have) + extract, $"downloading {image.Name}" + (extract > 0 ? " and extracting it" : "")); + var result = await Downloader.DownloadAsync(probe.FinalUri, partial, probe.Length, sha, report, ct); + downloadedBytes = result.Downloaded; + resumedFrom = result.ResumedFrom; + File.Move(partial, cached, overwrite: true); + partial = null; + downloaded = source = cached; + } + inCache = true; + } + if (!ImageFiles.HasMagic(source, extension)) + { + if (downloaded is not null) { ImageFiles.TryDelete(downloaded); downloaded = null; } + throw new HyperVOperationException($"The {(inCache ? "download" : "file")} is not a valid {extension} (its signature does not match)" + (inCache ? "; it was deleted." : "."), "UnsupportedImage"); + } + + string target; + if (extension == ".zip") + { + var entryExtension = Path.GetExtension(image.ArchiveRelativePath!).ToLowerInvariant(); + target = UniqueLibraryPath(store.Root, image.Name, entryExtension); + var temporary = target + ".partial"; + outputs.Add(temporary); + await ImageFiles.ExtractAsync(source, image.ArchiveRelativePath!, temporary, (done, total) => report("extracting " + image.ArchiveRelativePath, done, total), ct); + if (!ImageFiles.HasMagic(temporary, entryExtension)) + throw new HyperVOperationException($"The extracted '{image.ArchiveRelativePath}' is not a valid {entryExtension} (its signature does not match); it was deleted.", "UnsupportedImage"); + File.Move(temporary, target); + outputs.Add(target); + } + else if (inCache) + { + // A verified .vhdx or .iso download becomes the library file itself. + target = UniqueLibraryPath(store.Root, image.Name, extension); + File.Move(source, target); + downloaded = null; + outputs.Add(target); + } + else + { + target = UniqueLibraryPath(store.Root, image.Name, extension); + var temporary = target + ".partial"; + outputs.Add(temporary); + ImageFiles.EnsureSpace(store.Root, new FileInfo(source).Length, "copying " + image.Name); + await ImageFiles.CopyAsync(source, temporary, (done, total) => report("copying " + Path.GetFileName(source), done, total), ct); + File.Move(temporary, target); + outputs.Add(target); + } + + var archiveKept = extension == ".zip" && inCache && keepArchive ? source : null; + var entry = new ImageLibraryEntry(image.Name, "image", image.OsFamily, target, Path.GetExtension(target).TrimStart('.').ToLowerInvariant(), + new ImageSource(image.Catalog, catalogLocation, image.Name, image.Version, image.LastUpdated, image.DiskUri, sha, image.ArchiveRelativePath), + new ImageSizes(new FileInfo(target).Length, extension == ".zip" && inCache ? new FileInfo(source).Length : null), + new ImageConfig(image.SecureBoot, image.EnhancedSessionTransportType), Clock(), + Expired: image.Expired, ArchivePath: archiveKept, Warnings: image.Warnings.Count > 0 ? image.Warnings : null); + report("registering " + image.Name, 0, null); + await store.UpdateLibraryAsync(index => index.Find(entry.Name) is null + ? (index with { Entries = [.. index.Entries, entry] }, entry) + : throw new HyperVOperationException($"A library entry named '{entry.Name}' appeared while fetching; the extracted file was deleted.", "AlreadyExists"), ct); + outputs.Clear(); + if (extension == ".zip" && inCache && !keepArchive) ImageFiles.TryDelete(source); + downloaded = null; + progress.Report(new JobProgress(100, null, null, "done")); + return new + { + entry = ProjectEntry(entry), archive = archiveKept, downloadedBytes, resumedFromBytes = resumedFrom, sha256 = sha, + seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), + next = $"Deploy it with action=deploy imageName='{entry.Name}' vmName=." + }; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Cancellation removes everything this job produced: the partial download and any extraction output. + ImageFiles.TryDelete(partial); + foreach (var output in outputs) ImageFiles.TryDelete(output); + if (downloaded is not null && !keepArchive) ImageFiles.TryDelete(downloaded); + throw; + } + catch (Exception ex) + { + // Failures never leave extraction output behind. An interrupted download keeps its partial file for + // resume (a hash mismatch has already deleted it), and a verified archive is kept for a quick retry. + foreach (var output in outputs) ImageFiles.TryDelete(output); + if (downloaded is not null) + throw new HyperVOperationException(ex.Message + $" The verified download is kept at {downloaded}; fetch again to reuse it.", + ex is HyperVOperationException known ? known.ErrorType : "ExtractionFailed"); + throw; + } + finally + { + activeFetches.TryRemove(sha, out _); + } + } + + private static void RequireDiskType(string extension, CatalogImage image) + { + if (!ImageCatalogFeed.DiskExtensions.Contains(extension)) + throw new HyperVOperationException($"'{image.Name}' resolves to a '{extension}' file; only a .zip containing a .vhdx, a .vhdx or an .iso is supported.", "UnsupportedImage"); + if (extension == ".zip" && image.ArchiveRelativePath is null) + throw new HyperVOperationException($"'{image.Name}' is a .zip without archiveRelativePath.", "UnsupportedImage"); + } + + private static string UniqueLibraryPath(string root, string name, string extension) + { + var stem = ImageFiles.SafeName(name); + var path = Path.Combine(root, stem + extension); + for (var n = 2; File.Exists(path) || File.Exists(path + ".partial"); n++) path = Path.Combine(root, $"{stem}-{n}{extension}"); + return path; + } + + /// Progress at most four times a second per stage, plus every stage change and completion. + private static Action Throttle(IProgress progress) + { + var clock = Stopwatch.StartNew(); + var last = TimeSpan.MinValue; + string? stage = null; + return (current, done, total) => + { + if (current == stage && clock.Elapsed - last < TimeSpan.FromMilliseconds(250) && done != total) return; + last = clock.Elapsed; + stage = current; + progress.Report(new JobProgress(total is > 0 ? Math.Round(100.0 * done / total.Value, 1) : null, done, total, current)); + }; + } +} diff --git a/src/HyperVControl/Services/ImageCatalogService.Library.cs b/src/HyperVControl/Services/ImageCatalogService.Library.cs new file mode 100644 index 0000000..8c3f2b9 --- /dev/null +++ b/src/HyperVControl/Services/ImageCatalogService.Library.cs @@ -0,0 +1,144 @@ +using System.Text.Json; + +namespace HyperVControl.Services; + +/// Library entries: adopting local files in place and removing entries that nothing uses. +internal sealed partial class ImageCatalogService +{ + private static readonly string[] OsFamilies = ["windows", "linux", "other"]; + private static readonly string[] Kinds = ["image", "template"]; + + // Walks each disk's parent chain (current disks, checkpoint disks, recorded children) looking for $target. + private const string InUseGuard = """ + function Test-ControlChain([string]$Start) { + $current = $Start + for ($depth = 0; $current -and $depth -lt 64; $depth++) { + try { + if ([string]::Equals([System.IO.Path]::GetFullPath($current), $target, [System.StringComparison]::OrdinalIgnoreCase)) { return $depth } + $current = [string](Get-VHD -Path $current -ErrorAction Stop).ParentPath + } catch { return -1 } + } + return -1 + } + $references = New-Object System.Collections.ArrayList + foreach ($machine in @(Get-VM)) { + $paths = @(Get-VMHardDiskDrive -VM $machine | ForEach-Object { [string]$_.Path }) + foreach ($snapshot in @(Get-VMSnapshot -VM $machine)) { $paths += @(Get-VMHardDiskDrive -VMSnapshot $snapshot | ForEach-Object { [string]$_.Path }) } + foreach ($path in @($paths | Where-Object { $_ } | Select-Object -Unique)) { + $depth = Test-ControlChain $path + if ($depth -ge 0) { [void]$references.Add([ordered]@{ vm = $machine.Name; vmId = [string]$machine.Id; disk = $path; relation = $(if ($depth -eq 0) { 'attached' } else { 'differencingParent' }) }) } + } + foreach ($dvd in @(Get-VMDvdDrive -VM $machine)) { + $same = $false + try { $same = $dvd.Path -and [string]::Equals([System.IO.Path]::GetFullPath([string]$dvd.Path), $target, [System.StringComparison]::OrdinalIgnoreCase) } catch { } + if ($same) { [void]$references.Add([ordered]@{ vm = $machine.Name; vmId = [string]$machine.Id; disk = [string]$dvd.Path; relation = 'attachedDvd' }) } + } + } + foreach ($child in $children) { + $depth = Test-ControlChain $child + if ($depth -gt 0) { [void]$references.Add([ordered]@{ vm = $null; vmId = $null; disk = $child; relation = 'differencingParent' }) } + } + """; + + /// Adopts a local .vhdx or .iso in place. A template is made read-only, because it exists to be a differencing parent. + public async Task RegisterAsync(string? path, string? name, string? osFamily, string? kind, bool? secureBoot, string? notes, CancellationToken ct) + { + var file = ControlService.FullPath(path ?? throw new ArgumentException("path required: an absolute path to a local .vhdx or .iso.")); + if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 100) throw new ArgumentException("name required (1-100 characters)."); + var entryName = name.Trim(); + var format = Path.GetExtension(file).ToLowerInvariant() switch + { + ".vhdx" => "vhdx", + ".iso" => "iso", + _ => throw new ArgumentException("register accepts a .vhdx or an .iso file.") + }; + if (!File.Exists(file)) throw new HyperVOperationException($"File not found: {file}.", "NotFound"); + if (!ImageFiles.HasMagic(file, "." + format)) throw new HyperVOperationException($"{file} is not a valid .{format} (its signature does not match).", "UnsupportedImage"); + var entryKind = (kind ?? "image").Trim().ToLowerInvariant(); + if (!Kinds.Contains(entryKind)) throw new ArgumentException("kind must be image or template."); + if (format == "iso" && entryKind == "template") throw new ArgumentException("An ISO is installation media; register it as kind=image."); + var warnings = new List(); + var family = osFamily?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(family)) + { + family = ImageCatalogFeed.InferOsFamily(entryName + " " + Path.GetFileNameWithoutExtension(file), null, []); + warnings.Add($"osFamily was not given; '{family}' was inferred from the name. Remove and register again with osFamily if that is wrong."); + } + else if (!OsFamilies.Contains(family)) throw new ArgumentException("osFamily must be windows, linux or other."); + var store = await StoreAsync(ct); + var entry = await store.UpdateLibraryAsync(index => + { + if (index.Find(entryName) is not null) throw new HyperVOperationException($"A library entry named '{entryName}' already exists.", "AlreadyExists"); + if (index.Entries.FirstOrDefault(e => SamePath(e.Path, file)) is { } same) throw new HyperVOperationException($"{file} is already registered as '{same.Name}'.", "AlreadyExists"); + var template = entryKind == "template" && format == "vhdx"; + if (template) MakeReadOnly(file); + var added = new ImageLibraryEntry(entryName, entryKind, family, file, format, new ImageSource(RegisteredFrom: file), + new ImageSizes(new FileInfo(file).Length), new ImageConfig(secureBoot), Clock(), ReadOnly: template, Notes: notes); + return (index with { Entries = [.. index.Entries, added] }, added); + }, ct); + return new { registered = true, entry = ProjectEntry(entry), warnings }; + } + + /// + /// Removes a library entry (and with its file and kept archive). Refuses with + /// InUse while any VM attaches the file, any disk chain (current, checkpoint or recorded child) has it as + /// a differencing parent, or a VM's DVD drive holds it. + /// + public async Task RemoveAsync(string? name, bool deleteFile, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required: the library entry to remove."); + var store = await StoreAsync(ct); + var entry = (await store.LibraryAsync(ct)).Find(name.Trim()) + ?? throw new HyperVOperationException($"No library entry named '{name}'. Call hyperv_image list.", "NotFound"); + var references = await ReferencesAsync(entry, ct); + if (references.Count > 0) + { + var summary = string.Join("; ", references.Select(r => + $"{(r.TryGetProperty("vm", out var vm) && vm.ValueKind == JsonValueKind.String ? "VM '" + vm.GetString() + "'" : "an unattached disk")} " + + $"{r.GetProperty("disk").GetString()} ({r.GetProperty("relation").GetString()})")); + return new + { + ok = false, errorType = "InUse", references, + error = $"'{entry.Name}' is in use: {summary}. Remove those VMs and child disks first; removing a differencing parent breaks its children." + }; + } + var deleted = new List(); + if (deleteFile) + foreach (var path in new[] { entry.Path, entry.ArchivePath }) + if (path is not null && File.Exists(path)) + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + deleted.Add(path); + } + var removed = await store.UpdateLibraryAsync(index => + { + var current = index.Find(entry.Name) ?? throw new HyperVOperationException($"No library entry named '{entry.Name}'.", "NotFound"); + return (index with { Entries = index.Entries.Where(e => !ReferenceEquals(e, current)).ToList() }, current); + }, ct); + return new + { + removed = removed.Name, deleted, + kept = deleteFile ? null : removed.Path, + note = !deleteFile && removed.ReadOnly ? "The file was kept with its read-only attribute." : null + }; + } + + private async Task> ReferencesAsync(ImageLibraryEntry entry, CancellationToken ct) + { + var children = (entry.Deployments ?? []).Select(d => d.DiskPath).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var script = new HyperVScript() + .Line("$target = [System.IO.Path]::GetFullPath(" + PowerShellRunner.Literal(entry.Path) + ")") + .Line("$children = @(" + string.Join(", ", children.Select(PowerShellRunner.Literal)) + ")") + .Line(InUseGuard) + .Returns("@($references)"); + var data = await Runner.InvokeAsync(script, ct, 300); + return data.ValueKind == JsonValueKind.Array ? data.EnumerateArray().Select(e => e.Clone()).ToList() : []; + } + + private static void MakeReadOnly(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) == 0) File.SetAttributes(path, attributes | FileAttributes.ReadOnly); + } +} diff --git a/src/HyperVControl/Services/ImageCatalogService.cs b/src/HyperVControl/Services/ImageCatalogService.cs index 875bb65..1d1334d 100644 --- a/src/HyperVControl/Services/ImageCatalogService.cs +++ b/src/HyperVControl/Services/ImageCatalogService.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using System.Diagnostics; using System.Net; using System.Text.Json; @@ -14,14 +13,12 @@ internal sealed record ImageDeployRequest(string ImageName, string VmName, bool /// jobs and deployment. Downloads, hashing, extraction and disk-space checks are C#; host work (default paths, /// the in-use guard, the per-VM disk, lab defaults) runs as one per step. /// -internal sealed class ImageCatalogService(PowerShellRunner runner, JobRunner jobs) +internal sealed partial class ImageCatalogService(PowerShellRunner runner, JobRunner jobs) { /// Overrides the library root (default <Get-VMHost VirtualHardDiskPath>\HyperVControl\Images). public const string RootVariable = "HYPERV_CONTROL_IMAGE_ROOT"; /// Microsoft's fixed ID of the Default Switch; its name is localized. internal const string DefaultSwitchId = "c08cb7b8-9b3c-408e-8e30-5e16a3aeb444"; - private static readonly string[] OsFamilies = ["windows", "linux", "other"]; - private static readonly string[] Kinds = ["image", "template"]; private static readonly TimeSpan FeedLifetime = TimeSpan.FromMinutes(5); private const int MaxFeedBytes = 16 << 20; private static readonly HttpClient Http = CreateHttpClient(); @@ -42,42 +39,6 @@ internal sealed class ImageCatalogService(PowerShellRunner runner, JobRunner job private sealed record HostPaths(string VirtualHardDiskPath, string VirtualMachinePath); private sealed record Recommendation(CatalogImage? Linux, CatalogImage? Windows, ImageLibraryEntry? Deploy, object Summary); - private const string VmProjection = "($vm | Select-Object Id,Name,@{Name='State';Expression={[string]$_.State}},Generation,ProcessorCount,MemoryStartup,DynamicMemoryEnabled," - + "@{Name='CheckpointType';Expression={[string]$_.CheckpointType}},AutomaticCheckpointsEnabled," - + "@{Name='EnhancedSessionTransportType';Expression={[string]$_.EnhancedSessionTransportType}},Path,Notes)"; - - // Walks each disk's parent chain (current disks, checkpoint disks, recorded children) looking for $target. - private const string InUseGuard = """ - function Test-ControlChain([string]$Start) { - $current = $Start - for ($depth = 0; $current -and $depth -lt 64; $depth++) { - try { - if ([string]::Equals([System.IO.Path]::GetFullPath($current), $target, [System.StringComparison]::OrdinalIgnoreCase)) { return $depth } - $current = [string](Get-VHD -Path $current -ErrorAction Stop).ParentPath - } catch { return -1 } - } - return -1 - } - $references = New-Object System.Collections.ArrayList - foreach ($machine in @(Get-VM)) { - $paths = @(Get-VMHardDiskDrive -VM $machine | ForEach-Object { [string]$_.Path }) - foreach ($snapshot in @(Get-VMSnapshot -VM $machine)) { $paths += @(Get-VMHardDiskDrive -VMSnapshot $snapshot | ForEach-Object { [string]$_.Path }) } - foreach ($path in @($paths | Where-Object { $_ } | Select-Object -Unique)) { - $depth = Test-ControlChain $path - if ($depth -ge 0) { [void]$references.Add([ordered]@{ vm = $machine.Name; vmId = [string]$machine.Id; disk = $path; relation = $(if ($depth -eq 0) { 'attached' } else { 'differencingParent' }) }) } - } - foreach ($dvd in @(Get-VMDvdDrive -VM $machine)) { - $same = $false - try { $same = $dvd.Path -and [string]::Equals([System.IO.Path]::GetFullPath([string]$dvd.Path), $target, [System.StringComparison]::OrdinalIgnoreCase) } catch { } - if ($same) { [void]$references.Add([ordered]@{ vm = $machine.Name; vmId = [string]$machine.Id; disk = [string]$dvd.Path; relation = 'attachedDvd' }) } - } - } - foreach ($child in $children) { - $depth = Test-ControlChain $child - if ($depth -gt 0) { [void]$references.Add([ordered]@{ vm = $null; vmId = $null; disk = $child; relation = 'differencingParent' }) } - } - """; - private static HttpClient CreateHttpClient() { var client = new HttpClient(new SocketsHttpHandler @@ -347,486 +308,6 @@ private static IReadOnlyList CacheFiles(ImageLibraryStore store) => !Dir .Select(f => (object)new { f.Name, bytes = f.Length, partial = f.Name.EndsWith(".partial", StringComparison.OrdinalIgnoreCase), modified = f.LastWriteTimeUtc }) .ToList(); - // ---- fetch ---------------------------------------------------------------------------------------- - - /// - /// Starts (or joins) the fetch job for one catalog image and optionally waits for it. Returns the job snapshot, - /// an ok:false envelope around it when it failed within the wait, or the existing entry when the image - /// is already in the library. - /// - public async Task FetchAsync(string? imageName, string? catalog, bool keepArchive, int waitSeconds, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(imageName)) throw new ArgumentException("imageName required: a catalog image name from hyperv_image list."); - if (waitSeconds is < 0 or > 3600) throw new ArgumentException("waitSeconds must be between 0 and 3600."); - var store = await StoreAsync(ct); - var (image, source) = await FindImageAsync(store, imageName.Trim(), catalog?.Trim(), ct); - if (image.Sha256 is null) - throw new HyperVOperationException($"'{image.Name}' has no valid SHA-256 in catalog '{image.Catalog}'; unverifiable images are never fetched.", "UnsupportedImage"); - if (!image.Fetchable) - throw new HyperVOperationException($"'{image.Name}' cannot be fetched: {string.Join(" ", image.Warnings)}", "UnsupportedImage"); - JobInfo job; - await fetchGate.WaitAsync(ct); - try - { - var library = await store.LibraryAsync(ct); - var same = library.Entries.FirstOrDefault(e => SameText(e.Source.Sha256, image.Sha256)); - if (same is not null && File.Exists(same.Path)) - return new { alreadyInLibrary = true, entry = ProjectEntry(same), next = $"Deploy it with action=deploy imageName='{same.Name}'." }; - if (library.Find(image.Name) is { } named) - throw new HyperVOperationException($"The library already has an entry named '{named.Name}'" + (File.Exists(named.Path) ? " with different content" : " whose file is missing") - + "; remove it first (action=remove).", "AlreadyExists"); - if (activeFetches.TryGetValue(image.Sha256, out var activeId) && Jobs.Get(activeId) is { Status: JobStatus.Queued or JobStatus.Running } active) - return new { alreadyRunning = true, job = active }; - job = Jobs.Start("fetch " + image.Name, (progress, token) => RunFetchAsync(store, image, source.Location, keepArchive, progress, token)); - activeFetches[image.Sha256] = job.Id; - } - finally { fetchGate.Release(); } - return waitSeconds == 0 ? job : JobOutcome(await Jobs.WaitAsync(job.Id, TimeSpan.FromSeconds(waitSeconds), ct)); - } - - /// A job snapshot, wrapped in the failure envelope when the job failed or was cancelled. - internal static object JobOutcome(JobInfo job) => job.Status is JobStatus.Failed or JobStatus.Cancelled - ? new { ok = false, error = job.Error, errorType = job.ErrorType, job } - : job; - - private async Task RunFetchAsync(ImageLibraryStore store, CatalogImage image, string catalogLocation, bool keepArchive, IProgress progress, CancellationToken ct) - { - var clock = Stopwatch.StartNew(); - var sha = image.Sha256!; - var report = Throttle(progress); - string? partial = null; // a partial download this job owns - string? downloaded = null; // a complete, verified archive this job downloaded - var outputs = new List(); // extraction or copy output this job owns - try - { - Directory.CreateDirectory(store.CacheDirectory); - var uri = ImageCatalogFeed.ParseLocation(image.DiskUri); - string source, extension; - long downloadedBytes = 0, resumedFrom = 0; - bool inCache; - if (uri.IsFile) - { - // A local or UNC disk is verified in place and never copied to the cache. - source = uri.LocalPath; - extension = Path.GetExtension(source).ToLowerInvariant(); - RequireDiskType(extension, image); - if (!File.Exists(source)) throw new HyperVOperationException($"The catalog's disk file does not exist: {source}.", "NotFound"); - var length = new FileInfo(source).Length; - var actual = await ImageFiles.Sha256Async(source, done => report("verifying " + Path.GetFileName(source), done, length), ct); - if (!SameText(actual, sha)) - throw new HyperVOperationException($"SHA-256 mismatch: the catalog lists {sha} but {source} hashes to {actual}. The source file was left untouched.", "HashMismatch"); - inCache = false; - } - else - { - report("resolving " + uri.Host, 0, null); - var probe = await Downloader.ProbeAsync(uri, ct); - extension = probe.Extension; - RequireDiskType(extension, image); - var cached = Path.Combine(store.CacheDirectory, sha + extension); - if (File.Exists(cached) && SameText(await ImageFiles.Sha256Async(cached, done => report("verifying the cached download", done, new FileInfo(cached).Length), ct), sha)) - source = cached; - else - { - ImageFiles.TryDelete(cached); - partial = cached + ".partial"; - var have = File.Exists(partial) ? new FileInfo(partial).Length : 0; - long extract = 0; - if (extension == ".zip" && probe.ZipEntries is { } entries) - extract = (entries.FirstOrDefault(e => ImageFiles.SameEntry(e.FullName, image.ArchiveRelativePath!)) - ?? throw new HyperVOperationException($"The zip has no entry '{image.ArchiveRelativePath}' (entries: {string.Join(", ", entries.Take(20).Select(e => e.FullName))}); nothing was downloaded.", "UnsupportedImage")).UncompressedSize; - if (probe.Length is long total) - ImageFiles.EnsureSpace(store.Root, Math.Max(0, total - have) + extract, $"downloading {image.Name}" + (extract > 0 ? " and extracting it" : "")); - var result = await Downloader.DownloadAsync(probe.FinalUri, partial, probe.Length, sha, report, ct); - downloadedBytes = result.Downloaded; - resumedFrom = result.ResumedFrom; - File.Move(partial, cached, overwrite: true); - partial = null; - downloaded = source = cached; - } - inCache = true; - } - if (!ImageFiles.HasMagic(source, extension)) - { - if (downloaded is not null) { ImageFiles.TryDelete(downloaded); downloaded = null; } - throw new HyperVOperationException($"The {(inCache ? "download" : "file")} is not a valid {extension} (its signature does not match)" + (inCache ? "; it was deleted." : "."), "UnsupportedImage"); - } - - string target; - if (extension == ".zip") - { - var entryExtension = Path.GetExtension(image.ArchiveRelativePath!).ToLowerInvariant(); - target = UniqueLibraryPath(store.Root, image.Name, entryExtension); - var temporary = target + ".partial"; - outputs.Add(temporary); - await ImageFiles.ExtractAsync(source, image.ArchiveRelativePath!, temporary, (done, total) => report("extracting " + image.ArchiveRelativePath, done, total), ct); - if (!ImageFiles.HasMagic(temporary, entryExtension)) - throw new HyperVOperationException($"The extracted '{image.ArchiveRelativePath}' is not a valid {entryExtension} (its signature does not match); it was deleted.", "UnsupportedImage"); - File.Move(temporary, target); - outputs.Add(target); - } - else if (inCache) - { - // A verified .vhdx or .iso download becomes the library file itself. - target = UniqueLibraryPath(store.Root, image.Name, extension); - File.Move(source, target); - downloaded = null; - outputs.Add(target); - } - else - { - target = UniqueLibraryPath(store.Root, image.Name, extension); - var temporary = target + ".partial"; - outputs.Add(temporary); - ImageFiles.EnsureSpace(store.Root, new FileInfo(source).Length, "copying " + image.Name); - await ImageFiles.CopyAsync(source, temporary, (done, total) => report("copying " + Path.GetFileName(source), done, total), ct); - File.Move(temporary, target); - outputs.Add(target); - } - - var archiveKept = extension == ".zip" && inCache && keepArchive ? source : null; - var entry = new ImageLibraryEntry(image.Name, "image", image.OsFamily, target, Path.GetExtension(target).TrimStart('.').ToLowerInvariant(), - new ImageSource(image.Catalog, catalogLocation, image.Name, image.Version, image.LastUpdated, image.DiskUri, sha, image.ArchiveRelativePath), - new ImageSizes(new FileInfo(target).Length, extension == ".zip" && inCache ? new FileInfo(source).Length : null), - new ImageConfig(image.SecureBoot, image.EnhancedSessionTransportType), Clock(), - Expired: image.Expired, ArchivePath: archiveKept, Warnings: image.Warnings.Count > 0 ? image.Warnings : null); - report("registering " + image.Name, 0, null); - await store.UpdateLibraryAsync(index => index.Find(entry.Name) is null - ? (index with { Entries = [.. index.Entries, entry] }, entry) - : throw new HyperVOperationException($"A library entry named '{entry.Name}' appeared while fetching; the extracted file was deleted.", "AlreadyExists"), ct); - outputs.Clear(); - if (extension == ".zip" && inCache && !keepArchive) ImageFiles.TryDelete(source); - downloaded = null; - progress.Report(new JobProgress(100, null, null, "done")); - return new - { - entry = ProjectEntry(entry), archive = archiveKept, downloadedBytes, resumedFromBytes = resumedFrom, sha256 = sha, - seconds = Math.Round(clock.Elapsed.TotalSeconds, 1), - next = $"Deploy it with action=deploy imageName='{entry.Name}' vmName=." - }; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Cancellation removes everything this job produced: the partial download and any extraction output. - ImageFiles.TryDelete(partial); - foreach (var output in outputs) ImageFiles.TryDelete(output); - if (downloaded is not null && !keepArchive) ImageFiles.TryDelete(downloaded); - throw; - } - catch (Exception ex) - { - // Failures never leave extraction output behind. An interrupted download keeps its partial file for - // resume (a hash mismatch has already deleted it), and a verified archive is kept for a quick retry. - foreach (var output in outputs) ImageFiles.TryDelete(output); - if (downloaded is not null) - throw new HyperVOperationException(ex.Message + $" The verified download is kept at {downloaded}; fetch again to reuse it.", - ex is HyperVOperationException known ? known.ErrorType : "ExtractionFailed"); - throw; - } - finally - { - activeFetches.TryRemove(sha, out _); - } - } - - private static void RequireDiskType(string extension, CatalogImage image) - { - if (!ImageCatalogFeed.DiskExtensions.Contains(extension)) - throw new HyperVOperationException($"'{image.Name}' resolves to a '{extension}' file; only a .zip containing a .vhdx, a .vhdx or an .iso is supported.", "UnsupportedImage"); - if (extension == ".zip" && image.ArchiveRelativePath is null) - throw new HyperVOperationException($"'{image.Name}' is a .zip without archiveRelativePath.", "UnsupportedImage"); - } - - private static string UniqueLibraryPath(string root, string name, string extension) - { - var stem = ImageFiles.SafeName(name); - var path = Path.Combine(root, stem + extension); - for (var n = 2; File.Exists(path) || File.Exists(path + ".partial"); n++) path = Path.Combine(root, $"{stem}-{n}{extension}"); - return path; - } - - /// Progress at most four times a second per stage, plus every stage change and completion. - private static Action Throttle(IProgress progress) - { - var clock = Stopwatch.StartNew(); - var last = TimeSpan.MinValue; - string? stage = null; - return (current, done, total) => - { - if (current == stage && clock.Elapsed - last < TimeSpan.FromMilliseconds(250) && done != total) return; - last = clock.Elapsed; - stage = current; - progress.Report(new JobProgress(total is > 0 ? Math.Round(100.0 * done / total.Value, 1) : null, done, total, current)); - }; - } - - // ---- register and remove -------------------------------------------------------------------------- - - /// Adopts a local .vhdx or .iso in place. A template is made read-only, because it exists to be a differencing parent. - public async Task RegisterAsync(string? path, string? name, string? osFamily, string? kind, bool? secureBoot, string? notes, CancellationToken ct) - { - var file = ControlService.FullPath(path ?? throw new ArgumentException("path required: an absolute path to a local .vhdx or .iso.")); - if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 100) throw new ArgumentException("name required (1-100 characters)."); - var entryName = name.Trim(); - var format = Path.GetExtension(file).ToLowerInvariant() switch - { - ".vhdx" => "vhdx", - ".iso" => "iso", - _ => throw new ArgumentException("register accepts a .vhdx or an .iso file.") - }; - if (!File.Exists(file)) throw new HyperVOperationException($"File not found: {file}.", "NotFound"); - if (!ImageFiles.HasMagic(file, "." + format)) throw new HyperVOperationException($"{file} is not a valid .{format} (its signature does not match).", "UnsupportedImage"); - var entryKind = (kind ?? "image").Trim().ToLowerInvariant(); - if (!Kinds.Contains(entryKind)) throw new ArgumentException("kind must be image or template."); - if (format == "iso" && entryKind == "template") throw new ArgumentException("An ISO is installation media; register it as kind=image."); - var warnings = new List(); - var family = osFamily?.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(family)) - { - family = ImageCatalogFeed.InferOsFamily(entryName + " " + Path.GetFileNameWithoutExtension(file), null, []); - warnings.Add($"osFamily was not given; '{family}' was inferred from the name. Remove and register again with osFamily if that is wrong."); - } - else if (!OsFamilies.Contains(family)) throw new ArgumentException("osFamily must be windows, linux or other."); - var store = await StoreAsync(ct); - var entry = await store.UpdateLibraryAsync(index => - { - if (index.Find(entryName) is not null) throw new HyperVOperationException($"A library entry named '{entryName}' already exists.", "AlreadyExists"); - if (index.Entries.FirstOrDefault(e => SamePath(e.Path, file)) is { } same) throw new HyperVOperationException($"{file} is already registered as '{same.Name}'.", "AlreadyExists"); - var template = entryKind == "template" && format == "vhdx"; - if (template) MakeReadOnly(file); - var added = new ImageLibraryEntry(entryName, entryKind, family, file, format, new ImageSource(RegisteredFrom: file), - new ImageSizes(new FileInfo(file).Length), new ImageConfig(secureBoot), Clock(), ReadOnly: template, Notes: notes); - return (index with { Entries = [.. index.Entries, added] }, added); - }, ct); - return new { registered = true, entry = ProjectEntry(entry), warnings }; - } - - /// - /// Removes a library entry (and with its file and kept archive). Refuses with - /// InUse while any VM attaches the file, any disk chain (current, checkpoint or recorded child) has it as - /// a differencing parent, or a VM's DVD drive holds it. - /// - public async Task RemoveAsync(string? name, bool deleteFile, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required: the library entry to remove."); - var store = await StoreAsync(ct); - var entry = (await store.LibraryAsync(ct)).Find(name.Trim()) - ?? throw new HyperVOperationException($"No library entry named '{name}'. Call hyperv_image list.", "NotFound"); - var references = await ReferencesAsync(entry, ct); - if (references.Count > 0) - { - var summary = string.Join("; ", references.Select(r => - $"{(r.TryGetProperty("vm", out var vm) && vm.ValueKind == JsonValueKind.String ? "VM '" + vm.GetString() + "'" : "an unattached disk")} " - + $"{r.GetProperty("disk").GetString()} ({r.GetProperty("relation").GetString()})")); - return new - { - ok = false, errorType = "InUse", references, - error = $"'{entry.Name}' is in use: {summary}. Remove those VMs and child disks first; removing a differencing parent breaks its children." - }; - } - var deleted = new List(); - if (deleteFile) - foreach (var path in new[] { entry.Path, entry.ArchivePath }) - if (path is not null && File.Exists(path)) - { - File.SetAttributes(path, FileAttributes.Normal); - File.Delete(path); - deleted.Add(path); - } - var removed = await store.UpdateLibraryAsync(index => - { - var current = index.Find(entry.Name) ?? throw new HyperVOperationException($"No library entry named '{entry.Name}'.", "NotFound"); - return (index with { Entries = index.Entries.Where(e => !ReferenceEquals(e, current)).ToList() }, current); - }, ct); - return new - { - removed = removed.Name, deleted, - kept = deleteFile ? null : removed.Path, - note = !deleteFile && removed.ReadOnly ? "The file was kept with its read-only attribute." : null - }; - } - - private async Task> ReferencesAsync(ImageLibraryEntry entry, CancellationToken ct) - { - var children = (entry.Deployments ?? []).Select(d => d.DiskPath).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - var script = new HyperVScript() - .Line("$target = [System.IO.Path]::GetFullPath(" + PowerShellRunner.Literal(entry.Path) + ")") - .Line("$children = @(" + string.Join(", ", children.Select(PowerShellRunner.Literal)) + ")") - .Line(InUseGuard) - .Returns("@($references)"); - var data = await Runner.InvokeAsync(script, ct, 300); - return data.ValueKind == JsonValueKind.Array ? data.EnumerateArray().Select(e => e.Clone()).ToList() : []; - } - - private static void MakeReadOnly(string path) - { - var attributes = File.GetAttributes(path); - if ((attributes & FileAttributes.ReadOnly) == 0) File.SetAttributes(path, attributes | FileAttributes.ReadOnly); - } - - // ---- deploy --------------------------------------------------------------------------------------- - - /// - /// Creates a VM from a library entry: a differencing child of the entry (linked, the default) or a full copy under - /// the VM's folder, the VM through , then the lab defaults in one script: - /// static memory, automatic checkpoints off, Standard checkpoints, Secure Boot per OS family (off when the image - /// says so), HvSocket enhanced session for Linux, and a vTPM for Windows (after the template, which the vTPM locks). - /// Any failure removes the half-created VM and its new disk. - /// - public async Task DeployAsync(ImageDeployRequest request, VmLifecycleService lifecycle, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(request.ImageName)) throw new ArgumentException("imageName required: a library entry name from hyperv_image list."); - var vmName = request.VmName?.Trim(); - if (string.IsNullOrEmpty(vmName) || vmName.Length > 100) throw new ArgumentException("vmName required (1-100 characters)."); - if (request.MemoryMb is < 32 or > 1048576 || request.Processors is < 1 or > 2048) throw new ArgumentException("Invalid memoryMb or processors."); - var store = await StoreAsync(ct); - var entry = (await store.LibraryAsync(ct)).Find(request.ImageName.Trim()) - ?? throw new HyperVOperationException($"No library entry named '{request.ImageName}'. Fetch or register it first; hyperv_image list shows the library.", "NotFound"); - if (entry.Format != "vhdx") - throw new ArgumentException($"'{entry.Name}' is an ISO (installation media), not a bootable disk. Create a VM with a new disk and attach the ISO instead."); - if (!File.Exists(entry.Path)) throw new HyperVOperationException($"The file of '{entry.Name}' is missing: {entry.Path}.", "NotFound"); - var vmRoot = request.VmPath is null ? (await HostPathsAsync(ct)).VirtualMachinePath : ControlService.FullPath(request.VmPath); - var folder = Path.Combine(vmRoot, ImageFiles.SafeDirectoryName(vmName)); - var directory = Path.Combine(folder, "Virtual Hard Disks"); - var disk = Path.Combine(directory, ImageFiles.SafeDirectoryName(vmName) + ".vhdx"); - if (File.Exists(disk)) throw new HyperVOperationException($"{disk} already exists; choose another vmName or vmPath.", "AlreadyExists"); - var warnings = new List(entry.Warnings ?? []); - var createdFolder = !Directory.Exists(folder); - Directory.CreateDirectory(directory); - var ownsDisk = false; - string? vmId = null; - try - { - // 1. One script: refuse a duplicate VM name, resolve the switch, and create the differencing child. - var prepare = new HyperVScript() - .Line("$name = " + PowerShellRunner.Literal(vmName)) - .Line("if (@(Get-VM | Where-Object { $_.Name -eq $name }).Count -gt 0) { Stop-Control 'InvalidArgument' (\"A VM named '\" + $name + \"' already exists. Choose another vmName.\") }"); - if (request.SwitchName is { } requested) - prepare.Line("$switchName = " + PowerShellRunner.Literal(requested)) - .Line("if (@(Get-VMSwitch | Where-Object { $_.Name -eq $switchName }).Count -eq 0) { Stop-Control 'NotFound' (\"Switch '\" + $switchName + \"' not found. Call hyperv_network list.\") }"); - else - prepare.Line("$switchName = $null") - .Line("try { $default = Get-VMSwitch -Id " + PsValue.Of(Guid.Parse(DefaultSwitchId)).Text + " -ErrorAction Stop; if ($default) { $switchName = [string]@($default)[0].Name } } catch { }"); - if (request.Linked) - prepare.Cmd("$child = New-VHD", ("Path", PsValue.Of(disk)), ("ParentPath", PsValue.Of(entry.Path)), ("Differencing", PsValue.Of(true))); - ownsDisk = request.Linked; - var prepared = await Runner.InvokeAsync(prepare.Returns("@{ switchName = $switchName }"), ct, 300); - var switchName = prepared.TryGetProperty("switchName", out var chosen) && chosen.ValueKind == JsonValueKind.String ? chosen.GetString() : null; - if (switchName is null) warnings.Add("No switchName was given and this host has no Default Switch, so the VM's network adapter is not connected."); - if (request.Linked) entry = await ProtectParentAsync(store, entry, ct); - else - { - ImageFiles.EnsureSpace(directory, new FileInfo(entry.Path).Length, "copying " + entry.Name); - ownsDisk = true; - await ImageFiles.CopyAsync(entry.Path, disk, null, ct); - } - - // 2. The VM itself; this is the only call into the lifecycle service. - var created = await lifecycle.CreateAsync(vmName, vmRoot, disk, 2, request.MemoryMb, request.Processors, 0, switchName, ct); - vmId = created.GetProperty("Id").GetString() ?? throw new HyperVOperationException("New-VM returned no VM ID.", "InvalidResponse"); - - // 3. Lab defaults, then the optional start. - var configured = await Runner.InvokeAsync(LabDefaults(vmId, entry, request, warnings), ct, 300); - if (configured.TryGetProperty("startError", out var startError) && startError.ValueKind == JsonValueKind.String) - warnings.Add("The VM was created and configured but did not start: " + startError.GetString()); - var deployment = new ImageDeployment(vmName, vmId, disk, request.Linked, Clock()); - await store.UpdateLibraryAsync(index => index.Find(entry.Name) is { } current - ? (Replace(index, current, current with { Deployments = [.. current.Deployments ?? [], deployment] }), true) - : (index, false), ct); - return new - { - vm = configured.GetProperty("vm"), - disk = new { path = disk, type = request.Linked ? "differencing" : "copy", parentPath = request.Linked ? entry.Path : null }, - parent = new { entry.Name, entry.Path, entry.Kind, entry.OsFamily, entry.ReadOnly }, - switchName, - firmware = configured.TryGetProperty("firmware", out var firmware) ? firmware : default, - security = configured.TryGetProperty("security", out var security) ? security : default, - started = request.Start && !(startError.ValueKind == JsonValueKind.String), - warnings - }; - } - catch - { - await RollbackAsync(vmId, ownsDisk ? disk : null, createdFolder ? folder : directory); - throw; - } - } - - private static HyperVScript LabDefaults(string vmId, ImageLibraryEntry entry, ImageDeployRequest request, List warnings) - { - var vm = PsValue.Raw("$vm"); - var windows = entry.OsFamily == "windows"; - var linux = entry.OsFamily == "linux"; - var transport = entry.Config.EnhancedSessionTransportType ?? (linux ? "HvSocket" : null); - var script = new HyperVScript().Vm(vmId).RequireOff() - .Cmd("Set-VM", ("VM", vm), ("StaticMemory", PsValue.Of(true)), ("AutomaticCheckpointsEnabled", PsValue.Of(false)), - ("CheckpointType", PsValue.Token("Standard", "Standard")), - ("EnhancedSessionTransportType", transport is null ? null : PsValue.Token(transport, "HvSocket", "VMBus")), - ("Notes", request.Notes is null ? null : PsValue.Of(request.Notes))); - // Secure Boot follows the image (off when it says so), else the template that matches the OS family. - var template = entry.Config.SecureBoot == false ? null - : windows ? "MicrosoftWindows" - : linux || entry.Config.SecureBoot == true ? "MicrosoftUEFICertificateAuthority" - : null; - if (template is null) - { - script.Cmd("Set-VMFirmware", ("VM", vm), ("EnableSecureBoot", PsValue.Token("Off", "On", "Off"))); - if (entry.Config.SecureBoot is null) - warnings.Add("Secure Boot is off because the image's OS family is 'other'; enable it with hyperv_firmware if the guest supports it."); - } - else - script.Cmd("Set-VMFirmware", ("VM", vm), ("EnableSecureBoot", PsValue.Token("On", "On", "Off")), - ("SecureBootTemplate", PsValue.Token(template, "MicrosoftWindows", "MicrosoftUEFICertificateAuthority"))); - // The vTPM comes after the template: initializing it locks the Secure Boot template. - if (windows) script.Cmd("Set-VMKeyProtector", ("VM", vm), ("NewLocalKeyProtector", PsValue.Of(true))).Cmd("Enable-VMTPM", ("VM", vm)); - script.Line("$startError = $null"); - if (request.Start) script.Line("try { Start-VM -VM $vm } catch { $startError = [string]$_.Exception.Message }"); - return script.Line("$vm = Get-VM -Id $vm.Id -ErrorAction Stop").Returns( - "@{ vm = " + VmProjection - + "; firmware = (Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={[string]$_.SecureBoot}},SecureBootTemplate)" - + "; security = (Get-VMSecurity -VM $vm | Select-Object TpmEnabled); startError = $startError }"); - } - - private static async Task ProtectParentAsync(ImageLibraryStore store, ImageLibraryEntry entry, CancellationToken ct) => - await store.UpdateLibraryAsync(index => - { - var current = index.Find(entry.Name) ?? entry; - MakeReadOnly(current.Path); - if (current.ReadOnly || !index.Entries.Contains(current)) return (index, current with { ReadOnly = true }); - var updated = current with { ReadOnly = true }; - return (Replace(index, current, updated), updated); - }, ct); - - /// Best effort: removes a half-created VM, the disk this deploy created and the folders it left empty. - private async Task RollbackAsync(string? vmId, string? disk, string folder) - { - if (vmId is not null && Guid.TryParse(vmId, out var id)) - { - try - { - await Runner.InvokeAsync(new HyperVScript() - .Line("$vm = $null; try { $vm = Get-VM -Id " + PsValue.Of(id).Text + " -ErrorAction Stop } catch { }") - .Line("if ($vm) { if ([string]$vm.State -ne 'Off') { Stop-VM -VM $vm -TurnOff -Force }; Remove-VM -VM $vm -Force }") - .Returns("$true"), CancellationToken.None, 120); - } - catch (Exception) { /* the original failure is the one reported */ } - } - ImageFiles.TryDelete(disk); - DeleteEmptyFolders(folder); - } - - private static void DeleteEmptyFolders(string folder) - { - try - { - if (!Directory.Exists(folder)) return; - foreach (var child in Directory.GetDirectories(folder)) DeleteEmptyFolders(child); - if (!Directory.EnumerateFileSystemEntries(folder).Any()) Directory.Delete(folder); - } - catch (IOException) { } - catch (UnauthorizedAccessException) { } - } - private static ImageLibraryIndex Replace(ImageLibraryIndex index, ImageLibraryEntry current, ImageLibraryEntry updated) => index with { Entries = index.Entries.Select(e => ReferenceEquals(e, current) ? updated : e).ToList() }; diff --git a/src/HyperVControl/Services/ImageFiles.cs b/src/HyperVControl/Services/ImageFiles.cs index 260e4ae..c3bb282 100644 --- a/src/HyperVControl/Services/ImageFiles.cs +++ b/src/HyperVControl/Services/ImageFiles.cs @@ -85,7 +85,8 @@ public static bool SameEntry(string entryName, string relativePath) => /// Bytes available to this user on the volume holding (mount points and UNC paths included). public static long FreeBytes(string directory) { - if (!GetDiskFreeSpaceExW(Path.TrimEndingDirectorySeparator(directory) + Path.DirectorySeparatorChar, out var available, out _, out _)) + var path = Path.EndsInDirectorySeparator(directory) ? directory : directory + Path.DirectorySeparatorChar; + if (!GetDiskFreeSpaceExW(path, out var available, out _, out _)) throw new IOException($"Cannot read the free space of {directory} (Win32 error {Marshal.GetLastPInvokeError()})."); return (long)Math.Min(available, long.MaxValue); } diff --git a/tests/HyperVControl.Tests/ImageTests.cs b/tests/HyperVControl.Tests/ImageTests.cs index 80c89fe..4991c37 100644 --- a/tests/HyperVControl.Tests/ImageTests.cs +++ b/tests/HyperVControl.Tests/ImageTests.cs @@ -111,6 +111,7 @@ private static async Task ZipDirectories(Action check, string temp "CD001"u8.CopyTo(isoBytes.AsSpan(0x8801)); await File.WriteAllBytesAsync(iso, isoBytes); check(ImageFiles.HasMagic(iso, ".iso") && !ImageFiles.HasMagic(iso, ".vhdx") && ImageFiles.HasMagic(zip, ".zip"), "Magic bytes: CD001 at 0x8801, PK zip header"); + check(ImageFiles.FreeBytes(Path.GetPathRoot(temp)!) > 0 && ImageFiles.FreeBytes(temp) > 0, "Free space reads for a drive root and for a folder"); } private static async Task LibraryScenarios(Action check, string temp, FixtureServer server) From d84405bd226fecbc951dc0999c3d2335c59e1280 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:01 -0500 Subject: [PATCH 05/23] Forward configuration variables to the elevated worker A UAC launch starts the worker with the user's profile environment, so every HYPERV_CONTROL_* override set only in the MCP client's process environment was silently lost. The parent now writes an allowlisted set of variables (HYPERV_CONTROL_*, HYPERV_GUEST_*, _NT_SYMBOL_PATH) as a length-prefixed JSON header over the private pipe before relaying MCP traffic, and the worker applies them before starting. Verified live: the worker reports the image root set in the client environment. --- .../hyper-v-control/references/images.md | 2 +- .../hyper-v-control/references/runtime.md | 2 +- src/HyperVControl/Services/ElevationBridge.cs | 43 +++++++++++++++++++ tests/HyperVControl.Tests/BridgeTests.cs | 39 +++++++++++++++++ tests/HyperVControl.Tests/Program.cs | 1 + 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 tests/HyperVControl.Tests/BridgeTests.cs diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/images.md b/plugins/hyper-v-control/skills/hyper-v-control/references/images.md index d98f65a..91bbe3e 100644 --- a/plugins/hyper-v-control/skills/hyper-v-control/references/images.md +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/images.md @@ -42,7 +42,7 @@ Library entries keep the warnings of the catalog image they came from. ## Library -The library root is `\HyperVControl\Images`, or `HYPERV_CONTROL_IMAGE_ROOT` when it is set. It holds `library.json`, `catalogs.json`, the `cache\` download directory and the image files. The launcher's elevated worker does not inherit environment variables set only for the MCP client process; set the override as a user environment variable and restart the client. +The library root is `\HyperVControl\Images`, or `HYPERV_CONTROL_IMAGE_ROOT` when it is set. It holds `library.json`, `catalogs.json`, the `cache\` download directory and the image files. The elevated worker receives `HYPERV_CONTROL_*` variables from the MCP client's process environment through the launcher bridge, so the override can be set in the client's MCP configuration or as a user environment variable; restart the client after changing it. Each entry records `name`, `kind` (`image` or `template`), `osFamily` (`windows`, `linux` or `other`), `path`, `format` (`vhdx` or `iso`), `source` (catalog, image, URI and verified SHA-256, or the registered path), sizes, `config` (`secureBoot`, `enhancedSessionTransportType`), `registeredAt`, `readOnly`, `notes`, `warnings` and `deployments` (the clones and copies made from it). `list` adds `exists` and the current file size. diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md b/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md index 08585c2..1372ed2 100644 --- a/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/runtime.md @@ -2,7 +2,7 @@ Install the plugin through the marketplace. Its launcher invokes `scripts/install.ps1` when the required executable is missing, downloads the exact runtime pinned in `runtime.json`, verifies its checksum, and then starts the MCP. Later starts reuse that installation. A development or offline installation may supply a matching `bin/HyperVControl.exe`. Plugin and runtime versions are independent; a launcher or skill update does not require a new native executable. -The launcher verifies the EXE hash and starts `--elevate`. The parent keeps MCP stdin/stdout; a same-user worker receives them over a private named pipe. Only the current Windows user's SID has access, and both ends verify process IDs. UAC consent can appear once when a split-token administrator starts it. Cancellation is an error with no VM mutation. Do not disable UAC or grant permanent group membership to make a call work. +The launcher verifies the EXE hash and starts `--elevate`. The parent keeps MCP stdin/stdout; a same-user worker receives them over a private named pipe. Only the current Windows user's SID has access, and both ends verify process IDs. Because a UAC launch starts the worker with the profile environment, the bridge forwards the client process's `HYPERV_CONTROL_*`, `HYPERV_GUEST_*` and `_NT_SYMBOL_PATH` variables over the pipe before any MCP traffic. UAC consent can appear once when a split-token administrator starts it. Cancellation is an error with no VM mutation. Do not disable UAC or grant permanent group membership to make a call work. An ordinary account may run the EXE directly when granted the required Hyper-V rights. PowerShell Direct and some host operations require administrator rights beyond Hyper-V inventory access. The UAC bridge supports the same account's elevated token. Entering another administrator's credentials does not satisfy that identity boundary: run the MCP client/server under the intended administrator account instead. A locked or noninteractive host cannot display a console/UAC UI. diff --git a/src/HyperVControl/Services/ElevationBridge.cs b/src/HyperVControl/Services/ElevationBridge.cs index 44f6744..57e779c 100644 --- a/src/HyperVControl/Services/ElevationBridge.cs +++ b/src/HyperVControl/Services/ElevationBridge.cs @@ -2,6 +2,7 @@ using System.IO.Pipes; using System.Runtime.InteropServices; using System.Security.Principal; +using System.Text.Json; namespace HyperVControl.Services; @@ -9,6 +10,16 @@ internal static class ElevationBridge { internal static bool IsAdministrator => new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + /// + /// Process environment does not survive a UAC launch: the elevated worker starts with the + /// user's profile environment, not the MCP client's. These configuration variables are + /// therefore forwarded over the private pipe before any MCP traffic. + /// + internal static bool IsForwarded(string name) => + name.StartsWith("HYPERV_CONTROL_", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("HYPERV_GUEST_", StringComparison.OrdinalIgnoreCase) + || name.Equals("_NT_SYMBOL_PATH", StringComparison.OrdinalIgnoreCase); + internal static async Task RunAsync() { var pipeName = "HyperVControl-" + Guid.NewGuid().ToString("N"); @@ -22,6 +33,7 @@ internal static async Task RunAsync() await pipe.WaitForConnectionAsync(timeout.Token); if (!GetNamedPipeClientProcessId(pipe.SafePipeHandle.DangerousGetHandle(), out var client) || client != worker.Id) throw new InvalidOperationException("Unexpected elevation bridge client."); + await WriteEnvironmentAsync(pipe, Environment.GetEnvironmentVariables(), timeout.Token); using var stop = new CancellationTokenSource(); var input = Console.OpenStandardInput().CopyToAsync(pipe, stop.Token); var output = pipe.CopyToAsync(Console.OpenStandardOutput(), stop.Token); @@ -52,9 +64,40 @@ internal static async Task ConnectWorkerAsync(string name await pipe.ConnectAsync(30000); if (!GetNamedPipeServerProcessId(pipe.SafePipeHandle.DangerousGetHandle(), out var server) || server != parentId || !IsSameUserProcess(parentId)) { pipe.Dispose(); throw new InvalidOperationException("Unexpected elevation bridge server."); } + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + foreach (var (variable, value) in await ReadEnvironmentAsync(pipe, timeout.Token)) + Environment.SetEnvironmentVariable(variable, value); return pipe; } + /// Writes the forwarded variables as a 4-byte little-endian length followed by UTF-8 JSON. + internal static async Task WriteEnvironmentAsync(Stream pipe, System.Collections.IDictionary environment, CancellationToken ct) + { + var variables = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (System.Collections.DictionaryEntry entry in environment) + if (entry.Key is string name && entry.Value is string value && IsForwarded(name)) variables[name] = value; + var payload = JsonSerializer.SerializeToUtf8Bytes(new { environment = variables }); + await pipe.WriteAsync(BitConverter.GetBytes(payload.Length), ct); + await pipe.WriteAsync(payload, ct); + await pipe.FlushAsync(ct); + } + + /// Reads the header written by ; only allowlisted names are returned. + internal static async Task> ReadEnvironmentAsync(Stream pipe, CancellationToken ct) + { + var header = new byte[4]; + await pipe.ReadExactlyAsync(header, ct); + var length = BitConverter.ToInt32(header); + if (length is < 2 or > 1_048_576) throw new InvalidOperationException("Invalid elevation bridge header."); + var payload = new byte[length]; + await pipe.ReadExactlyAsync(payload, ct); + using var document = JsonDocument.Parse(payload); + var variables = new List<(string, string)>(); + foreach (var property in document.RootElement.GetProperty("environment").EnumerateObject()) + if (IsForwarded(property.Name) && property.Value.ValueKind == JsonValueKind.String) variables.Add((property.Name, property.Value.GetString()!)); + return variables; + } + private static bool IsSameUserProcess(int pid) { using var process = Process.GetProcessById(pid); diff --git a/tests/HyperVControl.Tests/BridgeTests.cs b/tests/HyperVControl.Tests/BridgeTests.cs new file mode 100644 index 0000000..c992ebc --- /dev/null +++ b/tests/HyperVControl.Tests/BridgeTests.cs @@ -0,0 +1,39 @@ +using System.Collections; +using HyperVControl.Services; + +namespace HyperVControl.Tests; + +/// The elevation bridge forwards configuration variables to the UAC worker, which otherwise starts with the profile environment. +internal static class BridgeTests +{ + internal static async Task RunAsync(Action check) + { + check(ElevationBridge.IsForwarded("HYPERV_CONTROL_IMAGE_ROOT") && ElevationBridge.IsForwarded("hyperv_control_data") + && ElevationBridge.IsForwarded("HYPERV_GUEST_PASSWORD") && ElevationBridge.IsForwarded("_NT_SYMBOL_PATH") + && !ElevationBridge.IsForwarded("PATH") && !ElevationBridge.IsForwarded("HYPERVCONTROL_X") && !ElevationBridge.IsForwarded("USERPROFILE"), + "Only HYPERV_CONTROL_*, HYPERV_GUEST_* and _NT_SYMBOL_PATH are forwarded to the elevated worker"); + + var environment = new Hashtable + { + ["HYPERV_CONTROL_IMAGE_ROOT"] = @"D:\Images with spaces\空间", + ["HYPERV_CONTROL_GUEST_PASSWORD"] = "p'a\"s$s", + ["PATH"] = @"C:\Windows", + ["_NT_SYMBOL_PATH"] = "srv*C:\\symbols*https://msdl.microsoft.com/download/symbols" + }; + using var pipe = new MemoryStream(); + await ElevationBridge.WriteEnvironmentAsync(pipe, environment, default); + pipe.Write("{\"jsonrpc\":\"2.0\"}"u8); + pipe.Position = 0; + var forwarded = await ElevationBridge.ReadEnvironmentAsync(pipe, default); + var rest = new byte[pipe.Length - pipe.Position]; + await pipe.ReadExactlyAsync(rest); + check(forwarded.Count == 3 && forwarded.Any(v => v.Name == "HYPERV_CONTROL_IMAGE_ROOT" && v.Value == @"D:\Images with spaces\空间") + && forwarded.Any(v => v.Name == "HYPERV_CONTROL_GUEST_PASSWORD" && v.Value == "p'a\"s$s") && forwarded.Any(v => v.Name == "_NT_SYMBOL_PATH") + && !forwarded.Any(v => v.Name == "PATH") && System.Text.Encoding.UTF8.GetString(rest) == "{\"jsonrpc\":\"2.0\"}", + "Environment header round-trips Unicode and quoting and leaves the MCP stream untouched behind it"); + + using var bad = new MemoryStream(BitConverter.GetBytes(2_000_000).Concat(new byte[8]).ToArray()); + try { await ElevationBridge.ReadEnvironmentAsync(bad, default); check(false, "Oversized bridge header rejected"); } + catch (InvalidOperationException) { check(true, "Oversized bridge header rejected"); } + } +} diff --git a/tests/HyperVControl.Tests/Program.cs b/tests/HyperVControl.Tests/Program.cs index 4c76be9..e0a28e6 100644 --- a/tests/HyperVControl.Tests/Program.cs +++ b/tests/HyperVControl.Tests/Program.cs @@ -31,6 +31,7 @@ await HyperVControl.Tests.LifecycleV2Tests.RunAsync(Check); await HyperVControl.Tests.ImageTests.RunAsync(Check); await HyperVControl.Tests.ImageBuildTests.RunAsync(Check); + await HyperVControl.Tests.BridgeTests.RunAsync(Check); } catch (Exception ex) { Console.Error.WriteLine(ex); Environment.ExitCode = 1; return; } Console.WriteLine("UNIT PASS"); From d37fa141c34193e6b7f1e09dff9814c40eda1869 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:28 -0500 Subject: [PATCH 06/23] Route image, library and job workflows in the skill and README --- plugins/hyper-v-control/README.md | 1 + plugins/hyper-v-control/skills/hyper-v-control/SKILL.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/hyper-v-control/README.md b/plugins/hyper-v-control/README.md index c0280c1..d64d600 100644 --- a/plugins/hyper-v-control/README.md +++ b/plugins/hyper-v-control/README.md @@ -5,6 +5,7 @@ Manage a Hyper-V lab from your AI agent. Create and restore experiments, interac ## What it helps with - **VM management:** create, configure, start, stop, clone, import, and export virtual machines; manage disks and networking. +- **Images and templates:** list Microsoft's Quick Create gallery or your own catalog, download and verify images into a local library as background jobs, register your own disks, and deploy linked-clone lab VMs from them in seconds with lab-friendly defaults. - **Repeatable experiments:** inspect checkpoint trees, save a known state, and restore it after testing. - **Desktop interaction:** use Basic or Enhanced Session, capture screenshots, send mouse and keyboard input, and work with guest UI Automation. - **Guest automation:** run commands and processes, transfer files and folders, and manage reusable credentials. diff --git a/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md b/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md index 15c430a..2258fb4 100644 --- a/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md +++ b/plugins/hyper-v-control/skills/hyper-v-control/SKILL.md @@ -14,6 +14,9 @@ Choose the channel that fits the task: | Task | Channel | | --- | --- | | Inventory, creation, hardware, start/stop, export/import | VM tools; no console needed | +| Obtain a bootable image: Microsoft's gallery or a custom catalog, the local library, verified download and extraction | `image` (`list`, `fetch` as a job, `register`, `remove`, `catalogs`); poll with `jobs` | +| Create a lab VM from a library image in seconds (linked clone with lab defaults) | `image` `deploy`, or `vm_create` with `source=image` | +| Anything that outlives one call: downloads, extraction, export/import, full clones, image builds | `jobs` (`list`, `status`, `wait`, `cancel`) | | Reproduce and roll back a test | Checkpoint tools; retain the checkpoint ID and initial power state | | Boot, firmware, sign-in, locked desktop, visual clicks | Basic console + inline screenshot + input | | Resizable remote desktop and clipboard | Enhanced console; check the actual mode returned | @@ -23,6 +26,6 @@ Choose the channel that fits the task: | Debug the VM's kernel | KDNET or two-phase KDCOM, then host KD session | | Analyze an existing dump | `debug_open` with `mode=dump`; copy guest dumps to host first | -For lifecycle/rollback details read [VM operations](references/operations.md). For console, keyboard/mouse, clipboard and winapp read [guest interaction](references/interaction.md). For live debugging read [debugging](references/debugging.md). +For lifecycle/rollback details read [VM operations](references/operations.md). For catalogs, the image library, fetch jobs and deployment read [images](references/images.md). For console, keyboard/mouse, clipboard and winapp read [guest interaction](references/interaction.md). For live debugging read [debugging](references/debugging.md). Read the actual MCP schema for parameter names. Tool failures arrive as `isError` with an explanation; do not interpret process startup, BCD configuration or a timeout as successful attachment/execution. After a mutation, verify through the channel that observes its result. Report unsupported states rather than substituting host input or changing guest security settings silently. From 627e9942dccc0e439a0e1a80b3626c606a1688f8 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:17:32 -0500 Subject: [PATCH 07/23] Gate VM changes by state and extend create, remove, clone and disk tools Add VmStateRules, the setting x VM state table from the verified Hyper-V matrix. Configure, firmware, network and disk calls render one in-script gate that refuses the whole request before any change and lists every setting that needs the VM Off; Running conditions cover static memory resize, dynamic memory bounds that may only grow or shrink, Generation 2 adapter hot add/remove, SCSI data disks and online VHDX resize. hyperv_vm_create gains sources new_disk, iso, existing_vhd, linked and none, host default paths, Default Switch by ID, OS-family Secure Boot and vTPM defaults (template before key protector before Enable-VMTPM), a duplicate-name guard, a free-space preflight and rollback of a half-made VM. hyperv_vm_remove takes a name or ID, never powers off, and with deleteDisks removes checkpoints, waits for their merge, removes the VM and deletes only files no other VM or differencing child uses; dryRun returns the plan. hyperv_vm_configure, hyperv_firmware, hyperv_network and hyperv_disk gain the planned settings and actions; hyperv_vhd and hyperv_vm_clone (linked and full) are new; hyperv_vm_info and hyperv_capabilities report more state. The recording mock gains lifecycle.functions.ps1 with disk files, chains, checkpoint merges, adapters, firmware, TPM and NAT, and LifecycleV2Tests cover the table, refusals without mutation, cmdlet order and plans. LifecycleTests follow the new create and remove signatures. --- src/HyperVControl/HyperVTools.Vm.cs | 102 ++- src/HyperVControl/HyperVTools.cs | 43 +- .../Services/VmLifecycleService.Hardware.cs | 275 ++++++ .../Services/VmLifecycleService.Storage.cs | 245 ++++++ .../Services/VmLifecycleService.cs | 578 +++++++++++-- src/HyperVControl/Services/VmScriptLibrary.cs | 365 ++++++++ src/HyperVControl/Services/VmStateRules.cs | 121 +++ .../HyperVControl.Tests/LifecycleLiveTests.cs | 345 ++++++++ tests/HyperVControl.Tests/LifecycleTests.cs | 28 +- tests/HyperVControl.Tests/LifecycleV2Tests.cs | 726 ++++++++++++++++ .../Hyper-V/lifecycle.functions.ps1 | 790 ++++++++++++++++++ tests/HyperVControl.Tests/PhaseStubs.cs | 12 - 12 files changed, 3481 insertions(+), 149 deletions(-) create mode 100644 src/HyperVControl/Services/VmLifecycleService.Hardware.cs create mode 100644 src/HyperVControl/Services/VmLifecycleService.Storage.cs create mode 100644 src/HyperVControl/Services/VmScriptLibrary.cs create mode 100644 src/HyperVControl/Services/VmStateRules.cs create mode 100644 tests/HyperVControl.Tests/LifecycleLiveTests.cs create mode 100644 tests/HyperVControl.Tests/LifecycleV2Tests.cs create mode 100644 tests/HyperVControl.Tests/MockHyperV/Hyper-V/lifecycle.functions.ps1 diff --git a/src/HyperVControl/HyperVTools.Vm.cs b/src/HyperVControl/HyperVTools.Vm.cs index 676ba51..e47c5b0 100644 --- a/src/HyperVControl/HyperVTools.Vm.cs +++ b/src/HyperVControl/HyperVTools.Vm.cs @@ -1,28 +1,73 @@ using System.ComponentModel; +using HyperVControl.Services; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; namespace HyperVControl; -/// VM lifecycle, media and network tools: thin wrappers over VmLifecycleService. +/// VM lifecycle, storage, media and network tools: thin wrappers over VmLifecycleService. internal sealed partial class HyperVTools { [McpServerTool(Name = "hyperv_vm_create")] - [Description("Create a Generation 1/2 VM with existing VHD or new VHDX. All paths are absolute host paths. Returns VM configuration; leaves VM Off.")] - public Task CreateVm(string name, string path, string vhdPath, int generation = 2, int memoryMb = 4096, - int processors = 2, int newDiskGb = 0, string? switchName = null, CancellationToken ct = default) => Run(async () => - await service.Lifecycle.CreateAsync(name, path, vhdPath, generation, memoryMb, processors, newDiskGb, switchName, ct)); + [Description("Create a VM; it stays Off unless start=true. Defaults: Generation 2, 4096 MB static memory, 2 vCPUs, a 64 GB dynamic VHDX, Standard checkpoints with automatic checkpoints off, the Default Switch (else the first switch). The VM folder is \\ (path defaults to the host VirtualMachinePath); new disks go to \\\\Virtual Hard Disks when path is given, else the host VirtualHardDiskPath. osFamily windows: Secure Boot with the MicrosoftWindows template and a vTPM with a local key protector; linux: Secure Boot with MicrosoftUEFICertificateAuthority and the HvSocket enhanced-session transport; other: Secure Boot off. Refuses an existing VM name (unless allowDuplicateName), an existing target disk file, a disk another VM uses, and a disk larger than the volume's free space, all before any change; a failed later step removes the half-created VM and its new disks. Returns the VM with firmware, security, createdFiles, warnings and nextSteps.")] + public Task CreateVm(string name, + [Description("new_disk (default: empty dynamic VHDX of diskGb) | iso (new disk plus a DVD drive with isoPath, set as first boot device) | existing_vhd (attach vhdPath) | linked (differencing disk over parentPath, which is marked read-only) | none (no disk)")] string source = "new_disk", + [Description("existing_vhd: the disk to attach. new_disk/iso: optional target .vhdx path. linked: optional path of the differencing child.")] string? vhdPath = null, + [Description("linked: the parent .vhdx; no VM may attach it.")] string? parentPath = null, + [Description("iso: installation ISO.")] string? isoPath = null, + [Description("Absolute folder for the VM's own folder; default the host VirtualMachinePath.")] string? path = null, + int generation = 2, int memoryMb = 4096, bool dynamicMemory = false, int? memoryMinMb = null, int? memoryMaxMb = null, + int processors = 2, int diskGb = 64, + [Description("Exact switch name. Omit for the Default Switch (or the first switch); an empty string leaves the adapter disconnected.")] string? switchName = null, + [Description("windows | linux | other; sets the Secure Boot, vTPM and enhanced-session defaults.")] string osFamily = "windows", + [Description("Default: on for windows and linux, off for other.")] bool? secureBoot = null, + [Description("Default: on for Generation 2 windows (local key protector).")] bool? tpm = null, + string? notes = null, bool autoCheckpoints = false, + [Description("Standard | Production | ProductionOnly | Disabled")] string checkpointType = "Standard", + bool start = false, bool allowDuplicateName = false, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.CreateAsync(new VmCreateRequest(name) + { + Source = source, VhdPath = vhdPath, ParentPath = parentPath, IsoPath = isoPath, Path = path, Generation = generation, + MemoryMb = memoryMb, DynamicMemory = dynamicMemory, MemoryMinMb = memoryMinMb, MemoryMaxMb = memoryMaxMb, Processors = processors, + DiskGb = diskGb, SwitchName = switchName, OsFamily = osFamily, SecureBoot = secureBoot, Tpm = tpm, Notes = notes, + AutoCheckpoints = autoCheckpoints, CheckpointType = checkpointType, Start = start, AllowDuplicateName = allowDuplicateName + }, ct)); [McpServerTool(Name = "hyperv_vm_remove", Destructive = true)] - [Description("Unregister exactly one Off VM by GUID. Hyper-V checkpoint merges may occur. Leaves standalone VHD files on disk.")] - public Task RemoveVm(string machineId, CancellationToken ct) => Run(async () => - await service.Lifecycle.RemoveAsync(Guid.Parse(machineId), ct)); + [Description("Remove one VM by exact name or ID. It must be Off or Saved: this tool never powers a VM off. Without deleteDisks only the VM's configuration and runtime state are deleted; every disk file stays, and Hyper-V merges checkpoint .avhdx files into their parents in the background. With deleteDisks the VM's checkpoints are removed first and their merges awaited, then the VM, then its VHD/VHDX files, except files another VM attaches, differencing parents of other disks (linked-clone templates) and ISOs; each deletion is verified, and an emptied VM folder is removed. dryRun=true returns that plan (checkpoints, merge, delete, keep with reasons) and changes nothing. Returns what was removed, deleted and kept, with reasons.")] + public Task RemoveVm(string machine, bool deleteDisks = false, bool dryRun = false, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.RemoveAsync(machine, deleteDisks, dryRun, ct)); [McpServerTool(Name = "hyperv_vm_configure", Destructive = true)] - [Description("Configure an Off VM's CPU, memory, dynamic memory, checkpoint type or name. Omitted values remain unchanged.")] - public Task ConfigureVm(string machine, int? processors = null, int? memoryMb = null, - bool? dynamicMemory = null, string? checkpointType = null, string? newName = null, CancellationToken ct = default) => Run(async () => - await service.Lifecycle.ConfigureAsync(machine, processors, memoryMb, dynamicMemory, checkpointType, newName, ct)); + [Description("Change VM settings; omitted values stay unchanged. The VM's state decides what is allowed, all-or-nothing: a refusal names every setting that needs the VM Off and changes nothing. Running accepts memoryMb with static memory (the guest OS must support resize), memoryMaxMb increases, memoryMinMb decreases and memoryBufferPercent with dynamic memory, checkpointType, autoCheckpoints, newName, notes, autoStartAction, autoStartDelaySeconds and integrationServices. Saved accepts newName only. Off is needed for processors, dynamicMemory, autoStopAction, nestedVirtualization and enhancedSessionTransport. Paused and transitional states accept nothing. Returns the refreshed VM.")] + public Task ConfigureVm(string machine, int? processors = null, int? memoryMb = null, bool? dynamicMemory = null, + [Description("Standard | Production | ProductionOnly | Disabled")] string? checkpointType = null, string? newName = null, + int? memoryMinMb = null, int? memoryMaxMb = null, int? memoryBufferPercent = null, string? notes = null, + [Description("Nothing | StartIfRunning | Start")] string? autoStartAction = null, int? autoStartDelaySeconds = null, + [Description("TurnOff | Save | ShutDown")] string? autoStopAction = null, bool? autoCheckpoints = null, bool? nestedVirtualization = null, + [Description("Service name to enabled, e.g. {\"Guest Service Interface\": true}. Names: Guest Service Interface, Heartbeat, Key-Value Pair Exchange, Shutdown, Time Synchronization, VSS.")] Dictionary? integrationServices = null, + [Description("VMBus | HvSocket (Linux xrdp)")] string? enhancedSessionTransport = null, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.ConfigureAsync(machine, new VmConfigureRequest + { + Processors = processors, MemoryMb = memoryMb, DynamicMemory = dynamicMemory, CheckpointType = checkpointType, NewName = newName, + MemoryMinMb = memoryMinMb, MemoryMaxMb = memoryMaxMb, MemoryBufferPercent = memoryBufferPercent, Notes = notes, + AutoStartAction = autoStartAction, AutoStartDelaySeconds = autoStartDelaySeconds, AutoStopAction = autoStopAction, + AutoCheckpoints = autoCheckpoints, NestedVirtualization = nestedVirtualization, IntegrationServices = integrationServices, + EnhancedSessionTransport = enhancedSessionTransport + }, ct)); + + [McpServerTool(Name = "hyperv_vm_clone", Destructive = true)] + [Description("Clone an Off VM as newName; the clone stays Off. mode=linked: a differencing disk over each of the source's current disks plus a new VM with the source's generation, memory, vCPUs, Secure Boot, checkpoint settings and adapters (new MACs) and, when the source has one, a new vTPM. The source disks become read-only parents: the source can no longer start, and changing them would break every linked clone. Sources with checkpoints are refused. mode=full: Export-VM, then Import-VM -Copy -GenerateNewId into \\ (destination defaults to the host VirtualMachinePath) with new MACs and disk identifiers; synchronous and slow for large disks. Both copy the guest installation (same computer name and SID); sysprep the source for independent machines.")] + public Task CloneVm(string machine, string newName, [Description("linked | full")] string mode, + [Description("Absolute folder that receives ; default the host VirtualMachinePath.")] string? destination = null, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.CloneAsync(machine, newName, mode, destination, ct)); + + [McpServerTool(Name = "hyperv_vhd", Destructive = true)] + [Description("Standalone virtual disk files at absolute paths. create: a dynamic or fixed .vhdx/.vhd of sizeGb, or a differencing disk over parentPath (the parent is then marked read-only; refused when a VM attaches it); never overwrites. inspect: format, type, sizes, parent chain, read-only flag, the VMs that attach it and the disks it is a parent of. delete: refuses a disk a VM attaches (also through checkpoints), a differencing parent of another disk, or one mounted on the host; dryRun=true reports whether it could be deleted and why not, changing nothing.")] + public Task Vhd([Description("create | inspect | delete")] string action, string path, + [Description("create: dynamic | fixed | differencing")] string type = "dynamic", long? sizeGb = null, string? parentPath = null, + bool dryRun = false, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.VhdAsync(action, path, type, sizeGb, parentPath, dryRun, ct)); [McpServerTool(Name = "hyperv_vm_export")] [Description("Export a VM including its checkpoint chain to an absolute host directory.")] @@ -30,24 +75,35 @@ public Task Export(string machine, string destination, Cancellat await service.Lifecycle.ExportAsync(machine, destination, ct)); [McpServerTool(Name = "hyperv_vm_import")] - [Description("Import an exported .vmcx by copying it and generating a new VM ID. This is also the clone path. Supply fresh VM and VHD destination directories.")] + [Description("Import an exported .vmcx by copying it and generating a new VM ID. Supply fresh VM and VHD destination directories. hyperv_vm_clone mode=full does export plus import in one call.")] public Task Import(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => Run(async () => await service.Lifecycle.ImportAsync(configurationPath, vmDestination, vhdDestination, ct)); [McpServerTool(Name = "hyperv_network", Destructive = true)] - [Description("List virtual switches, create private/internal switches, remove an exact switch, or connect/disconnect a named VM adapter. External switch creation is deliberately explicit via host administration.")] - public Task Network(string action = "list", string? machine = null, string? switchName = null, string adapterName = "Network Adapter", string switchType = "Private", CancellationToken ct = default) => Run(async () => action switch + [Description("Virtual switches and VM network adapters. list; create (switchType Private | Internal | NAT: an Internal switch whose host adapter takes the first address of natSubnet, e.g. 192.168.200.0/24, plus a WinNAT network; WinNAT has no DHCP); remove (exact switchName, plus a NAT network of the same name); connect / disconnect (adapterName to switchName); add_adapter (adapterName unique on the VM, optional switchName); remove_adapter; set_adapter (staticMac such as 00155D0A0B0C or 'dynamic', macSpoofing, vlanId 1-4094 or 0 for untagged). Running VMs accept connect/disconnect, macSpoofing, vlanId, and Generation 2 adapter add/remove; staticMac needs Off. External switches are not created here.")] + public Task Network([Description("list | create | remove | connect | disconnect | add_adapter | remove_adapter | set_adapter")] string action = "list", + string? machine = null, string? switchName = null, string adapterName = "Network Adapter", + [Description("create: Private | Internal | NAT")] string switchType = "Private", string? natSubnet = null, + string? staticMac = null, bool? macSpoofing = null, int? vlanId = null, CancellationToken ct = default) => Run(async () => { - "list" => await service.Lifecycle.ListSwitchesAsync(ct), - "create" => await service.Lifecycle.CreateSwitchAsync(switchName ?? throw new ArgumentException("Name and Private/Internal switchType required."), switchType, ct), - "remove" => await service.Lifecycle.RemoveSwitchAsync(switchName ?? throw new ArgumentException("switchName required."), ct), - "connect" => await service.Lifecycle.ConnectAdapterAsync(machine ?? throw new ArgumentException("machine required."), adapterName, switchName ?? throw new ArgumentException("switchName required."), ct), - "disconnect" => await service.Lifecycle.ConnectAdapterAsync(machine ?? throw new ArgumentException("machine required."), adapterName, null, ct), - _ => throw new ArgumentException("Unknown network action.") + string Machine() => machine ?? throw new ArgumentException("machine required."); + return action switch + { + "list" => await service.Lifecycle.ListSwitchesAsync(ct), + "create" => await service.Lifecycle.CreateSwitchAsync(switchName ?? throw new ArgumentException("switchName required."), switchType, natSubnet, ct), + "remove" => await service.Lifecycle.RemoveSwitchAsync(switchName ?? throw new ArgumentException("switchName required."), ct), + "connect" => await service.Lifecycle.ConnectAdapterAsync(Machine(), adapterName, switchName ?? throw new ArgumentException("switchName required."), ct), + "disconnect" => await service.Lifecycle.ConnectAdapterAsync(Machine(), adapterName, null, ct), + "add_adapter" => await service.Lifecycle.AddAdapterAsync(Machine(), adapterName, switchName, ct), + "remove_adapter" => await service.Lifecycle.RemoveAdapterAsync(Machine(), adapterName, ct), + "set_adapter" => await service.Lifecycle.SetAdapterAsync(Machine(), adapterName, staticMac, macSpoofing, vlanId, ct), + _ => throw new ArgumentException("Unknown network action.") + }; }); [McpServerTool(Name = "hyperv_disk", Destructive = true)] - [Description("Manage VM media: list, attach_vhd, detach_vhd by exact path, attach_iso, eject_iso, or resize_vhd. Changes require VM Off; does not delete disk files.")] - public Task Disk(string machine, string action = "list", string? path = null, long? sizeGb = null, CancellationToken ct = default) => Run(async () => + [Description("VM media: list, attach_vhd (refused when another VM attaches the file or it is a differencing parent), detach_vhd by exact path, attach_iso (into the DVD drive, adding one when missing), eject_iso, resize_vhd (sizeGb; refused for checkpoint files and shared parents). Running VMs accept SCSI disk attach/detach (never the boot disk), ISO insert/eject, a Generation 2 DVD drive add, and online resize of a VHDX on SCSI; Saved and Paused VMs accept no change. Never deletes disk files (hyperv_vhd does).")] + public Task Disk(string machine, [Description("list | attach_vhd | detach_vhd | attach_iso | eject_iso | resize_vhd")] string action = "list", + string? path = null, long? sizeGb = null, CancellationToken ct = default) => Run(async () => action == "list" ? await service.Lifecycle.InfoAsync(machine, ct) : await service.Lifecycle.DiskAsync(machine, action, path, sizeGb, ct)); } diff --git a/src/HyperVControl/HyperVTools.cs b/src/HyperVControl/HyperVTools.cs index 6b17cc9..80e6567 100644 --- a/src/HyperVControl/HyperVTools.cs +++ b/src/HyperVControl/HyperVTools.cs @@ -44,23 +44,37 @@ internal static async Task Run(Func> action) }; [McpServerTool(Name = "hyperv_capabilities", ReadOnly = true)] - [Description("Probe local Hyper-V rights, host availability, console and guest prerequisites, and installed debugger versions. No VM mutations.")] - public Task Capabilities(CancellationToken ct) => Run(async () => new + [Description("Probe local Hyper-V rights, host availability, console and guest prerequisites, and installed debugger versions. hostDefaults gives the default VM and VHD folders, supported VM configuration versions, Secure Boot templates, Default Switch presence and free space on those volumes. No VM mutations.")] + public Task Capabilities(CancellationToken ct) => Run(async () => { - elevated = ElevationBridge.IsAdministrator, host = await service.HyperV.GetInventoryAsync(ct), - debuggers = await service.Debugger.DiscoverAsync(ct), - basic = "Hyper-V WMI framebuffer and synthetic input; console window uses RDP ActiveX on port 2179.", - enhanced = "Supported guest, Enhanced Session enabled; RDP ActiveX. Capture requires foreground unobscured window.", - guest = "PowerShell Direct requires a running Windows guest and guest credentials. UI Automation also needs an unlocked interactive sign-in.", - elevation = "Use --elevate to bridge stdio to a same-user UAC worker. Cancelling UAC exits cleanly. Alternate-admin credentials require starting the MCP client in that account." + var host = service.HyperV.GetInventoryAsync(ct); + var defaults = Probe(() => service.Lifecycle.HostDefaultsAsync(ct)); + return new + { + elevated = ElevationBridge.IsAdministrator, host = await host, hostDefaults = await defaults, + debuggers = await service.Debugger.DiscoverAsync(ct), + basic = "Hyper-V WMI framebuffer and synthetic input; console window uses RDP ActiveX on port 2179.", + enhanced = "Supported guest, Enhanced Session enabled; RDP ActiveX. Capture requires foreground unobscured window.", + guest = "PowerShell Direct requires a running Windows guest and guest credentials. UI Automation also needs an unlocked interactive sign-in.", + elevation = "Use --elevate to bridge stdio to a same-user UAC worker. Cancelling UAC exits cleanly. Alternate-admin credentials require starting the MCP client in that account." + }; }); + /// A probe section that reports its own failure instead of failing the whole capability report. + private static async Task Probe(Func> probe) + { + try { return await probe(); } + catch (OperationCanceledException) { throw; } + catch (HyperVOperationException ex) { return new { ok = false, error = ex.Message, errorType = ex.ErrorType }; } + catch (Exception ex) { return new { ok = false, error = ex.Message, errorType = ex.GetType().Name }; } + } + [McpServerTool(Name = "hyperv_list", ReadOnly = true)] [Description("List all local Hyper-V VMs with exact IDs, names, states, memory and CPUs.")] public Task List(CancellationToken ct) => Run(async () => await service.HyperV.GetInventoryAsync(ct)); [McpServerTool(Name = "hyperv_vm_info", ReadOnly = true)] - [Description("Read detailed VM hardware, integration services, disks, network, COM ports and current checkpoint ID.")] + [Description("Read one VM's configuration: state, configuration version, creation time, notes, memory (startup, minimum, maximum, buffer), processor (count, nested virtualization), automatic start/stop/checkpoint actions, checkpoint type and count, firmware with boot order (Generation 2) or BIOS order (Generation 1), security (vTPM, key protector), network adapters, disks with their VHD parent chains, DVD drives, COM ports and integration services. The vm object keeps Hyper-V's raw values (State 3 = Off; ParentSnapshotId is the current checkpoint).")] public Task Info(string machine, CancellationToken ct) => Run(async () => await service.Lifecycle.InfoAsync(machine, ct)); [McpServerTool(Name = "hyperv_power", Destructive = true)] @@ -111,10 +125,9 @@ public Task Checkpoint(string machine, string action, string che }); [McpServerTool(Name = "hyperv_firmware", Destructive = true)] - [Description("Set and verify Secure Boot on an Off Generation 2 VM. Change only for the requested guest task; debugging tools never disable it automatically.")] - public Task Firmware(string machine, bool secureBoot, CancellationToken ct) => Run(async () => - { - var result = await service.HyperV.SetSecureBootAsync((await service.ResolveAsync(machine, ct)).Id, secureBoot, ct); - return Outcome(result.Success, result.Error, result.Capability, result); - }); + [Description("Generation 2 firmware and virtual TPM; changes need the VM Off. Applied in this order: secureBootTemplate (it locks once the vTPM is initialized), secureBoot, tpm (enabling creates a local key protector when the VM has none; disabling makes guest-sealed secrets such as BitLocker keys unavailable), firstBootDevice. Change only what the guest task needs; debugging tools never disable Secure Boot automatically. Omit every setting to read the current state. Returns firmware (with boot order) and security read back and verified after the change.")] + public Task Firmware(string machine, bool? secureBoot = null, + [Description("MicrosoftWindows | MicrosoftUEFICertificateAuthority (Linux and other third-party loaders) | OpenSourceShieldedVM")] string? secureBootTemplate = null, + bool? tpm = null, [Description("dvd | disk | network")] string? firstBootDevice = null, CancellationToken ct = default) => Run(async () => + await service.Lifecycle.FirmwareAsync(machine, secureBoot, secureBootTemplate, tpm, firstBootDevice, ct)); } diff --git a/src/HyperVControl/Services/VmLifecycleService.Hardware.cs b/src/HyperVControl/Services/VmLifecycleService.Hardware.cs new file mode 100644 index 0000000..5353ffe --- /dev/null +++ b/src/HyperVControl/Services/VmLifecycleService.Hardware.cs @@ -0,0 +1,275 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; + +namespace HyperVControl.Services; + +/// Disk media, firmware/vTPM and networking, each gated by before any change. +internal sealed partial class VmLifecycleService +{ + private static readonly string[] SecureBootTemplates = ["MicrosoftWindows", "MicrosoftUEFICertificateAuthority", "OpenSourceShieldedVM"]; + private static readonly string[] BootDevices = ["dvd", "disk", "network"]; + private const string MediaResult = "[ordered]@{ success = $true; disks = @(Get-VMHardDiskDrive -VM $vm | ForEach-Object { ConvertTo-ControlDrive $_ }); dvd = @(Get-VMDvdDrive -VM $vm | Select-Object Path,ControllerNumber,ControllerLocation) }"; + private const string AdapterResult = "[ordered]@{ success = $true; adapters = @(Get-VMNetworkAdapter -VM $vm | ForEach-Object { ConvertTo-ControlAdapter $_ }) }"; + + /// Generation 2: whether $__drive is the disk the firmware boots first (SCSI 0:0 when the boot order names no disk). + private const string BootDisk = """ + $__bootDisk = $false + if ([int]$vm.Generation -eq 2) { + $__bootEntry = @((Get-VMFirmware -VM $vm).BootOrder | Where-Object { [string]$_.BootType -eq 'Drive' -and $null -ne $_.Device -and [string]$_.Device.Path -match '\.a?vhdx?$' }) | Select-Object -First 1 + if ($null -ne $__bootEntry) { $__bootDisk = (Get-ControlPathKey ([string]$__bootEntry.Device.Path)) -eq (Get-ControlPathKey ([string]$__drive.Path)) } + else { $__bootDisk = ([int]$__drive.ControllerNumber -eq 0 -and [int]$__drive.ControllerLocation -eq 0) } + } + """; + + private static string AttachedDrive(string literal) => + $"$__drives = @(Get-VMHardDiskDrive -VM $vm | Where-Object {{ $_.Path -eq {literal} }})\n" + + "if ($__drives.Count -ne 1) { Stop-Control 'NotFound' 'The disk is not attached exactly once to this VM.' }\n" + + "$__drive = $__drives[0]"; + + /// Media changes: attach_vhd, detach_vhd, attach_iso, eject_iso, resize_vhd. Never deletes files. + public Task DiskAsync(string machine, string action, string? path, long? sizeGb, CancellationToken ct) + { + if (action is not "eject_iso" && path == null) throw new ArgumentException("path required."); + if (action == "attach_iso" && Extension(path!) != ".iso") throw new ArgumentException("attach_iso needs an .iso file."); + var literal = path == null ? "" : L(ControlService.FullPath(path)); + var s = Script().Vm(machine); + switch (action) + { + case "attach_vhd": + s.Line("$__path = " + literal) + .Line("if (-not [System.IO.File]::Exists($__path)) { Stop-Control 'NotFound' ('Virtual disk not found: ' + $__path) }") + .Line("$__reason = Get-ControlReferenceReason (Get-ControlDiskReferences $null) (Get-ControlPathKey $__path)") + .Line("if ($null -ne $__reason) { Stop-Control 'InvalidArgument' ('Cannot attach ' + $__path + ': it is ' + $__reason + '. Nothing was changed.') }") + .Line(VmStateRules.Gate([("attach_vhd", null)])) + // Generation 1 tries IDE first, which a running VM refuses; SCSI is hot-pluggable on both generations. + .Line("if ([int]$vm.Generation -eq 2 -or [string]$vm.State -eq 'Running') { " + + Render("Add-VMHardDiskDrive", ("VM", VmRef), ("ControllerType", PsValue.Raw("SCSI")), ("Path", PsValue.Raw("$__path"))) + " } else { " + + Render("Add-VMHardDiskDrive", ("VM", VmRef), ("Path", PsValue.Raw("$__path"))) + " }"); + break; + case "detach_vhd": + s.Line(AttachedDrive(literal)).Line(BootDisk).Line(VmStateRules.Gate([("detach_vhd", null)])) + .Cmd("Remove-VMHardDiskDrive", ("VMHardDiskDrive", PsValue.Raw("$__drive"))); + break; + case "attach_iso": + s.Line("$__iso = " + literal) + .Line("if (-not [System.IO.File]::Exists($__iso)) { Stop-Control 'NotFound' ('ISO not found: ' + $__iso) }") + .Line("$__dvd = @(Get-VMDvdDrive -VM $vm); $__dvdCount = $__dvd.Count") + .Line("if ($__dvdCount -gt 1) { Stop-Control 'InvalidArgument' 'The VM has several DVD drives; this action needs exactly one or none.' }") + .Line(VmStateRules.Gate([("attach_iso", null)])) + .Line("if ($__dvdCount -eq 0) { " + Render("Add-VMDvdDrive", ("VM", VmRef), ("Path", PsValue.Raw("$__iso"))) + " } else { " + + Render("Set-VMDvdDrive", ("VMDvdDrive", PsValue.Raw("$__dvd[0]")), ("Path", PsValue.Raw("$__iso"))) + " }"); + break; + case "eject_iso": + s.Line(VmStateRules.Gate([("eject_iso", null)])) + .Line("foreach ($__dvd in @(Get-VMDvdDrive -VM $vm)) { " + Render("Set-VMDvdDrive", ("VMDvdDrive", PsValue.Raw("$__dvd")), ("Path", PsValue.Raw("$null"))) + " }"); + break; + case "resize_vhd" when sizeGb is > 0 and <= 65536: + s.Line(AttachedDrive(literal)) + .Line("if ([string]$__drive.Path -match '\\.avhdx?$') { Stop-Control 'InvalidArgument' 'The VM writes to a checkpoint file (.avhdx); remove its checkpoints before resizing. Nothing was changed.' }") + .Line("$__reason = Get-ControlReferenceReason (Get-ControlDiskReferences $vm) (Get-ControlPathKey ([string]$__drive.Path))") + .Line("if ($null -ne $__reason) { Stop-Control 'InvalidArgument' ('Cannot resize ' + $__drive.Path + ': it is ' + $__reason + '. Nothing was changed.') }") + .Line(VmStateRules.Gate([("resize_vhd", null)])) + .Cmd("Resize-VHD", ("Path", PsValue.Raw("$__drive.Path")), ("SizeBytes", PsValue.Gigabytes(sizeGb.Value))); + break; + default: throw new ArgumentException("Unknown disk action, or resize_vhd without sizeGb 1-65536."); + } + return runner.InvokeAsync(s.Returns(MediaResult), ct, 300); + } + + /// + /// Generation 2 firmware and vTPM on an Off VM, in the safe order: Secure Boot template (it locks once + /// the vTPM is initialized), Secure Boot, key protector (created when missing), TPM, first boot + /// device. Reads firmware and security back and verifies the requested values. + /// + public Task FirmwareAsync(string machine, bool? secureBoot, string? template, bool? tpm, string? firstBootDevice, CancellationToken ct) + { + if (template is not null) Choice(template, SecureBootTemplates, "secureBootTemplate"); + if (firstBootDevice is not null) Choice(firstBootDevice, BootDevices, "firstBootDevice"); + var requested = new List<(string, PsValue?)>(); + if (template is not null) requested.Add(("secureBootTemplate", null)); + if (secureBoot is not null) requested.Add(("secureBoot", null)); + if (tpm is not null) requested.Add(("tpm", null)); + if (firstBootDevice is not null) requested.Add(("firstBootDevice", null)); + var s = Script().Vm(machine) + .Line("$__warnings = New-Object System.Collections.ArrayList") + .Line("if ([int]$vm.Generation -ne 2) { Stop-Control 'InvalidArgument' (\"VM '\" + $vm.Name + \"' is Generation \" + $vm.Generation + '; Secure Boot, boot order and the virtual TPM here apply to Generation 2 VMs.') }") + .Line(VmStateRules.Gate(requested)); + if (firstBootDevice is not null) + { + var (lookup, missing) = firstBootDevice switch + { + "dvd" => ("@(Get-VMDvdDrive -VM $vm) | Select-Object -First 1", "DVD drive (attach an ISO with hyperv_disk action=attach_iso)"), + "disk" => ("@(Get-VMHardDiskDrive -VM $vm | Sort-Object ControllerNumber, ControllerLocation) | Select-Object -First 1", "virtual disk"), + _ => ("@(Get-VMNetworkAdapter -VM $vm) | Select-Object -First 1", "network adapter") + }; + s.Line("$__boot = " + lookup) + .Line($"if ($null -eq $__boot) {{ Stop-Control 'InvalidArgument' 'The VM has no {missing} to boot from. Nothing was changed.' }}"); + } + if (tpm is not null) s.Line("$__security = Get-VMSecurity -VM $vm"); + if (template is not null) s.Cmd("Set-VMFirmware", ("VM", VmRef), ("SecureBootTemplate", PsValue.Of(template))); + if (secureBoot is { } enabled) s.Cmd("Set-VMFirmware", ("VM", VmRef), ("EnableSecureBoot", PsValue.Token(enabled ? "On" : "Off", "On", "Off"))); + if (tpm == true) + s.Line("if (-not $__security.TpmEnabled) {") + .Line("if (-not (Test-ControlKeyProtector $vm)) { " + Render("Set-VMKeyProtector", ("VM", VmRef), ("NewLocalKeyProtector", PsValue.Of(true))) + + "; [void]$__warnings.Add('Created a local key protector: the VM starts only on hosts that hold this host''s Shielded VM guardian certificates.') }") + .Cmd("Enable-VMTPM", ("VM", VmRef)) + .Line("}"); + else if (tpm == false) + s.Line("if ($__security.TpmEnabled) { " + Render("Disable-VMTPM", ("VM", VmRef)) + + "; [void]$__warnings.Add('Disabled the virtual TPM: secrets the guest sealed to it (BitLocker, Windows Hello) stay unavailable until it is enabled again with the same key protector.') }"); + if (firstBootDevice is not null) s.Cmd("Set-VMFirmware", ("VM", VmRef), ("FirstBootDevice", PsValue.Raw("$__boot"))); + s.Line("$__firmware = ConvertTo-ControlFirmware $vm").Line("$__readback = ConvertTo-ControlSecurity $vm"); + if (template is not null) + s.Line($"if ($__firmware.SecureBootTemplate -ne {L(template)}) {{ Stop-Control 'HyperVOperationFailed' ('Hyper-V did not apply the Secure Boot template; it reads ' + $__firmware.SecureBootTemplate + '.') }}"); + if (secureBoot is { } expected) + s.Line($"if (($__firmware.SecureBoot -eq 'On') -ne {B(expected)}) {{ Stop-Control 'HyperVOperationFailed' 'Hyper-V did not apply the requested Secure Boot setting.' }}"); + if (tpm is { } expectedTpm) + s.Line($"if ($null -ne $__readback -and $__readback.TpmEnabled -ne {B(expectedTpm)}) {{ Stop-Control 'HyperVOperationFailed' 'Hyper-V did not apply the requested virtual TPM setting.' }}"); + return runner.InvokeAsync(s.Returns("[ordered]@{ success = $true; machine = [ordered]@{ id = [string]$vm.Id; name = [string]$vm.Name; state = [string]$vm.State }; secureBootEnabled = ($__firmware.SecureBoot -eq 'On'); firmware = $__firmware; security = $__readback; warnings = @($__warnings) }"), ct); + } + + public Task ListSwitchesAsync(CancellationToken ct) => runner.InvokeAsync( + new HyperVScript().Returns("@(Get-VMSwitch | Select-Object Id,Name,@{Name='SwitchType';Expression={[string]$_.SwitchType}},NetAdapterInterfaceDescription)"), ct); + + /// Private or Internal switches, or NAT: an Internal switch whose host adapter gets the + /// subnet's first address, plus a WinNAT network for the subnet. A failed NAT step removes what it made. + public Task CreateSwitchAsync(string name, string switchType, string? natSubnet, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("switchName required."); + var type = Choice(switchType, ["Private", "Internal", "NAT"], "switchType"); + var s = Script().Line("$__name = " + L(name)) + .Line("if (@(Get-VMSwitch | Where-Object { $_.Name -eq $__name }).Count -gt 0) { Stop-Control 'InvalidArgument' (\"A virtual switch named '\" + $__name + \"' already exists. Nothing was changed.\") }"); + if (type != "NAT") + { + if (natSubnet is not null) throw new ArgumentException("natSubnet applies only to switchType=NAT."); + return runner.InvokeAsync(s.Cmd("$switch = New-VMSwitch", ("Name", PsValue.Raw("$__name")), ("SwitchType", PsValue.Token(type, "Private", "Internal"))) + .Returns("($switch | Select-Object Id,Name,@{Name='SwitchType';Expression={[string]$_.SwitchType}})"), ct); + } + var (prefix, gateway, length) = NatSubnet(natSubnet ?? throw new ArgumentException("switchType=NAT needs natSubnet, for example 192.168.200.0/24.")); + s.Line("$__subnet = " + L(prefix)).Line("$__gateway = " + L(gateway)).Line(""" + Import-ControlNetModules + foreach ($__existing in @(Get-NetNat -ErrorAction SilentlyContinue)) { + $__prefix = [string]$__existing.InternalIPInterfaceAddressPrefix + if ([string]$__existing.Name -eq $__name) { Stop-Control 'InvalidArgument' ("A NAT network named '" + $__name + "' already exists. Nothing was changed.") } + if ($__prefix -and $__prefix -notmatch ':' -and (Test-ControlPrefixOverlap $__prefix $__subnet)) { + Stop-Control 'InvalidArgument' ("NAT network '" + $__existing.Name + "' already uses " + $__prefix + ', which overlaps ' + $__subnet + '. Windows supports one NAT network per host: choose a non-overlapping natSubnet or reuse that network. Nothing was changed.') + } + } + $__switch = $null; $__nat = $null + try { + """) + .Cmd("$__switch = New-VMSwitch", ("Name", PsValue.Raw("$__name")), ("SwitchType", PsValue.Raw("Internal"))) + .Line(""" + $__hostAdapter = $null + for ($__try = 0; $__try -lt 20 -and $null -eq $__hostAdapter; $__try++) { + $__mac = ([string](@(Get-VMNetworkAdapter -ManagementOS | Where-Object { $_.SwitchName -eq $__name }) | Select-Object -First 1).MacAddress) -replace '[^0-9A-Fa-f]', '' + $__hostAdapter = @(Get-NetAdapter -IncludeHidden | Where-Object { $__mac -and ((([string]$_.MacAddress) -replace '[^0-9A-Fa-f]', '') -eq $__mac) }) | Select-Object -First 1 + if ($null -eq $__hostAdapter) { Start-Sleep -Milliseconds 500 } + } + if ($null -eq $__hostAdapter) { Stop-Control 'HyperVOperationFailed' 'The host network adapter of the new switch did not appear.' } + """) + .Cmd("$null = New-NetIPAddress", ("InterfaceIndex", PsValue.Raw("$__hostAdapter.ifIndex")), ("IPAddress", PsValue.Raw("$__gateway")), ("PrefixLength", PsValue.Of(length))) + .Cmd("$__nat = New-NetNat", ("Name", PsValue.Raw("$__name")), ("InternalIPInterfaceAddressPrefix", PsValue.Raw("$__subnet"))) + .Line(""" + } catch { + $__failure = $_ + if ($null -ne $__nat) { try { Remove-NetNat -Name $__name -Confirm:$false } catch { } } + if ($null -ne $__switch) { try { Remove-VMSwitch -VMSwitch $__switch -Force } catch { } } + throw $__failure + } + """); + return runner.InvokeAsync(s.Returns("[ordered]@{ Id = [string]$__switch.Id; Name = [string]$__switch.Name; SwitchType = 'Internal'; nat = [ordered]@{ name = $__name; prefix = $__subnet; gateway = $__gateway }; note = ('WinNAT has no DHCP: give each guest a static address in ' + $__subnet + ', gateway ' + $__gateway + ', and a DNS server.') }"), ct, 180); + } + + /// Parses an IPv4 network prefix; returns it with the host gateway (the first address) and its length. + internal static (string Prefix, string Gateway, int Length) NatSubnet(string value) + { + var parts = value.Trim().Split('/'); + if (parts.Length != 2 || !IPAddress.TryParse(parts[0], out var address) || address.AddressFamily != AddressFamily.InterNetwork + || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var length) || length is < 8 or > 30) + throw new ArgumentException("natSubnet must be an IPv4 network prefix such as 192.168.200.0/24 (length 8-30)."); + var bytes = address.GetAddressBytes(); + var number = (uint)bytes[0] << 24 | (uint)bytes[1] << 16 | (uint)bytes[2] << 8 | bytes[3]; + var mask = uint.MaxValue << (32 - length); + static string Text(uint n) => new IPAddress([(byte)(n >> 24), (byte)(n >> 16), (byte)(n >> 8), (byte)n]).ToString(); + if ((number & ~mask) != 0) throw new ArgumentException($"natSubnet must name its network address: {Text(number & mask)}/{length}."); + return ($"{Text(number)}/{length}", Text(number + 1), length); + } + + /// Removes one switch by exact name, then a NAT network of the same name (the NAT create makes one). + public Task RemoveSwitchAsync(string name, CancellationToken ct) => runner.InvokeAsync(Script() + .Line("$switches = @(Get-VMSwitch | Where-Object { $_.Name -eq " + L(name) + " })") + .Line("if ($switches.Count -ne 1) { Stop-Control 'NotFound' 'Switch missing or ambiguous' }") + .Cmd("$switches[0] | Remove-VMSwitch", ("Force", PsValue.Of(true))) + .Line("$__nats = @(); try { Import-ControlNetModules; $__nats = @(Get-NetNat -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq " + L(name) + " }) } catch { }") + .Line("foreach ($__nat in $__nats) { Remove-NetNat -Name $__nat.Name -Confirm:$false }") + .Returns("[ordered]@{ success = $true; removedNat = @($__nats | ForEach-Object { [string]$_.Name }) }"), ct); + + private static string AdapterLookup(string adapterName) => + "$__adapters = @(Get-VMNetworkAdapter -VM $vm | Where-Object { $_.Name -eq " + L(adapterName) + " })\n" + + "if ($__adapters.Count -ne 1) { Stop-Control 'NotFound' ('Network adapter ' + " + L(L(adapterName)) + " + ' is missing or ambiguous; this VM has: ' + ((@(Get-VMNetworkAdapter -VM $vm) | ForEach-Object { $_.Name }) -join ', ') + '.') }\n" + + "$__adapter = $__adapters[0]"; + + private static string SwitchLookup(string switchName) => + "$__switches = @(Get-VMSwitch | Where-Object { $_.Name -eq " + L(switchName) + " })\n" + + "if ($__switches.Count -eq 0) { Stop-Control 'NotFound' ('Virtual switch ' + " + L(L(switchName)) + " + ' not found. Call hyperv_network action=list.') }\n" + + "if ($__switches.Count -gt 1) { Stop-Control 'AmbiguousName' 'Several virtual switches have that name.' }"; + + public Task ConnectAdapterAsync(string machine, string adapterName, string? switchName, CancellationToken ct) + { + var s = Script().Vm(machine).Line(AdapterLookup(adapterName)); + if (switchName is not null) s.Line(SwitchLookup(switchName)); + s.Line(VmStateRules.Gate([(switchName is null ? "disconnect" : "connect", null)])); + if (switchName is null) s.Cmd("Disconnect-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$__adapter"))); + else s.Cmd("Connect-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$__adapter")), ("SwitchName", PsValue.Of(switchName))); + return runner.InvokeAsync(s.Returns(AdapterResult), ct); + } + + public Task AddAdapterAsync(string machine, string adapterName, string? switchName, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(adapterName)) throw new ArgumentException("adapterName required."); + var s = Script().Vm(machine) + .Line("if (@(Get-VMNetworkAdapter -VM $vm | Where-Object { $_.Name -eq " + L(adapterName) + " }).Count -gt 0) { Stop-Control 'InvalidArgument' ('The VM already has an adapter named ' + " + L(L(adapterName)) + " + '; pass a unique adapterName. Nothing was changed.') }"); + if (!string.IsNullOrEmpty(switchName)) s.Line(SwitchLookup(switchName)); + s.Line(VmStateRules.Gate([("add_adapter", null)])) + .Cmd("Add-VMNetworkAdapter", ("VM", VmRef), ("Name", PsValue.Of(adapterName)), ("SwitchName", string.IsNullOrEmpty(switchName) ? null : PsValue.Of(switchName))); + return runner.InvokeAsync(s.Returns(AdapterResult), ct); + } + + public Task RemoveAdapterAsync(string machine, string adapterName, CancellationToken ct) => runner.InvokeAsync(Script().Vm(machine) + .Line(AdapterLookup(adapterName)).Line(VmStateRules.Gate([("remove_adapter", null)])) + .Cmd("Remove-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$__adapter"))) + .Returns(AdapterResult), ct); + + /// Static or dynamic MAC (Off only), MAC spoofing and access VLAN (live). + public Task SetAdapterAsync(string machine, string adapterName, string? staticMac, bool? macSpoofing, int? vlanId, CancellationToken ct) + { + if (staticMac is null && macSpoofing is null && vlanId is null) throw new ArgumentException("set_adapter needs staticMac, macSpoofing or vlanId."); + if (vlanId is < 0 or > 4094) throw new ArgumentException("vlanId must be 1-4094, or 0 to remove the VLAN."); + var mac = staticMac is null || staticMac.Equals("dynamic", StringComparison.OrdinalIgnoreCase) ? staticMac : MacAddress(staticMac); + var requested = new List<(string, PsValue?)>(); + if (staticMac is not null) requested.Add(("staticMac", null)); + if (macSpoofing is not null) requested.Add(("macSpoofing", null)); + if (vlanId is not null) requested.Add(("vlanId", null)); + var s = Script().Vm(machine).Line(AdapterLookup(adapterName)).Line(VmStateRules.Gate(requested)); + if (mac is not null || macSpoofing is not null) + s.Cmd("Set-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$__adapter")), + ("StaticMacAddress", mac is null || mac.Equals("dynamic", StringComparison.OrdinalIgnoreCase) ? null : PsValue.Of(mac)), + ("DynamicMacAddress", mac is not null && mac.Equals("dynamic", StringComparison.OrdinalIgnoreCase) ? PsValue.Of(true) : null), + ("MacAddressSpoofing", macSpoofing is { } spoofing ? PsValue.Token(spoofing ? "On" : "Off", "On", "Off") : null)); + if (vlanId is 0) s.Cmd("Set-VMNetworkAdapterVlan", ("VMNetworkAdapter", PsValue.Raw("$__adapter")), ("Untagged", PsValue.Of(true))); + else if (vlanId is { } vlan) s.Cmd("Set-VMNetworkAdapterVlan", ("VMNetworkAdapter", PsValue.Raw("$__adapter")), ("Access", PsValue.Of(true)), ("VlanId", PsValue.Of(vlan))); + return runner.InvokeAsync(s.Returns(AdapterResult), ct); + } + + /// Normalizes a unicast MAC address to 12 uppercase hex digits. + internal static string MacAddress(string value) + { + var hex = new string(value.Where(c => c is not ('-' or ':' or '.')).ToArray()).ToUpperInvariant(); + if (hex.Length != 12 || !hex.All(Uri.IsHexDigit) || hex == "000000000000" || (Convert.ToByte(hex[..2], 16) & 1) != 0) + throw new ArgumentException("staticMac must be 12 hex digits (separators allowed) of a unicast address, for example 00155D0A0B0C, or 'dynamic'."); + return hex; + } +} diff --git a/src/HyperVControl/Services/VmLifecycleService.Storage.cs b/src/HyperVControl/Services/VmLifecycleService.Storage.cs new file mode 100644 index 0000000..355bdec --- /dev/null +++ b/src/HyperVControl/Services/VmLifecycleService.Storage.cs @@ -0,0 +1,245 @@ +using System.Text.Json; + +namespace HyperVControl.Services; + +/// Standalone virtual disk files and VM clones. +internal sealed partial class VmLifecycleService +{ + private static readonly string[] DiskTypes = ["dynamic", "fixed", "differencing"]; + + /// Format, sizes, parent chain, attaching VMs and children of $__path. + private const string VhdDetails = """ + $__chain = @(Get-ControlVhdChain $__path) + $__disk = $__chain[0] + $__uses = @($__references.Map[(Get-ControlPathKey $__path)] | Where-Object { $null -ne $_ }) + $__children = Find-ControlUnattachedChildren @($__path) @([System.IO.Path]::GetDirectoryName($__path), [string](Get-VMHost).VirtualHardDiskPath) + $__details = [ordered]@{ + path = $__path + format = Get-ControlText $__disk.VhdFormat + type = Get-ControlText $__disk.VhdType + sizeBytes = $(if ($null -ne $__disk.Size) { [long]$__disk.Size } else { $null }) + fileSizeBytes = $(if ($null -ne $__disk.FileSize) { [long]$__disk.FileSize } else { $null }) + minimumSizeBytes = $(if ($null -ne $__disk.MinimumSize) { [long]$__disk.MinimumSize } else { $null }) + parentPath = $(if ([string]::IsNullOrWhiteSpace([string]$__disk.ParentPath)) { $null } else { [string]$__disk.ParentPath }) + diskIdentifier = Get-ControlText $__disk.DiskIdentifier + mountedOnHost = [bool]$__disk.Attached + readOnly = (([System.IO.File]::GetAttributes($__path) -band [System.IO.FileAttributes]::ReadOnly) -ne 0) + error = $(if ($null -ne $__disk.PSObject.Properties['Error']) { [string]$__disk.Error } else { $null }) + chain = @($__chain | ForEach-Object { ConvertTo-ControlVhdLink $_ }) + attachedTo = @($__uses | Where-Object { $_.Attached } | ForEach-Object { [ordered]@{ vmId = $_.VmId; vmName = $_.VmName } }) + parentOf = @(@($__uses | Where-Object { -not $_.Attached } | ForEach-Object { [ordered]@{ child = $_.Via; vmId = $_.VmId; vmName = $_.VmName } }) + @($__children.Values | ForEach-Object { [ordered]@{ child = [string]$_; vmId = $null; vmName = $null } })) + } + """; + + /// create (dynamic | fixed | differencing), inspect, or delete one virtual disk file. + public Task VhdAsync(string action, string path, string type, long? sizeGb, string? parentPath, bool dryRun, CancellationToken ct) + { + ControlService.FullPath(path); + var s = Script().Line("$__path = " + L(path)).Line("$__warnings = New-Object System.Collections.ArrayList"); + switch (action) + { + case "create": + { + var kind = Choice(type, DiskTypes, "type"); + if (Extension(path) is not (".vhdx" or ".vhd")) throw new ArgumentException("path must end in .vhdx (or .vhd)."); + if (kind == "differencing") + { + if (parentPath is null || sizeGb is not null) throw new ArgumentException("type=differencing needs parentPath and no sizeGb; the child has its parent's size."); + if (Extension(ControlService.FullPath(parentPath)) != Extension(path)) throw new ArgumentException("A differencing disk must use its parent's format (.vhdx or .vhd)."); + } + else + { + if (parentPath is not null) throw new ArgumentException("parentPath applies only to type=differencing."); + if (sizeGb is not > 0 || sizeGb > (Extension(path) == ".vhd" ? 2040 : 65536)) throw new ArgumentException("sizeGb must be 1-65536 for .vhdx (1-2040 for .vhd)."); + } + s.Line("if ([System.IO.File]::Exists($__path) -or [System.IO.Directory]::Exists($__path)) { Stop-Control 'InvalidArgument' ('Something already exists at ' + $__path + '; hyperv_vhd create never overwrites. Nothing was changed.') }"); + if (kind == "differencing") + s.Line("$__parent = " + L(parentPath!)) + .Line("if (-not [System.IO.File]::Exists($__parent)) { Stop-Control 'NotFound' ('Parent disk not found: ' + $__parent) }") + .Line("$__attached = @((Get-ControlDiskReferences $null).Map[(Get-ControlPathKey $__parent)] | Where-Object { $null -ne $_ -and $_.Attached })") + .Line("if ($__attached.Count -gt 0) { Stop-Control 'InvalidArgument' ('Parent ' + $__parent + ' is ' + (Format-ControlReference $__attached[0]) + '; a differencing parent must never change. Nothing was changed.') }") + .Line("Assert-ControlFreeSpace $__path ([long](Get-VHD -Path $__parent).Size) ('A differencing disk over ' + $__parent)"); + else s.Line($"Assert-ControlFreeSpace $__path ([long]{sizeGb}*1GB) 'A {sizeGb} GB disk'"); + s.Line("[void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($__path))"); + if (kind == "differencing") + s.Cmd("$null = New-VHD", ("Path", PsValue.Raw("$__path")), ("ParentPath", PsValue.Raw("$__parent")), ("Differencing", PsValue.Of(true))) + .Line("Set-ControlReadOnly $__parent $true") + .Line("[void]$__warnings.Add('Marked the parent ' + $__parent + ' read-only: changing it would break this differencing disk.')"); + else + s.Cmd("$null = New-VHD", ("Path", PsValue.Raw("$__path")), ("SizeBytes", PsValue.Gigabytes(sizeGb!.Value)), (kind == "fixed" ? "Fixed" : "Dynamic", PsValue.Of(true))); + s.Line("$__references = Get-ControlDiskReferences $null").Line(VhdDetails) + .Line("$__details.createdFiles = @($__path)").Line("$__details.warnings = @($__warnings)"); + return runner.InvokeAsync(s.Returns("$__details"), ct, kind == "fixed" ? 3600 : 300); + } + case "inspect": + s.Line("if (-not [System.IO.File]::Exists($__path)) { Stop-Control 'NotFound' ('Virtual disk not found: ' + $__path) }") + .Line("$__references = Get-ControlDiskReferences $null").Line(VhdDetails); + return runner.InvokeAsync(s.Returns("$__details"), ct, 300); + case "delete": + if (Extension(path) is not (".vhdx" or ".vhd" or ".avhdx" or ".avhd")) throw new ArgumentException("hyperv_vhd deletes only .vhdx, .vhd, .avhdx and .avhd files."); + s.Line("if (-not [System.IO.File]::Exists($__path)) { Stop-Control 'NotFound' ('Virtual disk not found: ' + $__path) }") + .Line("$__references = Get-ControlDiskReferences $null").Line(VhdDetails) + .Line(""" + $__blockers = New-Object System.Collections.ArrayList + foreach ($__use in $__uses) { [void]$__blockers.Add((Format-ControlReference $__use)) } + foreach ($__child in @($__children.Values)) { [void]$__blockers.Add('differencing parent of ' + $__child) } + if ($__details.mountedOnHost) { [void]$__blockers.Add('mounted on the host (Dismount-VHD it first)') } + if ($__references.Unreadable -gt 0) { [void]$__blockers.Add('Hyper-V could not read ' + $__references.Unreadable + ' VM(s), so their use of this file cannot be ruled out') } + """); + if (dryRun) + return runner.InvokeAsync(s.Returns("[ordered]@{ success = $true; dryRun = $true; path = $__path; deletable = ($__blockers.Count -eq 0); blockers = @($__blockers); readOnly = $__details.readOnly; details = $__details }"), ct, 300); + s.Line("if ($__blockers.Count -gt 0) { Stop-Control 'InvalidArgument' ('Refusing to delete ' + $__path + ': ' + ($__blockers -join '; ') + '. Nothing was changed.') }") + .Line("Set-ControlReadOnly $__path $false") + .Line("Microsoft.PowerShell.Management\\Remove-Item -LiteralPath $__path -Force -ErrorAction Stop") + .Line("if ([System.IO.File]::Exists($__path)) { Stop-Control 'HyperVOperationFailed' ($__path + ' still exists after deletion.') }"); + return runner.InvokeAsync(s.Returns("[ordered]@{ success = $true; dryRun = $false; deleted = @($__path); wasReadOnly = $__details.readOnly }"), ct, 300); + default: + throw new ArgumentException("action must be create, inspect or delete."); + } + } + + /// + /// Clones an Off VM. linked: differencing disks over the source's current disks, which become + /// read-only parents, plus a new VM with the source's settings. full: Export-VM, then Import-VM + /// -Copy -GenerateNewId into a fresh folder (synchronous; minutes for large disks). + /// + public Task CloneAsync(string machine, string newName, string mode, string? destination, CancellationToken ct) + { + VmName(newName, "newName"); + var kind = Choice(mode, ["linked", "full"], "mode"); + if (destination is not null) ControlService.FullPath(destination); + var s = Script().Vm(machine).RequireState(VmStateRules.StateNames("clone")) + .Line("$__warnings = New-Object System.Collections.ArrayList") + .Line("$__created = New-Object System.Collections.ArrayList") + .Line("$__name = " + L(newName)) + .Line("$__same = @(Hyper-V\\Get-VM | Where-Object { $_.Name -eq $__name })") + .Line("if ($__same.Count -gt 0) { Stop-Control 'InvalidArgument' (\"A VM named '\" + $__name + \"' already exists (ID \" + $__same[0].Id + '). Choose another newName. Nothing was changed.') }") + .Line("$__host = Get-VMHost") + .Line("$__root = " + (destination is null ? "[string]$__host.VirtualMachinePath" : L(destination))); + if (kind == "linked") + { + s.Line("$__vhdRoot = " + (destination is null ? "[string]$__host.VirtualHardDiskPath" : "[System.IO.Path]::Combine($__root, $__name, 'Virtual Hard Disks')")) + .Line(LinkedClone); + return runner.InvokeAsync(s.Returns("$__result"), ct, 600); + } + return runner.InvokeAsync(s.Line(FullClone).Returns("$__result"), ct, 7200); + } + + private const string CloneResult = """ + $__clone = Hyper-V\Get-VM -Id $__clone.Id -ErrorAction Stop + $__result = [ordered]@{} + foreach ($__property in (ConvertTo-ControlVm $__clone).PSObject.Properties) { $__result[$__property.Name] = $__property.Value } + $__result.source = [ordered]@{ id = [string]$vm.Id; name = [string]$vm.Name } + """; + + private const string LinkedClone = """ + if (@(Get-VMSnapshot -VM $vm).Count -gt 0) { Stop-Control 'InvalidVmState' ("VM '" + $vm.Name + "' has checkpoints, so its current disks are checkpoint files that Hyper-V merges and changes. Remove its checkpoints first, or use mode=full. Nothing was changed.") } + $__drives = @(Get-VMHardDiskDrive -VM $vm | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Path) } | Sort-Object { [string]$_.ControllerType }, ControllerNumber, ControllerLocation) + if ($__drives.Count -eq 0) { Stop-Control 'InvalidArgument' ("VM '" + $vm.Name + "' has no virtual disk to link to; use hyperv_vm_create instead.") } + $__references = Get-ControlDiskReferences $vm + $__links = @() + for ($__i = 0; $__i -lt $__drives.Count; $__i++) { + $__parentPath = [string]$__drives[$__i].Path + $__suffix = '' + if ($__i -gt 0) { $__suffix = '-' + $__i } + $__child = [System.IO.Path]::Combine($__vhdRoot, $__name + $__suffix + [System.IO.Path]::GetExtension($__parentPath)) + if ([System.IO.File]::Exists($__child)) { Stop-Control 'InvalidArgument' ('Disk file already exists: ' + $__child + '. Choose another newName or destination. Nothing was changed.') } + $__users = @($__references.Map[(Get-ControlPathKey $__parentPath)] | Where-Object { $null -ne $_ -and $_.Attached }) + if ($__users.Count -gt 0) { Stop-Control 'InvalidArgument' ('Source disk ' + $__parentPath + ' is also ' + (Format-ControlReference $__users[0]) + '; a linked parent must never change. Nothing was changed.') } + $__links += [pscustomobject]@{ Drive = $__drives[$__i]; Parent = $__parentPath; Child = $__child } + } + $__memory = Get-VMMemory -VM $vm + $__processor = Get-VMProcessor -VM $vm + $__adapters = @(Get-VMNetworkAdapter -VM $vm) + $__firmware = $null; $__security = $null + if ([int]$vm.Generation -eq 2) { $__firmware = Get-VMFirmware -VM $vm; $__security = Get-VMSecurity -VM $vm } + $__clone = $null + try { + [void][System.IO.Directory]::CreateDirectory($__vhdRoot) + if (-not [System.IO.Directory]::Exists($__root)) { [void][System.IO.Directory]::CreateDirectory($__root) } + foreach ($__link in $__links) { New-VHD -Path $__link.Child -ParentPath $__link.Parent -Differencing:$true | Out-Null; [void]$__created.Add($__link.Child) } + $__clone = New-VM -Name $__name -Path $__root -Generation ([int]$vm.Generation) -MemoryStartupBytes ([long]$__memory.Startup) -VHDPath $__links[0].Child + for ($__i = 1; $__i -lt $__links.Count; $__i++) { + $__drive = $__links[$__i].Drive + Add-VMHardDiskDrive -VM $__clone -ControllerType $__drive.ControllerType -ControllerNumber $__drive.ControllerNumber -ControllerLocation $__drive.ControllerLocation -Path $__links[$__i].Child + } + Set-VMProcessor -VM $__clone -Count ([long]$vm.ProcessorCount) -ExposeVirtualizationExtensions:([bool]$__processor.ExposeVirtualizationExtensions) + if ($__memory.DynamicMemoryEnabled) { Set-VMMemory -VM $__clone -DynamicMemoryEnabled:$true -MinimumBytes ([long]$__memory.Minimum) -MaximumBytes ([long]$__memory.Maximum) -Buffer ([int]$__memory.Buffer) } + $__settings = @{ CheckpointType = $vm.CheckpointType; AutomaticCheckpointsEnabled = [bool]$vm.AutomaticCheckpointsEnabled } + if ($null -ne $vm.EnhancedSessionTransportType) { $__settings.EnhancedSessionTransportType = $vm.EnhancedSessionTransportType } + Set-VM -VM $__clone @__settings + if ($null -ne $__firmware) { + # The template locks once the vTPM is initialized, so it is copied first. + Set-VMFirmware -VM $__clone -SecureBootTemplate ([string]$__firmware.SecureBootTemplate) + Set-VMFirmware -VM $__clone -EnableSecureBoot $__firmware.SecureBoot + if ($__security.TpmEnabled) { Set-VMKeyProtector -VM $__clone -NewLocalKeyProtector:$true; Enable-VMTPM -VM $__clone } + } + foreach ($__default in @(Get-VMNetworkAdapter -VM $__clone)) { Remove-VMNetworkAdapter -VMNetworkAdapter $__default } + foreach ($__adapter in $__adapters) { + $__add = @{ Name = [string]$__adapter.Name } + if (-not [string]::IsNullOrWhiteSpace([string]$__adapter.SwitchName)) { $__add.SwitchName = [string]$__adapter.SwitchName } + Add-VMNetworkAdapter -VM $__clone @__add + } + foreach ($__link in $__links) { Set-ControlReadOnly $__link.Parent $true } + } catch { + $__failure = $_ + if ($null -ne $__clone) { try { Remove-VM -VM $__clone -Force } catch { } } + foreach ($__file in @($__created)) { try { if ([System.IO.File]::Exists($__file)) { [System.IO.File]::Delete($__file) } } catch { } } + throw $__failure + } + [void]$__warnings.Add("Marked the source disks read-only: they are now the parents of this clone's differencing disks. VM '" + $vm.Name + "' can no longer start with them, and any change to them breaks every linked clone. Remove the clones and clear the read-only attribute before using the source again.") + [void]$__warnings.Add('The clone runs the same guest installation as the source (same computer name and SID); generalize the source with sysprep first when both must be on one network or domain.') + if ($null -ne $__security -and $__security.TpmEnabled) { [void]$__warnings.Add('The clone has a new virtual TPM with a local key protector; secrets the guest sealed to the source TPM (BitLocker, Windows Hello) are not available in the clone.') } + """ + "\n" + CloneResult + "\n" + """ + $__result.mode = 'linked' + $__result.createdFiles = @($__created) + $__result.readOnlyParents = @($__links | ForEach-Object { $_.Parent }) + $__result.warnings = @($__warnings) + """; + + private const string FullClone = """ + $__target = [System.IO.Path]::Combine($__root, $__name) + if ([System.IO.File]::Exists($__target) -or ([System.IO.Directory]::Exists($__target) -and @([System.IO.Directory]::GetFileSystemEntries($__target)).Count -gt 0)) { Stop-Control 'InvalidArgument' ('Destination ' + $__target + ' already exists and is not empty. Nothing was changed.') } + $__bytes = [long]0 + $__seen = @{} + $__drives = @(Get-VMHardDiskDrive -VM $vm) + foreach ($__checkpoint in @(Get-VMSnapshot -VM $vm)) { $__drives += @(Get-VMHardDiskDrive -VMSnapshot $__checkpoint) } + foreach ($__drive in $__drives) { + if ([string]::IsNullOrWhiteSpace([string]$__drive.Path)) { continue } + foreach ($__link in @(Get-ControlVhdChain ([string]$__drive.Path))) { + $__key = Get-ControlPathKey ([string]$__link.Path) + if (-not $__seen.ContainsKey($__key) -and $null -ne $__link.FileSize) { $__seen[$__key] = $true; $__bytes += [long]$__link.FileSize } + } + } + Assert-ControlFreeSpace $__root (2 * $__bytes + 1GB) ('Exporting and importing ' + [math]::Round($__bytes / 1GB, 1) + ' GB of disks') + $__export = [System.IO.Path]::Combine($__root, '.hyperv-control-export-' + [guid]::NewGuid().ToString('N')) + $__createdTarget = -not [System.IO.Directory]::Exists($__target) + $__clone = $null + try { + [void][System.IO.Directory]::CreateDirectory($__export) + Export-VM -VM $vm -Path $__export + $__config = @(Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $__export -Recurse -Filter '*.vmcx' -File | Where-Object { $_.Extension -eq '.vmcx' -and $_.Directory.Name -eq 'Virtual Machines' }) | Select-Object -First 1 + if ($null -eq $__config) { Stop-Control 'HyperVOperationFailed' 'Export-VM produced no virtual machine configuration (.vmcx).' } + $__clone = Import-VM -Path $__config.FullName -Copy:$true -GenerateNewId:$true -VirtualMachinePath $__target -VhdDestinationPath ([System.IO.Path]::Combine($__target, 'Virtual Hard Disks')) -SnapshotFilePath $__target -SmartPagingFilePath $__target + Rename-VM -VM $__clone -NewName $__name + foreach ($__adapter in @(Get-VMNetworkAdapter -VM $__clone | Where-Object { -not $_.DynamicMacAddressEnabled })) { Set-VMNetworkAdapter -VMNetworkAdapter $__adapter -DynamicMacAddress:$true } + if (@(Get-VMSnapshot -VM $__clone).Count -eq 0) { + foreach ($__drive in @(Get-VMHardDiskDrive -VM $__clone | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Path) })) { + if ([string](Get-VHD -Path $__drive.Path).VhdType -ne 'Differencing') { Set-VHD -Path $__drive.Path -ResetDiskIdentifier:$true -Force:$true } + } + } else { [void]$__warnings.Add('The clone keeps the source''s checkpoints, so its disk identifiers were not reset.') } + } catch { + $__failure = $_ + if ($null -ne $__clone) { try { Remove-VM -VM $__clone -Force } catch { } } + if ($__createdTarget -and [System.IO.Directory]::Exists($__target)) { try { [System.IO.Directory]::Delete($__target, $true) } catch { } } + throw $__failure + } finally { + if ([System.IO.Directory]::Exists($__export)) { try { [System.IO.Directory]::Delete($__export, $true) } catch { [void]$__warnings.Add('Could not delete the temporary export ' + $__export + ': ' + $_.Exception.Message) } } + } + [void]$__warnings.Add('The clone is a copy of the same guest installation and vTPM (same computer name, SID and TPM identity); generalize the source with sysprep first when both must be on one network or domain.') + """ + "\n" + CloneResult + "\n" + """ + $__result.mode = 'full' + $__result.createdFiles = @(Get-VMHardDiskDrive -VM $__clone | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Path) } | ForEach-Object { [string]$_.Path }) + $__result.warnings = @($__warnings) + """; +} diff --git a/src/HyperVControl/Services/VmLifecycleService.cs b/src/HyperVControl/Services/VmLifecycleService.cs index 714d58b..646337f 100644 --- a/src/HyperVControl/Services/VmLifecycleService.cs +++ b/src/HyperVControl/Services/VmLifecycleService.cs @@ -2,134 +2,534 @@ namespace HyperVControl.Services; +/// A hyperv_vm_create request; the defaults are the tool's. +internal sealed record VmCreateRequest(string Name) +{ + public string Source { get; init; } = "new_disk"; + public string? VhdPath { get; init; } + public string? ParentPath { get; init; } + public string? IsoPath { get; init; } + public string? Path { get; init; } + public int Generation { get; init; } = 2; + public int MemoryMb { get; init; } = 4096; + public bool DynamicMemory { get; init; } + public int? MemoryMinMb { get; init; } + public int? MemoryMaxMb { get; init; } + public int Processors { get; init; } = 2; + public int DiskGb { get; init; } = 64; + public string? SwitchName { get; init; } + public string OsFamily { get; init; } = "windows"; + public bool? SecureBoot { get; init; } + public bool? Tpm { get; init; } + public string? Notes { get; init; } + public bool AutoCheckpoints { get; init; } + public string CheckpointType { get; init; } = "Standard"; + public bool Start { get; init; } + public bool AllowDuplicateName { get; init; } +} + +/// A hyperv_vm_configure request; null leaves a setting unchanged. +internal sealed record VmConfigureRequest +{ + public int? Processors { get; init; } + public int? MemoryMb { get; init; } + public bool? DynamicMemory { get; init; } + public string? CheckpointType { get; init; } + public string? NewName { get; init; } + public int? MemoryMinMb { get; init; } + public int? MemoryMaxMb { get; init; } + public int? MemoryBufferPercent { get; init; } + public string? Notes { get; init; } + public string? AutoStartAction { get; init; } + public int? AutoStartDelaySeconds { get; init; } + public string? AutoStopAction { get; init; } + public bool? AutoCheckpoints { get; init; } + public bool? NestedVirtualization { get; init; } + public IReadOnlyDictionary? IntegrationServices { get; init; } + public string? EnhancedSessionTransport { get; init; } +} + /// /// VM lifecycle and hardware operations on the local host. Every operation builds one /// and runs it through the shared runner, so a tool call costs one /// PowerShell process, every failure carries the uniform envelope, and tests can drive this -/// layer against a mock Hyper-V module without a Hyper-V host. +/// layer against a mock Hyper-V module without a Hyper-V host. Changes pass the +/// gate before the first mutation. /// -internal sealed class VmLifecycleService(PowerShellRunner runner) +internal sealed partial class VmLifecycleService(PowerShellRunner runner) { - internal const string VmProperties = "Id,Name,State,Generation,ProcessorCount,MemoryStartup,MemoryAssigned,DynamicMemoryEnabled,CheckpointType,Path"; - internal const string VmProjection = "($vm | Microsoft.PowerShell.Utility\\Select-Object " + VmProperties + ")"; private const string Success = "@{ success = $true }"; private const string Refresh = "$vm = Hyper-V\\Get-VM -Id $vm.Id -ErrorAction Stop"; + private const string Projection = "(ConvertTo-ControlVm $vm)"; + private static readonly string[] Sources = ["new_disk", "existing_vhd", "iso", "linked", "none"]; + private static readonly string[] OsFamilies = ["windows", "linux", "other"]; private static readonly string[] CheckpointTypes = ["Standard", "Production", "ProductionOnly", "Disabled"]; + private static readonly string[] StartActions = ["Nothing", "StartIfRunning", "Start"]; + private static readonly string[] StopActions = ["TurnOff", "Save", "ShutDown"]; + private static readonly string[] SessionTransports = ["VMBus", "HvSocket"]; + + /// Locale-independent integration service IDs (the suffix of each service's Id), by English name. + internal static readonly IReadOnlyDictionary IntegrationServiceIds = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Guest Service Interface"] = "6C09BB55-D683-4DA0-8931-C9BF705F6480", + ["Heartbeat"] = "84EAAE65-2F2E-45F5-9BB5-0E857DC8EB47", + ["Key-Value Pair Exchange"] = "2A34B1C2-FD73-4043-8A5B-DD2159BC743F", + ["Shutdown"] = "9F8233AC-BE49-4C79-8EE3-E7E1985B2077", + ["Time Synchronization"] = "2497F4DE-E9FA-4204-80E4-4B75C46419C0", + ["VSS"] = "5CED1297-4598-4915-A5FC-AD21BB4D02A4", + }; private static PsValue VmRef => PsValue.Raw("$vm"); + private static string L(string value) => PowerShellRunner.Literal(value); + private static string B(bool value) => value ? "$true" : "$false"; - public Task InfoAsync(string machine, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machine).Returns(""" - @{ vm=($vm | Select-Object Id,Name,State,Generation,Version,ProcessorCount,MemoryStartup,MemoryAssigned,MemoryMinimum,MemoryMaximum,DynamicMemoryEnabled,CheckpointType,ParentSnapshotId,Path); - firmware=$(if($vm.Generation -eq 2){ Get-VMFirmware -VM $vm | Select-Object @{Name='SecureBoot';Expression={$_.SecureBoot.ToString()}},SecureBootTemplate,PreferredNetworkBootProtocol }else{$null}); - network=@(Get-VMNetworkAdapter -VM $vm | Select-Object Id,Name,SwitchName,MacAddress,IPAddresses,Status); - disks=@(Get-VMHardDiskDrive -VM $vm | Select-Object Path,ControllerType,ControllerNumber,ControllerLocation); - dvd=@(Get-VMDvdDrive -VM $vm | Select-Object Path,ControllerNumber,ControllerLocation); - com=@(Get-VMComPort -VM $vm | Select-Object Name,Path); - integration=@(Get-VMIntegrationService -VM $vm | Select-Object Id,Name,Enabled,PrimaryStatusDescription) - } - """), ct); + /// A script with the shared lifecycle functions loaded. + private static HyperVScript Script() => new HyperVScript().Line(VmScriptLibrary.Functions); + + /// One command rendered by , for use inside a larger line. + private static string Render(string command, params (string Name, PsValue? Value)[] parameters) => + new HyperVScript().Cmd(command, parameters).Body.TrimEnd('\n'); + + /// A PowerShell array of string literals. + private static string Array(IEnumerable values) => "@(" + string.Join(", ", values.Select(L)) + ")"; + + private static string Choice(string? value, string[] allowed, string parameter) => + value is not null && allowed.Contains(value, StringComparer.Ordinal) ? value + : throw new ArgumentException($"{parameter} must be one of: {string.Join(", ", allowed)}."); - public Task CreateAsync(string name, string path, string vhdPath, int generation, int memoryMb, int processors, int newDiskGb, string? switchName, CancellationToken ct) + private static void Memory(int megabytes, string parameter) { - ControlService.FullPath(path); ControlService.FullPath(vhdPath); - if (string.IsNullOrWhiteSpace(name) || generation is < 1 or > 2 || memoryMb is < 32 or > 1048576 || processors is < 1 or > 2048 || newDiskGb < 0) - throw new ArgumentException("Invalid VM name, generation, memory, processors or disk size."); - var script = new HyperVScript() - .Cmd("$vm = New-VM", - ("Name", PsValue.Of(name)), ("Path", PsValue.Of(path)), ("Generation", PsValue.Of(generation)), - ("MemoryStartupBytes", PsValue.Megabytes(memoryMb)), - newDiskGb == 0 ? ("VHDPath", PsValue.Of(vhdPath)) : ("NewVHDPath", PsValue.Of(vhdPath)), - ("NewVHDSizeBytes", newDiskGb == 0 ? null : PsValue.Gigabytes(newDiskGb)), - ("SwitchName", switchName is null ? null : PsValue.Of(switchName))) - .Cmd("Set-VMProcessor", ("VM", VmRef), ("Count", PsValue.Of(processors))) - .Returns(VmProjection); - return runner.InvokeAsync(script, ct); + if (megabytes is < 32 or > 1048576 || megabytes % 2 != 0) + throw new ArgumentException($"{parameter} must be an even number of MB from 32 to 1048576."); } - /// Unregisters an Off VM. Disk files are never deleted here. - public Task RemoveAsync(Guid machineId, CancellationToken ct) => runner.InvokeAsync( - new HyperVScript().Vm(machineId).RequireOff().Cmd("Remove-VM", ("VM", VmRef), ("Force", PsValue.Of(true))).Returns(Success), ct, 300); + private static string Extension(string path) => System.IO.Path.GetExtension(path).ToLowerInvariant(); - public Task ConfigureAsync(string machine, int? processors, int? memoryMb, bool? dynamicMemory, string? checkpointType, string? newName, CancellationToken ct) + private static void VmName(string? name, string parameter) { - if (processors is < 1 or > 2048) throw new ArgumentException("Invalid processors."); - if (memoryMb is < 32 or > 1048576) throw new ArgumentException("Invalid memoryMb."); - var script = new HyperVScript().Vm(machine).RequireOff(); - if (processors is not null) script.Cmd("Set-VMProcessor", ("VM", VmRef), ("Count", PsValue.Of(processors.Value))); - if (memoryMb is not null) script.Cmd("Set-VMMemory", ("VM", VmRef), ("StartupBytes", PsValue.Megabytes(memoryMb.Value))); - if (dynamicMemory is not null) script.Cmd("Set-VMMemory", ("VM", VmRef), ("DynamicMemoryEnabled", PsValue.Of(dynamicMemory.Value))); - if (checkpointType is not null) script.Cmd("Set-VM", ("VM", VmRef), ("CheckpointType", PsValue.Token(checkpointType, CheckpointTypes))); - if (newName is not null) script.Cmd("Rename-VM", ("VM", VmRef), ("NewName", PsValue.Of(newName))); - return runner.InvokeAsync(script.Line(Refresh).Returns(VmProjection), ct); + if (string.IsNullOrWhiteSpace(name) || name.Length > 100 || name != name.Trim() || name.EndsWith('.') + || name.IndexOfAny(['\\', '/', ':', '*', '?', '"', '<', '>', '|']) >= 0 || name.Any(char.IsControl)) + throw new ArgumentException($"{parameter} must be 1-100 characters without \\ / : * ? \" < > |, control characters, or leading/trailing spaces (the name becomes a folder)."); } - public Task ExportAsync(string machine, string destination, CancellationToken ct) => runner.InvokeAsync( - new HyperVScript().Vm(machine).Cmd("Export-VM", ("VM", VmRef), ("Path", PsValue.Of(ControlService.FullPath(destination)))).Returns(Success), ct, 1800); - - public Task ImportAsync(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => runner.InvokeAsync( - new HyperVScript().Cmd("$vm = Import-VM", - ("Path", PsValue.Of(ControlService.FullPath(configurationPath))), ("Copy", PsValue.Of(true)), ("GenerateNewId", PsValue.Of(true)), - ("VirtualMachinePath", PsValue.Of(ControlService.FullPath(vmDestination))), ("VhdDestinationPath", PsValue.Of(ControlService.FullPath(vhdDestination)))) - .Returns(VmProjection), ct, 1800); + public Task InfoAsync(string machine, CancellationToken ct) => runner.InvokeAsync(Script().Vm(machine).Line(""" + $__memory = $null; $__processor = $null; $__bios = $null + try { $__memory = Get-VMMemory -VM $vm } catch { } + try { $__processor = Get-VMProcessor -VM $vm } catch { } + if ([int]$vm.Generation -ne 2) { + try { $__biosInfo = Get-VMBios -VM $vm; $__bios = [ordered]@{ StartupOrder = @($__biosInfo.StartupOrder | ForEach-Object { [string]$_ }); NumLockEnabled = [bool]$__biosInfo.NumLockEnabled } } catch { } + } + $__info = [ordered]@{ + vm = ($vm | Select-Object Id,Name,State,Generation,Version,ProcessorCount,MemoryStartup,MemoryAssigned,MemoryMinimum,MemoryMaximum,DynamicMemoryEnabled,CheckpointType,ParentSnapshotId,Path) + state = [string]$vm.State + configurationVersion = Get-ControlText $vm.Version + creationTime = $(if ($null -ne $vm.CreationTime) { ([datetime]$vm.CreationTime).ToUniversalTime().ToString('o') } else { $null }) + notes = [string]$vm.Notes + memory = [ordered]@{ + startupBytes = [long]$vm.MemoryStartup; assignedBytes = [long]$vm.MemoryAssigned + dynamicMemoryEnabled = [bool]$vm.DynamicMemoryEnabled; minimumBytes = [long]$vm.MemoryMinimum; maximumBytes = [long]$vm.MemoryMaximum + bufferPercent = $(if ($null -ne $__memory) { [int]$__memory.Buffer } else { $null }) + } + processor = [ordered]@{ + count = [int]$vm.ProcessorCount + nestedVirtualization = $(if ($null -ne $__processor) { [bool]$__processor.ExposeVirtualizationExtensions } else { $null }) + reservePercent = $(if ($null -ne $__processor) { [long]$__processor.Reserve } else { $null }) + limitPercent = $(if ($null -ne $__processor) { [long]$__processor.Maximum } else { $null }) + relativeWeight = $(if ($null -ne $__processor) { [int]$__processor.RelativeWeight } else { $null }) + } + automaticActions = [ordered]@{ + startAction = Get-ControlText $vm.AutomaticStartAction; startDelaySeconds = [int]$vm.AutomaticStartDelay + stopAction = Get-ControlText $vm.AutomaticStopAction; checkpointsEnabled = [bool]$vm.AutomaticCheckpointsEnabled + } + checkpointType = Get-ControlText $vm.CheckpointType + checkpointCount = @(Get-VMSnapshot -VM $vm).Count + enhancedSessionTransport = Get-ControlText $vm.EnhancedSessionTransportType + firmware = (ConvertTo-ControlFirmware $vm) + bios = $__bios + security = (ConvertTo-ControlSecurity $vm) + network = @(Get-VMNetworkAdapter -VM $vm | ForEach-Object { ConvertTo-ControlAdapter $_ }) + disks = @(Get-VMHardDiskDrive -VM $vm | ForEach-Object { ConvertTo-ControlDrive $_ }) + dvd = @(Get-VMDvdDrive -VM $vm | Select-Object Path,ControllerNumber,ControllerLocation) + com = @(Get-VMComPort -VM $vm | Select-Object Name,Path) + integration = @(Get-VMIntegrationService -VM $vm | Select-Object Id,Name,Enabled,PrimaryStatusDescription) + } + """).Returns("$__info"), ct); - public Task ListSwitchesAsync(CancellationToken ct) => runner.InvokeAsync( - new HyperVScript().Returns("@(Get-VMSwitch | Select-Object Id,Name,SwitchType,NetAdapterInterfaceDescription)"), ct); + /// Host defaults for hyperv_capabilities: default paths, configuration versions, + /// Secure Boot templates, the Default Switch and free space on the default volumes. + public Task HostDefaultsAsync(CancellationToken ct) => runner.InvokeAsync(Script().Line($$""" + $__host = Get-VMHost + $__versions = @(Get-VMHostSupportedVersion | ForEach-Object { [string]$_.Version }) + $__default = Get-VMHostSupportedVersion -Default + $__switch = @(Get-VMSwitch | Where-Object { [string]$_.Id -eq '{{VmScriptLibrary.DefaultSwitchId}}' }) | Select-Object -First 1 + $__defaults = [ordered]@{ + virtualMachinePath = [string]$__host.VirtualMachinePath + virtualHardDiskPath = [string]$__host.VirtualHardDiskPath + configurationVersions = [ordered]@{ supported = $__versions; default = $(if ($null -ne $__default) { [string]$__default.Version } else { $null }) } + secureBootTemplates = @($__host.SecureBootTemplates | Where-Object { $null -ne $_ } | ForEach-Object { if ($null -ne $_.PSObject.Properties['Name']) { [string]$_.Name } else { [string]$_ } }) + defaultSwitch = [ordered]@{ present = ($null -ne $__switch); id = '{{VmScriptLibrary.DefaultSwitchId}}'; name = $(if ($null -ne $__switch) { [string]$__switch.Name } else { $null }) } + freeSpace = @(@([string]$__host.VirtualMachinePath, [string]$__host.VirtualHardDiskPath) | ForEach-Object { Get-ControlFreeSpace $_ } | Where-Object { $null -ne $_ }) + } + """).Returns("$__defaults"), ct); - public Task CreateSwitchAsync(string name, string switchType, CancellationToken ct) + public Task CreateAsync(VmCreateRequest r, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name and Private/Internal switchType required."); - return runner.InvokeAsync(new HyperVScript() - .Cmd("$switch = New-VMSwitch", ("Name", PsValue.Of(name)), ("SwitchType", PsValue.Token(switchType, "Private", "Internal"))) - .Returns("($switch | Select-Object Id,Name,SwitchType)"), ct); + VmName(r.Name, "name"); + var source = Choice(r.Source, Sources, "source"); + var os = Choice(r.OsFamily, OsFamilies, "osFamily"); + Choice(r.CheckpointType, CheckpointTypes, "checkpointType"); + if (r.Generation is not (1 or 2)) throw new ArgumentException("generation must be 1 or 2."); + var gen2 = r.Generation == 2; + Memory(r.MemoryMb, "memoryMb"); + if (r.Processors is < 1 or > 2048 || (!gen2 && r.Processors > 64)) throw new ArgumentException("processors must be 1-2048 (1-64 on Generation 1)."); + if (r.Path is not null) ControlService.FullPath(r.Path); + if (r.VhdPath is not null) ControlService.FullPath(r.VhdPath); + if (r.ParentPath is not null) ControlService.FullPath(r.ParentPath); + if (r.IsoPath is not null) ControlService.FullPath(r.IsoPath); + if (r.IsoPath is not null && source != "iso") throw new ArgumentException("isoPath applies only to source=iso."); + if (r.ParentPath is not null && source != "linked") throw new ArgumentException("parentPath applies only to source=linked."); + if (r.VhdPath is not null && source == "none") throw new ArgumentException("source=none creates no disk; omit vhdPath."); + if (source is "new_disk" or "iso" && r.DiskGb is < 1 or > 65536) throw new ArgumentException("diskGb must be 1-65536."); + + int? minMb = null, maxMb = null; + if (r.DynamicMemory) + { + minMb = r.MemoryMinMb ?? Math.Min(512, r.MemoryMb); + maxMb = r.MemoryMaxMb ?? 1048576; + Memory(minMb.Value, "memoryMinMb"); Memory(maxMb.Value, "memoryMaxMb"); + if (minMb > r.MemoryMb || r.MemoryMb > maxMb) throw new ArgumentException("Dynamic memory needs memoryMinMb <= memoryMb <= memoryMaxMb."); + } + else if (r.MemoryMinMb is not null || r.MemoryMaxMb is not null) throw new ArgumentException("memoryMinMb and memoryMaxMb need dynamicMemory=true."); + + switch (source) + { + case "new_disk" or "iso" when r.VhdPath is not null && Extension(r.VhdPath) != ".vhdx": + throw new ArgumentException("A new disk must be a .vhdx file."); + case "iso" when r.IsoPath is null || Extension(r.IsoPath) != ".iso": + throw new ArgumentException("source=iso needs isoPath to an .iso file."); + case "existing_vhd" when r.VhdPath is null || Extension(r.VhdPath) is not (".vhdx" or ".vhd"): + throw new ArgumentException("source=existing_vhd needs vhdPath to a .vhdx (or, for Generation 1, .vhd) file."); + case "linked" when r.ParentPath is null || Extension(r.ParentPath) is not (".vhdx" or ".vhd"): + throw new ArgumentException("source=linked needs parentPath to the read-only parent .vhdx."); + case "linked" when r.VhdPath is not null && Extension(r.VhdPath) != Extension(r.ParentPath!): + throw new ArgumentException("A differencing disk must use its parent's format (.vhdx or .vhd)."); + } + var disk = source switch { "existing_vhd" => r.VhdPath, "linked" => r.ParentPath, _ => null }; + if (gen2 && disk is not null && Extension(disk) == ".vhd") throw new ArgumentException("Generation 2 VMs need .vhdx disks."); + if (!gen2 && r.SecureBoot == true) throw new ArgumentException("Secure Boot needs a Generation 2 VM."); + if (!gen2 && r.Tpm == true) throw new ArgumentException("A virtual TPM needs a Generation 2 VM."); + var secureBoot = gen2 && (r.SecureBoot ?? os != "other"); + var template = os == "windows" ? "MicrosoftWindows" : "MicrosoftUEFICertificateAuthority"; + var tpm = gen2 && (r.Tpm ?? os == "windows"); + + var s = Script() + .Line("$__warnings = New-Object System.Collections.ArrayList") + .Line("$__created = New-Object System.Collections.ArrayList") + .Line("$__name = " + L(r.Name)) + .Line("$__host = Get-VMHost") + .Line("$__vmRoot = " + (r.Path is null ? "[string]$__host.VirtualMachinePath" : L(r.Path))) + .Line("$__vhdRoot = " + (r.Path is null ? "[string]$__host.VirtualHardDiskPath" : "[System.IO.Path]::Combine($__vmRoot, $__name, 'Virtual Hard Disks')")) + .Line("$__same = @(Hyper-V\\Get-VM | Where-Object { $_.Name -eq $__name })"); + s.Line(r.AllowDuplicateName + ? "if ($__same.Count -gt 0) { [void]$__warnings.Add('Another VM already has this name; use this VM''s ID in later calls.') }" + : "if ($__same.Count -gt 0) { Stop-Control 'InvalidArgument' (\"A VM named '\" + $__name + \"' already exists (ID \" + $__same[0].Id + \"). Choose another name, or pass allowDuplicateName=true; later calls must then use the VM ID.\") }"); + + s.Line("$__switches = @(Get-VMSwitch)"); + if (r.SwitchName is null) + s.Line($$""" + $__switch = @($__switches | Where-Object { [string]$_.Id -eq '{{VmScriptLibrary.DefaultSwitchId}}' }) | Select-Object -First 1 + if ($null -eq $__switch) { + $__switch = $__switches | Select-Object -First 1 + if ($null -eq $__switch) { [void]$__warnings.Add('No virtual switch exists; the network adapter is not connected.') } + else { [void]$__warnings.Add("The Default Switch is missing; the network adapter is connected to '" + $__switch.Name + "'.") } + } + """); + else if (r.SwitchName.Length == 0) s.Line("$__switch = $null"); + else s.Line("$__switch = @($__switches | Where-Object { $_.Name -eq " + L(r.SwitchName) + " })") + .Line("if ($__switch.Count -eq 0) { Stop-Control 'NotFound' ('Virtual switch ' + " + L(L(r.SwitchName)) + " + ' not found. Call hyperv_network action=list.') }") + .Line("if ($__switch.Count -gt 1) { Stop-Control 'AmbiguousName' 'Several virtual switches have that name.' }") + .Line("$__switch = $__switch[0]"); + + switch (source) + { + case "new_disk" or "iso": + s.Line("$__disk = " + (r.VhdPath is null ? "[System.IO.Path]::Combine($__vhdRoot, $__name + '.vhdx')" : L(r.VhdPath))) + .Line("if ([System.IO.File]::Exists($__disk)) { Stop-Control 'InvalidArgument' ('Disk file already exists: ' + $__disk + '. Pass another vhdPath, or source=existing_vhd to use it.') }"); + if (source == "iso") + s.Line("$__iso = " + L(r.IsoPath!)) + .Line("if (-not [System.IO.File]::Exists($__iso)) { Stop-Control 'NotFound' ('ISO not found: ' + $__iso) }"); + s.Line($"Assert-ControlFreeSpace $__disk ([long]{r.DiskGb}*1GB) 'A {r.DiskGb} GB disk'"); + break; + case "existing_vhd": + s.Line("$__disk = " + L(r.VhdPath!)) + .Line("if (-not [System.IO.File]::Exists($__disk)) { Stop-Control 'NotFound' ('Virtual disk not found: ' + $__disk) }") + .Line("$__reason = Get-ControlReferenceReason (Get-ControlDiskReferences $null) (Get-ControlPathKey $__disk)") + .Line("if ($null -ne $__reason) { Stop-Control 'InvalidArgument' ('Cannot use ' + $__disk + ': it is ' + $__reason + '. Writing to it would corrupt that VM or its children.') }"); + break; + case "linked": + s.Line("$__parent = " + L(r.ParentPath!)) + .Line("if (-not [System.IO.File]::Exists($__parent)) { Stop-Control 'NotFound' ('Parent disk not found: ' + $__parent) }") + .Line("$__disk = " + (r.VhdPath is null ? "[System.IO.Path]::Combine($__vhdRoot, $__name + [System.IO.Path]::GetExtension($__parent))" : L(r.VhdPath))) + .Line("if ([System.IO.File]::Exists($__disk)) { Stop-Control 'InvalidArgument' ('Disk file already exists: ' + $__disk + '. Pass another vhdPath.') }") + .Line("$__users = (Get-ControlDiskReferences $null).Map[(Get-ControlPathKey $__parent)]") + .Line("$__attached = @($__users | Where-Object { $null -ne $_ -and $_.Attached })") + .Line("if ($__attached.Count -gt 0) { Stop-Control 'InvalidArgument' ('Parent ' + $__parent + ' is ' + (Format-ControlReference $__attached[0]) + '; a linked parent must never change. Use a disk no VM attaches, for example a template from hyperv_vm_clone.') }") + .Line("Assert-ControlFreeSpace $__disk ([long](Get-VHD -Path $__parent).Size) ('A differencing disk over ' + $__parent)"); + break; + } + + (string, PsValue?)[] newVm = + [ + ("Name", PsValue.Raw("$__name")), ("Path", PsValue.Raw("$__vmRoot")), ("Generation", PsValue.Of(r.Generation)), + ("MemoryStartupBytes", PsValue.Megabytes(r.MemoryMb)), + ("NewVHDPath", source is "new_disk" or "iso" ? PsValue.Raw("$__disk") : null), + ("NewVHDSizeBytes", source is "new_disk" or "iso" ? PsValue.Gigabytes(r.DiskGb) : null), + ("VHDPath", source is "existing_vhd" or "linked" ? PsValue.Raw("$__disk") : null), + ("NoVHD", source == "none" ? PsValue.Of(true) : null), + ]; + s.Line("$vm = $null").Line("try {") + .Line("if (-not [System.IO.Directory]::Exists($__vmRoot)) { [void][System.IO.Directory]::CreateDirectory($__vmRoot) }"); + if (source != "none" && source != "existing_vhd") + s.Line("[void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($__disk))"); + if (source == "linked") + s.Cmd("$null = New-VHD", ("Path", PsValue.Raw("$__disk")), ("ParentPath", PsValue.Raw("$__parent")), ("Differencing", PsValue.Of(true))).Line("[void]$__created.Add($__disk)"); + s.Line("if ($null -ne $__switch) { " + Render("$vm = New-VM", [.. newVm, ("SwitchName", PsValue.Raw("$__switch.Name"))]) + " } else { " + Render("$vm = New-VM", newVm) + " }"); + if (source is "new_disk" or "iso") s.Line("[void]$__created.Add($__disk)"); + s.Cmd("Set-VMProcessor", ("VM", VmRef), ("Count", PsValue.Of(r.Processors))); + if (r.DynamicMemory) + s.Cmd("Set-VMMemory", ("VM", VmRef), ("DynamicMemoryEnabled", PsValue.Of(true)), + ("MinimumBytes", PsValue.Megabytes(minMb!.Value)), ("MaximumBytes", PsValue.Megabytes(maxMb!.Value))); + s.Cmd("Set-VM", ("VM", VmRef), ("CheckpointType", PsValue.Token(r.CheckpointType, CheckpointTypes)), + ("AutomaticCheckpointsEnabled", PsValue.Of(r.AutoCheckpoints)), ("Notes", r.Notes is null ? null : PsValue.Of(r.Notes)), + ("EnhancedSessionTransportType", os == "linux" ? PsValue.Token("HvSocket", SessionTransports) : null)); + if (gen2) + { + // The template locks once the vTPM is initialized, so it is set first. + s.Cmd("Set-VMFirmware", ("VM", VmRef), ("SecureBootTemplate", PsValue.Of(template))) + .Cmd("Set-VMFirmware", ("VM", VmRef), ("EnableSecureBoot", PsValue.Token(secureBoot ? "On" : "Off", "On", "Off"))); + if (tpm) s.Cmd("Set-VMKeyProtector", ("VM", VmRef), ("NewLocalKeyProtector", PsValue.Of(true))).Cmd("Enable-VMTPM", ("VM", VmRef)); + } + if (source == "iso" && gen2) + // Creating a Gen2 VM with -BootDevice CD plus a disk fails (both claim SCSI 0:0): add the drive afterwards. + s.Cmd("$__dvd = Add-VMDvdDrive", ("VM", VmRef), ("Path", PsValue.Raw("$__iso")), ("Passthru", PsValue.Of(true))) + .Cmd("Set-VMFirmware", ("VM", VmRef), ("FirstBootDevice", PsValue.Raw("$__dvd"))); + else if (source == "iso") + // Generation 1 VMs get an IDE DVD drive from New-VM and boot from CD first by default. + s.Line("$__dvd = @(Get-VMDvdDrive -VM $vm) | Select-Object -First 1") + .Line("if ($null -eq $__dvd) { " + Render("Add-VMDvdDrive", ("VM", VmRef), ("Path", PsValue.Raw("$__iso"))) + " } else { " + + Render("Set-VMDvdDrive", ("VMDvdDrive", PsValue.Raw("$__dvd")), ("Path", PsValue.Raw("$__iso"))) + " }"); + if (source == "linked") + s.Line("Set-ControlReadOnly $__parent $true") + .Line("[void]$__warnings.Add('Marked the parent ' + $__parent + ' read-only: changing it would break this VM''s differencing disk.')"); + s.Line("} catch {") + .Line("$__failure = $_") + .Line("if ($null -ne $vm) { try { Remove-VM -VM $vm -Force } catch { } }") + .Line("foreach ($__file in @($__created)) { try { if ([System.IO.File]::Exists($__file)) { [System.IO.File]::Delete($__file) } } catch { } }") + .Line("throw $__failure") + .Line("}"); + if (r.Start) + s.Line("try { Start-VM -VM $vm } catch { [void]$__warnings.Add('The VM was created but did not start: ' + $_.Exception.Message) }"); + if (tpm) s.Line("[void]$__warnings.Add('The vTPM uses a local key protector: the VM starts only on hosts that hold this host''s Shielded VM guardian certificates.')"); + if (gen2 && !secureBoot) s.Line("[void]$__warnings.Add('Secure Boot is off.')"); + + s.Line(Refresh) + .Line("$__result = [ordered]@{}") + .Line("foreach ($__property in (ConvertTo-ControlVm $vm).PSObject.Properties) { $__result[$__property.Name] = $__property.Value }") + .Line("$__result.Firmware = (ConvertTo-ControlFirmware $vm)") + .Line("$__result.Security = (ConvertTo-ControlSecurity $vm)") + .Line("$__result.createdFiles = @($__created)") + .Line("$__result.warnings = @($__warnings)") + .Line("$__result.nextSteps = " + Array(CreateNextSteps(source, r.Start))); + return runner.InvokeAsync(s.Returns("$__result"), ct, 600); } - public Task RemoveSwitchAsync(string name, CancellationToken ct) => runner.InvokeAsync(new HyperVScript() - .Line("$switches = @(Get-VMSwitch | Where-Object { $_.Name -eq " + PowerShellRunner.Literal(name) + " })") - .Line("if ($switches.Count -ne 1) { Stop-Control 'NotFound' 'Switch missing or ambiguous' }") - .Cmd("$switches[0] | Remove-VMSwitch", ("Force", PsValue.Of(true))) - .Returns(Success), ct); + private static IEnumerable CreateNextSteps(string source, bool started) + { + switch (source) + { + case "new_disk": + yield return "The new disk is empty: attach installation media with hyperv_disk action=attach_iso and set hyperv_firmware firstBootDevice=dvd (or create with source=iso) before starting."; + break; + case "iso": + yield return started + ? "Open hyperv_console now and press a key: Windows setup ISOs wait only a few seconds at 'Press any key to boot from CD or DVD' before falling through to the empty disk." + : "Start with hyperv_power action=start, then open hyperv_console at once and press a key: Windows setup ISOs wait only a few seconds at 'Press any key to boot from CD or DVD'."; + break; + case "none": + yield return "The VM has no disk: create one with hyperv_vhd action=create and attach it with hyperv_disk action=attach_vhd before starting."; + break; + default: + if (!started) yield return "Start it with hyperv_power action=start; it boots from its disk."; + break; + } + yield return "Inspect the result with hyperv_vm_info; change hardware with hyperv_vm_configure, hyperv_firmware, hyperv_disk and hyperv_network."; + } - public Task ConnectAdapterAsync(string machine, string adapterName, string? switchName, CancellationToken ct) + /// + /// Removes a VM that is Off or Saved; never powers it off. With , its + /// checkpoints are removed first and their merges awaited, then the VM, then the disk files no other + /// VM or differencing child uses. runs a script with no mutation at all. + /// + public Task RemoveAsync(string machine, bool deleteDisks, bool dryRun, CancellationToken ct) { - var script = new HyperVScript().Vm(machine) - .Line("$adapters = @(Get-VMNetworkAdapter -VM $vm | Where-Object { $_.Name -eq " + PowerShellRunner.Literal(adapterName) + " })") - .Line("if ($adapters.Count -ne 1) { Stop-Control 'NotFound' 'Adapter missing or ambiguous' }"); - if (switchName is null) script.Cmd("Disconnect-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$adapters[0]"))); - else script.Cmd("Connect-VMNetworkAdapter", ("VMNetworkAdapter", PsValue.Raw("$adapters[0]")), ("SwitchName", PsValue.Of(switchName))); - return runner.InvokeAsync(script.Returns(Success), ct); + var s = Script().Vm(machine).RequireState(VmStateRules.StateNames("remove")) + .Line("$__warnings = New-Object System.Collections.ArrayList") + .Line("$__checkpoints = @(Get-VMSnapshot -VM $vm)"); + if (deleteDisks) + s.Line("if ($__checkpoints.Count -gt 0 -and [string]$vm.State -ne 'Off') { Stop-Control 'InvalidVmState' (\"VM '\" + $vm.Name + \"' is \" + $vm.State + ' and has ' + $__checkpoints.Count + ' checkpoint(s). Deleting its disks first removes the checkpoints and waits for their merge, which needs the VM Off. Turn it off (this discards the saved state) or remove it without deleteDisks. Nothing was changed.') }"); + else + s.Line("if ($__checkpoints.Count -gt 0) { [void]$__warnings.Add('Hyper-V merges this VM''s checkpoint files (.avhdx) into their parent disks in the background after removal; do not move or delete those disks until the merge finishes.') }"); + s.Line($"$__plan = Get-ControlRemovalPlan $vm {B(deleteDisks)}"); + if (dryRun) + return runner.InvokeAsync(s.Returns($"[ordered]@{{ success = $true; dryRun = $true; deleteDisks = {B(deleteDisks)}; vm = [ordered]@{{ id = [string]$vm.Id; name = [string]$vm.Name; state = [string]$vm.State }}; plan = $__plan; warnings = @($__warnings) }}"), ct, 300); + + s.Line("$__removedCheckpoints = 0").Line("$__merged = @($__plan.merge)"); + if (deleteDisks) + s.Line("if ($__checkpoints.Count -gt 0) {") + .Line("foreach ($__checkpoint in @($__checkpoints | Where-Object { -not $_.ParentSnapshotId })) { " + Render("Remove-VMSnapshot", ("VMSnapshot", PsValue.Raw("$__checkpoint")), ("IncludeAllChildSnapshots", PsValue.Of(true))) + " }") + .Line("foreach ($__checkpoint in @(Get-VMSnapshot -VM $vm)) { " + Render("Remove-VMSnapshot", ("VMSnapshot", PsValue.Raw("$__checkpoint"))) + " }") + .Line("$__removedCheckpoints = $__checkpoints.Count") + .Line("$vm = Wait-ControlMerge $vm 1500") + .Line("$__plan = Get-ControlRemovalPlan $vm $true") + .Line("}"); + s.Line("$__id = [string]$vm.Id; $__vmName = [string]$vm.Name; $__vmPath = [string]$vm.Path") + .Cmd("Remove-VM", ("VM", VmRef), ("Force", PsValue.Of(true))) + .Line("$__deleted = New-Object System.Collections.ArrayList") + .Line("$__kept = New-Object System.Collections.ArrayList") + .Line("foreach ($__entry in @($__plan.keep)) { [void]$__kept.Add($__entry) }") + .Line("$__removedDirectories = @()"); + if (deleteDisks) + s.Line(""" + $__after = Get-ControlDiskReferences $null + $__targets = @($__plan.delete) + @($__merged | Where-Object { [System.IO.File]::Exists($_) }) + foreach ($__file in $__targets) { + $__reason = Get-ControlReferenceReason $__after (Get-ControlPathKey $__file) + if ($null -ne $__reason) { [void]$__kept.Add([pscustomobject][ordered]@{ path = $__file; reason = $__reason }); continue } + try { + if ([System.IO.File]::Exists($__file)) { Set-ControlReadOnly $__file $false; Microsoft.PowerShell.Management\Remove-Item -LiteralPath $__file -Force -ErrorAction Stop } + if ([System.IO.File]::Exists($__file)) { [void]$__kept.Add([pscustomobject][ordered]@{ path = $__file; reason = 'still present after deletion' }) } + else { [void]$__deleted.Add($__file) } + } catch { [void]$__kept.Add([pscustomobject][ordered]@{ path = $__file; reason = 'deletion failed: ' + $_.Exception.Message }) } + } + $__hostPaths = Get-VMHost + $__removedDirectories = @(Remove-ControlVmDirectory $__vmPath $__vmName @([string]$__hostPaths.VirtualMachinePath, [string]$__hostPaths.VirtualHardDiskPath) | Where-Object { $null -ne $_ }) + """); + return runner.InvokeAsync(s.Returns($"[ordered]@{{ success = $true; dryRun = $false; deleteDisks = {B(deleteDisks)}; vm = [ordered]@{{ id = $__id; name = $__vmName }}; checkpointsRemoved = $__removedCheckpoints; deleted = @($__deleted); kept = @($__kept); removedDirectories = $__removedDirectories; warnings = @($__warnings) }}"), ct, 1800); } - /// Media changes: attach_vhd, detach_vhd, attach_iso, eject_iso, resize_vhd. Requires Off. - public Task DiskAsync(string machine, string action, string? path, long? sizeGb, CancellationToken ct) + public Task ConfigureAsync(string machine, int? processors, int? memoryMb, bool? dynamicMemory, string? checkpointType, string? newName, CancellationToken ct) => + ConfigureAsync(machine, new VmConfigureRequest { Processors = processors, MemoryMb = memoryMb, DynamicMemory = dynamicMemory, CheckpointType = checkpointType, NewName = newName }, ct); + + /// + /// Applies every requested setting the VM's current state allows, or refuses the whole request + /// before any change, listing the settings that need the VM Off. Returns the refreshed projection. + /// + public Task ConfigureAsync(string machine, VmConfigureRequest r, CancellationToken ct) { - if (action is not "eject_iso" && path == null) throw new ArgumentException("path required."); - var literal = path == null ? "" : PowerShellRunner.Literal(ControlService.FullPath(path)); - var script = new HyperVScript().Vm(machine).RequireOff(); - switch (action) + if (r.Processors is < 1 or > 2048) throw new ArgumentException("processors must be 1-2048."); + if (r.MemoryMb is { } startup) Memory(startup, "memoryMb"); + if (r.MemoryMinMb is { } minimum) Memory(minimum, "memoryMinMb"); + if (r.MemoryMaxMb is { } maximum) Memory(maximum, "memoryMaxMb"); + if (r.MemoryBufferPercent is < 5 or > 2000) throw new ArgumentException("memoryBufferPercent must be 5-2000."); + if (r.AutoStartDelaySeconds is < 0) throw new ArgumentException("autoStartDelaySeconds must be 0 or more."); + if (r.NewName is not null) VmName(r.NewName, "newName"); + if (r.CheckpointType is not null) Choice(r.CheckpointType, CheckpointTypes, "checkpointType"); + if (r.AutoStartAction is not null) Choice(r.AutoStartAction, StartActions, "autoStartAction"); + if (r.AutoStopAction is not null) Choice(r.AutoStopAction, StopActions, "autoStopAction"); + if (r.EnhancedSessionTransport is not null) Choice(r.EnhancedSessionTransport, SessionTransports, "enhancedSessionTransport"); + if (r.IntegrationServices is { } map && map.Keys.Any(string.IsNullOrWhiteSpace)) throw new ArgumentException("integrationServices names must not be empty."); + + PsValue? Mb(int? value) => value is null ? null : PsValue.Megabytes(value.Value); + var requested = new List<(string, PsValue?)>(); + void Want(bool present, string setting, PsValue? value = null) { if (present) requested.Add((setting, value)); } + Want(r.Processors is not null, "processors"); + Want(r.MemoryMb is not null, "memoryMb", Mb(r.MemoryMb)); + Want(r.DynamicMemory is not null, "dynamicMemory"); + Want(r.MemoryMinMb is not null, "memoryMinMb", Mb(r.MemoryMinMb)); + Want(r.MemoryMaxMb is not null, "memoryMaxMb", Mb(r.MemoryMaxMb)); + Want(r.MemoryBufferPercent is not null, "memoryBufferPercent"); + Want(r.CheckpointType is not null, "checkpointType"); + Want(r.AutoCheckpoints is not null, "autoCheckpoints"); + Want(r.NewName is not null, "newName"); + Want(r.Notes is not null, "notes"); + Want(r.AutoStartAction is not null, "autoStartAction"); + Want(r.AutoStartDelaySeconds is not null, "autoStartDelaySeconds"); + Want(r.AutoStopAction is not null, "autoStopAction"); + Want(r.NestedVirtualization is not null, "nestedVirtualization"); + Want(r.IntegrationServices is { Count: > 0 }, "integrationServices"); + Want(r.EnhancedSessionTransport is not null, "enhancedSessionTransport"); + + var s = Script().Vm(machine).Line(VmStateRules.Gate(requested)); + + // Validation against the VM's current values; still before any change. + var dynamicBounds = r.MemoryMinMb is not null || r.MemoryMaxMb is not null || r.MemoryBufferPercent is not null; + if (dynamicBounds || r.MemoryMb is not null || r.DynamicMemory is not null) { - case "attach_vhd": script.Cmd("Add-VMHardDiskDrive", ("VM", VmRef), ("Path", PsValue.Raw(literal))); break; - case "detach_vhd": script.Line($"Get-VMHardDiskDrive -VM $vm | Where-Object {{ $_.Path -eq {literal} }} | Remove-VMHardDiskDrive"); break; - case "attach_iso": script.Line($"$dvd = @(Get-VMDvdDrive -VM $vm); if ($dvd.Count -eq 0) {{ Add-VMDvdDrive -VM $vm -Path {literal} }} elseif ($dvd.Count -eq 1) {{ $dvd[0] | Set-VMDvdDrive -Path {literal} }} else {{ Stop-Control 'InvalidArgument' 'Multiple DVD drives; use an exact device configuration' }}"); break; - case "eject_iso": script.Line("Get-VMDvdDrive -VM $vm | Set-VMDvdDrive -Path $null"); break; - case "resize_vhd" when sizeGb > 0: - script.Line($"$disks = @(Get-VMHardDiskDrive -VM $vm | Where-Object {{ $_.Path -eq {literal} }}); if ($disks.Count -ne 1) {{ Stop-Control 'NotFound' 'Disk is not attached exactly once to this VM' }}") - .Cmd("Resize-VHD", ("Path", PsValue.Raw(literal)), ("SizeBytes", PsValue.Gigabytes(sizeGb.Value))); - break; - default: throw new ArgumentException("Unknown disk action or missing sizeGb."); + s.Line("$__dynamic = " + (r.DynamicMemory is { } dynamic ? B(dynamic) : "[bool]$vm.DynamicMemoryEnabled")) + .Line("$__startup = " + (Mb(r.MemoryMb)?.Text ?? "[long]$vm.MemoryStartup")) + .Line("$__minimum = " + (Mb(r.MemoryMinMb)?.Text ?? "[long]$vm.MemoryMinimum")) + .Line("$__maximum = " + (Mb(r.MemoryMaxMb)?.Text ?? "[long]$vm.MemoryMaximum")); + if (dynamicBounds) + s.Line("if (-not $__dynamic) { Stop-Control 'InvalidArgument' 'memoryMinMb, memoryMaxMb and memoryBufferPercent apply only with dynamic memory: pass dynamicMemory=true or enable it first. Nothing was changed.' }"); + s.Line("if ($__dynamic -and ($__minimum -gt $__startup -or $__startup -gt $__maximum)) { Stop-Control 'InvalidArgument' ('Dynamic memory needs minimum <= startup <= maximum; this request gives ' + ($__minimum / 1MB) + ' / ' + ($__startup / 1MB) + ' / ' + ($__maximum / 1MB) + ' MB. Nothing was changed.') }"); + } + var services = r.IntegrationServices?.ToList() ?? []; + if (services.Count > 0) + { + s.Line("$__services = @(Get-VMIntegrationService -VM $vm)"); + for (var i = 0; i < services.Count; i++) + { + var name = services[i].Key.Trim(); + var id = IntegrationServiceIds.TryGetValue(name, out var known) ? known : Guid.TryParse(name, out var guid) ? guid.ToString("D").ToUpperInvariant() : null; + var match = (id is null ? "" : $"([string]$_.Id).EndsWith('\\{id}', [System.StringComparison]::OrdinalIgnoreCase) -or ") + "$_.Name -eq " + L(name); + s.Line($"$__service{i} = @($__services | Where-Object {{ {match} }})") + .Line($"if ($__service{i}.Count -ne 1) {{ Stop-Control 'InvalidArgument' ('Integration service ' + {L(L(name))} + ' not found. This VM has: ' + (($__services | ForEach-Object {{ $_.Name }}) -join ', ') + '. Nothing was changed.') }}"); + } } - return runner.InvokeAsync(script.Returns(Success), ct, 300); + + if (r.Processors is not null || r.NestedVirtualization is not null) + s.Cmd("Set-VMProcessor", ("VM", VmRef), ("Count", r.Processors is { } count ? PsValue.Of(count) : null), + ("ExposeVirtualizationExtensions", r.NestedVirtualization is { } nested ? PsValue.Of(nested) : null)); + if (r.MemoryMb is not null || r.DynamicMemory is not null || dynamicBounds) + s.Cmd("Set-VMMemory", ("VM", VmRef), ("DynamicMemoryEnabled", r.DynamicMemory is { } dm ? PsValue.Of(dm) : null), + ("StartupBytes", Mb(r.MemoryMb)), ("MinimumBytes", Mb(r.MemoryMinMb)), ("MaximumBytes", Mb(r.MemoryMaxMb)), + ("Buffer", r.MemoryBufferPercent is { } buffer ? PsValue.Of(buffer) : null)); + if (r.CheckpointType is not null || r.AutoCheckpoints is not null || r.Notes is not null || r.AutoStartAction is not null + || r.AutoStartDelaySeconds is not null || r.AutoStopAction is not null || r.EnhancedSessionTransport is not null) + s.Cmd("Set-VM", ("VM", VmRef), + ("CheckpointType", r.CheckpointType is null ? null : PsValue.Token(r.CheckpointType, CheckpointTypes)), + ("AutomaticCheckpointsEnabled", r.AutoCheckpoints is { } auto ? PsValue.Of(auto) : null), + ("Notes", r.Notes is null ? null : PsValue.Of(r.Notes)), + ("AutomaticStartAction", r.AutoStartAction is null ? null : PsValue.Token(r.AutoStartAction, StartActions)), + ("AutomaticStartDelay", r.AutoStartDelaySeconds is { } delay ? PsValue.Of(delay) : null), + ("AutomaticStopAction", r.AutoStopAction is null ? null : PsValue.Token(r.AutoStopAction, StopActions)), + ("EnhancedSessionTransportType", r.EnhancedSessionTransport is null ? null : PsValue.Token(r.EnhancedSessionTransport, SessionTransports))); + for (var i = 0; i < services.Count; i++) + s.Cmd(services[i].Value ? "Enable-VMIntegrationService" : "Disable-VMIntegrationService", ("VM", VmRef), ("Name", PsValue.Raw($"$__service{i}[0].Name"))); + if (r.NewName is not null) s.Cmd("Rename-VM", ("VM", VmRef), ("NewName", PsValue.Of(r.NewName))); + return runner.InvokeAsync(s.Line(Refresh).Returns(Projection), ct); } - public Task PowerResetAsync(string machine, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machine) + public Task ExportAsync(string machine, string destination, CancellationToken ct) => runner.InvokeAsync( + new HyperVScript().Vm(machine).Cmd("Export-VM", ("VM", VmRef), ("Path", PsValue.Of(ControlService.FullPath(destination)))).Returns(Success), ct, 1800); + + public Task ImportAsync(string configurationPath, string vmDestination, string vhdDestination, CancellationToken ct) => runner.InvokeAsync( + Script().Cmd("$vm = Import-VM", + ("Path", PsValue.Of(ControlService.FullPath(configurationPath))), ("Copy", PsValue.Of(true)), ("GenerateNewId", PsValue.Of(true)), + ("VirtualMachinePath", PsValue.Of(ControlService.FullPath(vmDestination))), ("VhdDestinationPath", PsValue.Of(ControlService.FullPath(vhdDestination)))) + .Returns(Projection), ct, 1800); + + public Task PowerResetAsync(string machine, CancellationToken ct) => runner.InvokeAsync(Script().Vm(machine) .Cmd("Restart-VM", ("VM", VmRef), ("Force", PsValue.Of(true)), ("Confirm", PsValue.Of(false))) - .Line(Refresh).Returns(VmProjection), ct); + .Line(Refresh).Returns(Projection), ct); public Task RenameCheckpointAsync(Guid machineId, Guid checkpointId, string newName, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machineId) - .Line($"Get-VMSnapshot -VM $vm | Where-Object {{ $_.Id -eq ([guid]'{checkpointId:D}') }} | Rename-VMSnapshot -NewName {PowerShellRunner.Literal(newName)}") + .Line($"Get-VMSnapshot -VM $vm | Where-Object {{ $_.Id -eq ([guid]'{checkpointId:D}') }} | Rename-VMSnapshot -NewName {L(newName)}") .Returns(Success), ct); - /// Maps a COM port to a named pipe on an Off VM and reads the port back. - public Task SetComPortAsync(Guid machineId, int number, string pipePath, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machineId).RequireOff() + /// Maps a COM port to a named pipe and reads the port back. + public Task SetComPortAsync(Guid machineId, int number, string pipePath, CancellationToken ct) => runner.InvokeAsync(new HyperVScript().Vm(machineId) + .RequireState(VmStateRules.StateNames("comPort")) .Cmd("Set-VMComPort", ("VM", VmRef), ("Number", PsValue.Of(number)), ("Path", PsValue.Of(pipePath))) .Returns($"(Get-VMComPort -VM $vm -Number {number} | Select-Object Name,Path)"), ct); } diff --git a/src/HyperVControl/Services/VmScriptLibrary.cs b/src/HyperVControl/Services/VmScriptLibrary.cs new file mode 100644 index 0000000..646246a --- /dev/null +++ b/src/HyperVControl/Services/VmScriptLibrary.cs @@ -0,0 +1,365 @@ +namespace HyperVControl.Services; + +/// +/// PowerShell functions shared by the lifecycle scripts: projections, the state gate, virtual disk +/// chains and references, removal planning, merge waiting and preflight checks. Constant text with no +/// user input; scripts include it after the module bootstrap, so it runs against the real or mock module. +/// +internal static class VmScriptLibrary +{ + internal const string DefaultSwitchId = "c08cb7b8-9b3c-408e-8e30-5e16a3aeb444"; + + internal const string Functions = + """ + function Get-ControlText($Value) { if ($null -eq $Value) { return $null }; return [string]$Value } + + function Get-ControlPathKey([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { return '' } + $full = $Path + try { $full = [System.IO.Path]::GetFullPath($Path) } catch { } + return $full.TrimEnd('\').ToLowerInvariant() + } + + # The differencing chain of one virtual disk, the file itself first. A link that cannot be read + # ends the chain with an entry carrying Error instead of failing the caller. + function Get-ControlVhdChain([string]$Path) { + $chain = New-Object System.Collections.ArrayList + $seen = @{} + $current = $Path + while (-not [string]::IsNullOrWhiteSpace($current) -and $chain.Count -lt 64) { + $key = Get-ControlPathKey $current + if ($seen.ContainsKey($key)) { break } + $seen[$key] = $true + try { $disk = Get-VHD -Path $current -ErrorAction Stop } + catch { + [void]$chain.Add([pscustomobject]@{ Path = $current; VhdFormat = $null; VhdType = $null; ParentPath = $null; Size = $null; FileSize = $null; Error = [string]$_.Exception.Message }) + break + } + [void]$chain.Add($disk) + $current = [string]$disk.ParentPath + } + return $chain.ToArray() + } + + function ConvertTo-ControlVhdLink($Disk) { + $problem = $null + if ($null -ne $Disk.PSObject.Properties['Error']) { $problem = [string]$Disk.Error } + [pscustomobject][ordered]@{ + Path = [string]$Disk.Path + Format = Get-ControlText $Disk.VhdFormat + Type = Get-ControlText $Disk.VhdType + SizeBytes = $(if ($null -ne $Disk.Size) { [long]$Disk.Size } else { $null }) + FileSizeBytes = $(if ($null -ne $Disk.FileSize) { [long]$Disk.FileSize } else { $null }) + ParentPath = $(if ([string]::IsNullOrWhiteSpace([string]$Disk.ParentPath)) { $null } else { [string]$Disk.ParentPath }) + Error = $problem + } + } + + # Every virtual disk file a VM other than $ExceptVm uses: attached as a current or checkpoint drive, + # or an ancestor (differencing parent) of such a file. Map: path key -> list of references. + function Get-ControlDiskReferences($ExceptVm) { + $map = @{} + $unreadable = @() + $machines = @(Hyper-V\Get-VM -ErrorAction SilentlyContinue -ErrorVariable unreadable) + foreach ($other in $machines) { + if ($null -ne $ExceptVm -and [string]$other.Id -eq [string]$ExceptVm.Id) { continue } + $drives = @(Get-VMHardDiskDrive -VM $other) + foreach ($checkpoint in @(Get-VMSnapshot -VM $other)) { $drives += @(Get-VMHardDiskDrive -VMSnapshot $checkpoint) } + foreach ($drive in $drives) { + $leaf = [string]$drive.Path + if ([string]::IsNullOrWhiteSpace($leaf)) { continue } + $links = @(Get-ControlVhdChain $leaf) + for ($i = 0; $i -lt $links.Count; $i++) { + $key = Get-ControlPathKey ([string]$links[$i].Path) + if (-not $map.ContainsKey($key)) { $map[$key] = New-Object System.Collections.ArrayList } + [void]$map[$key].Add([pscustomobject]@{ VmId = [string]$other.Id; VmName = [string]$other.Name; Attached = ($i -eq 0); Via = $leaf }) + } + } + } + return [pscustomobject]@{ Map = $map; Unreadable = @($unreadable).Count } + } + + function Format-ControlReference($Reference) { + if ($Reference.Attached) { return "attached to VM '" + $Reference.VmName + "' (" + $Reference.VmId + ")" } + return 'differencing parent of ' + $Reference.Via + " (VM '" + $Reference.VmName + "')" + } + + # Why a file is in use according to $References, an attachment before a parent link; $null if unused. + function Get-ControlReferenceReason($References, [string]$Key) { + if (-not $References.Map.ContainsKey($Key)) { return $null } + $list = @($References.Map[$Key]) + $attached = @($list | Where-Object { $_.Attached }) + if ($attached.Count -gt 0) { return Format-ControlReference $attached[0] } + return Format-ControlReference $list[0] + } + + # Virtual disks in the given directories, other than $Exclude, whose differencing chain passes through + # one of the given files, whether or not a VM attaches them. Map: parent key -> child file. Best effort: + # children elsewhere on the host are not discovered. + function Find-ControlUnattachedChildren([string[]]$Paths, [string[]]$Directories, [string[]]$Exclude = @()) { + $targets = @{} + $skip = @{} + foreach ($path in $Paths) { if (-not [string]::IsNullOrWhiteSpace($path)) { $targets[(Get-ControlPathKey $path)] = $true; $skip[(Get-ControlPathKey $path)] = $true } } + foreach ($path in $Exclude) { if (-not [string]::IsNullOrWhiteSpace($path)) { $skip[(Get-ControlPathKey $path)] = $true } } + $found = @{} + $scanned = @{} + foreach ($directory in $Directories) { + if ([string]::IsNullOrWhiteSpace($directory)) { continue } + $directoryKey = Get-ControlPathKey $directory + if ($scanned.ContainsKey($directoryKey) -or -not [System.IO.Directory]::Exists($directory)) { continue } + $scanned[$directoryKey] = $true + foreach ($file in [System.IO.Directory]::GetFiles($directory)) { + if ($file -notmatch '\.(vhdx?|avhdx?)$' -or $skip.ContainsKey((Get-ControlPathKey $file))) { continue } + $links = @(Get-ControlVhdChain $file) + for ($i = 1; $i -lt $links.Count; $i++) { + $key = Get-ControlPathKey ([string]$links[$i].Path) + if ($targets.ContainsKey($key) -and -not $found.ContainsKey($key)) { $found[$key] = $file } + } + } + } + return $found + } + + function Get-ControlFreeSpace([string]$Path) { + try { + $root = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Path)) + $drive = New-Object System.IO.DriveInfo $root + if (-not $drive.IsReady) { return $null } + return [pscustomobject][ordered]@{ path = $Path; root = $root; freeBytes = [long]$drive.AvailableFreeSpace; totalBytes = [long]$drive.TotalSize } + } catch { return $null } + } + + # Refuses before any change when the volume holding $Path has less than $Bytes free. Paths without + # a readable local volume (shares) are not checked. + function Assert-ControlFreeSpace([string]$Path, [long]$Bytes, [string]$What) { + $space = Get-ControlFreeSpace $Path + if ($null -eq $space -or $space.freeBytes -ge $Bytes) { return } + Stop-Control 'InvalidArgument' ($What + ' needs ' + [math]::Round($Bytes / 1GB, 1) + ' GB but ' + $space.root + ' has ' + [math]::Round($space.freeBytes / 1GB, 1) + ' GB free. Nothing was changed.') + } + + function Set-ControlReadOnly([string]$Path, [bool]$ReadOnly) { + $attributes = [System.IO.File]::GetAttributes($Path) + if ($ReadOnly) { $attributes = $attributes -bor [System.IO.FileAttributes]::ReadOnly } + else { $attributes = $attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) } + [System.IO.File]::SetAttributes($Path, [System.IO.FileAttributes]$attributes) + } + + # A VM without a key protector returns only a short empty marker from Get-VMKeyProtector. + function Test-ControlKeyProtector($Vm) { + try { $protector = Get-VMKeyProtector -VM $Vm } catch { return $null } + return (@($protector).Count -gt 16) + } + + function ConvertTo-ControlVm($Vm) { + $memory = $null; $processor = $null; $services = [ordered]@{} + try { $memory = Get-VMMemory -VM $Vm } catch { } + try { $processor = Get-VMProcessor -VM $Vm } catch { } + try { foreach ($service in @(Get-VMIntegrationService -VM $Vm)) { $services[[string]$service.Name] = [bool]$service.Enabled } } catch { } + [pscustomobject][ordered]@{ + Id = [string]$Vm.Id + Name = [string]$Vm.Name + State = [string]$Vm.State + Generation = [int]$Vm.Generation + Version = Get-ControlText $Vm.Version + ProcessorCount = [int]$Vm.ProcessorCount + NestedVirtualization = $(if ($null -ne $processor) { [bool]$processor.ExposeVirtualizationExtensions } else { $null }) + MemoryStartup = [long]$Vm.MemoryStartup + MemoryAssigned = [long]$Vm.MemoryAssigned + DynamicMemoryEnabled = [bool]$Vm.DynamicMemoryEnabled + MemoryMinimum = [long]$Vm.MemoryMinimum + MemoryMaximum = [long]$Vm.MemoryMaximum + MemoryBufferPercent = $(if ($null -ne $memory) { [int]$memory.Buffer } else { $null }) + CheckpointType = Get-ControlText $Vm.CheckpointType + AutomaticCheckpointsEnabled = [bool]$Vm.AutomaticCheckpointsEnabled + AutomaticStartAction = Get-ControlText $Vm.AutomaticStartAction + AutomaticStartDelaySeconds = [int]$Vm.AutomaticStartDelay + AutomaticStopAction = Get-ControlText $Vm.AutomaticStopAction + EnhancedSessionTransportType = Get-ControlText $Vm.EnhancedSessionTransportType + Notes = [string]$Vm.Notes + IntegrationServices = $services + Path = [string]$Vm.Path + } + } + + function ConvertTo-ControlAdapter($Adapter) { + $vlan = $Adapter.VlanSetting + [pscustomobject][ordered]@{ + Id = [string]$Adapter.Id + Name = [string]$Adapter.Name + SwitchName = Get-ControlText $Adapter.SwitchName + Connected = [bool]$Adapter.Connected + MacAddress = [string]$Adapter.MacAddress + DynamicMacAddressEnabled = [bool]$Adapter.DynamicMacAddressEnabled + MacAddressSpoofing = Get-ControlText $Adapter.MacAddressSpoofing + VlanMode = $(if ($null -ne $vlan) { Get-ControlText $vlan.OperationMode } else { $null }) + VlanId = $(if ($null -ne $vlan) { [int]$vlan.AccessVlanId } else { $null }) + IPAddresses = @($Adapter.IPAddresses | Where-Object { $_ } | ForEach-Object { [string]$_ }) + Status = @($Adapter.Status | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) + } + } + + function ConvertTo-ControlDrive($Drive) { + $chain = @() + if (-not [string]::IsNullOrWhiteSpace([string]$Drive.Path)) { $chain = @(Get-ControlVhdChain ([string]$Drive.Path) | ForEach-Object { ConvertTo-ControlVhdLink $_ }) } + [pscustomobject][ordered]@{ + Path = [string]$Drive.Path + ControllerType = Get-ControlText $Drive.ControllerType + ControllerNumber = [int]$Drive.ControllerNumber + ControllerLocation = [int]$Drive.ControllerLocation + Chain = $chain + } + } + + function ConvertTo-ControlBootOrder($Firmware) { + @($Firmware.BootOrder | Where-Object { $null -ne $_ } | ForEach-Object { + $device = $_.Device + $target = $null + if ($null -ne $device) { + if ($null -ne $device.PSObject.Properties['Path'] -and -not [string]::IsNullOrWhiteSpace([string]$device.Path)) { $target = [string]$device.Path } + else { $target = [string]$device.Name } + } elseif ($null -ne $_.PSObject.Properties['FirmwarePath']) { $target = [string]$_.FirmwarePath } + [pscustomobject][ordered]@{ BootType = Get-ControlText $_.BootType; Description = [string]$_.Description; Device = $target } + }) + } + + function ConvertTo-ControlFirmware($Vm) { + if ([int]$Vm.Generation -ne 2) { return $null } + $firmware = Get-VMFirmware -VM $Vm + [pscustomobject][ordered]@{ + SecureBoot = Get-ControlText $firmware.SecureBoot + SecureBootTemplate = Get-ControlText $firmware.SecureBootTemplate + PreferredNetworkBootProtocol = Get-ControlText $firmware.PreferredNetworkBootProtocol + BootOrder = @(ConvertTo-ControlBootOrder $firmware) + } + } + + function ConvertTo-ControlSecurity($Vm) { + try { $security = Get-VMSecurity -VM $Vm } catch { return $null } + [pscustomobject][ordered]@{ + TpmEnabled = [bool]$security.TpmEnabled + KeyProtector = Test-ControlKeyProtector $Vm + Shielded = [bool]$security.Shielded + KsdEnabled = [bool]$security.KsdEnabled + EncryptStateAndVmMigrationTraffic = [bool]$security.EncryptStateAndVmMigrationTraffic + VirtualizationBasedSecurityOptOut = [bool]$security.VirtualizationBasedSecurityOptOut + } + } + + # Refuses before any change, listing every requested setting the VM's state does not allow. Each + # rule: @{ setting; label; states; when }, where 'when' must also hold while the VM is Running. + function Assert-ControlSettings($Vm, [object[]]$Rules) { + $state = [string]$Vm.State + if ($state -eq 'FastSaved') { $state = 'Saved' } + $blocked = New-Object System.Collections.ArrayList + foreach ($rule in $Rules) { + $allowed = @($rule.states) -contains $state + if ($allowed -and $state -eq 'Running' -and $null -ne $rule.when) { $allowed = [bool](& $rule.when) } + if (-not $allowed) { [void]$blocked.Add([string]$rule.setting + ' (' + [string]$rule.label + ')') } + } + if ($blocked.Count -eq 0) { return } + $list = $blocked -join '; ' + $prefix = "VM '" + [string]$Vm.Name + "' is " + [string]$Vm.State + '. ' + if ($state -eq 'Running') { $text = 'These settings need it Off: ' + $list + '.' } + elseif ($state -eq 'Saved') { $text = 'A saved VM accepts only rename, removal, export and checkpoint operations. These settings need it Off: ' + $list + '.' } + else { $text = 'Resume it or wait for Off (or Running, for live changes) before changing: ' + $list + '.' } + Stop-Control 'InvalidVmState' ($prefix + $text + ' Nothing was changed.') + } + + # Hyper-V merges checkpoint files asynchronously after Remove-VMSnapshot returns. Waits until the VM + # has no checkpoints, reports no merge in progress and attaches no .avhdx file. + function Wait-ControlMerge($Vm, [int]$TimeoutSeconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ($true) { + $current = Hyper-V\Get-VM -Id $Vm.Id -ErrorAction Stop + $merging = @($current.OperationalStatus | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) -contains 'MergingDisks' + $left = @(Get-VMSnapshot -VM $current).Count + $differencing = @(Get-VMHardDiskDrive -VM $current | Where-Object { [string]$_.Path -match '\.avhdx?$' }).Count + if (-not $merging -and $left -eq 0 -and $differencing -eq 0) { return $current } + if ([DateTime]::UtcNow -gt $deadline) { + Stop-Control 'HyperVOperationFailed' ("Checkpoint merge for VM '" + $current.Name + "' did not finish within " + $TimeoutSeconds + ' s (checkpoints left: ' + $left + ', merging: ' + $merging + '). The checkpoints were deleted; the VM was not removed and no file was deleted. Retry later.') + } + Start-Sleep -Seconds 2 + } + } + + # What removing $Vm does to its disk files. Own files: each drive's file and, through .avhdx + # checkpoint files, the base disk they merge into; a linked clone's parent is never own. Own files + # another VM uses, or that are the differencing parent of a disk beside them or in the host disk + # folder, are kept. + function Get-ControlRemovalPlan($Vm, [bool]$DeleteDisks) { + $own = [ordered]@{} + $merge = [ordered]@{} + $checkpoints = @(Get-VMSnapshot -VM $Vm) + $drives = @(Get-VMHardDiskDrive -VM $Vm) + foreach ($checkpoint in $checkpoints) { $drives += @(Get-VMHardDiskDrive -VMSnapshot $checkpoint) } + foreach ($drive in $drives) { + if ([string]::IsNullOrWhiteSpace([string]$drive.Path)) { continue } + foreach ($link in @(Get-ControlVhdChain ([string]$drive.Path))) { + $path = [string]$link.Path + $key = Get-ControlPathKey $path + if ($path -match '\.avhdx?$') { $merge[$key] = $path; continue } + $own[$key] = $path + break + } + } + $references = Get-ControlDiskReferences $Vm + $directories = @($own.Values | ForEach-Object { [System.IO.Path]::GetDirectoryName($_) }) + @([string](Get-VMHost).VirtualHardDiskPath) + # The VM's own checkpoint files sit beside its base disk and merge into it; they are not children. + $children = Find-ControlUnattachedChildren @($own.Values) $directories @($merge.Values) + $delete = New-Object System.Collections.ArrayList + $keep = New-Object System.Collections.ArrayList + foreach ($key in @($own.Keys)) { + $path = $own[$key] + $reason = Get-ControlReferenceReason $references $key + if ($null -eq $reason -and $children.ContainsKey($key)) { $reason = 'differencing parent of ' + $children[$key] } + if ($null -eq $reason -and $references.Unreadable -gt 0) { $reason = 'Hyper-V could not read ' + $references.Unreadable + ' VM(s), so their use of this file cannot be ruled out' } + if ($null -eq $reason -and -not $DeleteDisks) { $reason = 'deleteDisks is false' } + if ($null -eq $reason) { [void]$delete.Add($path) } else { [void]$keep.Add([pscustomobject][ordered]@{ path = $path; reason = $reason }) } + } + foreach ($dvd in @(Get-VMDvdDrive -VM $Vm | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Path) })) { + [void]$keep.Add([pscustomobject][ordered]@{ path = [string]$dvd.Path; reason = 'ISO media is never deleted' }) + } + [pscustomobject][ordered]@{ checkpoints = $checkpoints.Count; merge = @($merge.Values); delete = @($delete); keep = @($keep) } + } + + # Deletes a VM's own configuration folder (New-VM -Path creates \) when it holds + # no file at all. Volume roots and the given protected folders are never deleted. + function Remove-ControlVmDirectory([string]$Path, [string]$Name, [string[]]$Protected) { + try { + if ([string]::IsNullOrWhiteSpace($Path) -or -not [System.IO.Directory]::Exists($Path)) { return $null } + if ([System.IO.Path]::GetFileName($Path.TrimEnd('\')) -ne $Name) { return $null } + $key = Get-ControlPathKey $Path + if ($key -eq (Get-ControlPathKey ([System.IO.Path]::GetPathRoot($Path)))) { return $null } + foreach ($protectedPath in $Protected) { if ($key -eq (Get-ControlPathKey $protectedPath)) { return $null } } + if (@([System.IO.Directory]::GetFiles($Path, '*', [System.IO.SearchOption]::AllDirectories)).Count -gt 0) { return $null } + [System.IO.Directory]::Delete($Path, $true) + return $Path + } catch { return $null } + } + + # The networking modules NAT switches need; a test module standing in for Hyper-V provides them. + function Import-ControlNetModules { + if (-not [string]::IsNullOrWhiteSpace($env:HYPERV_CONTROL_MODULE_PATH)) { return } + foreach ($module in 'NetAdapter', 'NetTCPIP', 'NetNat') { + Import-Module -Name ([System.IO.Path]::Combine($systemModuleRoot, $module, $module + '.psd1')) -ErrorAction Stop + } + } + + function ConvertTo-ControlPrefix([string]$Prefix) { + $parts = $Prefix.Split('/') + $bytes = [System.Net.IPAddress]::Parse($parts[0]).GetAddressBytes() + [Array]::Reverse($bytes) + $address = [BitConverter]::ToUInt32($bytes, 0) + $length = [int]$parts[1] + $mask = if ($length -eq 0) { [uint32]0 } else { [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $length)) } + return @{ network = ($address -band $mask); mask = $mask } + } + + function Test-ControlPrefixOverlap([string]$First, [string]$Second) { + $a = ConvertTo-ControlPrefix $First + $b = ConvertTo-ControlPrefix $Second + $mask = $a.mask -band $b.mask + return (($a.network -band $mask) -eq ($b.network -band $mask)) + } + """; +} diff --git a/src/HyperVControl/Services/VmStateRules.cs b/src/HyperVControl/Services/VmStateRules.cs new file mode 100644 index 0000000..29a6b9b --- /dev/null +++ b/src/HyperVControl/Services/VmStateRules.cs @@ -0,0 +1,121 @@ +using System.Text; + +namespace HyperVControl.Services; + +/// +/// Which VM states allow each setting, from the verified Hyper-V state matrix. Off allows everything; +/// Running allows the live-changeable settings, some only while a condition holds; Saved allows only +/// rename, removal, export and checkpoints; Paused, transitional and critical states allow nothing. +/// renders one in-script check that refuses a whole request before any change and +/// names every setting that needs the VM Off, so a call is all-or-nothing. +/// +internal static class VmStateRules +{ + [Flags] + internal enum States { None = 0, Off = 1, Running = 2, Saved = 4 } + + /// + /// A PowerShell condition that must also hold while the VM is Running. It may read $vm and + /// variables bound earlier in the script; {value} is replaced by the requested value. + /// + internal sealed record Condition(string Expression, string Explanation); + + internal sealed record Rule(States Allowed, string Description, Condition? WhileRunning = null) + { + public bool Allows(States state) => (Allowed & state) != 0; + } + + private const States Live = States.Off | States.Running; + private static readonly Condition Generation2 = new("[int]$vm.Generation -eq 2", "only on Generation 2"); + private static readonly Condition StaticMemory = new("-not $vm.DynamicMemoryEnabled", "only with static memory"); + private static readonly Condition DynamicMemory = new("[bool]$vm.DynamicMemoryEnabled", "only with dynamic memory on"); + + /// The table, keyed by the tool parameter or action that requests the change. + internal static readonly IReadOnlyDictionary Table = new Dictionary(StringComparer.Ordinal) + { + // hyperv_vm_configure + ["processors"] = new(States.Off, "vCPU count; Hyper-V has no vCPU hot add"), + ["memoryMb"] = new(Live, "startup memory", StaticMemory), + ["dynamicMemory"] = new(States.Off, "dynamic memory on/off"), + ["memoryMinMb"] = new(Live, "dynamic memory minimum", new("[bool]$vm.DynamicMemoryEnabled -and {value} -le [long]$vm.MemoryMinimum", "it can only decrease, with dynamic memory on")), + ["memoryMaxMb"] = new(Live, "dynamic memory maximum", new("[bool]$vm.DynamicMemoryEnabled -and {value} -ge [long]$vm.MemoryMaximum", "it can only increase, with dynamic memory on")), + ["memoryBufferPercent"] = new(Live, "dynamic memory buffer", DynamicMemory), + ["checkpointType"] = new(Live, "checkpoint type"), + ["autoCheckpoints"] = new(Live, "automatic checkpoints"), + ["newName"] = new(States.Off | States.Running | States.Saved, "rename"), + ["notes"] = new(Live, "notes"), + ["autoStartAction"] = new(Live, "automatic start action"), + ["autoStartDelaySeconds"] = new(Live, "automatic start delay"), + ["autoStopAction"] = new(States.Off, "automatic stop action; sources conflict on live changes"), + ["nestedVirtualization"] = new(States.Off, "nested virtualization"), + ["integrationServices"] = new(Live, "integration services"), + ["enhancedSessionTransport"] = new(States.Off, "enhanced session transport"), + // hyperv_firmware (Generation 2) + ["secureBoot"] = new(States.Off, "Secure Boot"), + ["secureBootTemplate"] = new(States.Off, "Secure Boot template"), + ["tpm"] = new(States.Off, "virtual TPM and key protector"), + ["firstBootDevice"] = new(States.Off, "boot order"), + // hyperv_network + ["connect"] = new(Live, "connect adapter to a switch"), + ["disconnect"] = new(Live, "disconnect adapter"), + ["add_adapter"] = new(Live, "add network adapter", Generation2), + ["remove_adapter"] = new(Live, "remove network adapter", Generation2), + ["staticMac"] = new(States.Off, "MAC address"), + ["macSpoofing"] = new(Live, "MAC address spoofing"), + ["vlanId"] = new(Live, "VLAN"), + // hyperv_disk + ["attach_vhd"] = new(Live, "attach virtual disk (to SCSI while Running)"), + ["detach_vhd"] = new(Live, "detach virtual disk", new("[string]$__drive.ControllerType -eq 'SCSI' -and -not $__bootDisk", "only a SCSI data disk, never the boot disk")), + ["attach_iso"] = new(Live, "insert ISO", new("$__dvdCount -gt 0 -or [int]$vm.Generation -eq 2", "Generation 1 needs an existing DVD drive")), + ["eject_iso"] = new(Live, "eject ISO"), + ["resize_vhd"] = new(Live, "resize virtual disk", new("[string]$__drive.ControllerType -eq 'SCSI' -and [string]$__drive.Path -match '\\.vhdx$'", "only a VHDX on a SCSI controller")), + // whole-VM operations + ["comPort"] = new(Live, "COM port named pipe"), + ["remove"] = new(States.Off | States.Saved, "remove VM"), + ["clone"] = new(States.Off, "clone source"), + }; + + internal static Rule Get(string setting) => + Table.TryGetValue(setting, out var rule) ? rule : throw new ArgumentException($"No state rule for '{setting}'."); + + /// Maps a Hyper-V state name to the table's states. FastSaved counts as Saved; Paused, + /// transitional and critical states map to , which nothing allows. + internal static States Parse(string state) => state switch + { + "Off" => States.Off, + "Running" => States.Running, + "Saved" or "FastSaved" => States.Saved, + _ => States.None + }; + + /// State names a setting allows, in the form Assert-ControlVmState takes. + internal static string[] StateNames(string setting) => + new[] { States.Off, States.Running, States.Saved }.Where(Get(setting).Allows).Select(s => s.ToString()).ToArray(); + + /// The requested settings that rules out on its own. Running + /// conditions depend on the VM and are evaluated in-script by . + internal static IReadOnlyList Blocked(string state, IEnumerable settings) => + settings.Where(setting => !Get(setting).Allows(Parse(state))).ToList(); + + /// + /// One script line that refuses the request with InvalidVmState before any change when the + /// VM's current state rules out any requested setting, listing every such setting. Requested values + /// feed value-dependent Running conditions (for example, a dynamic memory maximum may only grow). + /// + internal static string Gate(IEnumerable<(string Setting, PsValue? Value)> requested) + { + var rules = new List(); + foreach (var (setting, value) in requested) + { + var rule = Get(setting); + var label = rule.WhileRunning is null ? rule.Description : rule.Description + "; while Running " + rule.WhileRunning.Explanation; + var entry = new StringBuilder("@{ setting = ").Append(PowerShellRunner.Literal(setting)) + .Append("; label = ").Append(PowerShellRunner.Literal(label)) + .Append("; states = @(").Append(string.Join(", ", StateNames(setting).Select(PowerShellRunner.Literal))).Append(')'); + if (rule.WhileRunning is not null) + entry.Append("; when = { ").Append(rule.WhileRunning.Expression.Replace("{value}", value?.Text ?? "$null", StringComparison.Ordinal)).Append(" }"); + rules.Add(entry.Append(" }").ToString()); + } + return rules.Count == 0 ? "" : "Assert-ControlSettings $vm @(" + string.Join(", ", rules) + ")"; + } +} diff --git a/tests/HyperVControl.Tests/LifecycleLiveTests.cs b/tests/HyperVControl.Tests/LifecycleLiveTests.cs new file mode 100644 index 0000000..a129cfb --- /dev/null +++ b/tests/HyperVControl.Tests/LifecycleLiveTests.cs @@ -0,0 +1,345 @@ +using System.Text.Json; + +namespace HyperVControl.Tests; + +/// +/// Live validation of lifecycle v2 on the local Hyper-V host (phase lifecycle-live), authorized for +/// throwaway VMs only. Every VM is named <run>-<part>, every file stays under +/// \<run>, adapters stay disconnected, and other VMs and switches are only read +/// (before and after, to prove they did not change). Everything the run created is removed in a finally +/// block. Each step is recorded in lifecycle-live-summary.json beside the per-call outputs. +/// +internal static class LifecycleLiveTests +{ + internal const string Root = @"D:\HyperVControl-validation\lifecycle"; + internal const string Prefix = "HyperVControl-validation-lifecycle-"; + + private sealed record Step(string Name, bool Passed, string Detail); + + internal static async Task RunAsync(ToolCall call, Action check, string results) + { + var run = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var folder = Path.Combine(Root, run); + // The harness creates the folder (not the elevated server) so its files stay deletable by this user. + Directory.CreateDirectory(folder); + string Name(string part) => Prefix + run + "-" + part; + var (a, b, c, d) = (Name("a"), Name("b"), Name("clone"), Name("checkpoints")); + var steps = new List(); + var leftovers = new List(); + var baselineVms = Machines(await call("hyperv_list", label: "lifecycle-live-baseline-vms")); + var baselineSwitches = (await call("hyperv_network", new { action = "list" }, label: "lifecycle-live-baseline-switches")).GetRawText(); + check(!baselineVms.Any(m => m.Name.StartsWith(Prefix + run, StringComparison.Ordinal)), "No VM of this run exists before it starts"); + + async Task Run(string name, Func> body) + { + try { var detail = await body(); steps.Add(new(name, true, detail)); Console.WriteLine("PASS " + name + (detail.Length > 0 ? " - " + detail : "")); } + catch (Exception ex) { steps.Add(new(name, false, ex.Message)); Console.WriteLine("FAIL " + name + " - " + ex.Message); } + } + static void Expect(bool condition, string what) { if (!condition) throw new Exception(what); } + async Task Tool(string label, string name, object arguments, bool allowError = false) => await call(name, arguments, allowError, "lifecycle-live-" + label); + + try + { + await Run("capabilities report host defaults", async () => + { + var caps = await Tool("capabilities", "hyperv_capabilities", new { }); + var defaults = caps.GetProperty("hostDefaults"); + Expect(defaults.TryGetProperty("virtualMachinePath", out _) && defaults.GetProperty("configurationVersions").GetProperty("supported").GetArrayLength() > 0 + && defaults.GetProperty("freeSpace").GetArrayLength() > 0, "hostDefaults incomplete: " + defaults.GetRawText()); + return "default version " + defaults.GetProperty("configurationVersions").GetProperty("default").GetString() + + ", Default Switch present: " + defaults.GetProperty("defaultSwitch").GetProperty("present").GetBoolean(); + }); + + string? diskA = null; + await Run("create new_disk (Windows defaults, 1 GB disk, disconnected)", async () => + { + var vm = await Tool("create-a", "hyperv_vm_create", new { name = a, path = folder, diskGb = 1, memoryMb = 1024, switchName = "" }); + diskA = vm.GetProperty("createdFiles")[0].GetString(); + Expect(vm.GetProperty("State").GetString() == "Off" && vm.GetProperty("Generation").GetInt32() == 2 && vm.GetProperty("ProcessorCount").GetInt32() == 2 + && vm.GetProperty("MemoryStartup").GetInt64() == 1L << 30 && !vm.GetProperty("DynamicMemoryEnabled").GetBoolean() + && vm.GetProperty("CheckpointType").GetString() == "Standard" && !vm.GetProperty("AutomaticCheckpointsEnabled").GetBoolean() + && vm.GetProperty("Firmware").GetProperty("SecureBoot").GetString() == "On" && vm.GetProperty("Firmware").GetProperty("SecureBootTemplate").GetString() == "MicrosoftWindows" + && vm.GetProperty("Security").GetProperty("TpmEnabled").GetBoolean() && vm.GetProperty("Security").GetProperty("KeyProtector").GetBoolean() == true + && diskA!.StartsWith(folder, StringComparison.OrdinalIgnoreCase) && File.Exists(diskA), "unexpected create result: " + vm.GetRawText()); + return diskA!; + }); + + await Run("duplicate name refused before any change", async () => + { + var before = Machines(await call("hyperv_list")).Count; + var refused = await Tool("create-duplicate", "hyperv_vm_create", new { name = a, path = folder, source = "none" }, true); + Expect(Failure(refused) == "InvalidArgument" && Machines(await call("hyperv_list")).Count == before, refused.GetRawText()); + return refused.GetProperty("error").GetString()!; + }); + + await Run("configure while Off", async () => + { + var vm = await Tool("configure-off", "hyperv_vm_configure", new + { + machine = a, processors = 4, notes = "lifecycle-live", autoStartAction = "Nothing", autoStopAction = "ShutDown", autoCheckpoints = false, + integrationServices = new Dictionary { ["Guest Service Interface"] = true } + }); + Expect(vm.GetProperty("ProcessorCount").GetInt32() == 4 && vm.GetProperty("Notes").GetString() == "lifecycle-live" + && vm.GetProperty("AutomaticStartAction").GetString() == "Nothing" && vm.GetProperty("AutomaticStopAction").GetString() == "ShutDown" + && vm.GetProperty("IntegrationServices").GetProperty("Guest Service Interface").GetBoolean(), vm.GetRawText()); + return "4 vCPUs, notes, start/stop actions, Guest Service Interface on"; + }); + + await Run("nested virtualization toggles while Off", async () => + { + var on = await Tool("configure-nested-on", "hyperv_vm_configure", new { machine = a, nestedVirtualization = true }, true); + if (Failure(on) is { } error) return "host refused (" + error + "): " + on.GetProperty("error").GetString(); + var off = await Tool("configure-nested-off", "hyperv_vm_configure", new { machine = a, nestedVirtualization = false }); + Expect(on.GetProperty("NestedVirtualization").GetBoolean() && !off.GetProperty("NestedVirtualization").GetBoolean(), on.GetRawText()); + return "on then off"; + }); + + await Run("firmware read shows the vTPM and its key protector", async () => + { + var firmware = await Tool("firmware-read", "hyperv_firmware", new { machine = a }); + Expect(firmware.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && firmware.GetProperty("security").GetProperty("KeyProtector").GetBoolean() + && firmware.GetProperty("firmware").GetProperty("BootOrder").GetArrayLength() > 0, firmware.GetRawText()); + return firmware.GetProperty("firmware").GetProperty("BootOrder").GetArrayLength() + " boot entries"; + }); + + await Run("start without an OS (stays at the firmware)", async () => + { + var started = await Tool("start-a", "hyperv_power", new { machine = a, action = "start" }); + Expect(started.GetProperty("machine").GetProperty("state").GetString() == "running", started.GetRawText()); + return "running"; + }); + + await Run("static memory resize while Running", async () => + { + var resized = await Tool("configure-running-memory", "hyperv_vm_configure", new { machine = a, memoryMb = 2048 }, true); + var info = await Tool("info-after-resize", "hyperv_vm_info", new { machine = a }); + var startup = info.GetProperty("memory").GetProperty("startupBytes").GetInt64(); + Expect(Failure(resized) is null && startup == 2L << 30, + "resize to 2048 MB: " + (Failure(resized) is { } type ? type + ": " + resized.GetProperty("error").GetString() : "no error") + "; startup now " + (startup >> 20) + " MB"); + return "1024 -> 2048 MB"; + }); + + await Run("vCPU change while Running refused with the settings that need Off", async () => + { + var refused = await Tool("configure-running-cpu", "hyperv_vm_configure", new { machine = a, processors = 2, notes = "must not apply" }, true); + var info = await Tool("info-after-cpu-refusal", "hyperv_vm_info", new { machine = a }); + var message = refused.GetProperty("error").GetString()!; + Expect(Failure(refused) == "InvalidVmState" && message.Contains("processors (vCPU count") && !message.Contains("notes") + && message.Contains("Nothing was changed") && info.GetProperty("processor").GetProperty("count").GetInt32() == 4 + && info.GetProperty("notes").GetString() == "lifecycle-live", refused.GetRawText()); + return message; + }); + + await Run("live configure: notes, start action, integration services", async () => + { + var vm = await Tool("configure-running-live", "hyperv_vm_configure", new + { + machine = a, notes = "running", autoStartAction = "StartIfRunning", integrationServices = new Dictionary { ["Guest Service Interface"] = false } + }); + Expect(vm.GetProperty("Notes").GetString() == "running" && vm.GetProperty("AutomaticStartAction").GetString() == "StartIfRunning" + && !vm.GetProperty("IntegrationServices").GetProperty("Guest Service Interface").GetBoolean(), vm.GetRawText()); + return "applied while Running"; + }); + + await Run("adapter add, set and remove while Running", async () => + { + var added = await Tool("adapter-add", "hyperv_network", new { action = "add_adapter", machine = a, adapterName = "lifecycle-live-nic" }); + Expect(added.GetProperty("adapters").GetArrayLength() == 2, added.GetRawText()); + var set = await Tool("adapter-set", "hyperv_network", new { action = "set_adapter", machine = a, adapterName = "lifecycle-live-nic", macSpoofing = true, vlanId = 12 }); + var nic = set.GetProperty("adapters").EnumerateArray().Single(n => n.GetProperty("Name").GetString() == "lifecycle-live-nic"); + Expect(nic.GetProperty("MacAddressSpoofing").GetString() == "On" && nic.GetProperty("VlanId").GetInt32() == 12, set.GetRawText()); + var mac = await Tool("adapter-mac-running", "hyperv_network", new { action = "set_adapter", machine = a, adapterName = "lifecycle-live-nic", staticMac = "00155DFE0001" }, true); + Expect(Failure(mac) == "InvalidVmState" && mac.GetProperty("error").GetString()!.Contains("staticMac ("), mac.GetRawText()); + var removed = await Tool("adapter-remove", "hyperv_network", new { action = "remove_adapter", machine = a, adapterName = "lifecycle-live-nic" }); + Expect(removed.GetProperty("adapters").GetArrayLength() == 1, removed.GetRawText()); + return "hot add, MAC spoofing + VLAN 12, static MAC refused, hot remove"; + }); + + var data = Path.Combine(folder, "data.vhdx"); + await Run("vhd create and SCSI hot attach, online resize, detach while Running", async () => + { + var created = await Tool("vhd-create", "hyperv_vhd", new { action = "create", path = data, sizeGb = 1 }); + Expect(created.GetProperty("type").GetString() == "Dynamic" && File.Exists(data), created.GetRawText()); + await Tool("disk-attach-running", "hyperv_disk", new { machine = a, action = "attach_vhd", path = data }); + var resized = await Tool("disk-resize-running", "hyperv_disk", new { machine = a, action = "resize_vhd", path = data, sizeGb = 2 }, true); + var bootDetach = await Tool("disk-detach-boot-running", "hyperv_disk", new { machine = a, action = "detach_vhd", path = diskA }, true); + await Tool("disk-detach-running", "hyperv_disk", new { machine = a, action = "detach_vhd", path = data }); + var inspect = await Tool("vhd-inspect-data", "hyperv_vhd", new { action = "inspect", path = data }); + Expect(Failure(bootDetach) == "InvalidVmState" && inspect.GetProperty("attachedTo").GetArrayLength() == 0, bootDetach.GetRawText()); + return "resize: " + (Failure(resized) is { } error ? error + " " + resized.GetProperty("error").GetString() : (inspect.GetProperty("sizeBytes").GetInt64() >> 30) + " GB") + + "; boot disk detach refused"; + }); + + await Run("COM port pipe while Running (serial_host)", async () => + { + var com = await Tool("com-running", "hyperv_kernel_configure", new { machine = a, action = "serial_host", comPort = 2, pipeName = "HyperVControl-validation-lifecycle" }, true); + Expect(Failure(com) is null && com.GetProperty("result").GetProperty("Path").GetString() == @"\\.\pipe\HyperVControl-validation-lifecycle", com.GetRawText()); + return "COM 2 mapped while Running"; + }); + + await Run("firmware refused while Running", async () => + { + var refused = await Tool("firmware-running", "hyperv_firmware", new { machine = a, secureBoot = false }, true); + Expect(Failure(refused) == "InvalidVmState" && refused.GetProperty("error").GetString()!.Contains("secureBoot ("), refused.GetRawText()); + return "refused"; + }); + + await Run("turn off", async () => + { + var off = await Tool("turn-off-a", "hyperv_power", new { machine = a, action = "turn_off" }); + Expect(off.GetProperty("machine").GetProperty("state").GetString() == "off", off.GetRawText()); + return "off"; + }); + + await Run("Secure Boot template locked after the vTPM booted", async () => + { + var template = await Tool("firmware-template-after-boot", "hyperv_firmware", new { machine = a, secureBootTemplate = "MicrosoftUEFICertificateAuthority" }, true); + var read = await Tool("firmware-template-readback", "hyperv_firmware", new { machine = a }); + var now = read.GetProperty("firmware").GetProperty("SecureBootTemplate").GetString(); + return Failure(template) is { } error + ? "refused as " + error + " (" + template.GetProperty("errorId").GetString() + "): " + template.GetProperty("error").GetString() + "; template still " + now + : "accepted; template now " + now; + }); + + await Run("firmware Off: Secure Boot off/on, first boot device, vTPM off/on", async () => + { + var off = await Tool("firmware-secureboot-off", "hyperv_firmware", new { machine = a, secureBoot = false }); + var on = await Tool("firmware-secureboot-on", "hyperv_firmware", new { machine = a, secureBoot = true, firstBootDevice = "disk" }); + var tpmOff = await Tool("firmware-tpm-off", "hyperv_firmware", new { machine = a, tpm = false }); + var tpmOn = await Tool("firmware-tpm-on", "hyperv_firmware", new { machine = a, tpm = true }); + Expect(!off.GetProperty("secureBootEnabled").GetBoolean() && on.GetProperty("secureBootEnabled").GetBoolean() + && on.GetProperty("firmware").GetProperty("BootOrder")[0].GetProperty("BootType").GetString() == "Drive" + && !tpmOff.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && tpmOn.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() + && !tpmOn.GetProperty("warnings").EnumerateArray().Any(w => w.GetString()!.Contains("Created a local key protector")), on.GetRawText() + tpmOn.GetRawText()); + return "verified by readback; the existing key protector was reused"; + }); + + await Run("vTPM recipe on a VM without key protector: template, protector, TPM", async () => + { + var vm = await Tool("create-b", "hyperv_vm_create", new { name = b, path = folder, source = "none", tpm = false, memoryMb = 512, switchName = "" }); + Expect(!vm.GetProperty("Security").GetProperty("TpmEnabled").GetBoolean() && vm.GetProperty("Security").GetProperty("KeyProtector").GetBoolean() == false, vm.GetRawText()); + var firmware = await Tool("firmware-b-tpm", "hyperv_firmware", new { machine = b, secureBootTemplate = "MicrosoftUEFICertificateAuthority", tpm = true }); + Expect(firmware.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && firmware.GetProperty("security").GetProperty("KeyProtector").GetBoolean() + && firmware.GetProperty("firmware").GetProperty("SecureBootTemplate").GetString() == "MicrosoftUEFICertificateAuthority" + && firmware.GetProperty("warnings").EnumerateArray().Any(w => w.GetString()!.Contains("Created a local key protector")), firmware.GetRawText()); + return "local key protector created, vTPM enabled, UEFI CA template"; + }); + + string? child = null; + await Run("linked clone", async () => + { + var clone = await Tool("clone-linked", "hyperv_vm_clone", new { machine = a, newName = c, mode = "linked", destination = folder }); + child = clone.GetProperty("createdFiles")[0].GetString(); + var inspect = await Tool("vhd-inspect-child", "hyperv_vhd", new { action = "inspect", path = child }); + var parent = await Tool("vhd-inspect-parent", "hyperv_vhd", new { action = "inspect", path = diskA }); + Expect(clone.GetProperty("mode").GetString() == "linked" && clone.GetProperty("State").GetString() == "Off" && clone.GetProperty("ProcessorCount").GetInt32() == 4 + && clone.GetProperty("readOnlyParents")[0].GetString()!.Equals(diskA, StringComparison.OrdinalIgnoreCase) + && inspect.GetProperty("chain").GetArrayLength() == 2 && inspect.GetProperty("type").GetString() == "Differencing" + && parent.GetProperty("readOnly").GetBoolean() && parent.GetProperty("parentOf").GetArrayLength() == 1 + && parent.GetProperty("attachedTo")[0].GetProperty("vmName").GetString() == a, clone.GetRawText() + inspect.GetRawText()); + var info = await Tool("info-clone", "hyperv_vm_info", new { machine = c }); + Expect(info.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && info.GetProperty("disks")[0].GetProperty("Chain").GetArrayLength() == 2, info.GetRawText()); + return child!; + }); + + await Run("linked clone boots to its firmware", async () => + { + var started = await Tool("start-clone", "hyperv_power", new { machine = c, action = "start" }); + var stopped = await Tool("turn-off-clone", "hyperv_power", new { machine = c, action = "turn_off" }); + Expect(started.GetProperty("machine").GetProperty("state").GetString() == "running" && stopped.GetProperty("machine").GetProperty("state").GetString() == "off", started.GetRawText()); + return "started on a differencing disk over the read-only parent"; + }); + + await Run("remove dryRun keeps the clone parent and plans the clone's own disk", async () => + { + var parentPlan = (await Tool("remove-a-dryrun", "hyperv_vm_remove", new { machine = a, deleteDisks = true, dryRun = true })).GetProperty("plan"); + var childPlan = (await Tool("remove-clone-dryrun", "hyperv_vm_remove", new { machine = c, deleteDisks = true, dryRun = true })).GetProperty("plan"); + Expect(parentPlan.GetProperty("delete").GetArrayLength() == 0 && parentPlan.GetProperty("keep")[0].GetProperty("reason").GetString()!.Contains("differencing parent") + && childPlan.GetProperty("delete")[0].GetString()!.Equals(child, StringComparison.OrdinalIgnoreCase) && File.Exists(diskA) && File.Exists(child), + parentPlan.GetRawText() + childPlan.GetRawText()); + return parentPlan.GetProperty("keep")[0].GetProperty("reason").GetString()!; + }); + + await Run("remove with deleteDisks merges checkpoints first", async () => + { + await Tool("create-d", "hyperv_vm_create", new { name = d, path = folder, diskGb = 1, memoryMb = 512, tpm = false, switchName = "" }); + await Tool("checkpoint-d-1", "hyperv_checkpoint", new { machine = d, action = "create", checkpoint = "one" }); + await Tool("checkpoint-d-2", "hyperv_checkpoint", new { machine = d, action = "create", checkpoint = "two" }); + var before = await Tool("info-d-checkpoints", "hyperv_vm_info", new { machine = d }); + var chain = before.GetProperty("disks")[0].GetProperty("Chain").EnumerateArray().Select(l => l.GetProperty("Path").GetString()!).ToList(); + var plan = (await Tool("remove-d-dryrun", "hyperv_vm_remove", new { machine = d, deleteDisks = true, dryRun = true })).GetProperty("plan"); + Expect(before.GetProperty("checkpointCount").GetInt32() == 2 && chain.Count == 3 && plan.GetProperty("checkpoints").GetInt32() == 2 + && plan.GetProperty("merge").GetArrayLength() == 2 && plan.GetProperty("delete").GetArrayLength() == 1, before.GetRawText() + plan.GetRawText()); + var removed = await Tool("remove-d", "hyperv_vm_remove", new { machine = d, deleteDisks = true }); + Expect(removed.GetProperty("checkpointsRemoved").GetInt32() == 2 && removed.GetProperty("deleted").GetArrayLength() == 1 + && chain.All(path => !File.Exists(path)), removed.GetRawText()); + return "2 checkpoints merged, " + chain.Count + " chain files gone"; + }); + + await Run("remove clone, then its former parent, with deleteDisks", async () => + { + var clone = await Tool("remove-clone", "hyperv_vm_remove", new { machine = c, deleteDisks = true }); + var parent = await Tool("remove-a", "hyperv_vm_remove", new { machine = a, deleteDisks = true }); + var vmB = await Tool("remove-b", "hyperv_vm_remove", new { machine = b, deleteDisks = true }); + Expect(clone.GetProperty("deleted").GetArrayLength() == 1 && !File.Exists(child) && parent.GetProperty("deleted").GetArrayLength() == 1 && !File.Exists(diskA) + && vmB.GetProperty("success").GetBoolean() && parent.GetProperty("removedDirectories").GetArrayLength() == 1, clone.GetRawText() + parent.GetRawText()); + return "clone disk, then the read-only parent deleted; VM folder removed"; + }); + + await Run("vhd delete dryRun then delete", async () => + { + var plan = await Tool("vhd-delete-dryrun", "hyperv_vhd", new { action = "delete", path = data, dryRun = true }); + var deleted = await Tool("vhd-delete", "hyperv_vhd", new { action = "delete", path = data }); + Expect(plan.GetProperty("deletable").GetBoolean() && deleted.GetProperty("deleted").GetArrayLength() == 1 && !File.Exists(data), plan.GetRawText()); + return "deleted"; + }); + } + finally + { + // Clean up anything this run created, clones before their parents; failures are recorded, not thrown. + for (var pass = 0; pass < 2; pass++) + foreach (var vm in Machines(await call("hyperv_list")).Where(m => m.Name.StartsWith(Prefix + run, StringComparison.Ordinal)).OrderBy(m => m.Name.EndsWith("-a") ? 1 : 0)) + { + if (vm.State != "off") await call("hyperv_power", new { machine = vm.Id, action = "turn_off" }, true, "lifecycle-live-cleanup-off"); + await call("hyperv_vm_remove", new { machine = vm.Id, deleteDisks = true }, true, "lifecycle-live-cleanup-remove"); + } + foreach (var vm in Machines(await call("hyperv_list")).Where(m => m.Name.StartsWith(Prefix + run, StringComparison.Ordinal))) leftovers.Add("VM " + vm.Name); + if (Directory.Exists(folder)) + foreach (var disk in Directory.EnumerateFiles(folder, "*.*vhd*", SearchOption.AllDirectories)) + await call("hyperv_vhd", new { action = "delete", path = disk }, true, "lifecycle-live-cleanup-vhd"); + try + { + if (Directory.Exists(folder)) + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(folder, true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { leftovers.Add("folder " + folder + ": " + ex.Message); } + + var afterVms = Machines(await call("hyperv_list", label: "lifecycle-live-final-vms")); + var afterSwitches = (await call("hyperv_network", new { action = "list" }, label: "lifecycle-live-final-switches")).GetRawText(); + var untouched = baselineVms.All(before => afterVms.Any(after => after.Id == before.Id && after.Name == before.Name && after.State == before.State)) + && afterVms.Count == baselineVms.Count && afterSwitches == baselineSwitches; + steps.Add(new("other VMs and switches unchanged, nothing of this run left", untouched && leftovers.Count == 0, + untouched ? (leftovers.Count == 0 ? "" : string.Join("; ", leftovers)) : "baseline differs from the final inventory")); + await File.WriteAllTextAsync(Path.Combine(results, "lifecycle-live-summary.json"), JsonSerializer.Serialize(new + { + run, folder, passed = steps.Count(s => s.Passed), failed = steps.Count(s => !s.Passed), steps, leftovers + }, new JsonSerializerOptions { WriteIndented = true })); + } + var failures = steps.Where(s => !s.Passed).ToList(); + check(failures.Count == 0, $"Lifecycle live validation: {steps.Count - failures.Count}/{steps.Count} steps passed" + + (failures.Count == 0 ? "" : "; failed: " + string.Join(" | ", failures.Select(f => f.Name + ": " + f.Detail)))); + } + + private sealed record Machine(string Id, string Name, string State); + + private static List Machines(JsonElement inventory) => inventory.GetProperty("machines").EnumerateArray() + .Select(m => new Machine(m.GetProperty("id").GetString()!, m.GetProperty("name").GetString()!, m.GetProperty("state").GetString()!)).ToList(); + + /// The errorType of a failed tool call, or null when it succeeded. + private static string? Failure(JsonElement result) => + result.ValueKind == JsonValueKind.Object && result.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.False + ? result.GetProperty("errorType").GetString() : null; +} diff --git a/tests/HyperVControl.Tests/LifecycleTests.cs b/tests/HyperVControl.Tests/LifecycleTests.cs index 4f805b1..602d859 100644 --- a/tests/HyperVControl.Tests/LifecycleTests.cs +++ b/tests/HyperVControl.Tests/LifecycleTests.cs @@ -49,25 +49,33 @@ internal static async Task RunAsync(Action check) "Ambiguous names, unknown names and unknown GUIDs classify as AmbiguousName / NotFound"); mock.ClearCalls(); - var removeRunning = await Expect(() => service.Lifecycle.RemoveAsync(runningVm, default)); + var removeRunning = await Expect(() => service.Lifecycle.RemoveAsync(runningVm.ToString("D"), false, false, default)); check(removeRunning?.ErrorType == "InvalidVmState" && !mock.Called("Remove-VM"), "Remove on a Running VM is refused without calling Remove-VM"); mock.ClearCalls(); - var removed = await service.Lifecycle.RemoveAsync(offVm, default); + var removed = await service.Lifecycle.RemoveAsync(offVm.ToString("D"), false, false, default); var removeCall = mock.Calls().Single(c => c.Cmdlet == "Remove-VM"); check(removed.GetProperty("success").GetBoolean() && removeCall.Parameters.GetProperty("Force").GetBoolean() && removeCall.Parameters.GetProperty("VM").GetString() == offVm.ToString("D") && (await service.HyperV.GetInventoryAsync()).Machines.Count == 3, "Remove on an Off VM runs Remove-VM -Force for that VM only"); + // Create makes the VM and disk folders, so it runs against a temporary folder. mock.ClearCalls(); var name = "Lab 'quoted' \u7a7a\u95f4; $pwn = 1"; - var created = await service.Lifecycle.CreateAsync(name, @"C:\MockVMs", @"C:\MockVHDs\new.vhdx", 2, 2048, 2, 20, "Default Switch", default); - var newVm = mock.Calls().Single(c => c.Cmdlet == "New-VM").Parameters; - check(created.GetProperty("Name").GetString() == name && newVm.GetProperty("Name").GetString() == name - && newVm.GetProperty("Generation").GetInt32() == 2 && newVm.GetProperty("MemoryStartupBytes").GetInt64() == 2048L * 1024 * 1024 - && newVm.GetProperty("NewVHDPath").GetString() == @"C:\MockVHDs\new.vhdx" && newVm.GetProperty("NewVHDSizeBytes").GetInt64() == 20L * 1024 * 1024 * 1024 - && newVm.GetProperty("SwitchName").GetString() == "Default Switch" && !newVm.TryGetProperty("VHDPath", out _) - && mock.Calls().Any(c => c.Cmdlet == "Set-VMProcessor" && c.Parameters.GetProperty("Count").GetInt32() == 2), - "Create renders typed New-VM parameters, omits unused ones, and preserves quotes and Unicode in the name as data"); + var temp = Path.Combine(Path.GetTempPath(), "HyperVControl-create-" + Guid.NewGuid().ToString("N")[..8]); + var vhd = Path.Combine(temp, "VHDs", "new.vhdx"); + try + { + var created = await service.Lifecycle.CreateAsync(new VmCreateRequest(name) + { Path = Path.Combine(temp, "VMs"), VhdPath = vhd, MemoryMb = 2048, DiskGb = 2, SwitchName = "Default Switch" }, default); + var newVm = mock.Calls().Single(c => c.Cmdlet == "New-VM").Parameters; + check(created.GetProperty("Name").GetString() == name && newVm.GetProperty("Name").GetString() == name + && newVm.GetProperty("Generation").GetInt32() == 2 && newVm.GetProperty("MemoryStartupBytes").GetInt64() == 2048L * 1024 * 1024 + && newVm.GetProperty("NewVHDPath").GetString() == vhd && newVm.GetProperty("NewVHDSizeBytes").GetInt64() == 2L * 1024 * 1024 * 1024 + && newVm.GetProperty("SwitchName").GetString() == "Default Switch" && !newVm.TryGetProperty("VHDPath", out _) + && mock.Calls().Any(c => c.Cmdlet == "Set-VMProcessor" && c.Parameters.GetProperty("Count").GetInt32() == 2), + "Create renders typed New-VM parameters, omits unused ones, and preserves quotes and Unicode in the name as data"); + } + finally { try { Directory.Delete(temp, true); } catch (IOException) { } } var info = await service.Lifecycle.InfoAsync("Lab B", default); check(info.GetProperty("vm").GetProperty("Name").GetString() == "Lab B" && info.GetProperty("network").GetArrayLength() == 1 diff --git a/tests/HyperVControl.Tests/LifecycleV2Tests.cs b/tests/HyperVControl.Tests/LifecycleV2Tests.cs new file mode 100644 index 0000000..4745395 --- /dev/null +++ b/tests/HyperVControl.Tests/LifecycleV2Tests.cs @@ -0,0 +1,726 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using static HyperVControl.Services.VmStateRules; + +namespace HyperVControl.Tests; + +/// +/// Lifecycle v2 against the recording mock: the setting x state table and its in-script gate (a refusal +/// names every blocked setting and nothing changes first), create sources and their cmdlet order, removal +/// plans and guards, firmware/vTPM order, media, network, virtual disk and clone operations. Disk files are +/// real empty files in a temporary folder; no Hyper-V host is touched. +/// +internal static class LifecycleV2Tests +{ + private const string DefaultSwitchId = "c08cb7b8-9b3c-408e-8e30-5e16a3aeb444"; + private static readonly string[] MutatingVerbs = ["Set", "New", "Remove", "Add", "Enable", "Disable", "Rename", "Connect", "Disconnect", "Resize", + "Start", "Stop", "Suspend", "Resume", "Restart", "Export", "Import", "Checkpoint", "Restore", "Update", "Merge", "Mount", "Dismount"]; + + internal static async Task RunAsync(Action check) + { + StateTable(check); + await ConfigureAsync(check); + await CreateAsync(check); + await RemoveAsync(check); + await FirmwareAsync(check); + await NetworkAsync(check); + await MediaAsync(check); + await VhdAsync(check); + await CloneAndInfoAsync(check); + } + + private static void StateTable(Action check) + { + check(Table["processors"].Allowed == States.Off && Table["dynamicMemory"].Allowed == States.Off && Table["memoryMb"].Allowed == (States.Off | States.Running) + && Table["memoryMb"].WhileRunning is not null && Table["memoryMaxMb"].WhileRunning!.Expression.Contains("{value} -ge"), + "State table: vCPU count and dynamic memory toggle are Off-only; static memory resize is live with a condition; the maximum may only grow"); + check(new[] { "secureBoot", "secureBootTemplate", "tpm", "firstBootDevice", "staticMac", "autoStopAction", "nestedVirtualization", "enhancedSessionTransport", "clone" } + .All(setting => Table[setting].Allowed == States.Off) + && new[] { "notes", "autoStartAction", "integrationServices", "connect", "macSpoofing", "vlanId", "eject_iso", "comPort" }.All(setting => Table[setting].Allowed == (States.Off | States.Running)) + && Table["add_adapter"].WhileRunning is not null && Table["detach_vhd"].WhileRunning is not null, + "State table: firmware, TPM, MAC, stop action, nested virtualization and clone sources need Off; notes, start action, integration services, VLAN and ISO eject are live"); + check(Table["newName"].Allows(States.Saved) && !Table["notes"].Allows(States.Saved) && Table["remove"].Allowed == (States.Off | States.Saved) + && Table.Values.All(rule => !rule.Allows(States.None)), + "State table: Saved allows rename and removal only; nothing is allowed in Paused, transitional or critical states"); + check(Blocked("Running", ["processors", "notes", "memoryMb", "dynamicMemory"]).SequenceEqual(["processors", "dynamicMemory"]) + && Blocked("Paused", ["notes"]).Count == 1 && Blocked("Saved", ["newName"]).Count == 0 && Blocked("Off", Table.Keys).Count == 0 + && Parse("FastSaved") == States.Saved && Parse("RunningCritical") == States.None && Parse("Starting") == States.None, + "State evaluation lists exactly the settings a state rules out"); + check(Gate([]) == "" && Gate([("memoryMaxMb", PsValue.Megabytes(4096))]).Contains("([long]4096*1MB) -ge [long]$vm.MemoryMaximum") + && Gate([("notes", null)]).Contains("states = @('Off', 'Running')"), + "The gate renders requested values into Running conditions and quotes every state"); + } + + private static async Task ConfigureAsync(Action check) + { + var running = Vm("Run Static", "Running", ("memoryStartup", 2L << 30)); + var dynamic = Vm("Run Dynamic", "Running", ("dynamicMemory", true), ("memoryMinimum", 512L << 20), ("memoryMaximum", 8L << 30)); + var off = Vm("Off One", "Off"); + var offStatic = Vm("Off Two", "Off"); + var paused = Vm("Paused One", "Paused"); + var saved = Vm("Saved One", "Saved"); + using var mock = new MockHyperV(new { vms = new object[] { running, dynamic, off, offStatic, paused, saved }, switches = Switches() }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + mock.ClearCalls(); + var refused = await Expect(() => lifecycle.ConfigureAsync("Run Static", new VmConfigureRequest { Processors = 4, DynamicMemory = true, MemoryMb = 3072, Notes = "n" }, default)); + check(refused?.ErrorType == "InvalidVmState" && refused.Message.Contains("processors (") && refused.Message.Contains("dynamicMemory (") + && !refused.Message.Contains("memoryMb") && !refused.Message.Contains("notes") && refused.Message.Contains("Nothing was changed") && !Mutated(mock), + "Running: the refusal names exactly the settings that need Off (vCPUs, dynamic memory toggle) and nothing changes first"); + + mock.ClearCalls(); + var live = await lifecycle.ConfigureAsync("Run Static", new VmConfigureRequest + { + MemoryMb = 3072, Notes = "lab notes", AutoStartAction = "Start", AutoStartDelaySeconds = 30, AutoCheckpoints = false, CheckpointType = "Standard", + IntegrationServices = new Dictionary { ["Guest Service Interface"] = true, ["vss"] = false } + }, default); + var memory = Single(mock, "Set-VMMemory"); + var setVm = Single(mock, "Set-VM"); + check(memory.GetProperty("StartupBytes").GetInt64() == 3072L << 20 && !memory.TryGetProperty("DynamicMemoryEnabled", out _) + && setVm.GetProperty("Notes").GetString() == "lab notes" && setVm.GetProperty("AutomaticStartAction").GetString() == "Start" + && setVm.GetProperty("AutomaticStartDelay").GetInt32() == 30 && !setVm.GetProperty("AutomaticCheckpointsEnabled").GetBoolean() + && Single(mock, "Enable-VMIntegrationService").GetProperty("Name").GetString() == "Guest Service Interface" + && Single(mock, "Disable-VMIntegrationService").GetProperty("Name").GetString() == "VSS" + && live.GetProperty("Notes").GetString() == "lab notes" && live.GetProperty("MemoryStartup").GetInt64() == 3072L << 20 + && live.GetProperty("IntegrationServices").GetProperty("Guest Service Interface").GetBoolean() && !live.GetProperty("IntegrationServices").GetProperty("VSS").GetBoolean(), + "Running: static memory resize, notes, start action, checkpoints and integration services (matched by locale-independent ID) apply live"); + + mock.ClearCalls(); + var shrink = await Expect(() => lifecycle.ConfigureAsync("Run Dynamic", new VmConfigureRequest { MemoryMaxMb = 4096 }, default)); + var startup = await Expect(() => lifecycle.ConfigureAsync("Run Dynamic", new VmConfigureRequest { MemoryMb = 4096 }, default)); + check(shrink?.ErrorType == "InvalidVmState" && shrink.Message.Contains("memoryMaxMb") && shrink.Message.Contains("only increase") + && startup?.ErrorType == "InvalidVmState" && startup.Message.Contains("only with static memory") && !Mutated(mock), + "Running with dynamic memory: lowering the maximum or changing startup memory is refused before any change"); + + mock.ClearCalls(); + await lifecycle.ConfigureAsync("Run Dynamic", new VmConfigureRequest { MemoryMaxMb = 16384, MemoryMinMb = 256, MemoryBufferPercent = 30 }, default); + var bounds = Single(mock, "Set-VMMemory"); + check(bounds.GetProperty("MaximumBytes").GetInt64() == 16L << 30 && bounds.GetProperty("MinimumBytes").GetInt64() == 256L << 20 && bounds.GetProperty("Buffer").GetInt32() == 30, + "Running with dynamic memory: a larger maximum, smaller minimum and buffer apply in one Set-VMMemory call"); + + mock.ClearCalls(); + var pausedRefusal = await Expect(() => lifecycle.ConfigureAsync("Paused One", new VmConfigureRequest { Notes = "x" }, default)); + var savedRefusal = await Expect(() => lifecycle.ConfigureAsync("Saved One", new VmConfigureRequest { Notes = "x", NewName = "Saved Renamed" }, default)); + check(pausedRefusal?.ErrorType == "InvalidVmState" && pausedRefusal.Message.Contains("Resume") && savedRefusal?.ErrorType == "InvalidVmState" + && savedRefusal.Message.Contains("saved VM accepts only") && savedRefusal.Message.Contains("notes (") && !savedRefusal.Message.Contains("newName") && !Mutated(mock), + "Paused VMs accept nothing; a Saved VM refuses notes while allowing rename, all-or-nothing"); + await lifecycle.ConfigureAsync("Saved One", new VmConfigureRequest { NewName = "Saved Renamed" }, default); + check(Single(mock, "Rename-VM").GetProperty("NewName").GetString() == "Saved Renamed", "A Saved VM accepts a rename"); + + mock.ClearCalls(); + var applied = await lifecycle.ConfigureAsync("Off One", new VmConfigureRequest + { + Processors = 8, DynamicMemory = true, MemoryMinMb = 1024, MemoryMaxMb = 8192, MemoryMb = 2048, AutoStopAction = "ShutDown", + NestedVirtualization = true, EnhancedSessionTransport = "HvSocket" + }, default); + var processor = Single(mock, "Set-VMProcessor"); + var offMemory = Single(mock, "Set-VMMemory"); + var offVm = Single(mock, "Set-VM"); + check(processor.GetProperty("Count").GetInt64() == 8 && processor.GetProperty("ExposeVirtualizationExtensions").GetBoolean() + && offMemory.GetProperty("DynamicMemoryEnabled").GetBoolean() && offMemory.GetProperty("StartupBytes").GetInt64() == 2048L << 20 + && offMemory.GetProperty("MinimumBytes").GetInt64() == 1024L << 20 && offMemory.GetProperty("MaximumBytes").GetInt64() == 8192L << 20 + && offVm.GetProperty("AutomaticStopAction").GetString() == "ShutDown" && offVm.GetProperty("EnhancedSessionTransportType").GetString() == "HvSocket" + && applied.GetProperty("DynamicMemoryEnabled").GetBoolean() && applied.GetProperty("NestedVirtualization").GetBoolean() && applied.GetProperty("ProcessorCount").GetInt32() == 8, + "Off: vCPUs, dynamic memory with bounds, stop action, nested virtualization and session transport apply and read back"); + + mock.ClearCalls(); + var noDynamic = await Expect(() => lifecycle.ConfigureAsync("Off Two", new VmConfigureRequest { MemoryMinMb = 1024 }, default)); + var badOrder = await Expect(() => lifecycle.ConfigureAsync("Off Two", new VmConfigureRequest { DynamicMemory = true, MemoryMb = 4096, MemoryMaxMb = 2048 }, default)); + var unknownService = await Expect(() => lifecycle.ConfigureAsync("Off Two", new VmConfigureRequest { Notes = "x", IntegrationServices = new Dictionary { ["Nope"] = true } }, default)); + check(noDynamic?.ErrorType == "InvalidArgument" && badOrder?.ErrorType == "InvalidArgument" && badOrder.Message.Contains("minimum <= startup <= maximum") + && unknownService?.ErrorType == "InvalidArgument" && unknownService.Message.Contains("Heartbeat") && !Mutated(mock), + "Invalid memory bounds and unknown integration services are refused against the VM's current values before any change"); + check(await Throws(() => lifecycle.ConfigureAsync("Off Two", new VmConfigureRequest { MemoryMb = 33 }, default)) + && await Throws(() => lifecycle.ConfigureAsync("Off Two", new VmConfigureRequest { AutoStartAction = "Always" }, default)), + "Odd memory sizes and unknown actions fail validation before PowerShell runs"); + } + + private static async Task CreateAsync(Action check) + { + using var box = new Sandbox(); + var existing = Vm("Existing", "Off", ("disks", new[] { box.File(@"VHDs\existing.vhdx") })); + var parent = box.File(@"parents\parent.vhdx"); + var loose = box.File(@"loose\loose.vhdx"); + var iso = box.File(@"iso\setup.iso"); + using var mock = new MockHyperV(new + { + host = Host(box), vms = new object[] { existing }, switches = Switches(), + vhds = new object[] { Vhd(parent, size: 10L << 30) } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + var defaults = new VmCreateRequest("x"); + check(defaults is { Source: "new_disk", Generation: 2, MemoryMb: 4096, Processors: 2, DiskGb: 64, OsFamily: "windows", CheckpointType: "Standard", AutoCheckpoints: false, Start: false, AllowDuplicateName: false } + && defaults.SecureBoot is null && defaults.Tpm is null, + "Create defaults: new 64 GB disk, Generation 2, 4096 MB static, 2 vCPUs, Standard checkpoints without automatic ones, stays Off"); + + mock.ClearCalls(); + var created = await lifecycle.CreateAsync(new VmCreateRequest("Win A") { DiskGb = 2 }, default); + var disk = box.P(@"VHDs\Win A.vhdx"); + var newVm = Single(mock, "New-VM"); + check(newVm.GetProperty("Generation").GetInt32() == 2 && newVm.GetProperty("MemoryStartupBytes").GetInt64() == 4096L << 20 + && newVm.GetProperty("NewVHDPath").GetString() == disk && newVm.GetProperty("NewVHDSizeBytes").GetInt64() == 2L << 30 + && newVm.GetProperty("Path").GetString() == box.P("VMs") && newVm.GetProperty("SwitchName").GetString() == "Default Switch" && !newVm.TryGetProperty("BootDevice", out _) + && Single(mock, "Set-VMProcessor").GetProperty("Count").GetInt64() == 2 + && Single(mock, "Set-VM").GetProperty("CheckpointType").GetString() == "Standard" && !Single(mock, "Set-VM").GetProperty("AutomaticCheckpointsEnabled").GetBoolean() + && File.Exists(disk) && created.GetProperty("createdFiles").EnumerateArray().Single().GetString() == disk && created.GetProperty("State").GetString() == "Off", + "Create new_disk: default paths from Get-VMHost, Default Switch by ID, typed New-VM, CPU and checkpoint settings; the disk is reported as created"); + var template = First(mock, "Set-VMFirmware", p => p.TryGetProperty("SecureBootTemplate", out _)); + var secureBoot = First(mock, "Set-VMFirmware", p => p.TryGetProperty("EnableSecureBoot", out _)); + var protector = First(mock, "Set-VMKeyProtector", p => Flag(p, "NewLocalKeyProtector")); + var tpm = First(mock, "Enable-VMTPM"); + check(First(mock, "New-VM") < template && template < secureBoot && secureBoot < protector && protector < tpm + && Single(mock, "Set-VMFirmware", p => p.TryGetProperty("SecureBootTemplate", out _)).GetProperty("SecureBootTemplate").GetString() == "MicrosoftWindows" + && created.GetProperty("Security").GetProperty("TpmEnabled").GetBoolean() && created.GetProperty("Firmware").GetProperty("SecureBoot").GetString() == "On" + && Texts(created, "warnings").Any(w => w.Contains("key protector")) && Texts(created, "nextSteps").Any(s => s.Contains("attach_iso")), + "Create windows: Secure Boot template before Secure Boot, then a local key protector before Enable-VMTPM; warnings and next steps say why"); + + mock.ClearCalls(); + await lifecycle.CreateAsync(new VmCreateRequest("Win B") { DiskGb = 2, Path = box.P("custom") }, default); + check(Single(mock, "New-VM").GetProperty("NewVHDPath").GetString() == box.P(@"custom\Win B\Virtual Hard Disks\Win B.vhdx") && Single(mock, "New-VM").GetProperty("Path").GetString() == box.P("custom"), + "Create with path: the VM folder is \\ and its disk goes to \\\\Virtual Hard Disks"); + + mock.ClearCalls(); + var duplicate = await Expect(() => lifecycle.CreateAsync(new VmCreateRequest("Existing") { DiskGb = 2 }, default)); + check(duplicate?.ErrorType == "InvalidArgument" && duplicate.Message.Contains("allowDuplicateName") && !Mutated(mock), "Create refuses an existing VM name before any change"); + var twin = await lifecycle.CreateAsync(new VmCreateRequest("Existing") { Source = "none", AllowDuplicateName = true }, default); + check(Single(mock, "New-VM").GetProperty("NoVHD").GetBoolean() && !Single(mock, "New-VM").TryGetProperty("NewVHDPath", out _) + && Texts(twin, "warnings").Any(w => w.Contains("same name") || w.Contains("this name")) && twin.GetProperty("createdFiles").GetArrayLength() == 0, + "Create none with allowDuplicateName: New-VM -NoVHD and a warning to use the ID"); + + mock.ClearCalls(); + await lifecycle.CreateAsync(new VmCreateRequest("Iso A") { Source = "iso", IsoPath = iso, DiskGb = 2 }, default); + var isoNew = Single(mock, "New-VM"); + var dvd = First(mock, "Add-VMDvdDrive", p => Str(p, "Path") == iso && Flag(p, "Passthru")); + var firstBoot = First(mock, "Set-VMFirmware", p => Str(p, "FirstBootDevice") == "DVD"); + check(isoNew.TryGetProperty("NewVHDPath", out _) && !isoNew.TryGetProperty("BootDevice", out _) && First(mock, "New-VM") < dvd && dvd < firstBoot + && First(mock, "Enable-VMTPM") < dvd, + "Create iso: New-VM with the disk and no boot device, then Add-VMDvdDrive, then the DVD as first boot device"); + + mock.ClearCalls(); + await lifecycle.CreateAsync(new VmCreateRequest("Attach A") { Source = "existing_vhd", VhdPath = loose }, default); + check(Single(mock, "New-VM").GetProperty("VHDPath").GetString() == loose && !Single(mock, "New-VM").TryGetProperty("NewVHDPath", out _), + "Create existing_vhd attaches the given disk"); + mock.ClearCalls(); + var taken = await Expect(() => lifecycle.CreateAsync(new VmCreateRequest("Attach B") { Source = "existing_vhd", VhdPath = box.P(@"VHDs\existing.vhdx") }, default)); + check(taken?.ErrorType == "InvalidArgument" && taken.Message.Contains("attached to VM 'Existing'") && !Mutated(mock), "Create refuses a disk another VM attaches"); + + mock.ClearCalls(); + var linked = await lifecycle.CreateAsync(new VmCreateRequest("Linked A") { Source = "linked", ParentPath = parent }, default); + var child = box.P(@"VHDs\Linked A.vhdx"); + var newVhd = First(mock, "New-VHD", p => Str(p, "Path") == child && Str(p, "ParentPath") == parent && Flag(p, "Differencing")); + check(newVhd >= 0 && newVhd < First(mock, "New-VM") && Single(mock, "New-VM").GetProperty("VHDPath").GetString() == child + && File.GetAttributes(parent).HasFlag(FileAttributes.ReadOnly) && Texts(linked, "warnings").Any(w => w.Contains("read-only")) + && linked.GetProperty("createdFiles").EnumerateArray().Single().GetString() == child, + "Create linked: a differencing disk over the parent before New-VM, the parent marked read-only and reported"); + mock.ClearCalls(); + var attachedParent = await Expect(() => lifecycle.CreateAsync(new VmCreateRequest("Linked B") { Source = "linked", ParentPath = box.P(@"VHDs\existing.vhdx") }, default)); + check(attachedParent?.ErrorType == "InvalidArgument" && !mock.Called("New-VHD") && !Mutated(mock), "Create linked refuses a parent that a VM attaches"); + + mock.ClearCalls(); + await lifecycle.CreateAsync(new VmCreateRequest("Tux") { OsFamily = "linux", DiskGb = 2, SwitchName = "" }, default); + check(Single(mock, "Set-VMFirmware", p => p.TryGetProperty("SecureBootTemplate", out _)).GetProperty("SecureBootTemplate").GetString() == "MicrosoftUEFICertificateAuthority" + && Single(mock, "Set-VM").GetProperty("EnhancedSessionTransportType").GetString() == "HvSocket" && !mock.Called("Enable-VMTPM") && !mock.Called("Set-VMKeyProtector") + && !Single(mock, "New-VM").TryGetProperty("SwitchName", out _), + "Create linux: the UEFI CA template and HvSocket transport, no vTPM; an empty switchName leaves the adapter disconnected"); + + mock.ClearCalls(); + await lifecycle.CreateAsync(new VmCreateRequest("Lab Net") { Source = "none", SwitchName = "Lab Switch", OsFamily = "other" }, default); + var missingSwitch = await Expect(() => lifecycle.CreateAsync(new VmCreateRequest("No Net") { Source = "none", SwitchName = "Nope" }, default)); + check(First(mock, "New-VM", p => Str(p, "SwitchName") == "Lab Switch") >= 0 + && First(mock, "Set-VMFirmware", p => Str(p, "EnableSecureBoot") == "Off") >= 0 + && missingSwitch?.ErrorType == "NotFound" && Calls(mock, "New-VM").Count == 1, + "Create resolves an explicit switch by exact name, refuses an unknown one, and turns Secure Boot off for osFamily=other"); + + mock.ClearCalls(); + var huge = await Expect(() => lifecycle.CreateAsync(new VmCreateRequest("Huge") { DiskGb = 65536 }, default)); + check(huge?.ErrorType == "InvalidArgument" && huge.Message.Contains("free") && !Mutated(mock), "Create refuses a disk larger than the volume's free space before any change"); + + SetState(mock, state => state["failOn"] = new JsonArray("Enable-VMTPM")); + mock.ClearCalls(); + var failed = await Expect(() => lifecycle.CreateAsync(new VmCreateRequest("Rollback A") { DiskGb = 2 }, default)); + check(failed?.ErrorType == "HyperVOperationFailed" && mock.Called("Remove-VM") && !File.Exists(box.P(@"VHDs\Rollback A.vhdx")) + && !(await service.HyperV.GetInventoryAsync()).Machines.Any(m => m.Name == "Rollback A"), + "A failed step after New-VM removes the half-created VM and deletes its new disk, then reports the original error"); + SetState(mock, state => state.Remove("failOn")); + + check(await Throws(() => lifecycle.CreateAsync(new VmCreateRequest("Gen1 TPM") { Generation = 1, Tpm = true }, default)) + && await Throws(() => lifecycle.CreateAsync(new VmCreateRequest("bad/name"), default)) + && await Throws(() => lifecycle.CreateAsync(new VmCreateRequest("Rel") { Path = "relative" }, default)) + && await Throws(() => lifecycle.CreateAsync(new VmCreateRequest("NoDisk") { Source = "none", VhdPath = loose }, default)) + && await Throws(() => lifecycle.CreateAsync(new VmCreateRequest("Dyn") { DynamicMemory = true, MemoryMinMb = 8192 }, default)) + && await Throws(() => lifecycle.CreateAsync(new VmCreateRequest("Iso B") { Source = "iso" }, default)), + "Create validates generation features, names, absolute paths, source parameters and memory bounds before PowerShell runs"); + } + + private static async Task RemoveAsync(Action check) + { + using var box = new Sandbox(); + var snapFolder = box.P(@"VMs\Snap"); + var snapBase = box.File(@"VMs\Snap\Virtual Hard Disks\snap.vhdx"); + var snapA = box.File(@"VMs\Snap\Virtual Hard Disks\snap_A.avhdx"); + var snapB = box.File(@"VMs\Snap\Virtual Hard Disks\snap_B.avhdx"); + string s1 = Guid.NewGuid().ToString(), s2 = Guid.NewGuid().ToString(); + var snap = Vm("Snap", "Off", ("path", snapFolder), ("disks", new[] { snapB }), + ("snapshots", new object[] { Snapshot(s1, null, snapBase), Snapshot(s2, s1, snapA) })); + box.File($@"VMs\Snap\Virtual Machines\{Id(snap).ToUpperInvariant()}.vmcx"); + var baseDisk = box.File(@"VHDs\base.vhdx"); + var childDisk = box.File(@"VHDs\child.vhdx"); + var shared = box.File(@"VHDs\shared.vhdx"); + var lonely = box.File(@"VHDs\lonely.vhdx"); + var lonelyChild = box.File(@"VHDs\lonely-child.vhdx"); + var savedDisk = box.File(@"VHDs\saved1.vhdx"); + var isoDisk = box.File(@"VHDs\isovm.vhdx"); + var iso = box.File(@"iso\x.iso"); + using var mock = new MockHyperV(new + { + host = Host(box), switches = Switches(), + vms = new object[] + { + snap, Vm("Base", "Off", ("disks", new[] { baseDisk })), Vm("Child", "Off", ("disks", new[] { childDisk })), + Vm("Shared A", "Off", ("disks", new[] { shared })), Vm("Shared B", "Off", ("disks", new[] { shared })), + Vm("Lonely", "Off", ("disks", new[] { lonely })), Vm("Running One", "Running"), Vm("Paused One", "Paused"), + Vm("Saved Snap", "Saved", ("snapshots", new object[] { Snapshot(Guid.NewGuid().ToString(), null) })), + Vm("Saved One", "Saved", ("disks", new[] { savedDisk })), Vm("Iso VM", "Off", ("disks", new[] { isoDisk }), ("dvd", iso)) + }, + vhds = new object[] { Vhd(snapB, snapA), Vhd(snapA, snapBase), Vhd(childDisk, baseDisk), Vhd(lonelyChild, lonely) } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + mock.ClearCalls(); + var running = await Expect(() => lifecycle.RemoveAsync("Running One", true, false, default)); + var paused = await Expect(() => lifecycle.RemoveAsync("Paused One", false, false, default)); + var savedSnap = await Expect(() => lifecycle.RemoveAsync("Saved Snap", true, false, default)); + check(running?.ErrorType == "InvalidVmState" && paused?.ErrorType == "InvalidVmState" && savedSnap?.ErrorType == "InvalidVmState" + && savedSnap.Message.Contains("checkpoint") && !Mutated(mock), + "Remove never stops a VM: Running and Paused are refused, and a Saved VM with checkpoints cannot delete its disks; nothing changes"); + + mock.ClearCalls(); + var plan = (await lifecycle.RemoveAsync("Snap", true, true, default)).GetProperty("plan"); + check(plan.GetProperty("checkpoints").GetInt32() == 2 && Texts(plan, "merge").ToHashSet().SetEquals([snapA, snapB]) + && Texts(plan, "delete").SequenceEqual([snapBase]) && plan.GetProperty("keep").GetArrayLength() == 0 && !Mutated(mock) + && File.Exists(snapBase) && File.Exists(snapA) && File.Exists(snapB), + "dryRun plans the checkpoint merge (.avhdx files) and deletion of the base disk they merge into, and changes nothing"); + + var basePlan = (await lifecycle.RemoveAsync("Base", true, true, default)).GetProperty("plan"); + var childPlan = (await lifecycle.RemoveAsync("Child", true, true, default)).GetProperty("plan"); + var sharedPlan = (await lifecycle.RemoveAsync("Shared A", true, true, default)).GetProperty("plan"); + var lonelyPlan = (await lifecycle.RemoveAsync("Lonely", true, true, default)).GetProperty("plan"); + var isoPlan = (await lifecycle.RemoveAsync("Iso VM", true, true, default)).GetProperty("plan"); + var keepPlan = (await lifecycle.RemoveAsync("Snap", false, true, default)).GetProperty("plan"); + check(basePlan.GetProperty("delete").GetArrayLength() == 0 && Reason(basePlan, baseDisk).Contains("differencing parent") && Reason(basePlan, baseDisk).Contains("'Child'") + && Texts(childPlan, "delete").SequenceEqual([childDisk]) && childPlan.GetProperty("keep").GetArrayLength() == 0 + && Reason(sharedPlan, shared).Contains("attached to VM 'Shared B'") + && Reason(lonelyPlan, lonely).Contains("lonely-child.vhdx") + && Reason(isoPlan, iso).Contains("never deleted") && Texts(isoPlan, "delete").SequenceEqual([isoDisk]) + && Reason(keepPlan, snapBase) == "deleteDisks is false" && keepPlan.GetProperty("delete").GetArrayLength() == 0 && !Mutated(mock), + "Removal plans keep differencing parents, disks shared with another VM, parents of unattached disks beside them and ISOs, each with its reason"); + + mock.ClearCalls(); + var removed = await lifecycle.RemoveAsync("Snap", true, false, default); + var removeSnapshot = First(mock, "Remove-VMSnapshot", p => Str(p, "VMSnapshot") == s1 && Flag(p, "IncludeAllChildSnapshots")); + check(removeSnapshot >= 0 && removeSnapshot < First(mock, "Remove-VM") && removed.GetProperty("checkpointsRemoved").GetInt32() == 2 + && Texts(removed, "deleted").SequenceEqual([snapBase]) && !File.Exists(snapBase) && !File.Exists(snapA) && !File.Exists(snapB) + && Texts(removed, "removedDirectories").SequenceEqual([snapFolder]) && !Directory.Exists(snapFolder) + && !(await service.HyperV.GetInventoryAsync()).Machines.Any(m => m.Name == "Snap"), + "deleteDisks removes the checkpoints first and waits for the merge, then the VM, then the base disk, verified gone with the emptied VM folder"); + + mock.ClearCalls(); + var childRemoved = await lifecycle.RemoveAsync("Child", true, false, default); + var savedRemoved = await lifecycle.RemoveAsync("Saved One", false, false, default); + check(Texts(childRemoved, "deleted").SequenceEqual([childDisk]) && !File.Exists(childDisk) && File.Exists(baseDisk) + && savedRemoved.GetProperty("success").GetBoolean() && File.Exists(savedDisk) && Reason(savedRemoved, savedDisk, "kept") == "deleteDisks is false" + && Calls(mock, "Remove-VM").Count == 2, + "A linked clone's removal deletes its own differencing disk but never its parent; a Saved VM is removed with its disks kept"); + + var tools = new HyperVTools(service); + var envelope = await tools.RemoveVm("Running One"); + using (var document = JsonDocument.Parse(envelope.Content.OfType().Single().Text)) + check(envelope.IsError == true && document.RootElement.GetProperty("errorType").GetString() == "InvalidVmState", "hyperv_vm_remove reports refusals in the uniform envelope"); + } + + private static async Task FirmwareAsync(Action check) + { + using var mock = new MockHyperV(new + { + switches = Switches(), + vms = new object[] + { + Vm("Fw", "Off"), Vm("Fw Kp", "Off", ("keyProtector", true), ("disks", new[] { @"C:\MockVHDs\fwkp.vhdx" })), Vm("Fw Run", "Running"), Vm("Fw Gen1", "Off", ("generation", 1)), + Vm("Fw Tpm", "Off", ("tpm", true), ("keyProtector", true)) + } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + mock.ClearCalls(); + var result = await lifecycle.FirmwareAsync("Fw", true, "MicrosoftUEFICertificateAuthority", true, null, default); + var template = First(mock, "Set-VMFirmware", p => p.TryGetProperty("SecureBootTemplate", out _)); + var secureBoot = First(mock, "Set-VMFirmware", p => Str(p, "EnableSecureBoot") == "On"); + var protector = First(mock, "Set-VMKeyProtector"); + var tpm = First(mock, "Enable-VMTPM"); + check(template >= 0 && template < secureBoot && secureBoot < protector && protector < tpm + && result.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && result.GetProperty("security").GetProperty("KeyProtector").GetBoolean() + && result.GetProperty("firmware").GetProperty("SecureBootTemplate").GetString() == "MicrosoftUEFICertificateAuthority" + && result.GetProperty("secureBootEnabled").GetBoolean() && Texts(result, "warnings").Any(w => w.Contains("key protector")), + "Firmware order: template, Secure Boot, a new local key protector, then the vTPM; security is read back and verified"); + + mock.ClearCalls(); + await lifecycle.FirmwareAsync("Fw Kp", null, null, true, "disk", default); + check(!mock.Called("Set-VMKeyProtector") && mock.Called("Enable-VMTPM") + && First(mock, "Set-VMFirmware", p => Str(p, "FirstBootDevice") == @"C:\MockVHDs\fwkp.vhdx") > First(mock, "Enable-VMTPM"), + "An existing key protector is reused, and the first boot device is set last"); + + mock.ClearCalls(); + var running = await Expect(() => lifecycle.FirmwareAsync("Fw Run", false, null, true, null, default)); + var gen1 = await Expect(() => lifecycle.FirmwareAsync("Fw Gen1", false, null, null, null, default)); + var noDvd = await Expect(() => lifecycle.FirmwareAsync("Fw", null, null, null, "dvd", default)); + check(running?.ErrorType == "InvalidVmState" && running.Message.Contains("secureBoot (") && running.Message.Contains("tpm (") + && gen1?.ErrorType == "InvalidArgument" && gen1.Message.Contains("Generation 1") && noDvd?.ErrorType == "InvalidArgument" && noDvd.Message.Contains("DVD") + && !Mutated(mock), + "Firmware refuses Running VMs (listing every setting), Generation 1 VMs and a missing boot device, before any change"); + + mock.ClearCalls(); + var read = await lifecycle.FirmwareAsync("Fw", null, null, null, null, default); + var disabled = await lifecycle.FirmwareAsync("Fw Tpm", null, null, false, null, default); + check(read.GetProperty("firmware").GetProperty("BootOrder").GetArrayLength() >= 1 && mock.Called("Disable-VMTPM") + && !disabled.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && Texts(disabled, "warnings").Any(w => w.Contains("BitLocker")) + && Calls(mock, "Set-VMFirmware").Count == 0, + "Firmware without settings only reads; disabling the vTPM warns about sealed guest secrets"); + + var tools = new HyperVTools(service); + var legacy = await tools.Firmware("Fw", secureBoot: false); + using (var document = JsonDocument.Parse(legacy.Content.OfType().Single().Text)) + check(legacy.IsError != true && document.RootElement.GetProperty("success").GetBoolean() && !document.RootElement.GetProperty("secureBootEnabled").GetBoolean(), + "The secureBoot-only hyperv_firmware call keeps working and reports secureBootEnabled"); + } + + private static async Task NetworkAsync(Action check) + { + var adapters = new object[] { new { name = "Network Adapter", switchName = "Default Switch", mac = "00155D000010", dynamicMac = true, spoofing = "Off", vlan = 0 } }; + using var mock = new MockHyperV(new + { + switches = Switches(), nats = new object[] { new { name = "Existing NAT", prefix = "10.0.0.0/24" } }, + vms = new object[] { Vm("Net Run2", "Running", ("adapters", adapters)), Vm("Net Run1", "Running", ("generation", 1)), Vm("Net Off", "Off", ("adapters", adapters)) } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + mock.ClearCalls(); + var added = await lifecycle.AddAdapterAsync("Net Run2", "Second", "Lab Switch", default); + var add = Single(mock, "Add-VMNetworkAdapter"); + check(add.GetProperty("Name").GetString() == "Second" && add.GetProperty("SwitchName").GetString() == "Lab Switch" && added.GetProperty("adapters").GetArrayLength() == 2, + "add_adapter hot-adds a named adapter to a Running Generation 2 VM"); + mock.ClearCalls(); + var gen1 = await Expect(() => lifecycle.AddAdapterAsync("Net Run1", "Second", null, default)); + var twice = await Expect(() => lifecycle.AddAdapterAsync("Net Run2", "Second", null, default)); + check(gen1?.ErrorType == "InvalidVmState" && gen1.Message.Contains("add_adapter (") && gen1.Message.Contains("Generation 2") + && twice?.ErrorType == "InvalidArgument" && !Mutated(mock), + "add_adapter refuses a Running Generation 1 VM and a duplicate adapter name before any change"); + + mock.ClearCalls(); + var removed = await lifecycle.RemoveAdapterAsync("Net Run2", "Second", default); + var missing = await Expect(() => lifecycle.RemoveAdapterAsync("Net Run2", "Nope", default)); + check(Single(mock, "Remove-VMNetworkAdapter").GetProperty("VMNetworkAdapter").GetString()!.EndsWith(@"\Second") && removed.GetProperty("adapters").GetArrayLength() == 1 + && missing?.ErrorType == "NotFound", + "remove_adapter removes exactly the named adapter from a Running Generation 2 VM; an unknown name is NotFound"); + + mock.ClearCalls(); + var set = await lifecycle.SetAdapterAsync("Net Run2", "Network Adapter", null, true, 10, default); + var refusedMac = await Expect(() => lifecycle.SetAdapterAsync("Net Run2", "Network Adapter", "00155D0A0B0C", null, null, default)); + check(Single(mock, "Set-VMNetworkAdapter").GetProperty("MacAddressSpoofing").GetString() == "On" + && Single(mock, "Set-VMNetworkAdapterVlan").GetProperty("Access").GetBoolean() && Single(mock, "Set-VMNetworkAdapterVlan").GetProperty("VlanId").GetInt32() == 10 + && set.GetProperty("adapters")[0].GetProperty("VlanId").GetInt32() == 10 && set.GetProperty("adapters")[0].GetProperty("MacAddressSpoofing").GetString() == "On" + && refusedMac?.ErrorType == "InvalidVmState" && refusedMac.Message.Contains("staticMac ("), + "set_adapter changes MAC spoofing and VLAN live and refuses a MAC change while Running"); + mock.ClearCalls(); + await lifecycle.SetAdapterAsync("Net Off", "Network Adapter", "00-15-5d-0a-0b-0c", null, 0, default); + check(Single(mock, "Set-VMNetworkAdapter").GetProperty("StaticMacAddress").GetString() == "00155D0A0B0C" && Single(mock, "Set-VMNetworkAdapterVlan").GetProperty("Untagged").GetBoolean() + && await Throws(() => lifecycle.SetAdapterAsync("Net Off", "Network Adapter", "01155D0A0B0C", null, null, default)), + "set_adapter normalizes a static MAC on an Off VM, clears the VLAN with 0, and rejects multicast MACs"); + + mock.ClearCalls(); + await lifecycle.ConnectAdapterAsync("Net Run2", "Network Adapter", "Lab Switch", default); + var unknownSwitch = await Expect(() => lifecycle.ConnectAdapterAsync("Net Run2", "Network Adapter", "Nope", default)); + await lifecycle.ConnectAdapterAsync("Net Run2", "Network Adapter", null, default); + check(Single(mock, "Connect-VMNetworkAdapter").GetProperty("SwitchName").GetString() == "Lab Switch" && unknownSwitch?.ErrorType == "NotFound" && mock.Called("Disconnect-VMNetworkAdapter"), + "connect/disconnect work while Running and refuse an unknown switch"); + + mock.ClearCalls(); + var nat = await lifecycle.CreateSwitchAsync("Lab NAT", "NAT", "192.168.200.0/24", default); + var newSwitch = First(mock, "New-VMSwitch", p => Str(p, "SwitchType") == "Internal" && Str(p, "Name") == "Lab NAT"); + var ip = First(mock, "New-NetIPAddress", p => Str(p, "IPAddress") == "192.168.200.1" && Num(p, "PrefixLength") == 24 && Num(p, "InterfaceIndex") == 40); + var winNat = First(mock, "New-NetNat", p => Str(p, "InternalIPInterfaceAddressPrefix") == "192.168.200.0/24" && Str(p, "Name") == "Lab NAT"); + check(newSwitch >= 0 && newSwitch < ip && ip < winNat && nat.GetProperty("nat").GetProperty("gateway").GetString() == "192.168.200.1", + "create NAT: an Internal switch, the gateway address on its host adapter, then the WinNAT network"); + mock.ClearCalls(); + var overlap = await Expect(() => lifecycle.CreateSwitchAsync("Overlap", "NAT", "10.0.0.0/16", default)); + check(overlap?.ErrorType == "InvalidArgument" && overlap.Message.Contains("Existing NAT") && !Mutated(mock) + && await Throws(() => lifecycle.CreateSwitchAsync("Bad", "NAT", "192.168.200.5/24", default)) + && await Throws(() => lifecycle.CreateSwitchAsync("Ext", "External", null, default)), + "create NAT refuses an overlapping NAT prefix before any change, an unaligned subnet, and External switches"); + mock.ClearCalls(); + var removedSwitch = await lifecycle.RemoveSwitchAsync("Lab NAT", default); + check(First(mock, "Remove-VMSwitch") < First(mock, "Remove-NetNat") && Texts(removedSwitch, "removedNat").SequenceEqual(["Lab NAT"]), + "remove deletes the switch and then its NAT network"); + } + + private static async Task MediaAsync(Action check) + { + using var box = new Sandbox(); + var boot = box.File(@"VHDs\boot.vhdx"); + var data = box.File(@"VHDs\data.vhdx"); + var extra = box.File(@"VHDs\extra.vhdx"); + var extra2 = box.File(@"VHDs\extra2.vhdx"); + var ide = box.File(@"VHDs\ide.vhdx"); + var iso = box.File(@"iso\x.iso"); + using var mock = new MockHyperV(new + { + host = Host(box), switches = Switches(), + vms = new object[] + { + Vm("Disk Run", "Running", ("disks", new[] { boot, data }), ("dvd", "")), + Vm("Disk Run1", "Running", ("generation", 1), ("disks", new object[] { new { path = ide, controllerType = "IDE", controllerNumber = 0, controllerLocation = 0 } })) + } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + mock.ClearCalls(); + await lifecycle.DiskAsync("Disk Run", "attach_vhd", extra, null, default); + await lifecycle.DiskAsync("Disk Run1", "attach_vhd", extra2, null, default); + await lifecycle.DiskAsync("Disk Run", "detach_vhd", data, null, default); + await lifecycle.DiskAsync("Disk Run", "attach_iso", iso, null, default); + await lifecycle.DiskAsync("Disk Run", "eject_iso", null, null, default); + var resized = await lifecycle.DiskAsync("Disk Run", "resize_vhd", boot, 80, default); + check(Calls(mock, "Add-VMHardDiskDrive").All(c => c.GetProperty("ControllerType").GetString() == "SCSI") && Calls(mock, "Add-VMHardDiskDrive").Count == 2 + && Single(mock, "Remove-VMHardDiskDrive").GetProperty("VMHardDiskDrive").GetString() == data + && Calls(mock, "Set-VMDvdDrive").Count == 2 && Calls(mock, "Set-VMDvdDrive")[0].GetProperty("Path").GetString() == iso + && Single(mock, "Resize-VHD").GetProperty("SizeBytes").GetInt64() == 80L << 30 && resized.GetProperty("success").GetBoolean(), + "Running: SCSI disk attach (Generation 1 too) and detach, ISO insert and eject, and online VHDX resize on SCSI"); + + mock.ClearCalls(); + var bootDetach = await Expect(() => lifecycle.DiskAsync("Disk Run", "detach_vhd", boot, null, default)); + var ideDetach = await Expect(() => lifecycle.DiskAsync("Disk Run1", "detach_vhd", ide, null, default)); + var shared = await Expect(() => lifecycle.DiskAsync("Disk Run1", "attach_vhd", boot, null, default)); + check(bootDetach?.ErrorType == "InvalidVmState" && bootDetach.Message.Contains("boot disk") && ideDetach?.ErrorType == "InvalidVmState" + && shared?.ErrorType == "InvalidArgument" && shared.Message.Contains("attached to VM 'Disk Run'") && !Mutated(mock), + "Running: the boot disk and IDE disks stay attached, and a disk another VM uses is never attached; nothing changes"); + } + + private static async Task VhdAsync(Action check) + { + using var box = new Sandbox(); + var attached = box.File(@"disks\attached.vhdx"); + var template = box.File(@"disks\tmpl.vhdx"); + var kid = box.File(@"disks\kid.vhdx"); + var loose = box.File(@"disks\loose.vhdx"); + var orphanParent = box.File(@"disks\orphan-parent.vhdx"); + var orphanChild = box.File(@"disks\orphan-child.vhdx"); + using var mock = new MockHyperV(new + { + host = Host(box), switches = Switches(), + vms = new object[] { Vm("Vhd User", "Off", ("disks", new[] { attached })), Vm("Vhd Kid", "Off", ("disks", new[] { kid })) }, + vhds = new object[] { Vhd(kid, template), Vhd(orphanChild, orphanParent), Vhd(loose, size: 5L << 30) } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + mock.ClearCalls(); + var data = box.P(@"new\data.vhdx"); + var created = await lifecycle.VhdAsync("create", data, "dynamic", 2, null, false, default); + await lifecycle.VhdAsync("create", box.P(@"new\fixed.vhdx"), "fixed", 1, null, false, default); + var exists = await Expect(() => lifecycle.VhdAsync("create", loose, "dynamic", 2, null, false, default)); + check(First(mock, "New-VHD", p => Str(p, "Path") == data && Num(p, "SizeBytes") == 2L << 30 && Flag(p, "Dynamic")) >= 0 + && First(mock, "New-VHD", p => Flag(p, "Fixed")) >= 0 && File.Exists(data) + && Texts(created, "createdFiles").SequenceEqual([data]) && created.GetProperty("type").GetString() == "Dynamic" + && exists?.ErrorType == "InvalidArgument" && Calls(mock, "New-VHD").Count == 2, + "vhd create makes dynamic and fixed disks in a new folder and never overwrites an existing file"); + + mock.ClearCalls(); + var diff = box.P(@"new\diff.vhdx"); + var differencing = await lifecycle.VhdAsync("create", diff, "differencing", null, loose, false, default); + var overAttached = await Expect(() => lifecycle.VhdAsync("create", box.P(@"new\bad.vhdx"), "differencing", null, attached, false, default)); + check(Single(mock, "New-VHD").GetProperty("ParentPath").GetString() == loose && differencing.GetProperty("chain").GetArrayLength() == 2 + && File.GetAttributes(loose).HasFlag(FileAttributes.ReadOnly) && overAttached?.ErrorType == "InvalidArgument" && Calls(mock, "New-VHD").Count == 1, + "vhd create differencing marks its parent read-only and refuses a parent that a VM attaches"); + + var kidInfo = await lifecycle.VhdAsync("inspect", kid, "dynamic", null, null, false, default); + var templateInfo = await lifecycle.VhdAsync("inspect", template, "dynamic", null, null, false, default); + check(kidInfo.GetProperty("chain").EnumerateArray().Select(link => link.GetProperty("Path").GetString()).SequenceEqual([kid, template]) + && kidInfo.GetProperty("attachedTo")[0].GetProperty("vmName").GetString() == "Vhd Kid" + && templateInfo.GetProperty("attachedTo").GetArrayLength() == 0 && templateInfo.GetProperty("parentOf")[0].GetProperty("vmName").GetString() == "Vhd Kid", + "vhd inspect reports the parent chain, attaching VMs and the disks a file is a parent of"); + + mock.ClearCalls(); + var attachedDelete = await Expect(() => lifecycle.VhdAsync("delete", attached, "dynamic", null, null, false, default)); + var templatePlan = await lifecycle.VhdAsync("delete", template, "dynamic", null, null, true, default); + var orphanPlan = await lifecycle.VhdAsync("delete", orphanParent, "dynamic", null, null, true, default); + var dataPlan = await lifecycle.VhdAsync("delete", data, "dynamic", null, null, true, default); + check(attachedDelete?.ErrorType == "InvalidArgument" && attachedDelete.Message.Contains("attached to VM 'Vhd User'") && File.Exists(attached) + && !templatePlan.GetProperty("deletable").GetBoolean() && Texts(templatePlan, "blockers").Any(b => b.Contains("differencing parent")) + && !orphanPlan.GetProperty("deletable").GetBoolean() && Texts(orphanPlan, "blockers").Any(b => b.Contains("orphan-child.vhdx")) + && dataPlan.GetProperty("deletable").GetBoolean() && File.Exists(data) && !Mutated(mock), + "vhd delete refuses attached disks and differencing parents (also of unattached disks); dryRun changes nothing"); + var deleted = await lifecycle.VhdAsync("delete", data, "dynamic", null, null, false, default); + check(Texts(deleted, "deleted").SequenceEqual([data]) && !File.Exists(data) + && await Throws(() => lifecycle.VhdAsync("delete", box.File(@"iso\a.iso"), "dynamic", null, null, false, default)) + && await Throws(() => lifecycle.VhdAsync("create", box.P("x.vhdx"), "dynamic", null, null, false, default)), + "vhd delete removes an unused disk; non-disk files and a missing sizeGb fail validation"); + } + + private static async Task CloneAndInfoAsync(Action check) + { + using var box = new Sandbox(); + var source = box.File(@"src\src.vhdx"); + var snapDisk = box.File(@"src\snap.vhdx"); + var adapters = new object[] { new { name = "Network Adapter", switchName = "Default Switch", mac = "00155D0000AA", dynamicMac = false, spoofing = "Off", vlan = 0 } }; + using var mock = new MockHyperV(new + { + host = Host(box), switches = Switches(), + vms = new object[] + { + Vm("Src", "Off", ("disks", new[] { source }), ("adapters", adapters), ("tpm", true), ("keyProtector", true), ("template", "MicrosoftUEFICertificateAuthority"), + ("dynamicMemory", true), ("memoryMinimum", 1L << 30), ("memoryMaximum", 8L << 30), ("processorCount", 4), ("nested", true), ("notes", "golden")), + Vm("Src Run", "Running", ("disks", new[] { box.File(@"src\run.vhdx") })), + Vm("Src Snap", "Off", ("disks", new[] { snapDisk }), ("snapshots", new object[] { Snapshot(Guid.NewGuid().ToString(), null, snapDisk) })) + } + }); + using var service = new ControlService(); + var lifecycle = service.Lifecycle; + + var info = await lifecycle.InfoAsync("Src", default); + check(info.GetProperty("state").GetString() == "Off" && info.GetProperty("configurationVersion").GetString() == "12.0" && info.GetProperty("creationTime").GetString()!.StartsWith("2026-01-02") + && info.GetProperty("notes").GetString() == "golden" && info.GetProperty("memory").GetProperty("maximumBytes").GetInt64() == 8L << 30 + && info.GetProperty("memory").GetProperty("bufferPercent").GetInt32() == 20 && info.GetProperty("processor").GetProperty("nestedVirtualization").GetBoolean() + && info.GetProperty("automaticActions").GetProperty("stopAction").GetString() == "Save" && info.GetProperty("checkpointCount").GetInt32() == 0 + && info.GetProperty("security").GetProperty("TpmEnabled").GetBoolean() && info.GetProperty("firmware").GetProperty("BootOrder")[0].GetProperty("Device").GetString() == source + && info.GetProperty("disks")[0].GetProperty("Chain")[0].GetProperty("Path").GetString() == source && info.GetProperty("disks")[0].GetProperty("ControllerType").GetString() == "SCSI" + && info.GetProperty("vm").GetProperty("Name").GetString() == "Src", + "vm_info adds state, version, creation time, notes, memory bounds, processor, automatic actions, checkpoints, security, boot order and disk chains"); + var defaults = await lifecycle.HostDefaultsAsync(default); + check(defaults.GetProperty("virtualMachinePath").GetString() == box.P("VMs") && defaults.GetProperty("defaultSwitch").GetProperty("present").GetBoolean() + && defaults.GetProperty("configurationVersions").GetProperty("default").GetString() == "12.0" && defaults.GetProperty("configurationVersions").GetProperty("supported").GetArrayLength() == 5 + && defaults.GetProperty("freeSpace").GetArrayLength() == 2 && defaults.GetProperty("secureBootTemplates").GetArrayLength() == 3, + "Capabilities host defaults: default paths, configuration versions, Secure Boot templates, Default Switch presence and free space"); + + mock.ClearCalls(); + var running = await Expect(() => lifecycle.CloneAsync("Src Run", "Clone X", "linked", null, default)); + var withCheckpoints = await Expect(() => lifecycle.CloneAsync("Src Snap", "Clone Y", "linked", null, default)); + var taken = await Expect(() => lifecycle.CloneAsync("Src", "Src Run", "linked", null, default)); + check(running?.ErrorType == "InvalidVmState" && withCheckpoints?.ErrorType == "InvalidVmState" && withCheckpoints.Message.Contains("checkpoints") + && taken?.ErrorType == "InvalidArgument" && !Mutated(mock) + && await Throws(() => lifecycle.CloneAsync("Src", "Clone Z", "copy", null, default)), + "Clone refuses a Running source, a source with checkpoints and an existing name before any change"); + + mock.ClearCalls(); + var linked = await lifecycle.CloneAsync("Src", "Src Clone", "linked", box.P("clones"), default); + var child = box.P(@"clones\Src Clone\Virtual Hard Disks\Src Clone.vhdx"); + var newVhd = First(mock, "New-VHD", p => Str(p, "Path") == child && Str(p, "ParentPath") == source && Flag(p, "Differencing")); + var newVm = First(mock, "New-VM", p => Str(p, "VHDPath") == child && Str(p, "Path") == box.P("clones") && Num(p, "Generation") == 2 && Num(p, "MemoryStartupBytes") == 4L << 30); + var cloneMemory = Single(mock, "Set-VMMemory"); + var template = First(mock, "Set-VMFirmware", p => Str(p, "SecureBootTemplate") == "MicrosoftUEFICertificateAuthority"); + check(newVhd >= 0 && newVhd < newVm && Single(mock, "Set-VMProcessor").GetProperty("Count").GetInt64() == 4 && Single(mock, "Set-VMProcessor").GetProperty("ExposeVirtualizationExtensions").GetBoolean() + && cloneMemory.GetProperty("DynamicMemoryEnabled").GetBoolean() && cloneMemory.GetProperty("MaximumBytes").GetInt64() == 8L << 30 + && template > newVm && template < First(mock, "Set-VMKeyProtector") && First(mock, "Set-VMKeyProtector") < First(mock, "Enable-VMTPM") + && First(mock, "Remove-VMNetworkAdapter") < First(mock, "Add-VMNetworkAdapter", p => Str(p, "SwitchName") == "Default Switch" && !p.TryGetProperty("StaticMacAddress", out _)) + && File.GetAttributes(source).HasFlag(FileAttributes.ReadOnly) && Texts(linked, "readOnlyParents").SequenceEqual([source]) + && linked.GetProperty("mode").GetString() == "linked" && linked.GetProperty("State").GetString() == "Off" && Texts(linked, "warnings").Any(w => w.Contains("read-only")), + "Linked clone: differencing disks over the source disks, the source's generation, memory, vCPUs, template and a new vTPM, adapters with new MACs; parents marked read-only"); + + mock.ClearCalls(); + var full = await lifecycle.CloneAsync("Src Snap", "Src Full", "full", box.P("full"), default); + var export = First(mock, "Export-VM", p => Str(p, "Path")?.StartsWith(box.P(@"full\.hyperv-control-export-")) == true); + var import = First(mock, "Import-VM", p => Flag(p, "Copy") && Flag(p, "GenerateNewId") + && Str(p, "VirtualMachinePath") == box.P(@"full\Src Full") && Str(p, "VhdDestinationPath") == box.P(@"full\Src Full\Virtual Hard Disks") + && Str(p, "SnapshotFilePath") == box.P(@"full\Src Full") && Str(p, "SmartPagingFilePath") == box.P(@"full\Src Full")); + check(export >= 0 && export < import && import < First(mock, "Rename-VM", p => Str(p, "NewName") == "Src Full") && mock.Called("Set-VHD") + && !Directory.EnumerateDirectories(box.P("full"), ".hyperv-control-export-*").Any() && full.GetProperty("mode").GetString() == "full" && full.GetProperty("Name").GetString() == "Src Full" + && Texts(full, "createdFiles").SequenceEqual([box.P(@"full\Src Full\Virtual Hard Disks\snap.vhdx")]), + "Full clone: Export-VM into a temporary folder, Import-VM -Copy -GenerateNewId into fresh folders, rename, reset disk IDs, temporary export removed"); + } + + /// A temporary folder for mock disk files; read-only attributes are cleared before deletion. + private sealed class Sandbox : IDisposable + { + public string Root { get; } = Path.Combine(Path.GetTempPath(), "HyperVControl-v2-" + Guid.NewGuid().ToString("N")[..8]); + public Sandbox() => Directory.CreateDirectory(Root); + public string P(string relative) => Path.Combine(Root, relative); + + public string File(string relative) + { + var path = P(relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + System.IO.File.WriteAllBytes(path, []); + return path; + } + + public void Dispose() + { + try + { + foreach (var file in Directory.EnumerateFiles(Root, "*", SearchOption.AllDirectories)) System.IO.File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(Root, true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private static Dictionary Vm(string name, string state, params (string Key, object? Value)[] extra) + { + var row = new Dictionary + { + ["id"] = Guid.NewGuid().ToString(), ["name"] = name, ["state"] = state, ["generation"] = 2, ["processorCount"] = 2, + ["memoryStartup"] = 4L << 30, ["dynamicMemory"] = false, ["disks"] = Array.Empty(), ["snapshots"] = Array.Empty() + }; + foreach (var (key, value) in extra) row[key] = value; + return row; + } + + private static string Id(Dictionary vm) => (string)vm["id"]!; + private static object Host(Sandbox box) => new { virtualMachinePath = box.P("VMs"), virtualHardDiskPath = box.P("VHDs") }; + private static object Vhd(string path, string? parent = null, long size = 64L << 30) => new { path, parentPath = parent, size, type = parent is null ? "Dynamic" : "Differencing" }; + private static object Snapshot(string id, string? parent, params string[] disks) => new { id, name = "checkpoint " + id[..4], creationTime = "2026-01-01T00:00:00Z", parentId = parent, disks }; + private static object[] Switches() => + [new { id = DefaultSwitchId, name = "Default Switch", type = "Internal" }, new { id = Guid.NewGuid().ToString(), name = "Lab Switch", type = "Private" }]; + + private static void SetState(MockHyperV mock, Action change) + { + var state = JsonNode.Parse(File.ReadAllText(mock.StatePath))!.AsObject(); + change(state); + File.WriteAllText(mock.StatePath, state.ToJsonString()); + } + + private static bool Mutated(MockHyperV mock) => mock.Calls().Any(call => MutatingVerbs.Contains(call.Cmdlet.Split('-')[0])); + private static List Calls(MockHyperV mock, string cmdlet) => mock.Calls().Where(c => c.Cmdlet == cmdlet).Select(c => c.Parameters).ToList(); + private static JsonElement Single(MockHyperV mock, string cmdlet, Func? where = null) => + Calls(mock, cmdlet).Where(p => where?.Invoke(p) ?? true).ToList() is [var only] ? only : throw new Exception($"Expected exactly one {cmdlet} call."); + private static int First(MockHyperV mock, string cmdlet, Func? where = null) => + mock.Calls().ToList().FindIndex(c => c.Cmdlet == cmdlet && (where?.Invoke(c.Parameters) ?? true)); + private static IEnumerable Texts(JsonElement element, string property) => element.GetProperty(property).EnumerateArray().Select(e => e.GetString()!); + private static string? Str(JsonElement parameters, string name) => parameters.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static bool Flag(JsonElement parameters, string name) => parameters.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + private static long Num(JsonElement parameters, string name) => parameters.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt64() : long.MinValue; + + private static string Reason(JsonElement plan, string path, string list = "keep") => + plan.GetProperty(list).EnumerateArray().FirstOrDefault(e => e.GetProperty("path").GetString() == path) is { ValueKind: JsonValueKind.Object } entry + ? entry.GetProperty("reason").GetString()! : ""; + + private static async Task Expect(Func action) + { + try { await action(); return null; } + catch (HyperVOperationException ex) { return ex; } + } + + private static async Task Throws(Func action) where T : Exception + { + try { await action(); return false; } + catch (T) { return true; } + } +} diff --git a/tests/HyperVControl.Tests/MockHyperV/Hyper-V/lifecycle.functions.ps1 b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/lifecycle.functions.ps1 new file mode 100644 index 0000000..17e4cdb --- /dev/null +++ b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/lifecycle.functions.ps1 @@ -0,0 +1,790 @@ +# Lifecycle v2 additions to the recording mock, dot-sourced after Hyper-V.psm1's own functions, so the +# definitions here replace the base ones. Virtual disk files are real, empty files under the test's temp +# folder; the state's 'vhds' table carries each file's type, size and parent. Optional state keys: +# host { virtualMachinePath, virtualHardDiskPath } failOn [cmdlet names that throw] +# vhds [{ path, type, parentPath, size, fileSize, mounted }] nats [{ name, prefix }] hostAdapters [{ switchName, mac }] +# per VM: memoryMinimum, memoryMaximum, memoryBuffer, notes, checkpointType, autoCheckpoints, autoStartAction, +# autoStartDelay, autoStopAction, enhancedSessionTransport, nested, path, secureBoot, template, tpm, keyProtector, +# tpmInitialized, firstBoot, dvd ('' = empty drive), adapters [{ name, switchName, mac, dynamicMac, spoofing, vlan }], +# integration { name: enabled }, disks [path | { path, controllerType, controllerNumber, controllerLocation }], +# snapshots[].disks, mergeDelay (Get-VM calls a merge takes after the last checkpoint is removed). + +$script:MockServices = [ordered]@{ + 'Guest Service Interface' = '6C09BB55-D683-4DA0-8931-C9BF705F6480' + 'Heartbeat' = '84EAAE65-2F2E-45F5-9BB5-0E857DC8EB47' + 'Key-Value Pair Exchange' = '2A34B1C2-FD73-4043-8A5B-DD2159BC743F' + 'Shutdown' = '9F8233AC-BE49-4C79-8EE3-E7E1985B2077' + 'Time Synchronization' = '2497F4DE-E9FA-4204-80E4-4B75C46419C0' + 'VSS' = '5CED1297-4598-4915-A5FC-AD21BB4D02A4' +} + +function Get-MockValue($Row, [string]$Name, $Default) { + if ($null -ne $Row -and $null -ne $Row.PSObject.Properties[$Name] -and $null -ne $Row.$Name) { return $Row.$Name } + return $Default +} + +function Set-MockProperty($Row, [string]$Name, $Value) { + if ($null -eq $Row.PSObject.Properties[$Name]) { $Row | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } + else { $Row.$Name = $Value } +} + +function Stop-Mock([string]$Message, [string]$ErrorId, [System.Management.Automation.ErrorCategory]$Category) { + Write-Error -Exception ([System.InvalidOperationException]::new($Message)) -ErrorId $ErrorId -Category $Category -ErrorAction Stop +} + +function Assert-MockFailure([string]$Cmdlet) { + $state = Read-MockState + if ($null -ne $state.PSObject.Properties['failOn'] -and @($state.failOn) -contains $Cmdlet) { + Stop-Mock "Mock failure injected for $Cmdlet." "OperationFailed,Microsoft.HyperV.PowerShell.Commands.$Cmdlet" InvalidOperation + } +} + +function Assert-MockOff($VM, [string]$Cmdlet) { + $row = Get-MockRow $VM + if ($null -ne $row -and [string]$row.state -ne 'Off') { + Stop-Mock "The operation cannot be performed while the virtual machine is in its current state." "InvalidState,Microsoft.HyperV.PowerShell.Commands.$Cmdlet" InvalidOperation + } +} + +function Get-MockVhdRow($State, [string]$Path) { + if ($null -eq $State.PSObject.Properties['vhds']) { return $null } + return @($State.vhds | Where-Object { $null -ne $_ -and [string]$_.path -eq $Path }) | Select-Object -First 1 +} + +function Add-MockVhdRow($State, $Row) { + if ($null -eq $State.PSObject.Properties['vhds']) { $State | Add-Member -NotePropertyName vhds -NotePropertyValue @() } + $State.vhds = @(@($State.vhds | Where-Object { $null -ne $_ -and [string]$_.path -ne [string]$Row.path }) + $Row) +} + +function Get-MockDiskPath($Disk) { if ($Disk -is [string]) { return $Disk }; return [string]$Disk.path } + +function ConvertTo-MockDrive($Disk, $VmId, [int]$Index) { + $type = 'SCSI'; $number = 0; $location = $Index + if ($Disk -isnot [string]) { + $type = [string](Get-MockValue $Disk 'controllerType' 'SCSI') + $number = [int](Get-MockValue $Disk 'controllerNumber' 0) + $location = [int](Get-MockValue $Disk 'controllerLocation' $Index) + } + $path = Get-MockDiskPath $Disk + [pscustomobject]@{ Id = $path; VMId = [guid]$VmId; Path = $path; ControllerType = $type; ControllerNumber = $number; ControllerLocation = $location } +} + +function Get-MockAdapters($Row) { + if ($null -eq $Row.PSObject.Properties['adapters']) { return @([pscustomobject]@{ name = 'Network Adapter'; switchName = 'Default Switch'; mac = '00155D000001'; dynamicMac = $true; spoofing = 'Off'; vlan = 0 }) } + return @($Row.adapters | Where-Object { $null -ne $_ }) +} + +function ConvertTo-MockAdapter($Adapter, $VmId) { + $vlan = [int](Get-MockValue $Adapter 'vlan' 0) + $switchName = Get-MockValue $Adapter 'switchName' $null + [pscustomobject]@{ + Id = 'Microsoft:' + [string]$VmId + '\' + [string]$Adapter.name; VMId = [guid]$VmId; Name = [string]$Adapter.name + SwitchName = $switchName; Connected = (-not [string]::IsNullOrEmpty([string]$switchName)) + MacAddress = [string](Get-MockValue $Adapter 'mac' '000000000000'); DynamicMacAddressEnabled = [bool](Get-MockValue $Adapter 'dynamicMac' $true) + MacAddressSpoofing = [string](Get-MockValue $Adapter 'spoofing' 'Off') + VlanSetting = [pscustomobject]@{ OperationMode = $(if ($vlan -gt 0) { 'Access' } else { 'Untagged' }); AccessVlanId = $vlan } + IPAddresses = @(); Status = @() + } +} + +function Update-MockAdapter($Adapter, [hashtable]$Values) { + $state = Read-MockState + foreach ($row in @($state.vms)) { + if ([string]$row.id -ne [string]$Adapter.VMId) { continue } + $adapters = @(Get-MockAdapters $row) + foreach ($entry in $adapters) { if ([string]$entry.name -eq [string]$Adapter.Name) { foreach ($key in $Values.Keys) { Set-MockProperty $entry $key $Values[$key] } } } + Set-MockProperty $row 'adapters' $adapters + } + Write-MockState $state +} + +function ConvertTo-MockVm($Row) { + $status = @('Ok') + if ([int](Get-MockValue $Row 'mergePolls' 0) -gt 0) { $status = @('Ok', 'MergingDisks') } + [pscustomobject]@{ + Id = [guid]$Row.id + Name = [string]$Row.name + State = [string]$Row.state + Generation = [int]$Row.generation + Version = [string](Get-MockValue $Row 'version' '12.0') + ProcessorCount = [int]$Row.processorCount + MemoryStartup = [long]$Row.memoryStartup + MemoryAssigned = [long]0 + MemoryMinimum = [long](Get-MockValue $Row 'memoryMinimum' 536870912) + MemoryMaximum = [long](Get-MockValue $Row 'memoryMaximum' 1099511627776) + DynamicMemoryEnabled = [bool]$Row.dynamicMemory + CheckpointType = [string](Get-MockValue $Row 'checkpointType' 'Production') + AutomaticCheckpointsEnabled = [bool](Get-MockValue $Row 'autoCheckpoints' $true) + AutomaticStartAction = [string](Get-MockValue $Row 'autoStartAction' 'StartIfRunning') + AutomaticStartDelay = [int](Get-MockValue $Row 'autoStartDelay' 0) + AutomaticStopAction = [string](Get-MockValue $Row 'autoStopAction' 'Save') + EnhancedSessionTransportType = [string](Get-MockValue $Row 'enhancedSessionTransport' 'VMBus') + Notes = [string](Get-MockValue $Row 'notes' '') + ParentSnapshotId = $null + Path = [string](Get-MockValue $Row 'path' 'C:\MockVMs') + CreationTime = [datetime]::new(2026, 1, 2, 3, 4, 5, [System.DateTimeKind]::Utc) + OperationalStatus = $status + Uptime = [timespan]::Zero + GuestOperatingSystem = $null + } +} + +# Hyper-V merges checkpoint files asynchronously: each Get-VM call counts a pending merge down, and at zero +# every drive moves to its first non-.avhdx ancestor and the merged .avhdx files disappear. +function Step-MockMerge { + $state = Read-MockState + $changed = $false + foreach ($row in @($state.vms)) { + $polls = [int](Get-MockValue $row 'mergePolls' 0) + if ($polls -le 0) { continue } + $changed = $true + $polls-- + $row.mergePolls = $polls + if ($polls -gt 0) { continue } + $disks = @() + foreach ($disk in @($row.disks | Where-Object { $null -ne $_ })) { + $path = Get-MockDiskPath $disk + while ($path -match '\.avhdx?$') { + $parent = [string](Get-MockValue (Get-MockVhdRow $state $path) 'parentPath' '') + if ([System.IO.File]::Exists($path)) { [System.IO.File]::Delete($path) } + $state.vhds = @($state.vhds | Where-Object { $null -ne $_ -and [string]$_.path -ne $path }) + if (-not $parent) { break } + $path = $parent + } + if ($disk -is [string]) { $disks += $path } else { $disk.path = $path; $disks += $disk } + } + $row.disks = $disks + } + if ($changed) { Write-MockState $state } +} + +function Get-VM { + [CmdletBinding()] + param([Parameter(Position = 0)][string[]]$Name, [guid[]]$Id) + Write-MockCall 'Get-VM' $PSBoundParameters + Step-MockMerge + $rows = @((Read-MockState).vms) + if ($PSBoundParameters.ContainsKey('Id')) { + foreach ($one in $Id) { + $row = @($rows | Where-Object { [string]$_.id -eq [string]$one }) + if ($row.Count -eq 0) { + $exception = [System.Management.Automation.ItemNotFoundException]::new("Hyper-V was unable to find a virtual machine with ID $one.") + Write-Error -Exception $exception -ErrorId 'InvalidParameter,Microsoft.HyperV.PowerShell.Commands.GetVM' -Category ObjectNotFound -ErrorAction Stop + } + ConvertTo-MockVm $row[0] + } + return + } + if ($PSBoundParameters.ContainsKey('Name')) { + $patterns = $Name + $rows = @($rows | Where-Object { $candidate = [string]$_.name; @($patterns | Where-Object { $candidate -like $_ }).Count -gt 0 }) + } + foreach ($row in $rows) { ConvertTo-MockVm $row } +} + +function Get-VMHost { + [CmdletBinding()] param() + Write-MockCall 'Get-VMHost' $PSBoundParameters + $hostRow = Get-MockValue (Read-MockState) 'host' $null + [pscustomobject]@{ + VirtualHardDiskPath = [string](Get-MockValue $hostRow 'virtualHardDiskPath' 'C:\MockVHDs') + VirtualMachinePath = [string](Get-MockValue $hostRow 'virtualMachinePath' 'C:\MockVMs') + EnableEnhancedSessionMode = $true; LogicalProcessorCount = 8; MemoryCapacity = [long]34359738368 + SecureBootTemplates = @([pscustomobject]@{ Name = 'MicrosoftWindows' }, [pscustomobject]@{ Name = 'MicrosoftUEFICertificateAuthority' }, [pscustomobject]@{ Name = 'OpenSourceShieldedVM' }) + } +} + +function New-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Name, [string]$Path, [int]$Generation = 1, [long]$MemoryStartupBytes = 536870912, + [string]$VHDPath, [string]$NewVHDPath, [long]$NewVHDSizeBytes, [string]$SwitchName, [switch]$NoVHD, [string]$Version, [string]$BootDevice, [switch]$Force) + Write-MockCall 'New-VM' $PSBoundParameters + Assert-MockFailure 'New-VM' + $state = Read-MockState + if ($NewVHDPath) { + if ([System.IO.File]::Exists($NewVHDPath)) { Stop-Mock 'The file exists. (0x80070050)' 'CreateVHD,Microsoft.HyperV.PowerShell.Commands.NewVM' ResourceExists } + if (-not [System.IO.Directory]::Exists([System.IO.Path]::GetDirectoryName($NewVHDPath))) { Stop-Mock 'The system cannot find the path specified.' 'ObjectNotFound,Microsoft.HyperV.PowerShell.Commands.NewVM' ObjectNotFound } + [System.IO.File]::WriteAllBytes($NewVHDPath, [byte[]]@()) + Add-MockVhdRow $state ([pscustomobject]@{ path = $NewVHDPath; type = 'Dynamic'; parentPath = $null; size = $NewVHDSizeBytes }) + } + $disks = @() + if ($VHDPath) { $disks += $VHDPath } elseif ($NewVHDPath) { $disks += $NewVHDPath } + $id = [guid]::NewGuid().ToString() + $vmPath = 'C:\MockVMs' + if ($Path) { + $vmPath = [System.IO.Path]::Combine($Path, $Name) + $configuration = [System.IO.Path]::Combine($vmPath, 'Virtual Machines') + [void][System.IO.Directory]::CreateDirectory($configuration) + [System.IO.File]::WriteAllBytes([System.IO.Path]::Combine($configuration, $id.ToUpperInvariant() + '.vmcx'), [byte[]]@()) + } + $adapter = [pscustomobject]@{ name = 'Network Adapter'; switchName = $(if ($SwitchName) { $SwitchName } else { $null }); mac = '000000000000'; dynamicMac = $true; spoofing = 'Off'; vlan = 0 } + $row = [pscustomobject]@{ + id = $id; name = $Name; state = 'Off'; generation = $Generation; processorCount = 1; memoryStartup = $MemoryStartupBytes; dynamicMemory = $false + disks = $disks; snapshots = @(); path = $vmPath; adapters = @($adapter); secureBoot = 'On'; template = 'MicrosoftWindows'; tpm = $false; keyProtector = $false + checkpointType = 'Production'; autoCheckpoints = $true + } + if ($Generation -eq 1) { $row | Add-Member -NotePropertyName dvd -NotePropertyValue '' } + $state.vms = @($state.vms) + $row + Write-MockState $state + ConvertTo-MockVm $row +} + +function Remove-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [switch]$Force) + process { + Write-MockCall 'Remove-VM' $PSBoundParameters + Assert-MockFailure 'Remove-VM' + if ($VM) { + $row = Get-MockRow $VM + if ($null -ne $row -and @('Off', 'Saved') -notcontains [string]$row.state) { Stop-Mock 'The operation cannot be performed while the object is in its current state.' 'InvalidState,Microsoft.HyperV.PowerShell.Commands.RemoveVM' InvalidOperation } + $configuration = [System.IO.Path]::Combine([string]$VM.Path, 'Virtual Machines', ([string]$VM.Id).ToUpperInvariant() + '.vmcx') + if ([System.IO.File]::Exists($configuration)) { [System.IO.File]::Delete($configuration) } + Remove-MockVm $VM + } + } +} + +function Set-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [long]$ProcessorCount, [long]$MemoryStartupBytes, [long]$MemoryMinimumBytes, [long]$MemoryMaximumBytes, + [switch]$DynamicMemory, [switch]$StaticMemory, [string]$CheckpointType, [bool]$AutomaticCheckpointsEnabled, [string]$Notes, + [string]$AutomaticStartAction, [int]$AutomaticStartDelay, [string]$AutomaticStopAction, [string]$NewVMName, [string]$EnhancedSessionTransportType, + [string]$SnapshotFileLocation, [string]$SmartPagingFilePath, [switch]$Passthru) + process { + Write-MockCall 'Set-VM' $PSBoundParameters + Assert-MockFailure 'Set-VM' + if ($VM) { + $map = @{ ProcessorCount = 'processorCount'; MemoryStartupBytes = 'memoryStartup'; CheckpointType = 'checkpointType'; AutomaticCheckpointsEnabled = 'autoCheckpoints' + Notes = 'notes'; AutomaticStartAction = 'autoStartAction'; AutomaticStartDelay = 'autoStartDelay'; AutomaticStopAction = 'autoStopAction' + NewVMName = 'name'; EnhancedSessionTransportType = 'enhancedSessionTransport' } + foreach ($key in $map.Keys) { if ($PSBoundParameters.ContainsKey($key)) { Update-MockVm $VM $map[$key] $PSBoundParameters[$key] } } + if ($DynamicMemory) { Update-MockVm $VM 'dynamicMemory' $true } + if ($StaticMemory) { Update-MockVm $VM 'dynamicMemory' $false } + } + } +} + +function Get-VMMemory { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { + Write-MockCall 'Get-VMMemory' $PSBoundParameters + $row = Get-MockRow $VM + [pscustomobject]@{ VMId = $VM.Id; Startup = [long]$row.memoryStartup; Minimum = [long](Get-MockValue $row 'memoryMinimum' 536870912) + Maximum = [long](Get-MockValue $row 'memoryMaximum' 1099511627776); DynamicMemoryEnabled = [bool]$row.dynamicMemory + Buffer = [int](Get-MockValue $row 'memoryBuffer' 20); Priority = 50 } + } +} + +function Set-VMMemory { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [long]$StartupBytes, [long]$MinimumBytes, [long]$MaximumBytes, [bool]$DynamicMemoryEnabled, [int]$Buffer, [int]$Priority, [switch]$Passthru) + process { + Write-MockCall 'Set-VMMemory' $PSBoundParameters + Assert-MockFailure 'Set-VMMemory' + if ($VM) { + $map = @{ StartupBytes = 'memoryStartup'; MinimumBytes = 'memoryMinimum'; MaximumBytes = 'memoryMaximum'; DynamicMemoryEnabled = 'dynamicMemory'; Buffer = 'memoryBuffer' } + foreach ($key in $map.Keys) { if ($PSBoundParameters.ContainsKey($key)) { Update-MockVm $VM $map[$key] $PSBoundParameters[$key] } } + } + } +} + +function Get-VMProcessor { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { + Write-MockCall 'Get-VMProcessor' $PSBoundParameters + $row = Get-MockRow $VM + [pscustomobject]@{ VMId = $VM.Id; Count = [int]$row.processorCount; Reserve = [long]0; Maximum = [long]100; RelativeWeight = 100; ExposeVirtualizationExtensions = [bool](Get-MockValue $row 'nested' $false) } + } +} + +function Set-VMProcessor { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [long]$Count, [long]$Reserve, [long]$Maximum, [int]$RelativeWeight, [bool]$ExposeVirtualizationExtensions, [switch]$Passthru) + process { + Write-MockCall 'Set-VMProcessor' $PSBoundParameters + Assert-MockFailure 'Set-VMProcessor' + if ($VM -and $PSBoundParameters.ContainsKey('Count')) { Update-MockVm $VM 'processorCount' $Count } + if ($VM -and $PSBoundParameters.ContainsKey('ExposeVirtualizationExtensions')) { Update-MockVm $VM 'nested' $ExposeVirtualizationExtensions } + } +} + +function Get-VMHardDiskDrive { + [CmdletBinding()] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, $VMSnapshot, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation) + process { + Write-MockCall 'Get-VMHardDiskDrive' $PSBoundParameters + $disks = @(); $vmId = $null + if ($VMSnapshot) { + $row = @((Read-MockState).vms | Where-Object { [string]$_.id -eq [string]$VMSnapshot.VMId }) | Select-Object -First 1 + $snapshot = @($row.snapshots | Where-Object { $null -ne $_ -and [string]$_.id -eq [string]$VMSnapshot.Id }) | Select-Object -First 1 + $disks = @(Get-MockValue $snapshot 'disks' @()); $vmId = $row.id + } else { + $row = Get-MockRow $VM + if ($null -eq $row) { return } + $disks = @($row.disks); $vmId = $row.id + } + $index = 0 + foreach ($disk in $disks) { if ($null -ne $disk) { ConvertTo-MockDrive $disk $vmId $index; $index++ } } + } +} + +function Add-VMHardDiskDrive { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation, [string]$Path, [switch]$Passthru) + process { + Write-MockCall 'Add-VMHardDiskDrive' $PSBoundParameters + Assert-MockFailure 'Add-VMHardDiskDrive' + if ($VM) { + $row = Get-MockRow $VM + $disks = @(); if ($row -and $row.disks) { $disks = @($row.disks | Where-Object { $null -ne $_ }) } + $entry = [pscustomobject]@{ path = $Path; controllerType = $(if ($ControllerType) { $ControllerType } else { 'SCSI' }); controllerNumber = $ControllerNumber + controllerLocation = $(if ($PSBoundParameters.ContainsKey('ControllerLocation')) { $ControllerLocation } else { $disks.Count }) } + Update-MockVm $VM 'disks' (@($disks) + $entry) + } + } +} + +function Remove-VMHardDiskDrive { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMHardDiskDrive, [string]$VMName, [string]$ControllerType, [int]$ControllerNumber, [int]$ControllerLocation, [switch]$Passthru) + process { + Write-MockCall 'Remove-VMHardDiskDrive' $PSBoundParameters + if ($VMHardDiskDrive) { + $state = Read-MockState + foreach ($row in @($state.vms)) { + if ([string]$row.id -eq [string]$VMHardDiskDrive.VMId) { $row.disks = @($row.disks | Where-Object { $null -ne $_ -and (Get-MockDiskPath $_) -ne [string]$VMHardDiskDrive.Path }) } + } + Write-MockState $state + } + } +} + +function Get-VMDvdDrive { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [int]$ControllerNumber, [int]$ControllerLocation) + process { + Write-MockCall 'Get-VMDvdDrive' $PSBoundParameters + $row = Get-MockRow $VM + if ($null -ne $row -and $null -ne $row.PSObject.Properties['dvd'] -and $null -ne $row.dvd) { + [pscustomobject]@{ Id = 'DVD'; VMId = $VM.Id; Name = 'DVD Drive'; Path = $(if ($row.dvd) { [string]$row.dvd } else { $null }); ControllerNumber = 0; ControllerLocation = 1 } + } + } +} + +function Add-VMDvdDrive { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$Path, [int]$ControllerNumber, [int]$ControllerLocation, [switch]$Passthru) + process { + Write-MockCall 'Add-VMDvdDrive' $PSBoundParameters + Assert-MockFailure 'Add-VMDvdDrive' + if ($VM) { + Update-MockVm $VM 'dvd' $(if ($Path) { $Path } else { '' }) + if ($Passthru) { [pscustomobject]@{ Id = 'DVD'; VMId = $VM.Id; Name = 'DVD Drive'; Path = $Path; ControllerNumber = 0; ControllerLocation = 1 } } + } + } +} + +function Set-VMDvdDrive { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMDvdDrive, [string[]]$VMName, [string]$Path, [switch]$Passthru) + process { + Write-MockCall 'Set-VMDvdDrive' $PSBoundParameters + if ($VMDvdDrive) { + $state = Read-MockState + foreach ($row in @($state.vms)) { if ([string]$row.id -eq [string]$VMDvdDrive.VMId) { Set-MockProperty $row 'dvd' $(if ($Path) { $Path } else { '' }) } } + Write-MockState $state + } + } +} + +function Get-VMFirmware { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { + Write-MockCall 'Get-VMFirmware' $PSBoundParameters + $row = Get-MockRow $VM + $drives = @(); $dvd = @(); $network = @(); $index = 0 + foreach ($disk in @($row.disks | Where-Object { $null -ne $_ })) { $drives += [pscustomobject]@{ BootType = 'Drive'; Description = 'SCSI Disk'; Device = (ConvertTo-MockDrive $disk $row.id $index); FirmwarePath = '' }; $index++ } + if ($null -ne $row.PSObject.Properties['dvd'] -and $null -ne $row.dvd) { $dvd += [pscustomobject]@{ BootType = 'Drive'; Description = 'SCSI DVD'; Device = [pscustomobject]@{ Id = 'DVD'; Name = 'DVD Drive'; Path = [string]$row.dvd }; FirmwarePath = '' } } + foreach ($adapter in Get-MockAdapters $row) { $network += [pscustomobject]@{ BootType = 'Network'; Description = 'Network Adapter'; Device = (ConvertTo-MockAdapter $adapter $row.id); FirmwarePath = '' } } + $first = [string](Get-MockValue $row 'firstBoot' 'disk') + $order = if ($first -eq 'dvd') { @($dvd) + @($drives) + @($network) } elseif ($first -eq 'network') { @($network) + @($drives) + @($dvd) } else { @($drives) + @($dvd) + @($network) } + [pscustomobject]@{ VMId = $VM.Id; SecureBoot = [string](Get-MockValue $row 'secureBoot' 'On'); SecureBootTemplate = [string](Get-MockValue $row 'template' 'MicrosoftWindows') + PreferredNetworkBootProtocol = 'IPv4'; BootOrder = $order } + } +} + +function Set-VMFirmware { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$EnableSecureBoot, [string]$SecureBootTemplate, $FirstBootDevice, $BootOrder, [string]$PreferredNetworkBootProtocol, [switch]$Passthru) + process { + Write-MockCall 'Set-VMFirmware' $PSBoundParameters + Assert-MockFailure 'Set-VMFirmware' + if ($VM) { + Assert-MockOff $VM 'SetVMFirmware' + $row = Get-MockRow $VM + if ($PSBoundParameters.ContainsKey('SecureBootTemplate')) { + if ([bool](Get-MockValue $row 'tpmInitialized' $false)) { Stop-Mock 'Cannot modify the secure boot template ID property after the virtual TPM is initialized.' 'OperationFailed,Microsoft.HyperV.PowerShell.Commands.SetVMFirmware' InvalidOperation } + Update-MockVm $VM 'template' $SecureBootTemplate + } + if ($PSBoundParameters.ContainsKey('EnableSecureBoot')) { Update-MockVm $VM 'secureBoot' $EnableSecureBoot } + if ($PSBoundParameters.ContainsKey('FirstBootDevice')) { + $kind = if ([string]$FirstBootDevice.Id -eq 'DVD') { 'dvd' } elseif ($null -ne $FirstBootDevice.PSObject.Properties['ControllerType']) { 'disk' } else { 'network' } + Update-MockVm $VM 'firstBoot' $kind + } + } + } +} + +function Get-VMSecurity { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { + Write-MockCall 'Get-VMSecurity' $PSBoundParameters + $row = Get-MockRow $VM + [pscustomobject]@{ VMId = $VM.Id; TpmEnabled = [bool](Get-MockValue $row 'tpm' $false); KsdEnabled = $false; Shielded = $false; BindToHostTpm = $false + EncryptStateAndVmMigrationTraffic = $false; VirtualizationBasedSecurityOptOut = $false } + } +} + +function Get-VMKeyProtector { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { + Write-MockCall 'Get-VMKeyProtector' $PSBoundParameters + if ([bool](Get-MockValue (Get-MockRow $VM) 'keyProtector' $false)) { , ([byte[]](1..200)) } else { , ([byte[]](0, 0, 0, 4)) } + } +} + +function Set-VMKeyProtector { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [switch]$NewLocalKeyProtector, [switch]$RestoreLastKnownGoodKeyProtector, [byte[]]$KeyProtector, [switch]$Passthru) + process { + Write-MockCall 'Set-VMKeyProtector' $PSBoundParameters + Assert-MockFailure 'Set-VMKeyProtector' + if ($VM) { Assert-MockOff $VM 'SetVMKeyProtector'; if ($NewLocalKeyProtector) { Update-MockVm $VM 'keyProtector' $true } } + } +} + +function Enable-VMTPM { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [switch]$Passthru) + process { + Write-MockCall 'Enable-VMTPM' $PSBoundParameters + Assert-MockFailure 'Enable-VMTPM' + if ($VM) { + Assert-MockOff $VM 'EnableVMTPM' + if (-not [bool](Get-MockValue (Get-MockRow $VM) 'keyProtector' $false)) { Stop-Mock 'Unable to change the selected security settings for a virtual machine without a valid key protector configured.' 'OperationFailed,Microsoft.HyperV.PowerShell.Commands.EnableVMTPM' InvalidOperation } + Update-MockVm $VM 'tpm' $true + } + } +} + +function Disable-VMTPM { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [switch]$Passthru) + process { Write-MockCall 'Disable-VMTPM' $PSBoundParameters; if ($VM) { Assert-MockOff $VM 'DisableVMTPM'; Update-MockVm $VM 'tpm' $false } } +} + +function Get-VMBios { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName) + process { Write-MockCall 'Get-VMBios' $PSBoundParameters; [pscustomobject]@{ VMId = $VM.Id; StartupOrder = @('CD', 'IDE', 'LegacyNetworkAdapter', 'Floppy'); NumLockEnabled = $false } } +} + +function Get-VMIntegrationService { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name) + process { + Write-MockCall 'Get-VMIntegrationService' $PSBoundParameters + $overrides = Get-MockValue (Get-MockRow $VM) 'integration' $null + foreach ($service in $script:MockServices.Keys) { + $enabled = [bool](Get-MockValue $overrides $service ($service -ne 'Guest Service Interface')) + [pscustomobject]@{ VMId = $VM.Id; Id = 'Microsoft:' + [string]$VM.Id + '\' + $script:MockServices[$service]; Name = $service; Enabled = $enabled; PrimaryStatusDescription = $null } + } + } +} + +function Set-MockIntegration($VM, [string[]]$Names, [bool]$Enabled) { + $state = Read-MockState + foreach ($row in @($state.vms)) { + if ([string]$row.id -ne [string]$VM.Id) { continue } + $map = Get-MockValue $row 'integration' $null + if ($null -eq $map) { $map = [pscustomobject]@{}; Set-MockProperty $row 'integration' $map } + foreach ($name in $Names) { Set-MockProperty $map $name $Enabled } + } + Write-MockState $state +} + +function Enable-VMIntegrationService { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Enable-VMIntegrationService' $PSBoundParameters; if ($VM) { Set-MockIntegration $VM $Name $true } } +} + +function Disable-VMIntegrationService { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string[]]$Name, [switch]$Passthru) + process { Write-MockCall 'Disable-VMIntegrationService' $PSBoundParameters; if ($VM) { Set-MockIntegration $VM $Name $false } } +} + +function Get-VMNetworkAdapter { + [CmdletBinding()] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [Parameter(Position = 0)][string]$Name, [switch]$ManagementOS, [string]$SwitchName, [switch]$All) + process { + Write-MockCall 'Get-VMNetworkAdapter' $PSBoundParameters + if ($ManagementOS) { + foreach ($nic in @((Read-MockState).hostAdapters | Where-Object { $null -ne $_ })) { + if ($SwitchName -and [string]$nic.switchName -ne $SwitchName) { continue } + [pscustomobject]@{ Name = [string]$nic.switchName; SwitchName = [string]$nic.switchName; MacAddress = [string]$nic.mac; IsManagementOs = $true } + } + return + } + $row = Get-MockRow $VM + if ($null -eq $row) { return } + foreach ($adapter in Get-MockAdapters $row) { if (-not $Name -or [string]$adapter.name -eq $Name) { ConvertTo-MockAdapter $adapter $row.id } } + } +} + +function Add-VMNetworkAdapter { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$VMName, [string]$Name, [string]$SwitchName, [string]$StaticMacAddress, [switch]$DynamicMacAddress, [switch]$Passthru) + process { + Write-MockCall 'Add-VMNetworkAdapter' $PSBoundParameters + Assert-MockFailure 'Add-VMNetworkAdapter' + if ($VM) { + $row = Get-MockRow $VM + $entry = [pscustomobject]@{ name = $(if ($Name) { $Name } else { 'Network Adapter' }); switchName = $(if ($SwitchName) { $SwitchName } else { $null }); mac = '000000000000'; dynamicMac = $true; spoofing = 'Off'; vlan = 0 } + Update-MockVm $VM 'adapters' (@(Get-MockAdapters $row) + $entry) + if ($Passthru) { ConvertTo-MockAdapter $entry $row.id } + } + } +} + +function Remove-VMNetworkAdapter { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [switch]$Passthru) + process { + Write-MockCall 'Remove-VMNetworkAdapter' $PSBoundParameters + if ($VMNetworkAdapter) { + $state = Read-MockState + foreach ($row in @($state.vms)) { + if ([string]$row.id -eq [string]$VMNetworkAdapter.VMId) { Set-MockProperty $row 'adapters' @(Get-MockAdapters $row | Where-Object { [string]$_.name -ne [string]$VMNetworkAdapter.Name }) } + } + Write-MockState $state + } + } +} + +function Set-VMNetworkAdapter { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [string]$StaticMacAddress, [switch]$DynamicMacAddress, [string]$MacAddressSpoofing, [string]$DhcpGuard, [string]$RouterGuard, [switch]$Passthru) + process { + Write-MockCall 'Set-VMNetworkAdapter' $PSBoundParameters + if ($VMNetworkAdapter) { + $values = @{} + if ($StaticMacAddress) { $values.mac = $StaticMacAddress; $values.dynamicMac = $false } + if ($DynamicMacAddress) { $values.dynamicMac = $true } + if ($MacAddressSpoofing) { $values.spoofing = $MacAddressSpoofing } + Update-MockAdapter $VMNetworkAdapter $values + } + } +} + +function Set-VMNetworkAdapterVlan { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$VMNetworkAdapterName, [switch]$Untagged, [switch]$Access, [int]$VlanId, [switch]$Passthru) + process { + Write-MockCall 'Set-VMNetworkAdapterVlan' $PSBoundParameters + if ($VMNetworkAdapter) { Update-MockAdapter $VMNetworkAdapter @{ vlan = $(if ($Untagged) { 0 } else { $VlanId }) } } + } +} + +function Connect-VMNetworkAdapter { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [string]$SwitchName, [switch]$Passthru) + process { Write-MockCall 'Connect-VMNetworkAdapter' $PSBoundParameters; if ($VMNetworkAdapter) { Update-MockAdapter $VMNetworkAdapter @{ switchName = $SwitchName } } } +} + +function Disconnect-VMNetworkAdapter { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMNetworkAdapter, $VM, [string[]]$VMName, [string]$Name, [switch]$Passthru) + process { Write-MockCall 'Disconnect-VMNetworkAdapter' $PSBoundParameters; if ($VMNetworkAdapter) { Update-MockAdapter $VMNetworkAdapter @{ switchName = $null } } } +} + +function Remove-VMSnapshot { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VMSnapshot, [string]$Name, [switch]$IncludeAllChildSnapshots, [switch]$Passthru) + process { + Write-MockCall 'Remove-VMSnapshot' $PSBoundParameters + Assert-MockFailure 'Remove-VMSnapshot' + if (-not $VMSnapshot) { return } + $state = Read-MockState + foreach ($row in @($state.vms)) { + if ([string]$row.id -ne [string]$VMSnapshot.VMId) { continue } + $remove = @{ ([string]$VMSnapshot.Id) = $true } + $grew = [bool]$IncludeAllChildSnapshots + while ($grew) { + $grew = $false + foreach ($snapshot in @($row.snapshots | Where-Object { $null -ne $_ })) { + if ($snapshot.parentId -and $remove.ContainsKey([string]$snapshot.parentId) -and -not $remove.ContainsKey([string]$snapshot.id)) { $remove[[string]$snapshot.id] = $true; $grew = $true } + } + } + $row.snapshots = @($row.snapshots | Where-Object { $null -ne $_ -and -not $remove.ContainsKey([string]$_.id) }) + if (@($row.snapshots).Count -eq 0) { Set-MockProperty $row 'mergePolls' ([int](Get-MockValue $row 'mergeDelay' 2)) } + } + Write-MockState $state + } +} + +function New-VHD { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Path, [long]$SizeBytes, [switch]$Dynamic, [switch]$Fixed, [switch]$Differencing, [string]$ParentPath, [long]$BlockSizeBytes) + Write-MockCall 'New-VHD' $PSBoundParameters + Assert-MockFailure 'New-VHD' + if ([System.IO.File]::Exists($Path)) { Stop-Mock 'The file exists. (0x80070050)' 'CreateVHD,Microsoft.Vhd.PowerShell.Cmdlets.NewVhd' ResourceExists } + if (-not [System.IO.Directory]::Exists([System.IO.Path]::GetDirectoryName($Path))) { Stop-Mock 'The system cannot find the path specified.' 'ObjectNotFound,Microsoft.Vhd.PowerShell.Cmdlets.NewVhd' ObjectNotFound } + $state = Read-MockState + $type = 'Dynamic'; $size = $SizeBytes + if ($ParentPath) { + if (-not [System.IO.File]::Exists($ParentPath)) { Stop-Mock "Parent disk not found: $ParentPath" 'ObjectNotFound,Microsoft.Vhd.PowerShell.Cmdlets.NewVhd' ObjectNotFound } + $type = 'Differencing'; $size = [long](Get-MockValue (Get-MockVhdRow $state $ParentPath) 'size' 68719476736) + } elseif ($Fixed) { $type = 'Fixed' } + [System.IO.File]::WriteAllBytes($Path, [byte[]]@()) + Add-MockVhdRow $state ([pscustomobject]@{ path = $Path; type = $type; parentPath = $(if ($ParentPath) { $ParentPath } else { $null }); size = $size }) + Write-MockState $state + [pscustomobject]@{ Path = $Path; VhdFormat = 'VHDX'; VhdType = $type; ParentPath = $ParentPath; Size = $size; FileSize = [long]4194304; Attached = $false } +} + +function Get-VHD { + [CmdletBinding()] + param([Parameter(Position = 0, ValueFromPipelineByPropertyName)][Alias('FullName')][string[]]$Path, [guid[]]$VMId) + process { + Write-MockCall 'Get-VHD' $PSBoundParameters + $state = Read-MockState + foreach ($p in $Path) { + $row = Get-MockVhdRow $state $p + if ([bool](Get-MockValue $row 'missing' $false)) { Stop-Mock "The system cannot find the file '$p'." 'ObjectNotFound,Microsoft.Vhd.PowerShell.Cmdlets.GetVHD' ObjectNotFound } + $parent = [string](Get-MockValue $row 'parentPath' '') + [pscustomobject]@{ + Path = $p; VhdFormat = $(if ($p -match '\.a?vhd$') { 'VHD' } else { 'VHDX' }) + VhdType = [string](Get-MockValue $row 'type' $(if ($parent) { 'Differencing' } else { 'Dynamic' })) + ParentPath = $(if ($parent) { $parent } else { $null }) + Size = [long](Get-MockValue $row 'size' 68719476736); FileSize = [long](Get-MockValue $row 'fileSize' 4194304); MinimumSize = $null + Attached = [bool](Get-MockValue $row 'mounted' $false); DiskIdentifier = '6F1A2B3C-0000-4000-8000-000000000001' + LogicalSectorSize = 512; PhysicalSectorSize = 4096; BlockSize = 33554432 + } + } + } +} + +function Set-VHD { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Path, [string]$ParentPath, [switch]$ResetDiskIdentifier, [switch]$Force, [switch]$Passthru) + Write-MockCall 'Set-VHD' $PSBoundParameters +} + +function Export-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(ValueFromPipeline)]$VM, [string[]]$Name, [string]$Path, [switch]$Passthru) + process { + Write-MockCall 'Export-VM' $PSBoundParameters + Assert-MockFailure 'Export-VM' + if ($VM -and $Path) { + $row = Get-MockRow $VM + $root = [System.IO.Path]::Combine($Path, [string]$VM.Name) + $configuration = [System.IO.Path]::Combine($root, 'Virtual Machines') + $disks = [System.IO.Path]::Combine($root, 'Virtual Hard Disks') + [void][System.IO.Directory]::CreateDirectory($configuration) + [void][System.IO.Directory]::CreateDirectory($disks) + $file = [System.IO.Path]::Combine($configuration, ([string]$VM.Id).ToUpperInvariant() + '.vmcx') + [System.IO.File]::WriteAllText($file + '.json', ($row | ConvertTo-Json -Depth 12)) + [System.IO.File]::WriteAllBytes($file, [byte[]]@()) + foreach ($disk in @($row.disks | Where-Object { $null -ne $_ })) { [System.IO.File]::WriteAllBytes([System.IO.Path]::Combine($disks, [System.IO.Path]::GetFileName((Get-MockDiskPath $disk))), [byte[]]@()) } + } + } +} + +function Import-VM { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Path, [switch]$Copy, [switch]$GenerateNewId, [string]$VirtualMachinePath, [string]$VhdDestinationPath, [string]$SnapshotFilePath, [string]$SmartPagingFilePath, [switch]$Register) + Write-MockCall 'Import-VM' $PSBoundParameters + Assert-MockFailure 'Import-VM' + $state = Read-MockState + if ($Path -and [System.IO.File]::Exists($Path + '.json')) { + $row = Get-Content -LiteralPath ($Path + '.json') -Raw -Encoding UTF8 | ConvertFrom-Json + $row.id = [guid]::NewGuid().ToString() + $disks = @() + if ($VhdDestinationPath) { [void][System.IO.Directory]::CreateDirectory($VhdDestinationPath) } + foreach ($disk in @($row.disks | Where-Object { $null -ne $_ })) { + $target = [System.IO.Path]::Combine($VhdDestinationPath, [System.IO.Path]::GetFileName((Get-MockDiskPath $disk))) + [System.IO.File]::WriteAllBytes($target, [byte[]]@()) + Add-MockVhdRow $state ([pscustomobject]@{ path = $target; type = 'Dynamic'; parentPath = $null; size = [long]68719476736 }) + $disks += $target + } + $row.disks = $disks + $row.snapshots = @() + Set-MockProperty $row 'path' $VirtualMachinePath + if ($VirtualMachinePath) { + $configuration = [System.IO.Path]::Combine($VirtualMachinePath, 'Virtual Machines') + [void][System.IO.Directory]::CreateDirectory($configuration) + [System.IO.File]::WriteAllBytes([System.IO.Path]::Combine($configuration, ([string]$row.id).ToUpperInvariant() + '.vmcx'), [byte[]]@()) + } + } else { + $row = [pscustomobject]@{ id = [guid]::NewGuid().ToString(); name = 'Imported ' + [IO.Path]::GetFileNameWithoutExtension($Path); state = 'Off'; generation = 2; processorCount = 2; memoryStartup = [long]4294967296; dynamicMemory = $false; disks = @(); snapshots = @() } + } + $state.vms = @($state.vms) + $row + Write-MockState $state + ConvertTo-MockVm $row +} + +function New-VMSwitch { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Position = 0)][string]$Name, [string]$SwitchType, [string]$NetAdapterName, [bool]$AllowManagementOS) + Write-MockCall 'New-VMSwitch' $PSBoundParameters + Assert-MockFailure 'New-VMSwitch' + $state = Read-MockState + $row = [pscustomobject]@{ id = [guid]::NewGuid().ToString(); name = $Name; type = $(if ($SwitchType) { $SwitchType } else { 'External' }) } + $state.switches = @($state.switches) + $row + if ($SwitchType -eq 'Internal') { + if ($null -eq $state.PSObject.Properties['hostAdapters']) { $state | Add-Member -NotePropertyName hostAdapters -NotePropertyValue @() } + $state.hostAdapters = @(@($state.hostAdapters | Where-Object { $null -ne $_ }) + [pscustomobject]@{ switchName = $Name; mac = '00155D' + ('{0:X6}' -f (Get-Random -Maximum 16777215)) }) + } + Write-MockState $state + [pscustomobject]@{ Id = [guid]$row.id; Name = $row.name; SwitchType = $row.type; NetAdapterInterfaceDescription = $null } +} + +function Get-NetAdapter { + [CmdletBinding()] param([Parameter(Position = 0)][string[]]$Name, [switch]$IncludeHidden) + Write-MockCall 'Get-NetAdapter' $PSBoundParameters + $index = 40 + foreach ($nic in @((Read-MockState).hostAdapters | Where-Object { $null -ne $_ })) { + [pscustomobject]@{ Name = 'vEthernet (' + [string]$nic.switchName + ')'; MacAddress = ([string]$nic.mac -replace '(..)(?!$)', '$1-'); ifIndex = $index } + $index++ + } +} + +function New-NetIPAddress { + [CmdletBinding(SupportsShouldProcess)] param([int]$InterfaceIndex, [string]$IPAddress, [int]$PrefixLength) + Write-MockCall 'New-NetIPAddress' $PSBoundParameters + Assert-MockFailure 'New-NetIPAddress' + [pscustomobject]@{ InterfaceIndex = $InterfaceIndex; IPAddress = $IPAddress; PrefixLength = $PrefixLength } +} + +function Get-NetNat { + [CmdletBinding()] param([Parameter(Position = 0)][string]$Name) + Write-MockCall 'Get-NetNat' $PSBoundParameters + foreach ($nat in @((Read-MockState).nats | Where-Object { $null -ne $_ })) { + if (-not $Name -or [string]$nat.name -eq $Name) { [pscustomobject]@{ Name = [string]$nat.name; InternalIPInterfaceAddressPrefix = [string]$nat.prefix } } + } +} + +function New-NetNat { + [CmdletBinding(SupportsShouldProcess)] param([string]$Name, [string]$InternalIPInterfaceAddressPrefix) + Write-MockCall 'New-NetNat' $PSBoundParameters + Assert-MockFailure 'New-NetNat' + $state = Read-MockState + if ($null -eq $state.PSObject.Properties['nats']) { $state | Add-Member -NotePropertyName nats -NotePropertyValue @() } + $state.nats = @(@($state.nats | Where-Object { $null -ne $_ }) + [pscustomobject]@{ name = $Name; prefix = $InternalIPInterfaceAddressPrefix }) + Write-MockState $state + [pscustomobject]@{ Name = $Name; InternalIPInterfaceAddressPrefix = $InternalIPInterfaceAddressPrefix } +} + +function Remove-NetNat { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(Position = 0)][string]$Name) + Write-MockCall 'Remove-NetNat' $PSBoundParameters + $state = Read-MockState + if ($null -ne $state.PSObject.Properties['nats']) { $state.nats = @($state.nats | Where-Object { $null -ne $_ -and [string]$_.name -ne $Name }) } + Write-MockState $state +} diff --git a/tests/HyperVControl.Tests/PhaseStubs.cs b/tests/HyperVControl.Tests/PhaseStubs.cs index 9f6d40c..21133d4 100644 --- a/tests/HyperVControl.Tests/PhaseStubs.cs +++ b/tests/HyperVControl.Tests/PhaseStubs.cs @@ -5,12 +5,6 @@ namespace HyperVControl.Tests; /// Calls one MCP tool through the harness client: name, arguments, allowError, label. internal delegate Task ToolCall(string name, object? arguments = null, bool allowError = false, string? label = null); -/// Lifecycle v2 unit tests against the mock module (phase 2). -internal static class LifecycleV2Tests -{ - internal static Task RunAsync(Action check) => Task.CompletedTask; -} - /// Image catalog, library and job unit tests (phase 3). internal static class ImageTests { @@ -23,12 +17,6 @@ internal static class ImageBuildTests internal static Task RunAsync(Action check) => Task.CompletedTask; } -/// Live validation of lifecycle v2 on throwaway VMs (phase 2). Requires an elevated host and explicit authorization. -internal static class LifecycleLiveTests -{ - internal static Task RunAsync(ToolCall call, Action check, string results) => Task.CompletedTask; -} - /// Live validation of image fetch, library and deploy (phase 3). internal static class ImageLiveTests { From ccdfea84ddd72ef28c70fe2309e0298f9b8b4379 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:30:43 -0500 Subject: [PATCH 08/23] Add golden image builds (hyperv_image_build) Windows: apply an ISO, an install.wim/esd or a downloaded Microsoft evaluation ISO (eval, eval-ltsc, eval-iot; resumable and cached) offline to a GPT VHDX (300 MB ESP formatted FAT32 then retyped, 16 MB MSR, NTFS), make it bootable with bcdboot /s (plus /offline where supported), and write a specialize/oobeSystem unattend.xml: local administrator, RDP and its firewall group, autologon, device-encryption prevention, BypassNRO, and first-logon deletion of the answer file. Optional offline kernel-debug BCD settings and a C:\Tools copy. Where the host's bcdboot copies the boot files but cannot create the store on another disk's ESP (BFSVC c0000035), the store is completed with bcdedit from the image's own BCD-Template. Linux: download an Ubuntu cloud image checked against its SHA256SUMS, convert it with qemu-img (HYPERV_CONTROL_QEMU_IMG, PATH or Program Files), grow it, and build a NoCloud CIDATA seed ISO with inbox IMAPI2. Every build runs as a job, writes .image.json beside the VHDX for the image library, refuses existing outputs, detaches whatever it attached and deletes its outputs on failure. A generated password is returned once in the start response, never in the job result or the sidecar. Unit tests cover validation, unattend and cloud-init content, script rendering and ordering of both pipelines against the recording mock, cleanup on failure, resumable downloads over loopback HTTP and a real IMAPI2 seed ISO. --- src/HyperVControl/HyperVTools.ImageBuild.cs | 48 +- .../Services/ImageBuildCloudInit.cs | 120 +++ .../Services/ImageBuildDownloader.cs | 199 +++++ .../Services/ImageBuildModels.cs | 377 ++++++++ .../Services/ImageBuildScripts.cs | 279 ++++++ .../Services/ImageBuildService.cs | 632 ++++++++++++- .../Services/ImageBuildUnattend.cs | 171 ++++ tests/HyperVControl.Tests/ImageBuildTests.cs | 829 ++++++++++++++++++ .../Hyper-V/imagebuild.functions.ps1 | 213 +++++ tests/HyperVControl.Tests/PhaseStubs.cs | 6 - 10 files changed, 2863 insertions(+), 11 deletions(-) create mode 100644 src/HyperVControl/Services/ImageBuildCloudInit.cs create mode 100644 src/HyperVControl/Services/ImageBuildDownloader.cs create mode 100644 src/HyperVControl/Services/ImageBuildModels.cs create mode 100644 src/HyperVControl/Services/ImageBuildScripts.cs create mode 100644 src/HyperVControl/Services/ImageBuildUnattend.cs create mode 100644 tests/HyperVControl.Tests/ImageBuildTests.cs create mode 100644 tests/HyperVControl.Tests/MockHyperV/Hyper-V/imagebuild.functions.ps1 diff --git a/src/HyperVControl/HyperVTools.ImageBuild.cs b/src/HyperVControl/HyperVTools.ImageBuild.cs index 35d1843..5f419fa 100644 --- a/src/HyperVControl/HyperVTools.ImageBuild.cs +++ b/src/HyperVControl/HyperVTools.ImageBuild.cs @@ -1,6 +1,52 @@ +using System.ComponentModel; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + namespace HyperVControl; -/// Golden image build tool (hyperv_image_build). Implemented in phase 3. +/// Golden image build tool (hyperv_image_build): validates the request and starts a background job. internal sealed partial class HyperVTools { + [McpServerTool(Name = "hyperv_image_build")] + [Description("Build a golden template VHDX as a background job, without creating a VM. windows: apply an ISO (isoPath), install.wim/esd (imagePath) or a downloaded " + + "Microsoft evaluation ISO (source=eval|eval-ltsc|eval-iot, 4.7-7.7 GiB, cached, 90-day license) offline to a GPT VHDX with an unattend: local admin, RDP, " + + "autologon, no device encryption, optional offline kernel-debug BCD settings and C:\\Tools. linux: convert an Ubuntu cloud image (default 24.04, checked against " + + "SHA256SUMS; needs qemu-img) to VHDX and build a NoCloud CIDATA seed ISO. Writes .image.json beside the VHDX and refuses existing outputs. A generated " + + "password is returned once, in this response only. Poll hyperv_jobs for the result and nextSteps.")] + public Task ImageBuild( + [Description("windows or linux.")] string osFamily, + [Description("Output base name: .vhdx and .image.json (plus -seed.iso for linux) in outputDirectory.")] string name, + [Description("Absolute local directory. Default: HYPERV_CONTROL_IMAGE_ROOT, else \\HyperVControl\\Images. Downloads are cached in its cache subfolder.")] string? outputDirectory = null, + [Description("windows: local Windows .iso.")] string? isoPath = null, + [Description("windows: eval (Windows 11 Enterprise 26H2), eval-ltsc (Enterprise LTSC 2024) or eval-iot (IoT Enterprise LTSC 2024) evaluation ISO download, resumable.")] string? source = null, + [Description("windows: local install.wim or install.esd. linux: local cloud image (.img/.qcow2/.raw/.vmdk/.vhd are converted with qemu-img; .vhdx is copied).")] string? imagePath = null, + [Description("linux: https URL of a cloud image; default the Ubuntu 24.04 server cloud image, verified against its published SHA256SUMS.")] string? imageUrl = null, + [Description("Expected SHA-256 of the ISO or image; required for http:// URLs. The actual hash is recorded in the sidecar either way.")] string? sha256 = null, + [Description("windows: image index inside install.wim/esd.")] int? imageIndex = null, + [Description("windows: image name to select instead of imageIndex (exact, else a unique substring). Default: the first non-N edition.")] string? edition = null, + [Description("Virtual disk size in GB (dynamic VHDX). Default 100 for windows, 40 for linux.")] int? diskGb = null, + [Description("windows: computer name, at most 15 letters/digits/hyphens, or * for random. Default: derived from name.")] string? computerName = null, + [Description("windows: local administrator created by the unattend. Default labadmin.")] string? adminUser = null, + [Description("windows: administrator password. When omitted a strong one is generated and returned once.")] string? adminPassword = null, + [Description("windows: enable Remote Desktop and its firewall rule (Enhanced Session needs it). Default true.")] bool? enableRdp = null, + [Description("windows: sign the administrator in automatically. Default true.")] bool? autoLogon = null, + [Description("windows: prevent automatic device encryption (BitLocker). Default true.")] bool? preventDeviceEncryption = null, + [Description("windows: offline BCD kernel debugging {enabled, transport: net|serial, hostIp, port (default 50000), key (generated when omitted), comPort}. VMs from such an image need Secure Boot off.")] ImageBuildKernelDebug? kernelDebug = null, + [Description("windows: absolute host folder copied into the image as C:\\Tools.")] string? toolsDirectory = null, + [Description("windows: locale for input, system and user settings and the display language. Default en-US.")] string? language = null, + [Description("linux: user that cloud-init creates with passwordless sudo. Default lab.")] string? sshUser = null, + [Description("linux: OpenSSH public key line(s) for sshUser; required unless password is given.")] string? sshPublicKey = null, + [Description("linux: password for sshUser, stored only as a SHA-512-crypt hash; enables SSH password login only when no key is given.")] string? password = null, + [Description("linux: host name. Default: derived from name.")] string? hostname = null, + [Description("linux: packages installed at first boot. Default [linux-cloud-tools-virtual] (KVP, so Hyper-V can report the guest IP).")] string[]? packages = null, + [Description("Seconds (0-3600) to wait for the job before returning; then poll hyperv_jobs.")] int waitSeconds = 0, + CancellationToken ct = default) => Run(async () => await service.ImageBuild.StartAsync(new ImageBuildRequest + { + OsFamily = osFamily, Name = name, OutputDirectory = outputDirectory, IsoPath = isoPath, Source = source, ImagePath = imagePath, + ImageUrl = imageUrl, Sha256 = sha256, ImageIndex = imageIndex, Edition = edition, DiskGb = diskGb, ComputerName = computerName, + AdminUser = adminUser, AdminPassword = adminPassword, EnableRdp = enableRdp, AutoLogon = autoLogon, + PreventDeviceEncryption = preventDeviceEncryption, KernelDebug = kernelDebug, ToolsDirectory = toolsDirectory, Language = language, + SshUser = sshUser, SshPublicKey = sshPublicKey, Password = password, Hostname = hostname, Packages = packages + }, waitSeconds, ct)); } diff --git a/src/HyperVControl/Services/ImageBuildCloudInit.cs b/src/HyperVControl/Services/ImageBuildCloudInit.cs new file mode 100644 index 0000000..86e5c7f --- /dev/null +++ b/src/HyperVControl/Services/ImageBuildCloudInit.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using System.Text; + +namespace HyperVControl.Services; + +/// +/// NoCloud seed content for Ubuntu cloud images: user-data (cloud-config) and meta-data, both with LF line +/// endings and no byte order mark, as cloud-init expects. Values are emitted as double-quoted YAML scalars; inputs are +/// validated beforehand so no control characters can reach the document. +/// +internal static class ImageBuildCloudInit +{ + internal const string VolumeLabel = "CIDATA"; + internal static readonly Encoding FileEncoding = new UTF8Encoding(false); + + public static string UserData(string user, IReadOnlyList sshKeys, string? passwordHash, string hostname, IReadOnlyList packages) + { + var y = new StringBuilder(); + y.Append("#cloud-config\n"); + y.Append("hostname: ").Append(Quote(hostname)).Append('\n'); + y.Append("users:\n"); + y.Append(" - name: ").Append(Quote(user)).Append('\n'); + y.Append(" groups: [sudo]\n"); + y.Append(" shell: /bin/bash\n"); + y.Append(" sudo: \"ALL=(ALL) NOPASSWD:ALL\"\n"); + y.Append(" lock_passwd: ").Append(passwordHash is null ? "true" : "false").Append('\n'); + if (passwordHash is not null) y.Append(" passwd: ").Append(Quote(passwordHash)).Append('\n'); + if (sshKeys.Count > 0) + { + y.Append(" ssh_authorized_keys:\n"); + foreach (var key in sshKeys) y.Append(" - ").Append(Quote(key)).Append('\n'); + } + // Password SSH only when no key was supplied; a key-only image keeps password authentication off. + y.Append("ssh_pwauth: ").Append(passwordHash is not null && sshKeys.Count == 0 ? "true" : "false").Append('\n'); + if (packages.Count > 0) + { + y.Append("package_update: true\n"); + y.Append("packages:\n"); + foreach (var package in packages) y.Append(" - ").Append(Quote(package)).Append('\n'); + } + return y.ToString(); + } + + public static string MetaData(string instanceId, string hostname) => + "instance-id: " + Quote(instanceId) + "\nlocal-hostname: " + Quote(hostname) + "\n"; + + /// Writes user-data and meta-data into an empty staging directory for the ISO builder. + public static void WriteSeed(string directory, string userData, string metaData) + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "user-data"), userData, FileEncoding); + File.WriteAllText(Path.Combine(directory, "meta-data"), metaData, FileEncoding); + } + + internal static string Quote(string value) => "\"" + value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + + private const string CryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + /// + /// SHA-512-crypt ($6$, 5000 rounds), the format cloud-init's passwd and /etc/shadow expect, so the seed ISO + /// never carries a plain-text password. Implements Ulrich Drepper's published "Unix crypt using SHA-256 and SHA-512". + /// + public static string Sha512Crypt(string password, string? salt = null) + { + salt ??= RandomNumberGenerator.GetString(CryptAlphabet, 16); + if (salt.Length > 16) salt = salt[..16]; + var p = Encoding.UTF8.GetBytes(password); + var s = Encoding.ASCII.GetBytes(salt); + + var b = SHA512.HashData(p.Concat(s).Concat(p).ToArray()); + using var a = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + a.AppendData(p); + a.AppendData(s); + for (var n = p.Length; n > 0; n -= 64) a.AppendData(b, 0, Math.Min(64, n)); + for (var n = p.Length; n > 0; n >>= 1) a.AppendData((n & 1) != 0 ? b : p); + var c = a.GetHashAndReset(); + + using var dpHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + for (var i = 0; i < p.Length; i++) dpHash.AppendData(p); + var pBytes = Repeat(dpHash.GetHashAndReset(), p.Length); + + using var dsHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + for (var i = 0; i < 16 + c[0]; i++) dsHash.AppendData(s); + var sBytes = Repeat(dsHash.GetHashAndReset(), s.Length); + + using var round = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + for (var i = 0; i < 5000; i++) + { + round.AppendData((i & 1) != 0 ? pBytes : c); + if (i % 3 != 0) round.AppendData(sBytes); + if (i % 7 != 0) round.AppendData(pBytes); + round.AppendData((i & 1) != 0 ? c : pBytes); + c = round.GetHashAndReset(); + } + + int[][] order = + [ + [0, 21, 42], [22, 43, 1], [44, 2, 23], [3, 24, 45], [25, 46, 4], [47, 5, 26], [6, 27, 48], [28, 49, 7], [50, 8, 29], [9, 30, 51], + [31, 52, 10], [53, 11, 32], [12, 33, 54], [34, 55, 13], [56, 14, 35], [15, 36, 57], [37, 58, 16], [59, 17, 38], [18, 39, 60], + [40, 61, 19], [62, 20, 41] + ]; + var output = new StringBuilder("$6$").Append(salt).Append('$'); + foreach (var group in order) Base64(output, c[group[0]], c[group[1]], c[group[2]], 4); + Base64(output, 0, 0, c[63], 2); + return output.ToString(); + } + + private static byte[] Repeat(byte[] digest, int length) + { + var result = new byte[length]; + for (var i = 0; i < length; i++) result[i] = digest[i % digest.Length]; + return result; + } + + private static void Base64(StringBuilder output, byte b2, byte b1, byte b0, int count) + { + var w = (b2 << 16) | (b1 << 8) | b0; + for (var i = 0; i < count; i++) { output.Append(CryptAlphabet[w & 0x3f]); w >>= 6; } + } +} diff --git a/src/HyperVControl/Services/ImageBuildDownloader.cs b/src/HyperVControl/Services/ImageBuildDownloader.cs new file mode 100644 index 0000000..d7b3c58 --- /dev/null +++ b/src/HyperVControl/Services/ImageBuildDownloader.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; + +namespace HyperVControl.Services; + +/// +/// Downloads build sources into the image cache: resumable (HTTP range requests against a .partial file, guarded +/// by If-Range), retried on transient failures, checked against the expected SHA-256 when one is known, and kept for +/// later builds. A cached file is reused only when it still matches the expected hash or the advertised size. +/// +internal sealed class ImageBuildDownloader(HttpClient http) +{ + internal sealed record Result(string Path, long Bytes, string Sha256, Uri FinalUrl, bool Reused); + + private const int MaxAttempts = 5; + private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(90); + + /// Base delay before a resumed attempt; it grows linearly per attempt, capped at 30 seconds. + internal TimeSpan RetryDelay { get; init; } = TimeSpan.FromSeconds(2); + + internal static HttpClient CreateClient() + { + var client = new HttpClient(new SocketsHttpHandler + { + AllowAutoRedirect = true, MaxAutomaticRedirections = 10, AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(30), PooledConnectionLifetime = TimeSpan.FromMinutes(10) + }) { Timeout = Timeout.InfiniteTimeSpan }; + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("HyperVControl", Program.Version)); + return client; + } + + /// Reads the SHA256SUMS published next to and returns the entry for its file name. + public async Task PublishedSha256Async(Uri imageUrl, CancellationToken ct) + { + var sums = new Uri(imageUrl, "SHA256SUMS"); + using var response = await http.GetAsync(sums, ct); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + var text = await response.Content.ReadAsStringAsync(ct); + return ParseSha256Sums(text, Uri.UnescapeDataString(imageUrl.Segments[^1])); + } + + /// Parses sha256sum output (<hex> *name or <hex> name) for one exact file name. + internal static string? ParseSha256Sums(string text, string fileName) + { + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + var space = line.IndexOf(' '); + if (space != 64) continue; + var name = line[(space + 1)..].TrimStart().TrimStart('*'); + if (name == fileName && line[..64].All(Uri.IsHexDigit)) return line[..64].ToLowerInvariant(); + } + return null; + } + + public async Task DownloadAsync(Uri url, string cacheDirectory, string? expectedSha256, IProgress? progress, string label, CancellationToken ct) + { + Directory.CreateDirectory(cacheDirectory); + var head = await ProbeAsync(url, ct); + var fileName = SafeFileName(Uri.UnescapeDataString(head.FinalUrl.Segments[^1])); + var target = Path.Combine(cacheDirectory, fileName); + var partial = target + ".partial"; + + if (File.Exists(target)) + { + var cachedLength = new FileInfo(target).Length; + if (expectedSha256 is null && head.Length is { } advertised && advertised != cachedLength) File.Delete(target); + else + { + var cachedHash = await HashAsync(target, progress, label, ct); + if (expectedSha256 is null || cachedHash == expectedSha256) return new Result(target, cachedLength, cachedHash, head.FinalUrl, true); + File.Delete(target); + } + } + + for (var attempt = 1; ; attempt++) + { + try + { + await TransferAsync(url, partial, head, progress, label, ct); + break; + } + catch (Exception ex) when (attempt < MaxAttempts && !ct.IsCancellationRequested && ex is HttpRequestException or IOException or TimeoutException) + { + progress?.Report(new JobProgress(Message: $"{label}: transfer interrupted ({ex.Message}); resuming (attempt {attempt + 1}/{MaxAttempts})")); + await Task.Delay(TimeSpan.FromTicks(Math.Min(TimeSpan.FromSeconds(30).Ticks, RetryDelay.Ticks * attempt)), ct); + } + } + + var length = new FileInfo(partial).Length; + if (head.Length is { } expectedLength && length != expectedLength) + throw new IOException($"Download of {fileName} ended at {length} of {expectedLength} bytes."); + var hash = await HashAsync(partial, progress, label, ct); + if (expectedSha256 is not null && hash != expectedSha256) + { + File.Delete(partial); + throw new InvalidDataException($"SHA-256 mismatch for {fileName}: expected {expectedSha256}, got {hash}. The download was deleted."); + } + File.Move(partial, target); + return new Result(target, length, hash, head.FinalUrl, false); + } + + private sealed record Probe(Uri FinalUrl, long? Length, EntityTagHeaderValue? ETag, DateTimeOffset? LastModified); + + private async Task ProbeAsync(Uri url, CancellationToken ct) + { + using (var head = new HttpRequestMessage(HttpMethod.Head, url)) + using (var response = await http.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, ct)) + { + if (response.IsSuccessStatusCode) + return new Probe(response.RequestMessage?.RequestUri ?? url, response.Content.Headers.ContentLength, response.Headers.ETag, response.Content.Headers.LastModified); + } + // Some servers refuse HEAD; read only the headers of a GET instead. + using var get = new HttpRequestMessage(HttpMethod.Get, url); + using var fallback = await http.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, ct); + fallback.EnsureSuccessStatusCode(); + return new Probe(fallback.RequestMessage?.RequestUri ?? url, fallback.Content.Headers.ContentLength, fallback.Headers.ETag, fallback.Content.Headers.LastModified); + } + + private async Task TransferAsync(Uri url, string partial, Probe head, IProgress? progress, string label, CancellationToken ct) + { + FileStream file; + try { file = new FileStream(partial, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1 << 16, FileOptions.Asynchronous); } + catch (IOException ex) { throw new InvalidOperationException($"The cache file {partial} is in use by another download: {ex.Message}"); } + await using (file) + { + var offset = file.Length; + if (head.Length is { } known && offset >= known) + { + if (offset == known) return; + offset = 0; + file.SetLength(0); + } + using var request = new HttpRequestMessage(HttpMethod.Get, url); + if (offset > 0) + { + request.Headers.Range = new RangeHeaderValue(offset, null); + if (head.ETag is { IsWeak: false } etag) request.Headers.IfRange = new RangeConditionHeaderValue(etag); + else if (head.LastModified is { } modified) request.Headers.IfRange = new RangeConditionHeaderValue(modified); + } + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) + { + if (head.Length == offset) return; + file.SetLength(0); + throw new IOException("The server rejected the resume range; restarting the download."); + } + response.EnsureSuccessStatusCode(); + if (response.StatusCode == HttpStatusCode.PartialContent && response.Content.Headers.ContentRange?.From == offset) + file.Seek(offset, SeekOrigin.Begin); + else + { + // A full response (the server ignored the range or the content changed): start over. + offset = 0; + file.SetLength(0); + } + var total = head.Length ?? (response.Content.Headers.ContentLength is { } remaining ? offset + remaining : null); + await using var body = await response.Content.ReadAsStreamAsync(ct); + var buffer = new byte[1 << 20]; + var done = offset; + var clock = Stopwatch.StartNew(); + while (true) + { + using var stall = CancellationTokenSource.CreateLinkedTokenSource(ct); + stall.CancelAfter(StallTimeout); + int read; + try { read = await body.ReadAsync(buffer, stall.Token); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { throw new TimeoutException($"No data received for {StallTimeout.TotalSeconds:0} s."); } + if (read == 0) break; + await file.WriteAsync(buffer.AsMemory(0, read), ct); + done += read; + if (clock.ElapsedMilliseconds >= 500) + { + clock.Restart(); + progress?.Report(new JobProgress(total is > 0 ? Math.Round(100.0 * done / total.Value, 1) : null, done, total, $"{label}: downloading")); + } + } + await file.FlushAsync(ct); + progress?.Report(new JobProgress(total is > 0 ? 100 : null, done, total, $"{label}: downloaded")); + } + } + + internal static async Task HashAsync(string path, IProgress? progress, string label, CancellationToken ct) + { + progress?.Report(new JobProgress(Message: $"{label}: computing SHA-256")); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20, FileOptions.Asynchronous | FileOptions.SequentialScan); + return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, ct)); + } + + internal static string SafeFileName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var safe = new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim(' ', '.'); + return safe.Length == 0 ? "download.bin" : safe.Length > 200 ? safe[^200..] : safe; + } +} diff --git a/src/HyperVControl/Services/ImageBuildModels.cs b/src/HyperVControl/Services/ImageBuildModels.cs new file mode 100644 index 0000000..3081439 --- /dev/null +++ b/src/HyperVControl/Services/ImageBuildModels.cs @@ -0,0 +1,377 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace HyperVControl.Services; + +/// +/// Offline kernel-debug settings written into a Windows image's BCD store before first boot. +/// net needs hostIp (and generates key when omitted); serial uses comPort. +/// +internal sealed record ImageBuildKernelDebug(bool Enabled = false, string Transport = "net", string? HostIp = null, int Port = 50000, + string? Key = null, int ComPort = 1) +{ + public override string ToString() => $"ImageBuildKernelDebug {{ Enabled = {Enabled}, Transport = {Transport}, HostIp = {HostIp}, Port = {Port}, Key = , ComPort = {ComPort} }}"; +} + +/// Raw hyperv_image_build arguments. Null means "not supplied", so cross-family parameters can be rejected. +internal sealed record ImageBuildRequest +{ + public string? OsFamily { get; init; } + public string? Name { get; init; } + public string? OutputDirectory { get; init; } + public string? IsoPath { get; init; } + public string? Source { get; init; } + public string? ImagePath { get; init; } + public string? ImageUrl { get; init; } + public string? Sha256 { get; init; } + public int? ImageIndex { get; init; } + public string? Edition { get; init; } + public int? DiskGb { get; init; } + public string? ComputerName { get; init; } + public string? AdminUser { get; init; } + public string? AdminPassword { get; init; } + public bool? EnableRdp { get; init; } + public bool? AutoLogon { get; init; } + public bool? PreventDeviceEncryption { get; init; } + public ImageBuildKernelDebug? KernelDebug { get; init; } + public string? ToolsDirectory { get; init; } + public string? Language { get; init; } + public string? SshUser { get; init; } + public string? SshPublicKey { get; init; } + public string? Password { get; init; } + public string? Hostname { get; init; } + public string[]? Packages { get; init; } + + public override string ToString() => $"ImageBuildRequest {{ OsFamily = {OsFamily}, Name = {Name}, OutputDirectory = {OutputDirectory} }}"; +} + +/// Where the build reads its OS image from. +/// iso, wim, eval-download, image or image-download. +internal sealed record ImageBuildSourceSpec(string Kind, string? Path, Uri? Url, string? EvalSource, string? ExpectedSha256); + +internal sealed record ImageBuildWindowsSpec(int? ImageIndex, string? Edition, string ComputerName, string AdminUser, string AdminPassword, + bool PasswordGenerated, bool EnableRdp, bool AutoLogon, bool PreventDeviceEncryption, ImageBuildKernelDebug? KernelDebug, + bool KeyGenerated, string? ToolsDirectory, string Language) +{ + public override string ToString() => $"ImageBuildWindowsSpec {{ ComputerName = {ComputerName}, AdminUser = {AdminUser}, AdminPassword = , KernelDebug = {KernelDebug} }}"; +} + +internal sealed record ImageBuildLinuxSpec(string SshUser, IReadOnlyList SshKeys, string? PasswordHash, string Hostname, + IReadOnlyList Packages, string? QemuImg) +{ + public override string ToString() => $"ImageBuildLinuxSpec {{ SshUser = {SshUser}, Keys = {SshKeys.Count}, PasswordHash = {(PasswordHash is null ? "none" : "")}, Hostname = {Hostname} }}"; +} + +/// A validated build: every path is absolute and refused if it already exists; secrets are resolved. +internal sealed record ImageBuildPlan +{ + public required string OsFamily { get; init; } + public required string Name { get; init; } + public required string OutputDirectory { get; init; } + public required string VhdPath { get; init; } + public required string SidecarPath { get; init; } + public string? SeedIsoPath { get; init; } + public required int DiskGb { get; init; } + public required ImageBuildSourceSpec Source { get; init; } + public IReadOnlyList Warnings { get; init; } = []; + public ImageBuildWindowsSpec? Windows { get; init; } + public ImageBuildLinuxSpec? Linux { get; init; } + public string CacheDirectory => Path.Combine(OutputDirectory, "cache"); +} + +/// VM settings the template expects; the lifecycle tools apply them when creating a VM from the image. +internal sealed record ImageBuildVmConfig(int Generation, bool SecureBoot, string SecureBootTemplate, string EnhancedSessionTransportType, + bool Tpm, int MemoryMb, bool StaticMemory, int Processors, string CheckpointType, bool AutomaticCheckpoints); + +internal sealed record ImageBuildSourceRecord(string Kind, string? Path, string? Url, string? FinalUrl, string? EvalSource, + string? Sha256, long? Bytes, bool ChecksumVerified, string? ChecksumSource, bool Reused); + +internal sealed record ImageBuildImageRecord(int Index, string Name, string? EditionId, string? Version, int? Architecture, long Size, + IReadOnlyList Languages); + +/// Kernel-debug settings recorded in the sidecar. The KDNET key is returned once at build start and never stored here. +internal sealed record ImageBuildKernelDebugRecord(string Transport, string? HostIp, int? Port, int? ComPort, bool KeyStored); + +/// The <name>.image.json sidecar written next to every built VHDX, for the image library to register. +internal sealed record ImageBuildSidecar( + int SchemaVersion, string Name, string OsFamily, string Kind, string VhdPath, string? SeedIsoPath, ImageBuildVmConfig Config, + string? AdminUser, string? SshUser, ImageBuildSourceRecord BuiltFrom, DateTimeOffset BuiltAt, ImageBuildImageRecord? Image, + ImageBuildKernelDebugRecord? KernelDebug, string? BootConfiguration, IReadOnlyList Warnings); + +internal sealed record ImageBuildPartition(int Number, string GptType, long Size, string? DriveLetter); + +/// The job result of a completed build. It never contains the admin password or the KDNET key. +internal sealed record ImageBuildResult( + string OsFamily, string Name, string VhdPath, long VhdFileBytes, long? VhdVirtualBytes, string? SeedIsoPath, long? SeedIsoBytes, + string SidecarPath, ImageBuildSidecar Sidecar, IReadOnlyList? Partitions, string? BootConfiguration, + string? BootSummary, bool? KernelDebugApplied, object? ToolsCopied, IReadOnlyList Warnings, IReadOnlyList NextSteps, + double DurationSeconds); + +/// Input validation for hyperv_image_build. Every rule runs before any host mutation. +internal static partial class ImageBuildValidation +{ + internal const string DefaultAdminUser = "labadmin"; + internal const string DefaultSshUser = "lab"; + internal const string DefaultLanguage = "en-US"; + internal const string DefaultPackage = "linux-cloud-tools-virtual"; + internal const int DefaultWindowsDiskGb = 100; + internal const int DefaultLinuxDiskGb = 40; + internal const int MinDiskGb = 4; + internal const int MaxDiskGb = 65536; + + private static readonly string[] ReservedDeviceNames = + ["CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"]; + private static readonly string[] ReservedWindowsAccounts = ["administrator", "guest", "defaultaccount", "wdagutilityaccount", "system"]; + internal static readonly string[] EvalSources = ["eval", "eval-ltsc", "eval-iot"]; + internal static readonly string[] LinuxImageExtensions = [".img", ".qcow2", ".vhdx", ".vhd", ".raw", ".vmdk"]; + + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")] private static partial Regex NamePattern(); + [GeneratedRegex("^(?!\\d+$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,13}[A-Za-z0-9])?$")] private static partial Regex ComputerNamePattern(); + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._-]{0,19}$")] private static partial Regex WindowsUserPattern(); + [GeneratedRegex("^[a-z_][a-z0-9_-]{0,31}$")] private static partial Regex LinuxUserPattern(); + [GeneratedRegex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")] private static partial Regex HostnamePattern(); + [GeneratedRegex("^[a-z0-9][a-z0-9+.-]{1,127}$")] private static partial Regex PackagePattern(); + [GeneratedRegex("^[0-9a-fA-F]{64}$")] private static partial Regex Sha256Pattern(); + [GeneratedRegex("^[0-9a-z]{1,13}(\\.[0-9a-z]{1,13}){3}$", RegexOptions.IgnoreCase)] private static partial Regex KdKeyPattern(); + [GeneratedRegex("^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh\\.com|sk-ecdsa-sha2-nistp256@openssh\\.com) [A-Za-z0-9+/]+={0,3}( [^\\x00-\\x1f\\x7f]{0,1024})?$")] + private static partial Regex SshKeyPattern(); + + internal static string OsFamily(string? value) => value?.Trim().ToLowerInvariant() switch + { + "windows" => "windows", + "linux" => "linux", + _ => throw new ArgumentException("osFamily must be windows or linux.") + }; + + internal static string Name(string? value) + { + if (value is null || !NamePattern().IsMatch(value) || value.EndsWith('.')) + throw new ArgumentException("name must be 1-63 characters of letters, digits, '.', '_' or '-', start with a letter or digit, and not end with '.'; it becomes .vhdx."); + if (ReservedDeviceNames.Contains(value.Split('.')[0], StringComparer.OrdinalIgnoreCase)) + throw new ArgumentException($"name '{value}' is a reserved Windows device name."); + return value; + } + + /// An absolute local drive path (X:\...) without '..' components, normalized. + internal static string LocalPath(string value, string parameter) + { + if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value) || value.Split('\\', '/').Contains("..") + || !(value.Length >= 3 && char.IsAsciiLetter(value[0]) && value[1] == ':' && value[2] is '\\' or '/')) + throw new ArgumentException($"{parameter} must be an absolute local path such as D:\\Images, without '..' components."); + var full = Path.GetFullPath(value); + return full.Length > 3 ? full.TrimEnd('\\') : full; + } + + internal static string ExistingFile(string value, string parameter, params string[] extensions) + { + var path = LocalPath(value, parameter); + if (extensions.Length > 0 && !extensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) + throw new ArgumentException($"{parameter} must be a {string.Join(", ", extensions)} file."); + if (!File.Exists(path)) throw new ArgumentException($"{parameter} '{path}' does not exist."); + return path; + } + + internal static string? Sha256(string? value) => value is null ? null + : Sha256Pattern().IsMatch(value) ? value.ToLowerInvariant() + : throw new ArgumentException("sha256 must be 64 hexadecimal characters."); + + internal static int DiskGb(int? value, int fallback) => value switch + { + null => fallback, + < MinDiskGb or > MaxDiskGb => throw new ArgumentException($"diskGb must be {MinDiskGb}..{MaxDiskGb}."), + _ => value.Value + }; + + internal static string ComputerName(string? value, string name) + { + if (value is null) + { + var derived = new string(name.ToUpperInvariant().Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-').ToArray()); + derived = derived[..Math.Min(15, derived.Length)].Trim('-'); + return ComputerNamePattern().IsMatch(derived) ? derived : "*"; + } + if (value == "*" || ComputerNamePattern().IsMatch(value)) return value; + throw new ArgumentException("computerName must be 1-15 letters, digits or '-' (not all digits, no leading or trailing '-'), or '*' for a random name."); + } + + internal static string WindowsUser(string? value) + { + var user = value ?? DefaultAdminUser; + if (!WindowsUserPattern().IsMatch(user) || user.EndsWith('.')) + throw new ArgumentException("adminUser must be 1-20 letters, digits, '.', '_' or '-' and start with a letter or digit."); + if (ReservedWindowsAccounts.Contains(user, StringComparer.OrdinalIgnoreCase)) + throw new ArgumentException($"adminUser '{user}' is a built-in Windows account name; choose another name."); + return user; + } + + private static readonly System.Text.UTF8Encoding StrictUtf8 = new(false, true); + + /// A password that XML, PowerShell Direct and RDP all accept: printable, non-blank, well-formed UTF-16, bounded length. + internal static string Secret(string value, string parameter, int maxLength) + { + var valid = value.Length > 0 && value.Length <= maxLength && !string.IsNullOrWhiteSpace(value) && !value.Any(c => c < 0x20 || c == 0x7f); + if (valid) + { + try { StrictUtf8.GetByteCount(value); } + catch (System.Text.EncoderFallbackException) { valid = false; } + } + if (!valid) + throw new ArgumentException($"{parameter} must be 1-{maxLength} printable characters (no control characters); a blank password is refused by RDP and Enhanced Session."); + return value; + } + + /// A 20-character password with upper, lower, digit and symbol, free of quotes, backslashes, '$' and backticks. + internal static string GeneratePassword() + { + const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ", lower = "abcdefghijkmnopqrstuvwxyz", digits = "23456789", symbols = "!#%+-=?@^_"; + const string all = upper + lower + digits + symbols; + while (true) + { + var candidate = RandomNumberGenerator.GetString(all, 20); + if (candidate.IndexOfAny(upper.ToCharArray()) >= 0 && candidate.IndexOfAny(lower.ToCharArray()) >= 0 + && candidate.IndexOfAny(digits.ToCharArray()) >= 0 && candidate.IndexOfAny(symbols.ToCharArray()) >= 0) + return candidate; + } + } + + internal static string Language(string? value) + { + var name = value ?? DefaultLanguage; + try + { + var culture = CultureInfo.GetCultureInfo(name, predefinedOnly: true); + if (culture.IsNeutralCulture || string.IsNullOrEmpty(culture.Name) || !name.Contains('-')) + throw new CultureNotFoundException(); + return culture.Name; + } + catch (CultureNotFoundException) + { + throw new ArgumentException("language must be a specific culture name such as en-US or de-DE."); + } + } + + /// Same format as hyperv_kernel_configure: four base-36 components from 64 random bits each. + internal static string NewKdKey() + { + static string Base36(ulong value) + { + const string digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + var text = ""; + do { text = digits[(int)(value % 36)] + text; value /= 36; } while (value != 0); + return text; + } + return string.Join('.', Enumerable.Range(0, 4).Select(_ => Base36(BitConverter.ToUInt64(RandomNumberGenerator.GetBytes(8))))); + } + + internal static (ImageBuildKernelDebug? Settings, bool KeyGenerated) KernelDebug(ImageBuildKernelDebug? value) + { + if (value is null || !value.Enabled) return (null, false); + switch (value.Transport?.Trim().ToLowerInvariant()) + { + case "net": + if (!IPAddress.TryParse(value.HostIp, out var address) || address.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("kernelDebug.hostIp must be the host's IPv4 address that the guest can reach."); + if (value.Port is < 1024 or > 65535) + throw new ArgumentException("kernelDebug.port must be 1024..65535; use a unique port per VM (typically 50000..50039)."); + var generated = value.Key is null; + var key = (value.Key ?? NewKdKey()).ToLowerInvariant(); + if (!KdKeyPattern().IsMatch(key)) throw new ArgumentException("kernelDebug.key must be four dot-separated base-36 components, such as 1a.2b.3c.4d."); + return (new ImageBuildKernelDebug(true, "net", address.ToString(), value.Port, key, value.ComPort), generated); + case "serial": + if (value.ComPort is not (1 or 2)) throw new ArgumentException("kernelDebug.comPort must be 1 or 2."); + if (value.HostIp is not null || value.Key is not null) throw new ArgumentException("kernelDebug.hostIp and key apply to transport=net only."); + return (new ImageBuildKernelDebug(true, "serial", null, value.Port, null, value.ComPort), false); + default: + throw new ArgumentException("kernelDebug.transport must be net or serial."); + } + } + + internal static string LinuxUser(string? value) + { + var user = value ?? DefaultSshUser; + if (!LinuxUserPattern().IsMatch(user) || user == "root") + throw new ArgumentException("sshUser must be a lowercase Linux user name (letters, digits, '_' or '-', starting with a letter or '_'), not root."); + return user; + } + + internal static IReadOnlyList SshKeys(string? value) + { + if (value is null) return []; + var keys = value.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToList(); + if (keys.Count is 0 or > 16) throw new ArgumentException("sshPublicKey must contain 1-16 OpenSSH public keys, one per line."); + foreach (var key in keys) + if (key.Length > 16384 || !SshKeyPattern().IsMatch(key)) + throw new ArgumentException("sshPublicKey must be OpenSSH public key lines such as 'ssh-ed25519 AAAA... comment'."); + return keys; + } + + internal static string Hostname(string? value, string name) + { + if (value is null) + { + var derived = new string(name.ToLowerInvariant().Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-').ToArray()); + derived = derived[..Math.Min(63, derived.Length)].Trim('-'); + return HostnamePattern().IsMatch(derived) ? derived : "ubuntu"; + } + var lower = value.ToLowerInvariant(); + return HostnamePattern().IsMatch(lower) ? lower + : throw new ArgumentException("hostname must be one DNS label: 1-63 letters, digits or '-', without leading or trailing '-'."); + } + + internal static IReadOnlyList Packages(string[]? value) + { + if (value is null) return [DefaultPackage]; + if (value.Length > 64) throw new ArgumentException("packages accepts at most 64 package names."); + foreach (var package in value) + if (package is null || !PackagePattern().IsMatch(package)) + throw new ArgumentException($"Invalid package name '{package}'. Use Debian package names such as linux-cloud-tools-virtual."); + return value.Distinct(StringComparer.Ordinal).ToList(); + } + + internal static Uri ImageUrl(string value, bool hasSha256) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var url) || url.Scheme is not ("https" or "http") || !string.IsNullOrEmpty(url.UserInfo)) + throw new ArgumentException("imageUrl must be an absolute https:// URL without credentials."); + if (url.Scheme == "http" && !hasSha256) + throw new ArgumentException("An http:// imageUrl needs sha256; prefer https://."); + return url; + } + + internal static string ToolsDirectory(string value, string outputDirectory) + { + var path = LocalPath(value, "toolsDirectory"); + if (!Directory.Exists(path)) throw new ArgumentException($"toolsDirectory '{path}' does not exist."); + if (Path.GetPathRoot(path)!.TrimEnd('\\').Equals(path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("toolsDirectory must be a folder, not a drive root."); + if (IsSameOrUnder(outputDirectory, path)) + throw new ArgumentException("toolsDirectory must not contain the output directory."); + return path; + } + + internal static bool IsSameOrUnder(string path, string directory) + { + var a = Path.GetFullPath(path).TrimEnd('\\') + "\\"; + var b = Path.GetFullPath(directory).TrimEnd('\\') + "\\"; + return a.StartsWith(b, StringComparison.OrdinalIgnoreCase); + } + + /// Rejects parameters that belong to the other OS family, so a misplaced argument is never silently ignored. + internal static void RejectForeign(string family, ImageBuildRequest r) + { + (string Name, bool Given)[] windowsOnly = [("isoPath", r.IsoPath is not null), ("source", r.Source is not null), ("imageIndex", r.ImageIndex is not null), + ("edition", r.Edition is not null), ("computerName", r.ComputerName is not null), ("adminUser", r.AdminUser is not null), + ("adminPassword", r.AdminPassword is not null), ("enableRdp", r.EnableRdp is not null), ("autoLogon", r.AutoLogon is not null), + ("preventDeviceEncryption", r.PreventDeviceEncryption is not null), ("kernelDebug", r.KernelDebug is not null), + ("toolsDirectory", r.ToolsDirectory is not null), ("language", r.Language is not null)]; + (string Name, bool Given)[] linuxOnly = [("imageUrl", r.ImageUrl is not null), ("sshUser", r.SshUser is not null), + ("sshPublicKey", r.SshPublicKey is not null), ("password", r.Password is not null), ("hostname", r.Hostname is not null), + ("packages", r.Packages is not null)]; + var foreign = (family == "windows" ? linuxOnly : windowsOnly).Where(p => p.Given).Select(p => p.Name).ToList(); + if (foreign.Count > 0) + throw new ArgumentException($"{string.Join(", ", foreign)} {(foreign.Count == 1 ? "applies" : "apply")} to osFamily={(family == "windows" ? "linux" : "windows")} only."); + } +} diff --git a/src/HyperVControl/Services/ImageBuildScripts.cs b/src/HyperVControl/Services/ImageBuildScripts.cs new file mode 100644 index 0000000..7c863d3 --- /dev/null +++ b/src/HyperVControl/Services/ImageBuildScripts.cs @@ -0,0 +1,279 @@ +namespace HyperVControl.Services; + +/// +/// The host-side PowerShell steps of an image build, one each. Module autoloading is off in +/// the shared bootstrap, so the Storage and DISM modules are imported explicitly by path; under the test seam +/// (HYPERV_CONTROL_MODULE_PATH) those imports and the native-tool helper are skipped so the recording mock supplies them. +/// Every caller-supplied value is rendered as a single-quoted literal. +/// +internal static class ImageBuildScripts +{ + internal const string BasicDataGptType = "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}"; + internal const string EspGptType = "{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}"; + internal const string MsrGptType = "{e3c9e316-0b5c-4db8-817d-f92df00215ae}"; + internal const int EspMegabytes = 300; + internal const int MsrMegabytes = 16; + + private static PsValue L(string value) => PsValue.Of(value); + private static string Lit(string value) => PowerShellRunner.Literal(value); + internal static PsValue Array(IEnumerable values) => PsValue.Raw("@(" + string.Join(", ", values.Select(PowerShellRunner.Literal)) + ")"); + + /// + /// Runs a native tool without a shell. Stdout and stderr are kept apart; the exit code is returned, never thrown, so + /// each step decides what a failure means. Defined only outside mock mode. + /// + private const string NativeHelper = """ + function Invoke-ImageBuildNative([string]$FilePath, [string[]]$ArgumentList) { + if (-not [System.IO.File]::Exists($FilePath)) { Stop-Control 'NotFound' ('Executable not found: ' + $FilePath) } + $ErrorActionPreference = 'Continue' + $all = @(& $FilePath @ArgumentList 2>&1) + $code = $LASTEXITCODE + @{ + exitCode = [int]$code + stdout = (@($all | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | ForEach-Object { [string]$_ }) -join "`n") + stderr = (@($all | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { [string]$_ }) -join "`n") + } + } + """; + + private static HyperVScript Begin(bool storage = false, bool dism = false, bool native = false) + { + var script = new HyperVScript(); + if (!(storage || dism || native)) return script; + var lines = new List { "if ([string]::IsNullOrWhiteSpace($env:HYPERV_CONTROL_MODULE_PATH)) {" }; + if (storage) lines.Add(" Import-Module -Name ([System.IO.Path]::Combine($systemModuleRoot, 'Storage', 'Storage.psd1')) -ErrorAction Stop"); + if (dism) lines.Add(" Import-Module -Name ([System.IO.Path]::Combine($systemModuleRoot, 'Dism', 'Dism.psd1')) -ErrorAction Stop"); + if (native) lines.Add(NativeHelper); + lines.Add("}"); + return script.Line(string.Join("\n", lines)); + } + + public static HyperVScript DefaultImageRoot() => new HyperVScript().Returns("[string](Get-VMHost).VirtualHardDiskPath"); + + /// Reports whether the ISO is already attached (and its letter) without changing anything. + public static HyperVScript ProbeIso(string iso) => Begin(storage: true) + .Cmd("$image = Get-DiskImage", ("ImagePath", L(iso))) + .Line("$letter = $null") + .Line("if ($image.Attached) { $volume = @(Get-Volume -DiskImage $image)[0]; if ($volume -and ([string]$volume.DriveLetter) -match '^[A-Za-z]$') { $letter = [string]$volume.DriveLetter } }") + .Returns("@{ attached = [bool]$image.Attached; driveLetter = $letter }"); + + /// Mounts the ISO read-only and reads its drive letter back; a mount without a letter is undone. + public static HyperVScript MountIso(string iso) => Begin(storage: true) + .Cmd("$image = Mount-DiskImage", ("ImagePath", L(iso)), ("StorageType", L("ISO")), ("Access", L("ReadOnly")), ("PassThru", PsValue.Of(true))) + .Line("$letter = $null") + .Line("for ($attempt = 0; $attempt -lt 40 -and -not $letter; $attempt++) { $volume = @(Get-Volume -DiskImage $image)[0]; if ($volume -and ([string]$volume.DriveLetter) -match '^[A-Za-z]$') { $letter = [string]$volume.DriveLetter } else { Start-Sleep -Milliseconds 250 } }") + .Line("if (-not $letter) { Dismount-DiskImage -ImagePath " + Lit(iso) + " | Out-Null; Stop-Control 'HyperVOperationFailed' 'The ISO was mounted without a drive letter (no free letter, or automount is disabled). Free a drive letter, or extract sources\\install.wim and pass imagePath.' }") + .Returns("@{ driveLetter = $letter }"); + + public static HyperVScript ListImages(string imageFile) => Begin(dism: true) + .Line("$images = @(Get-WindowsImage -ImagePath " + Lit(imageFile) + " | ForEach-Object { @{ index = [int]$_.ImageIndex; name = [string]$_.ImageName; description = [string]$_.ImageDescription; size = [long]$_.ImageSize } })") + .Returns("$images"); + + public static HyperVScript ImageDetails(string imageFile, int index) => Begin(dism: true) + .Cmd("$image = Get-WindowsImage", ("ImagePath", L(imageFile)), ("Index", PsValue.Of(index))) + .Returns("@{ index = [int]$image.ImageIndex; name = [string]$image.ImageName; editionId = [string]$image.EditionId; installationType = [string]$image.InstallationType; " + + "architecture = $(if ($null -ne $image.Architecture) { [int]$image.Architecture } else { -1 }); version = [string]$image.Version; size = [long]$image.ImageSize; " + + "languages = @($image.Languages | ForEach-Object { [string]$_ }); defaultLanguageIndex = [int]$image.DefaultLanguageIndex }"); + + /// + /// GPT layout from Microsoft's UEFI guidance: a 300 MB EFI system partition (formatted FAT32 as a basic data partition, + /// then retyped, which Format-Volume requires), a 16 MB MSR and an NTFS Windows partition on the rest. Partitions are + /// formatted before they receive a letter, and both letters are read back rather than chosen. + /// + public static HyperVScript CreateDisk(string vhdx, int sizeGb) => Begin(storage: true) + .Cmd("$null = New-VHD", ("Path", L(vhdx)), ("SizeBytes", PsValue.Gigabytes(sizeGb)), ("Dynamic", PsValue.Of(true))) + .Cmd("$mounted = Mount-VHD", ("Path", L(vhdx)), ("Passthru", PsValue.Of(true))) + .Line("$diskNumber = $mounted.DiskNumber") + .Line("if ($null -eq $diskNumber) { $diskNumber = (Get-VHD -Path " + Lit(vhdx) + ").DiskNumber }") + .Line("if ($null -eq $diskNumber) { Stop-Control 'HyperVOperationFailed' 'The new VHDX was mounted without a disk number.' }") + .Line("$diskNumber = [int]$diskNumber") + .Cmd("Initialize-Disk", ("Number", PsValue.Raw("$diskNumber")), ("PartitionStyle", L("GPT"))) + // Initialize-Disk creates an MSR on its own; start from an empty table so the layout is exactly ESP, MSR, Windows. + .Line("@(Get-Partition -DiskNumber $diskNumber -ErrorAction SilentlyContinue) | ForEach-Object { Remove-Partition -DiskNumber $diskNumber -PartitionNumber $_.PartitionNumber -Confirm:$false }") + .Cmd("$system = New-Partition", ("DiskNumber", PsValue.Raw("$diskNumber")), ("Size", PsValue.Megabytes(EspMegabytes)), ("GptType", L(BasicDataGptType))) + .Cmd("$null = Format-Volume", ("Partition", PsValue.Raw("$system")), ("FileSystem", L("FAT32")), ("NewFileSystemLabel", L("System")), ("Force", PsValue.Of(true)), ("Confirm", PsValue.Of(false))) + .Cmd("Set-Partition", ("DiskNumber", PsValue.Raw("$diskNumber")), ("PartitionNumber", PsValue.Raw("$system.PartitionNumber")), ("GptType", L(EspGptType))) + .Cmd("Add-PartitionAccessPath", ("DiskNumber", PsValue.Raw("$diskNumber")), ("PartitionNumber", PsValue.Raw("$system.PartitionNumber")), ("AssignDriveLetter", PsValue.Of(true))) + .Cmd("$null = New-Partition", ("DiskNumber", PsValue.Raw("$diskNumber")), ("Size", PsValue.Megabytes(MsrMegabytes)), ("GptType", L(MsrGptType))) + .Cmd("$windows = New-Partition", ("DiskNumber", PsValue.Raw("$diskNumber")), ("UseMaximumSize", PsValue.Of(true)), ("GptType", L(BasicDataGptType))) + .Cmd("$null = Format-Volume", ("Partition", PsValue.Raw("$windows")), ("FileSystem", L("NTFS")), ("NewFileSystemLabel", L("Windows")), ("Force", PsValue.Of(true)), ("Confirm", PsValue.Of(false))) + .Cmd("Add-PartitionAccessPath", ("DiskNumber", PsValue.Raw("$diskNumber")), ("PartitionNumber", PsValue.Raw("$windows.PartitionNumber")), ("AssignDriveLetter", PsValue.Of(true))) + .Line("$systemLetter = [string](Get-Partition -DiskNumber $diskNumber -PartitionNumber $system.PartitionNumber).DriveLetter") + .Line("$windowsLetter = [string](Get-Partition -DiskNumber $diskNumber -PartitionNumber $windows.PartitionNumber).DriveLetter") + .Line("if ($systemLetter -notmatch '^[A-Za-z]$' -or $windowsLetter -notmatch '^[A-Za-z]$') { Stop-Control 'HyperVOperationFailed' 'No free drive letter could be assigned to the new partitions; free a drive letter and retry.' }") + .Returns("@{ diskNumber = $diskNumber; systemLetter = $systemLetter; windowsLetter = $windowsLetter; partitions = @(Get-Partition -DiskNumber $diskNumber | ForEach-Object { " + + "@{ number = [int]$_.PartitionNumber; gptType = [string]$_.GptType; size = [long]$_.Size; driveLetter = $(if ([string]$_.DriveLetter -match '^[A-Za-z]$') { [string]$_.DriveLetter } else { $null }) } }) }"); + + public static HyperVScript ApplyImage(string imageFile, int index, char windowsLetter) => Begin(dism: true) + .Cmd("$null = Expand-WindowsImage", ("ImagePath", L(imageFile)), ("Index", PsValue.Of(index)), ("ApplyPath", L(windowsLetter + ":\\"))) + .Returns("@{ applied = $true }"); + + /// + /// bcdboot <W>:\Windows /s <S>: /f UEFI: boot files come from the image, and with /s no firmware (NVRAM) + /// entry is written on the host. /offline is added when this bcdboot supports it (26100.8037+), so boot-file + /// selection does not depend on the host's own Secure Boot state and no host BitLocker reseal runs. The exit code + /// and verbose output are returned for the caller to judge. + /// + public static HyperVScript BcdBoot(char windowsLetter, char systemLetter) => Begin(native: true) + .Line("$bcdboot = [System.IO.Path]::Combine($env:SystemRoot, 'System32', 'bcdboot.exe')") + .Cmd("$help = Invoke-ImageBuildNative", ("FilePath", PsValue.Raw("$bcdboot")), ("ArgumentList", Array(["/?"]))) + .Line("$offline = [bool](($help.stdout + ' ' + $help.stderr) -match '(?i)/offline\\b')") + .Line("$arguments = @(" + Lit(windowsLetter + ":\\Windows") + ", '/s', " + Lit(systemLetter + ":") + ", '/f', 'UEFI', '/v')") + .Line("if ($offline) { $arguments += '/offline' }") + .Cmd("$run = Invoke-ImageBuildNative", ("FilePath", PsValue.Raw("$bcdboot")), ("ArgumentList", PsValue.Raw("$arguments"))) + .Returns("@{ exitCode = [int]$run.exitCode; offline = $offline; arguments = @($arguments); output = (($run.stdout + \"`n\" + $run.stderr).Trim()) }"); + + private static HyperVScript BcdEditor(char systemLetter) => Begin(native: true) + .Line("$bcdedit = [System.IO.Path]::Combine($env:SystemRoot, 'System32', 'bcdedit.exe')") + .Line("$store = " + Lit(systemLetter + ":\\EFI\\Microsoft\\Boot\\BCD")) + // Labels, not arguments, name a failed step: the dbgsettings arguments carry the KDNET key. + .Line("function Invoke-ImageBuildBcd([string]$Label, [string[]]$Arguments) { $run = Invoke-ImageBuildNative -FilePath $bcdedit -ArgumentList (@('/store', $store) + $Arguments); " + + "if ($run.exitCode -ne 0) { Stop-Control 'HyperVOperationFailed' ('bcdedit ' + $Label + ' failed (exit ' + $run.exitCode + '): ' + (($run.stdout + ' ' + $run.stderr).Trim())) }; $run }"); + + /// + /// The bcdedit sequence that completes a store copied from the image's BCD-Template: one Windows Boot Loader and its + /// resume object with the same elements bcdboot sets, the boot manager pointing at them, and the UEFI memory tester. + /// + internal static IReadOnlyList<(string Label, string[] Arguments)> BcdStoreSteps(char windowsLetter, char systemLetter, string locale, Guid loader, Guid resume) + { + var l = "{" + loader.ToString("D") + "}"; + var r = "{" + resume.ToString("D") + "}"; + var windows = "partition=" + windowsLetter + ":"; + var system = "partition=" + systemLetter + ":"; + return + [ + ("create resume", ["/create", r, "/d", "Windows Resume Application", "/application", "resume"]), + ("resume device", ["/set", r, "device", windows]), + ("resume path", ["/set", r, "path", "\\Windows\\system32\\winresume.efi"]), + ("resume locale", ["/set", r, "locale", locale]), + ("resume inherit", ["/set", r, "inherit", "{resumeloadersettings}"]), + ("resume filedevice", ["/set", r, "filedevice", windows]), + ("resume filepath", ["/set", r, "filepath", "\\hiberfil.sys"]), + ("resume isolatedcontext", ["/set", r, "isolatedcontext", "Yes"]), + ("resume allowedinmemorysettings", ["/set", r, "allowedinmemorysettings", "0x15000075"]), + ("resume bootmenupolicy", ["/set", r, "bootmenupolicy", "Standard"]), + ("create loader", ["/create", l, "/d", "Windows", "/application", "osloader"]), + ("loader device", ["/set", l, "device", windows]), + ("loader path", ["/set", l, "path", "\\Windows\\system32\\winload.efi"]), + ("loader locale", ["/set", l, "locale", locale]), + ("loader inherit", ["/set", l, "inherit", "{bootloadersettings}"]), + ("loader isolatedcontext", ["/set", l, "isolatedcontext", "Yes"]), + ("loader allowedinmemorysettings", ["/set", l, "allowedinmemorysettings", "0x15000075"]), + ("loader osdevice", ["/set", l, "osdevice", windows]), + ("loader systemroot", ["/set", l, "systemroot", "\\Windows"]), + ("loader resumeobject", ["/set", l, "resumeobject", r]), + ("loader nx", ["/set", l, "nx", "OptIn"]), + ("loader bootmenupolicy", ["/set", l, "bootmenupolicy", "Standard"]), + ("bootmgr device", ["/set", "{bootmgr}", "device", system]), + ("bootmgr path", ["/set", "{bootmgr}", "path", "\\EFI\\Microsoft\\Boot\\bootmgfw.efi"]), + ("bootmgr locale", ["/set", "{bootmgr}", "locale", locale]), + ("bootmgr default", ["/set", "{bootmgr}", "default", l]), + ("bootmgr resumeobject", ["/set", "{bootmgr}", "resumeobject", r]), + ("bootmgr displayorder", ["/displayorder", l]), + ("bootmgr toolsdisplayorder", ["/set", "{bootmgr}", "toolsdisplayorder", "{memdiag}"]), + ("memdiag device", ["/set", "{memdiag}", "device", system]), + ("memdiag path", ["/set", "{memdiag}", "path", "\\EFI\\Microsoft\\Boot\\memtest.efi"]) + ]; + } + + public static HyperVScript BcdStore(char windowsLetter, char systemLetter, string locale, Guid loader, Guid resume) + { + var script = BcdEditor(systemLetter); + var steps = BcdStoreSteps(windowsLetter, systemLetter, locale, loader, resume); + foreach (var (label, arguments) in steps) + script.Line("$null = Invoke-ImageBuildBcd " + Lit(label) + " " + Array(arguments).Text); + return script.Returns("@{ steps = " + steps.Count + " }"); + } + + /// Offline kernel-debug settings in the image's BCD store (Convert-WindowsImage's -EnableDebugger approach). + internal static IReadOnlyList<(string Label, string[] Arguments)> BcdDebugSteps(ImageBuildKernelDebug debug) => + [ + ("debug on", ["/set", "{default}", "debug", "on"]), + debug.Transport == "net" + ? ("dbgsettings net", ["/dbgsettings", "net", "hostip:" + debug.HostIp, "port:" + debug.Port, "key:" + debug.Key]) + : ("dbgsettings serial", ["/dbgsettings", "serial", "debugport:" + debug.ComPort, "baudrate:115200"]) + ]; + + public static HyperVScript BcdDebug(char systemLetter, ImageBuildKernelDebug debug) + { + var script = BcdEditor(systemLetter); + foreach (var (label, arguments) in BcdDebugSteps(debug)) + script.Line("$null = Invoke-ImageBuildBcd " + Lit(label) + " " + Array(arguments).Text); + return script + .Line("$check = Invoke-ImageBuildBcd 'enum {default}' @('/enum', '{default}')") + .Returns("@{ debugOn = [bool]($check.stdout -match '(?im)^debug\\s+Yes'); enumDefault = $check.stdout }"); + } + + /// Dismounts what the build attached; never throws, reports what is still attached. + public static HyperVScript Cleanup(string? vhdx, string? iso) + { + var script = Begin(storage: true).Line("$problems = @()"); + if (vhdx is not null) + script.Line("try { Dismount-VHD -Path " + Lit(vhdx) + " -ErrorAction Stop } catch { $dismountError = $_.Exception.Message; " + + "try { if ((Get-VHD -Path " + Lit(vhdx) + " -ErrorAction Stop).Attached) { $problems += ('The VHDX is still attached: ' + $dismountError) } } catch { } }"); + if (iso is not null) + script.Line("try { Dismount-DiskImage -ImagePath " + Lit(iso) + " -ErrorAction Stop | Out-Null } catch { $dismountError = $_.Exception.Message; " + + "try { if ((Get-DiskImage -ImagePath " + Lit(iso) + " -ErrorAction Stop).Attached) { $problems += ('The ISO is still mounted: ' + $dismountError) } } catch { } }"); + return script.Returns("@{ problems = @($problems) }"); + } + + public static HyperVScript QemuInfo(string qemuImg, string source) => Begin(native: true) + .Cmd("$run = Invoke-ImageBuildNative", ("FilePath", L(qemuImg)), ("ArgumentList", Array(["info", "--output=json", source]))) + .Line("if ($run.exitCode -ne 0) { Stop-Control 'HyperVOperationFailed' ('qemu-img info failed (exit ' + $run.exitCode + '): ' + (($run.stdout + ' ' + $run.stderr).Trim())) }") + .Line("$info = $run.stdout | ConvertFrom-Json") + .Returns("@{ format = [string]$info.format; virtualSize = [long]$info.'virtual-size'; backingFile = [string]$info.'backing-filename' }"); + + /// qemu-img convert -f <format> -O vhdx -o subformat=dynamic, the conversion Microsoft documents for Hyper-V. + public static HyperVScript QemuConvert(string qemuImg, string source, string format, string destination) => Begin(native: true) + .Cmd("$run = Invoke-ImageBuildNative", ("FilePath", L(qemuImg)), ("ArgumentList", Array(["convert", "-f", format, "-O", "vhdx", "-o", "subformat=dynamic", source, destination]))) + .Line("if ($run.exitCode -ne 0) { Stop-Control 'HyperVOperationFailed' ('qemu-img convert failed (exit ' + $run.exitCode + '): ' + (($run.stdout + ' ' + $run.stderr).Trim())) }") + .Returns("@{ converted = $true }"); + + /// Grows the virtual disk to the requested size; never shrinks it. + public static HyperVScript ResizeVhd(string vhdx, int sizeGb) => new HyperVScript() + .Cmd("$vhd = Get-VHD", ("Path", L(vhdx))) + .Line("$before = [long]$vhd.Size") + .Line("$target = " + PsValue.Gigabytes(sizeGb).Text) + .Line("if ($before -lt $target) { Resize-VHD -Path " + Lit(vhdx) + " -SizeBytes $target }") + .Cmd("$vhd = Get-VHD", ("Path", L(vhdx))) + .Returns("@{ sizeBefore = $before; size = [long]$vhd.Size; fileSize = [long]$vhd.FileSize; resized = ($before -lt $target) }"); + + private const string IsoWriter = """ + if (-not ('HyperVControlImageBuild.IsoStreamWriter' -as [type])) { + Add-Type -TypeDefinition @' + namespace HyperVControlImageBuild { + public static class IsoStreamWriter { + public static void Save(object comStream, string path) { + var stream = (System.Runtime.InteropServices.ComTypes.IStream)comStream; + var buffer = new byte[1048576]; + var read = System.Runtime.InteropServices.Marshal.AllocHGlobal(4); + try { + using (var file = new System.IO.FileStream(path, System.IO.FileMode.CreateNew, System.IO.FileAccess.Write)) { + while (true) { + stream.Read(buffer, buffer.Length, read); + var count = System.Runtime.InteropServices.Marshal.ReadInt32(read); + if (count <= 0) break; + file.Write(buffer, 0, count); + } + } + } finally { System.Runtime.InteropServices.Marshal.FreeHGlobal(read); } + } + } + } + '@ + } + """; + + /// + /// A data-only ISO (ISO 9660 + Joliet, so names such as user-data keep their spelling) built with the inbox IMAPI2 + /// file-system COM API; no ADK and no administrator rights are needed. The target must not exist. + /// + public static HyperVScript BuildIso(string sourceDirectory, string volumeLabel, string isoPath) => new HyperVScript() + .Line(IsoWriter) + .Line("$fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage") + .Line("$fsi.FileSystemsToCreate = 3") + .Line("$fsi.VolumeName = " + Lit(volumeLabel)) + .Line("$fsi.Root.AddTree(" + Lit(sourceDirectory) + ", $false)") + .Line("[HyperVControlImageBuild.IsoStreamWriter]::Save($fsi.CreateResultImage().ImageStream, " + Lit(isoPath) + ")") + .Returns("@{ path = " + Lit(isoPath) + "; size = [long]([System.IO.FileInfo]::new(" + Lit(isoPath) + ").Length) }"); +} diff --git a/src/HyperVControl/Services/ImageBuildService.cs b/src/HyperVControl/Services/ImageBuildService.cs index 01461f2..beed7a9 100644 --- a/src/HyperVControl/Services/ImageBuildService.cs +++ b/src/HyperVControl/Services/ImageBuildService.cs @@ -1,12 +1,636 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using static HyperVControl.Services.ImageBuildValidation; + namespace HyperVControl.Services; /// -/// Golden image builds: a Windows ISO applied offline to a bootable VHDX with an unattend file, -/// and a Linux cloud image converted with a NoCloud seed. Implemented in phase 3; this scaffold -/// only fixes the composition point. +/// Golden image builds, run as background jobs. Windows: an ISO (local or a downloaded Microsoft evaluation) or an +/// install.wim/esd is applied offline to a GPT VHDX (EFI system partition, MSR, NTFS), made bootable with bcdboot, and +/// given a specialize/oobeSystem unattend.xml, optional offline kernel-debug BCD settings and a C:\Tools folder. +/// Linux: an Ubuntu cloud image is downloaded and checked against its SHA256SUMS, converted to a dynamic VHDX with +/// qemu-img, grown, and paired with a NoCloud (CIDATA) seed ISO. Each build writes <name>.image.json next to the +/// VHDX for the image library. Disk, DISM, bcdboot and IMAPI2 steps are PowerShell (); +/// downloads, hashing and file writes are C#. Whatever a build attaches is detached in every outcome, and a failed or +/// cancelled build deletes its outputs (downloads stay cached for the next attempt). /// -internal sealed class ImageBuildService(PowerShellRunner runner, JobRunner jobs) +internal sealed partial class ImageBuildService(PowerShellRunner runner, JobRunner jobs) { internal PowerShellRunner Runner { get; } = runner; internal JobRunner Jobs { get; } = jobs; + + internal const string ImageRootVariable = "HYPERV_CONTROL_IMAGE_ROOT"; + internal const string QemuImgVariable = "HYPERV_CONTROL_QEMU_IMG"; + internal const string DefaultUbuntuImage = "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"; + private const long Gigabyte = 1L << 30; + private static readonly string[] QemuFormats = ["qcow2", "raw", "vmdk", "vpc", "vhdx", "vdi"]; + + /// Anonymous Evaluation Center links (en-US); each resolves to a static CDN file. + internal static readonly IReadOnlyDictionary EvaluationMedia = new Dictionary + { + ["eval"] = (new Uri("https://go.microsoft.com/fwlink/?LinkId=2382600&clcid=0x409&culture=en-us&country=us"), "Windows 11 Enterprise evaluation (26H2, about 7.7 GiB)"), + ["eval-ltsc"] = (new Uri("https://go.microsoft.com/fwlink/?linkid=2289029&clcid=0x409&culture=en-us&country=us"), "Windows 11 Enterprise LTSC 2024 evaluation (about 4.8 GiB)"), + ["eval-iot"] = (new Uri("https://go.microsoft.com/fwlink/?linkid=2270353&clcid=0x409&culture=en-us&country=us"), "Windows 11 IoT Enterprise LTSC 2024 evaluation (about 4.7 GiB)") + }; + + internal const string EvaluationWarning = "Evaluation media: the license runs 90 days. Give the first boot a switch with internet access so it activates; an expired or unactivated evaluation shows a black desktop and shuts down every hour."; + internal const string PasswordInImageWarning = "Until a VM's first sign-in deletes it, Windows\\Panther\\unattend.xml inside the VHDX holds the admin password in plain text: treat the VHDX as sensitive."; + internal const string QemuMissingMessage = "qemu-img was not found; converting the cloud image (QCOW2) to VHDX needs it. Install the QEMU disk image utility for Windows " + + "(the Cloudbase build Microsoft documents: https://cloudbase.it/qemu-img-windows/, or QEMU for Windows), then set HYPERV_CONTROL_QEMU_IMG to the full path of " + + "qemu-img.exe as a persistent user or machine environment variable (for example: setx HYPERV_CONTROL_QEMU_IMG \"C:\\Tools\\qemu-img\\qemu-img.exe\"), or put " + + "qemu-img.exe on the system PATH. Variables set only in the MCP client's session do not reach the elevated worker started by --elevate. A .vhdx imagePath needs no conversion."; + + /// Test seam: maps a drive letter the scripts report to the directory that C# file steps use. + internal Func DriveRoot { get; set; } = letter => letter + ":\\"; + internal IReadOnlyDictionary EvaluationUrls { get; set; } = EvaluationMedia.ToDictionary(p => p.Key, p => p.Value.Url); + internal Uri UbuntuImageUrl { get; set; } = new(DefaultUbuntuImage); + internal HttpClient Http { get; set; } = SharedHttp.Value; + private static readonly Lazy SharedHttp = new(ImageBuildDownloader.CreateClient); + private static readonly JsonSerializerOptions SidecarJson = new(Program.Json) { WriteIndented = true }; + private readonly ConcurrentDictionary activeOutputs = new(StringComparer.OrdinalIgnoreCase); + + [GeneratedRegex(@"(?Validates the request, starts the build job and returns the job plus anything shown only once. + public async Task StartAsync(ImageBuildRequest request, int waitSeconds, CancellationToken ct) + { + if (waitSeconds is < 0 or > 3600) throw new ArgumentException("waitSeconds must be 0..3600."); + var plan = await PrepareAsync(request, ct); + if (!activeOutputs.TryAdd(plan.VhdPath, 0)) throw new InvalidOperationException($"Another build is already writing {plan.VhdPath}."); + var job = await Jobs.StartAndWaitAsync($"image-build {plan.OsFamily} {plan.Name}", async (progress, token) => + { + try { return await ExecuteAsync(plan, progress, token); } + finally { activeOutputs.TryRemove(plan.VhdPath, out _); } + }, waitSeconds, ct); + return Describe(plan, job); + } + + internal static ImageBuildStart Describe(ImageBuildPlan plan, JobInfo job) + { + var failed = job.Status is JobStatus.Failed or JobStatus.Cancelled; + object? credentials = null, kernelDebug = null; + if (plan.Windows is { } w) + { + credentials = new ImageBuildCredentials(w.AdminUser, w.PasswordGenerated ? w.AdminPassword : null, w.PasswordGenerated, + (w.PasswordGenerated + ? "This generated password appears only in this response and the plugin does not store it. " + : "The supplied password is not echoed back. ") + + $"After you create a VM from this image and its first boot completes, store it with hyperv_credential (username {w.AdminUser}). {PasswordInImageWarning}"); + if (w.KernelDebug is { } kd) + kernelDebug = new ImageBuildKernelDebugStart(kd.Transport, kd.HostIp, kd.Transport == "net" ? kd.Port : null, kd.Transport == "serial" ? kd.ComPort : null, + kd.Transport == "net" ? $"net:port={kd.Port},key={kd.Key}" : null, w.KeyGenerated, + kd.Transport == "net" + ? "The attach string (with its key) appears only in this response; the sidecar and job result omit the key. VMs from this image need Secure Boot off; attach with hyperv_debug_open mode=kernel." + : $"Before first boot map COM{kd.ComPort} to a pipe with hyperv_kernel_configure action=serial_host and attach with the string it returns. VMs from this image need Secure Boot off."); + } + else if (plan.Linux is { } l) + credentials = new ImageBuildLinuxCredentials(l.SshUser, l.SshKeys.Count, l.PasswordHash is not null, + "cloud-init creates this user with passwordless sudo; a password is stored only as a SHA-512-crypt hash in the seed ISO."); + return new ImageBuildStart(!failed, failed ? job.Error : null, failed ? job.ErrorType : null, job, + new ImageBuildOutputs(plan.VhdPath, plan.SidecarPath, plan.SeedIsoPath, plan.Source.Url is null ? null : plan.CacheDirectory), + credentials, kernelDebug, + job.Status == JobStatus.Succeeded + ? "Done: the job result lists the outputs, warnings and nextSteps." + : failed ? "The build failed; nothing was left mounted and its outputs were removed (downloads stay cached)." + : $"Poll hyperv_jobs (action=wait or status, id={job.Id}) until the job finishes; its result lists the outputs, warnings and nextSteps."); + } + + internal async Task PrepareAsync(ImageBuildRequest r, CancellationToken ct) + { + var family = OsFamily(r.OsFamily); + var name = Name(r.Name); + RejectForeign(family, r); + var sha = Sha256(r.Sha256); + var warnings = new List(); + ImageBuildPlan plan; + if (family == "windows") + { + if (new[] { r.IsoPath, r.ImagePath, r.Source }.Count(v => v is not null) != 1) + throw new ArgumentException("Supply exactly one Windows source: isoPath (a local .iso), imagePath (a local install.wim or install.esd), or source=eval|eval-ltsc|eval-iot " + + "(downloads the Microsoft evaluation ISO: about 7.7, 4.8 or 4.7 GiB, cached for later builds)."); + ImageBuildSourceSpec source; + if (r.IsoPath is not null) source = new("iso", ExistingFile(r.IsoPath, "isoPath", ".iso"), null, null, sha); + else if (r.ImagePath is not null) source = new("wim", ExistingFile(r.ImagePath, "imagePath", ".wim", ".esd"), null, null, sha); + else + { + var key = r.Source!.Trim().ToLowerInvariant(); + if (!EvaluationUrls.TryGetValue(key, out var url)) throw new ArgumentException("source must be eval, eval-ltsc or eval-iot."); + source = new("eval-download", null, url, key, sha); + warnings.Add(EvaluationWarning); + } + if (r.ImageIndex is < 1) throw new ArgumentException("imageIndex must be 1 or greater."); + if (r.ImageIndex is not null && r.Edition is not null) throw new ArgumentException("Supply imageIndex or edition, not both."); + if (r.Edition is not null && (string.IsNullOrWhiteSpace(r.Edition) || r.Edition.Length > 128)) throw new ArgumentException("edition must be 1-128 characters of an image name."); + var diskGb = DiskGb(r.DiskGb, DefaultWindowsDiskGb); + if (diskGb < 64) warnings.Add($"Windows 11 requires a disk of at least 64 GB; diskGb={diskGb} suits tests only."); + var computerName = ComputerName(r.ComputerName, name); + var adminUser = WindowsUser(r.AdminUser); + var generated = r.AdminPassword is null; + var password = generated ? GeneratePassword() : Secret(r.AdminPassword!, "adminPassword", 127); + var (kernelDebug, keyGenerated) = KernelDebug(r.KernelDebug); + if (kernelDebug is not null) warnings.Add("Kernel debugging is enabled in the image's BCD store: VMs created from it need Secure Boot off (kernel debugging is not compatible with Secure Boot)."); + var language = Language(r.Language); + var output = await ResolveOutputDirectoryAsync(r.OutputDirectory, ct); + var tools = r.ToolsDirectory is null ? null : ToolsDirectory(r.ToolsDirectory, output); + plan = new ImageBuildPlan + { + OsFamily = family, Name = name, OutputDirectory = output, VhdPath = Path.Combine(output, name + ".vhdx"), + SidecarPath = Path.Combine(output, name + ".image.json"), DiskGb = diskGb, Source = source, Warnings = warnings, + Windows = new ImageBuildWindowsSpec(r.ImageIndex, r.Edition?.Trim(), computerName, adminUser, password, generated, + r.EnableRdp ?? true, r.AutoLogon ?? true, r.PreventDeviceEncryption ?? true, kernelDebug, keyGenerated, tools, language) + }; + } + else + { + if (r.ImagePath is not null && r.ImageUrl is not null) throw new ArgumentException("Supply imageUrl or imagePath, not both."); + ImageBuildSourceSpec source; + bool convert; + if (r.ImagePath is not null) + { + var path = ExistingFile(r.ImagePath, "imagePath", LinuxImageExtensions); + source = new("image", path, null, null, sha); + convert = !path.EndsWith(".vhdx", StringComparison.OrdinalIgnoreCase); + } + else + { + var url = r.ImageUrl is null ? UbuntuImageUrl : ImageUrl(r.ImageUrl, sha is not null); + source = new("image-download", null, url, null, sha); + convert = !url.AbsolutePath.EndsWith(".vhdx", StringComparison.OrdinalIgnoreCase); + } + var diskGb = DiskGb(r.DiskGb, DefaultLinuxDiskGb); + var user = LinuxUser(r.SshUser); + var keys = SshKeys(r.SshPublicKey); + var passwordHash = r.Password is null ? null : ImageBuildCloudInit.Sha512Crypt(Secret(r.Password, "password", 256)); + if (keys.Count == 0 && passwordHash is null) throw new ArgumentException("sshPublicKey is required unless password is given."); + var hostname = Hostname(r.Hostname, name); + var packages = Packages(r.Packages); + // Checked before anything is downloaded, so a missing converter costs nothing. + var qemu = convert ? ResolveQemuImg() : null; + var output = await ResolveOutputDirectoryAsync(r.OutputDirectory, ct); + plan = new ImageBuildPlan + { + OsFamily = family, Name = name, OutputDirectory = output, VhdPath = Path.Combine(output, name + ".vhdx"), + SidecarPath = Path.Combine(output, name + ".image.json"), SeedIsoPath = Path.Combine(output, name + "-seed.iso"), + DiskGb = diskGb, Source = source, Warnings = warnings, + Linux = new ImageBuildLinuxSpec(user, keys, passwordHash, hostname, packages, qemu) + }; + } + foreach (var existing in new[] { plan.VhdPath, plan.SidecarPath, plan.SeedIsoPath }.OfType()) + if (File.Exists(existing) || Directory.Exists(existing)) + throw new ArgumentException($"{existing} already exists; choose another name or remove it first."); + return plan; + } + + internal async Task ResolveOutputDirectoryAsync(string? requested, CancellationToken ct) + { + string directory; + if (requested is not null) directory = LocalPath(requested, "outputDirectory"); + else if (Environment.GetEnvironmentVariable(ImageRootVariable) is { Length: > 0 } configured) directory = LocalPath(configured, ImageRootVariable); + else + { + var vhdRoot = (await Runner.InvokeAsync(ImageBuildScripts.DefaultImageRoot(), ct)).GetString(); + if (string.IsNullOrWhiteSpace(vhdRoot)) throw new HyperVOperationException("Hyper-V reported no default virtual hard disk path; pass outputDirectory.", "InvalidResponse"); + directory = LocalPath(Path.Combine(vhdRoot, "HyperVControl", "Images"), "the host's default virtual hard disk path"); + } + if (!Directory.Exists(Path.GetPathRoot(directory))) throw new ArgumentException($"The drive of {directory} does not exist."); + return directory; + } + + /// HYPERV_CONTROL_QEMU_IMG (process, then the persistent user and machine values), then PATH, then %ProgramFiles%\qemu. + internal static string ResolveQemuImg() + { + foreach (var target in new[] { EnvironmentVariableTarget.Process, EnvironmentVariableTarget.User, EnvironmentVariableTarget.Machine }) + { + var configured = Environment.GetEnvironmentVariable(QemuImgVariable, target); + if (string.IsNullOrWhiteSpace(configured)) continue; + var path = configured.Trim().Trim('"'); + if (Path.IsPathFullyQualified(path) && File.Exists(path)) return path; + throw new HyperVOperationException($"{QemuImgVariable} ({target.ToString().ToLowerInvariant()} scope) is '{configured}', which is not an existing file; set it to the full path of qemu-img.exe.", "NotFound"); + } + foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var candidate = Path.Combine(directory.Trim('"'), "qemu-img.exe"); + if (Path.IsPathFullyQualified(candidate) && File.Exists(candidate)) return candidate; + } + var installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "qemu", "qemu-img.exe"); + return File.Exists(installed) ? installed : throw new HyperVOperationException(QemuMissingMessage, "NotFound"); + } + + internal async Task ExecuteAsync(ImageBuildPlan plan, IProgress progress, CancellationToken ct) => + plan.OsFamily == "windows" ? await BuildWindowsAsync(plan, progress, ct) : await BuildLinuxAsync(plan, progress, ct); + + private sealed record ImageInfo(int Index, string Name, string EditionId, string InstallationType, int Architecture, string Version, long Size, + string[] Languages, int DefaultLanguageIndex); + + private async Task BuildWindowsAsync(ImageBuildPlan plan, IProgress progress, CancellationToken ct) + { + var clock = Stopwatch.StartNew(); + var w = plan.Windows!; + var warnings = new List(plan.Warnings); + void Phase(int step, string text) => progress.Report(new JobProgress(Math.Round(step * 100.0 / 10, 1), Message: $"windows {step}/10: {text}")); + Directory.CreateDirectory(plan.OutputDirectory); + + Phase(1, "resolving the source image"); + var (sourceFile, builtFrom) = await ResolveSourceAsync(plan, warnings, progress, ct); + var isoPath = plan.Source.Kind is "iso" or "eval-download" ? sourceFile : null; + + var mountedIso = false; + var diskStarted = false; + Exception? failure = null; + ImageInfo? image = null; + ImageBuildPartition[] partitions = []; + string? bootConfiguration = null, bootSummary = null; + bool? debugApplied = null; + ImageBuildToolsCopy? tools = null; + try + { + string imageFile; + if (isoPath is not null) + { + Phase(2, "mounting the ISO read-only"); + var probe = await Runner.InvokeAsync(ImageBuildScripts.ProbeIso(isoPath), ct, 120); + char letter; + if (probe.GetProperty("attached").GetBoolean()) + { + letter = Letter(probe, "driveLetter") ?? throw new HyperVOperationException("The ISO is already mounted without a drive letter; dismount it or give it a letter, then retry.", "InvalidArgument"); + warnings.Add($"The ISO was already mounted as {letter}:; the build used it in place and left it mounted."); + } + else + { + mountedIso = true; + letter = Letter(await Runner.InvokeAsync(ImageBuildScripts.MountIso(isoPath), ct, 180), "driveLetter") + ?? throw new HyperVOperationException("Mount-DiskImage reported no drive letter.", "InvalidResponse"); + } + imageFile = LocateInstallImage(letter) ?? throw new HyperVOperationException( + "The ISO has no sources\\install.wim or sources\\install.esd (split .swm media is not supported); pass the install image with imagePath.", "NotFound"); + } + else imageFile = sourceFile; + + Phase(3, "reading the image list"); + var index = SelectImage(await Runner.InvokeAsync(ImageBuildScripts.ListImages(imageFile), ct, 900), w.ImageIndex, w.Edition); + image = ReadImage(await Runner.InvokeAsync(ImageBuildScripts.ImageDetails(imageFile, index), ct, 900)); + var uiLanguage = CheckImage(image, plan, warnings); + + Phase(4, "creating the GPT VHDX (EFI system, MSR and Windows partitions)"); + diskStarted = true; + var disk = await Runner.InvokeAsync(ImageBuildScripts.CreateDisk(plan.VhdPath, plan.DiskGb), ct, 900); + var system = Letter(disk, "systemLetter") ?? throw new HyperVOperationException("The disk script returned no system partition letter.", "InvalidResponse"); + var windows = Letter(disk, "windowsLetter") ?? throw new HyperVOperationException("The disk script returned no Windows partition letter.", "InvalidResponse"); + GuardLetters(system, windows); + partitions = disk.GetProperty("partitions").EnumerateArray().Select(p => new ImageBuildPartition(p.GetProperty("number").GetInt32(), + p.GetProperty("gptType").GetString() ?? "", p.GetProperty("size").GetInt64(), p.GetProperty("driveLetter").GetString())).ToArray(); + + Phase(5, $"applying image {index} ({image.Name}); this takes minutes"); + await Runner.InvokeAsync(ImageBuildScripts.ApplyImage(imageFile, index, windows), ct, 4 * 3600); + var windowsRoot = DriveRoot(windows); + if (!Directory.Exists(Path.Combine(windowsRoot, "Windows"))) + throw new HyperVOperationException("The image was applied but contains no \\Windows directory; it is not a Windows installation image.", "HyperVOperationFailed"); + + Phase(6, "writing boot files and the BCD store (bcdboot /s, UEFI)"); + (bootConfiguration, bootSummary) = await MakeBootableAsync(windows, system, warnings, ct); + + Phase(7, "writing Windows\\Panther\\unattend.xml"); + ImageBuildUnattend.Write(Path.Combine(windowsRoot, "Windows", "Panther", "unattend.xml"), new UnattendSettings(w.ComputerName, w.AdminUser, + w.AdminPassword, w.EnableRdp, w.AutoLogon, w.PreventDeviceEncryption, w.Language, w.Language, uiLanguage, w.Language)); + + if (w.KernelDebug is { } kd) + { + Phase(8, $"writing kernel-debug settings ({kd.Transport}) into the offline BCD store"); + debugApplied = (await Runner.InvokeAsync(ImageBuildScripts.BcdDebug(system, kd), ct, 300)).GetProperty("debugOn").GetBoolean(); + if (debugApplied != true) warnings.Add("bcdedit accepted the debug settings, but reading {default} back did not show 'debug Yes'; check with hyperv_kernel_configure action=status after first boot."); + } + if (w.ToolsDirectory is { } toolsDirectory) + { + Phase(8, "copying the tools folder to C:\\Tools"); + tools = CopyTree(toolsDirectory, Path.Combine(windowsRoot, "Tools"), "C:\\Tools"); + } + } + catch (Exception ex) { failure = ex; } + + Phase(9, "dismounting"); + await FinishAsync(plan, failure, diskStarted ? plan.VhdPath : null, mountedIso ? isoPath : null); + + Phase(10, "writing the sidecar"); + var config = new ImageBuildVmConfig(2, w.KernelDebug is null, "MicrosoftWindows", "VMBus", true, 4096, true, 2, "Standard", false); + var sidecar = new ImageBuildSidecar(1, plan.Name, "windows", "template", plan.VhdPath, null, config, w.AdminUser, null, builtFrom, DateTimeOffset.UtcNow, + new ImageBuildImageRecord(image!.Index, image.Name, NullIfEmpty(image.EditionId), NullIfEmpty(image.Version), image.Architecture < 0 ? null : image.Architecture, image.Size, image.Languages), + w.KernelDebug is { } debug ? new ImageBuildKernelDebugRecord(debug.Transport, debug.HostIp, debug.Transport == "net" ? debug.Port : null, debug.Transport == "serial" ? debug.ComPort : null, false) : null, + bootConfiguration, warnings); + return Complete(plan, sidecar, partitions, bootConfiguration, bootSummary, debugApplied, tools, warnings, WindowsNextSteps(plan), plan.DiskGb * Gigabyte, null, clock); + } + + private async Task BuildLinuxAsync(ImageBuildPlan plan, IProgress progress, CancellationToken ct) + { + var clock = Stopwatch.StartNew(); + var l = plan.Linux!; + var warnings = new List(plan.Warnings); + void Phase(int step, string text) => progress.Report(new JobProgress(Math.Round(step * 100.0 / 5, 1), Message: $"linux {step}/5: {text}")); + Directory.CreateDirectory(plan.OutputDirectory); + + Phase(1, "resolving the cloud image"); + var (source, builtFrom) = await ResolveSourceAsync(plan, warnings, progress, ct); + Exception? failure = null; + long? seedBytes = null; + long virtualBytes = 0; + string? staging = null; + try + { + if (l.QemuImg is null) + { + Phase(2, "copying the VHDX"); + await Task.Run(() => File.Copy(source, plan.VhdPath, overwrite: false), ct); + } + else + { + Phase(2, "converting to a dynamic VHDX with qemu-img"); + var info = await Runner.InvokeAsync(ImageBuildScripts.QemuInfo(l.QemuImg, source), ct, 300); + var format = info.GetProperty("format").GetString() ?? ""; + if (!string.IsNullOrEmpty(info.GetProperty("backingFile").GetString())) + throw new HyperVOperationException("The image references a backing file; only standalone images are converted.", "InvalidArgument"); + if (!QemuFormats.Contains(format)) + throw new HyperVOperationException($"qemu-img reports format '{format}'; expected one of {string.Join(", ", QemuFormats)}.", "InvalidArgument"); + await Runner.InvokeAsync(ImageBuildScripts.QemuConvert(l.QemuImg, source, format, plan.VhdPath), ct, 3600); + } + + Phase(3, $"growing the virtual disk to {plan.DiskGb} GB"); + var resize = await Runner.InvokeAsync(ImageBuildScripts.ResizeVhd(plan.VhdPath, plan.DiskGb), ct, 600); + virtualBytes = resize.GetProperty("size").GetInt64(); + if (resize.GetProperty("sizeBefore").GetInt64() > plan.DiskGb * Gigabyte) + warnings.Add($"The image's virtual disk ({resize.GetProperty("sizeBefore").GetInt64() / (double)Gigabyte:0.#} GB) is larger than diskGb={plan.DiskGb}; it was kept, because disks are never shrunk."); + + Phase(4, "building the NoCloud seed ISO (CIDATA)"); + staging = Path.Combine(Path.GetTempPath(), "HyperVControl-seed-" + Guid.NewGuid().ToString("N")); + ImageBuildCloudInit.WriteSeed(staging, ImageBuildCloudInit.UserData(l.SshUser, l.SshKeys, l.PasswordHash, l.Hostname, l.Packages), + ImageBuildCloudInit.MetaData("iid-" + Guid.NewGuid().ToString("N"), l.Hostname)); + seedBytes = (await Runner.InvokeAsync(ImageBuildScripts.BuildIso(staging, ImageBuildCloudInit.VolumeLabel, plan.SeedIsoPath!), ct, 300)).GetProperty("size").GetInt64(); + } + catch (Exception ex) { failure = ex; } + finally + { + if (staging is not null) + try { Directory.Delete(staging, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + await FinishAsync(plan, failure, null, null); + + Phase(5, "writing the sidecar"); + var config = new ImageBuildVmConfig(2, true, "MicrosoftUEFICertificateAuthority", "HvSocket", false, 2048, true, 2, "Standard", false); + var sidecar = new ImageBuildSidecar(1, plan.Name, "linux", "template", plan.VhdPath, plan.SeedIsoPath, config, null, l.SshUser, builtFrom, + DateTimeOffset.UtcNow, null, null, null, warnings); + return Complete(plan, sidecar, null, null, null, null, null, warnings, LinuxNextSteps(plan), virtualBytes, seedBytes, clock); + } + + /// Detaches what the build attached; a failed or cancelled build then loses its outputs and rethrows. + private async Task FinishAsync(ImageBuildPlan plan, Exception? failure, string? vhdx, string? iso) + { + var problems = new List(); + if (vhdx is not null || iso is not null) + { + try + { + var cleanup = await Runner.InvokeAsync(ImageBuildScripts.Cleanup(vhdx, iso), CancellationToken.None, 300); + problems.AddRange(cleanup.GetProperty("problems").EnumerateArray().Select(p => p.GetString() ?? "")); + } + catch (Exception ex) { problems.Add("Dismount failed: " + ex.Message); } + } + if (failure is null) + { + if (problems.Count > 0) + throw new HyperVOperationException($"The image was built but could not be detached ({string.Join("; ", problems)}). Run Dismount-VHD -Path '{plan.VhdPath}' before using it.", "HyperVOperationFailed"); + return; + } + foreach (var path in new[] { plan.VhdPath, plan.SidecarPath, plan.SeedIsoPath }.OfType()) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { problems.Add($"{path} could not be deleted: {ex.Message}"); } + } + if (failure is OperationCanceledException || problems.Count == 0) ExceptionDispatchInfo.Throw(failure); + throw new HyperVOperationException(failure.Message + " Cleanup: " + string.Join("; ", problems), (failure as HyperVOperationException)?.ErrorType ?? "HyperVOperationFailed"); + } + + private static ImageBuildResult Complete(ImageBuildPlan plan, ImageBuildSidecar sidecar, IReadOnlyList? partitions, string? bootConfiguration, + string? bootSummary, bool? debugApplied, ImageBuildToolsCopy? tools, List warnings, IReadOnlyList nextSteps, long virtualBytes, long? seedBytes, Stopwatch clock) + { + if (!File.Exists(plan.VhdPath)) throw new HyperVOperationException($"The build finished but {plan.VhdPath} is missing.", "HyperVOperationFailed"); + WriteSidecar(plan.SidecarPath, sidecar); + return new ImageBuildResult(plan.OsFamily, plan.Name, plan.VhdPath, new FileInfo(plan.VhdPath).Length, virtualBytes, plan.SeedIsoPath, seedBytes, + plan.SidecarPath, sidecar, partitions, bootConfiguration, bootSummary, debugApplied, tools, warnings, nextSteps, Math.Round(clock.Elapsed.TotalSeconds, 1)); + } + + internal static void WriteSidecar(string path, ImageBuildSidecar sidecar) + { + var temporary = path + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(sidecar, SidecarJson) + "\n", new UTF8Encoding(false)); + File.Move(temporary, path); + } + + private async Task<(string Path, ImageBuildSourceRecord Record)> ResolveSourceAsync(ImageBuildPlan plan, List warnings, IProgress progress, CancellationToken ct) + { + var source = plan.Source; + if (source.Url is { } url) + { + var downloader = new ImageBuildDownloader(Http); + var expected = source.ExpectedSha256; + var checksumSource = expected is null ? null : "sha256 parameter"; + if (expected is null && plan.OsFamily == "linux") + { + expected = await downloader.PublishedSha256Async(url, ct) + ?? throw new HyperVOperationException($"No SHA256SUMS entry is published next to {url}; pass sha256 to verify this image.", "NotFound"); + checksumSource = new Uri(url, "SHA256SUMS").ToString(); + } + var download = await downloader.DownloadAsync(url, plan.CacheDirectory, expected, progress, source.EvalSource ?? "image", ct); + if (expected is null) + warnings.Add($"Microsoft publishes no checksum for evaluation ISOs; the download's SHA-256 ({download.Sha256}) is recorded in the sidecar."); + return (download.Path, new ImageBuildSourceRecord(source.Kind, download.Path, url.ToString(), download.FinalUrl.ToString(), source.EvalSource, + download.Sha256, download.Bytes, expected is not null, checksumSource, download.Reused)); + } + var hash = await ImageBuildDownloader.HashAsync(source.Path!, progress, "source", ct); + if (source.ExpectedSha256 is not null && hash != source.ExpectedSha256) + throw new InvalidDataException($"SHA-256 mismatch for {source.Path}: expected {source.ExpectedSha256}, got {hash}."); + return (source.Path!, new ImageBuildSourceRecord(source.Kind, source.Path, null, null, null, hash, new FileInfo(source.Path!).Length, + source.ExpectedSha256 is not null, source.ExpectedSha256 is null ? null : "sha256 parameter", false)); + } + + internal string? LocateInstallImage(char letter) + { + foreach (var name in new[] { "install.wim", "install.esd" }) + if (File.Exists(Path.Combine(DriveRoot(letter), "sources", name))) return letter + ":\\sources\\" + name; + return null; + } + + /// imageIndex, else edition (exact name, else one unique substring match), else the first edition that is not an N edition. + internal static int SelectImage(JsonElement list, int? index, string? edition) + { + var images = (list.ValueKind == JsonValueKind.Array ? list.EnumerateArray().ToList() : [list]) + .Select(i => (Index: i.GetProperty("index").GetInt32(), Name: i.GetProperty("name").GetString() ?? "")).ToList(); + if (images.Count == 0) throw new HyperVOperationException("The install image contains no images.", "NotFound"); + var listing = string.Join("; ", images.Select(i => $"{i.Index}: {i.Name}")); + if (index is not null) + return images.Any(i => i.Index == index) ? index.Value : throw new HyperVOperationException($"imageIndex {index} does not exist. Available: {listing}.", "NotFound"); + if (edition is not null) + { + var exact = images.Where(i => i.Name.Equals(edition, StringComparison.OrdinalIgnoreCase)).ToList(); + var matches = exact.Count > 0 ? exact : images.Where(i => i.Name.Contains(edition, StringComparison.OrdinalIgnoreCase)).ToList(); + return matches.Count switch + { + 1 => matches[0].Index, + 0 => throw new HyperVOperationException($"No image name matches edition '{edition}'. Available: {listing}.", "NotFound"), + _ => throw new HyperVOperationException($"edition '{edition}' matches several images ({string.Join("; ", matches.Select(m => $"{m.Index}: {m.Name}"))}); pass imageIndex.", "AmbiguousName") + }; + } + return images.Where(i => !NEdition().IsMatch(i.Name)).Select(i => (int?)i.Index).FirstOrDefault() ?? images[0].Index; + } + + private static ImageInfo ReadImage(JsonElement e) => new(e.GetProperty("index").GetInt32(), e.GetProperty("name").GetString() ?? "", + e.GetProperty("editionId").GetString() ?? "", e.GetProperty("installationType").GetString() ?? "", e.GetProperty("architecture").GetInt32(), + e.GetProperty("version").GetString() ?? "", e.GetProperty("size").GetInt64(), + e.GetProperty("languages").ValueKind == JsonValueKind.Array ? e.GetProperty("languages").EnumerateArray().Select(v => v.GetString() ?? "").Where(v => v.Length > 0).ToArray() : [], + e.GetProperty("defaultLanguageIndex").GetInt32()); + + /// Architecture, size, free space, evaluation and language checks; returns the UI language to use. + private string CheckImage(ImageInfo image, ImageBuildPlan plan, List warnings) + { + var identified = image.EditionId.Length > 0 || image.Version is { Length: > 0 } and not "0.0.0.0"; + switch (image.Architecture) + { + case 9: break; + case 5 or 12: throw new HyperVOperationException($"Image {image.Index} is an ARM image; an x64 Hyper-V host cannot boot it.", "InvalidArgument"); + case 6: throw new HyperVOperationException($"Image {image.Index} is an Itanium image.", "InvalidArgument"); + case 0 when identified: throw new HyperVOperationException($"Image {image.Index} is 32-bit (x86); Generation 2 VMs need x64 Windows.", "InvalidArgument"); + default: warnings.Add($"Image {image.Index} carries no Windows version metadata (architecture {image.Architecture}); it was treated as a custom x64 capture."); break; + } + var windowsBytes = plan.DiskGb * Gigabyte - (ImageBuildScripts.EspMegabytes + ImageBuildScripts.MsrMegabytes + 2) * (1L << 20); + if (image.Size > windowsBytes * 0.95) + throw new HyperVOperationException($"Image {image.Index} expands to {image.Size / (double)Gigabyte:0.0} GB but diskGb={plan.DiskGb} leaves {windowsBytes / (double)Gigabyte:0.0} GB for Windows; raise diskGb.", "InvalidArgument"); + var drive = new DriveInfo(Path.GetPathRoot(plan.OutputDirectory)!); + var needed = (long)(image.Size * 1.1) + Gigabyte; + if (drive.AvailableFreeSpace < needed) + throw new HyperVOperationException($"{drive.Name} has {drive.AvailableFreeSpace / (double)Gigabyte:0.0} GB free; the applied image needs about {needed / (double)Gigabyte:0.0} GB.", "InvalidArgument"); + if (plan.Source.Kind != "eval-download" && (image.Name.Contains("Evaluation", StringComparison.OrdinalIgnoreCase) || image.EditionId.Contains("Eval", StringComparison.OrdinalIgnoreCase))) + warnings.Add(EvaluationWarning); + var language = plan.Windows!.Language; + if (image.Languages.Length == 0 || image.Languages.Contains(language, StringComparer.OrdinalIgnoreCase)) return language; + var fallback = image.Languages[Math.Clamp(image.DefaultLanguageIndex, 0, image.Languages.Length - 1)]; + warnings.Add($"The image has no {language} language pack ({string.Join(", ", image.Languages)}); the display language is {fallback} and {language} is used for input, system and user locales."); + return fallback; + } + + /// + /// bcdboot first. This host family's bcdboot can copy the boot files yet fail to create a store on another disk's + /// EFI partition (BFSVC status c0000035: the new store is loaded at the key the host's own store occupies); only in + /// that case, and only when the boot files were copied, the store is completed with bcdedit from the image's template. + /// + private async Task<(string Method, string Summary)> MakeBootableAsync(char windows, char system, List warnings, CancellationToken ct) + { + var boot = await Runner.InvokeAsync(ImageBuildScripts.BcdBoot(windows, system), ct, 600); + var exitCode = boot.GetProperty("exitCode").GetInt32(); + var output = boot.GetProperty("output").GetString() ?? ""; + var method = boot.GetProperty("offline").GetBoolean() ? "bcdboot /offline" : "bcdboot"; + var summary = SummarizeBcdBoot(output); + if (exitCode == 0) return (method, summary); + var espRoot = DriveRoot(system); + var bootDirectory = Path.Combine(espRoot, "EFI", "Microsoft", "Boot"); + var template = Path.Combine(DriveRoot(windows), "Windows", "System32", "config", "BCD-Template"); + var filesCopied = File.Exists(Path.Combine(espRoot, "EFI", "Boot", "bootx64.efi")) && File.Exists(Path.Combine(bootDirectory, "bootmgfw.efi")); + if (!IsStoreCollision(output) || !filesCopied || !File.Exists(template)) + throw new HyperVOperationException($"bcdboot failed (exit {exitCode}): {summary}", "HyperVOperationFailed"); + foreach (var stale in Directory.GetFiles(bootDirectory, "BCD*")) File.Delete(stale); + File.Copy(template, Path.Combine(bootDirectory, "BCD")); + await Runner.InvokeAsync(ImageBuildScripts.BcdStore(windows, system, DefaultLanguage, Guid.NewGuid(), Guid.NewGuid()), ct, 600); + warnings.Add("bcdboot copied the boot files but could not create the BCD store on the image's EFI partition on this host (BFSVC c0000035: it loads the new store at " + + "the registry key the host's own store occupies). The store was completed with bcdedit from the image's BCD-Template instead: the boot manager plus one " + + "Windows Boot Loader with the elements bcdboot sets. A VM's first boot is the confirmation."); + return (method + " (files) + bcdedit (store)", summary); + } + + internal static bool IsStoreCollision(string output) => + output.Contains("c0000035", StringComparison.OrdinalIgnoreCase) && output.Contains("store", StringComparison.OrdinalIgnoreCase); + + /// Errors, warnings and the final line of bcdboot's verbose output. + internal static string SummarizeBcdBoot(string output) + { + var lines = output.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var important = lines.Where(line => line.Contains("Error", StringComparison.OrdinalIgnoreCase) || line.Contains("Warning", StringComparison.OrdinalIgnoreCase) + || line.Contains("Failure", StringComparison.OrdinalIgnoreCase)).Take(12).ToList(); + if (lines.Length > 0 && !important.Contains(lines[^1])) important.Add(lines[^1]); + return important.Count == 0 ? "(no output)" : string.Join(" | ", important); + } + + private static void GuardLetters(char system, char windows) + { + var host = char.ToUpperInvariant(Path.GetPathRoot(Environment.SystemDirectory)![0]); + if (system == windows || system == host || windows == host) + throw new HyperVOperationException($"Unexpected drive letters for the new partitions (system {system}:, Windows {windows}:).", "HyperVOperationFailed"); + } + + private static char? Letter(JsonElement e, string property) => + e.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String && value.GetString() is [var c] && char.IsAsciiLetter(c) + ? char.ToUpperInvariant(c) : null; + + /// Copies a folder tree (hidden files included, reparse points skipped) into a directory that must not exist yet. + internal static ImageBuildToolsCopy CopyTree(string source, string destination, string guestPath) + { + if (Directory.Exists(destination) || File.Exists(destination)) throw new HyperVOperationException($"{guestPath} already exists in the image.", "InvalidArgument"); + var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }; + Directory.CreateDirectory(destination); + foreach (var directory in Directory.EnumerateDirectories(source, "*", options)) + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory))); + int files = 0; + long bytes = 0; + foreach (var file in Directory.EnumerateFiles(source, "*", options)) + { + File.Copy(file, Path.Combine(destination, Path.GetRelativePath(source, file)), overwrite: false); + files++; + bytes += new FileInfo(file).Length; + } + return new ImageBuildToolsCopy(guestPath, files, bytes); + } + + private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; + + internal static IReadOnlyList WindowsNextSteps(ImageBuildPlan plan) + { + var w = plan.Windows!; + var steps = new List + { + $"Keep {plan.VhdPath} unchanged as the template: give each VM a differencing child (New-VHD -Differencing -ParentPath '{plan.VhdPath}') or a full copy. Booting the template itself runs specialize and consumes its unattend.", + "Create the VM with hyperv_vm_create: generation=2, vhdPath=, memoryMb=4096 (static), processors=2, and switchName set to a switch with internet access (for example Default Switch). It is created Off.", + "Before the first start: hyperv_vm_configure dynamicMemory=false checkpointType=Standard; turn automatic checkpoints off; " + + (w.KernelDebug is null ? "Secure Boot On with the MicrosoftWindows template" : "Secure Boot Off (hyperv_firmware secureBoot=false), because the image has kernel debugging enabled") + + "; add a vTPM with a local key protector (Set-VMKeyProtector -NewLocalKeyProtector, then Enable-VMTPM).", + $"Start the VM. The first boot runs specialize and OOBE unattended (several minutes, possibly one restart){(w.AutoLogon ? $" and signs {w.AdminUser} in automatically" : "")}.", + $"When PowerShell Direct answers, store the credential: hyperv_credential machine= username={w.AdminUser} password=." + }; + if (w.KernelDebug is { } kd) + steps.Add(kd.Transport == "net" + ? $"Kernel debugging (KDNET to {kd.HostIp}:{kd.Port}) is preconfigured: allow inbound UDP {kd.Port} on the host firewall and attach with hyperv_debug_open mode=kernel connectString=." + : $"Kernel debugging (serial, COM{kd.ComPort}) is preconfigured: while the VM is Off run hyperv_kernel_configure action=serial_host comPort={kd.ComPort}, then attach with hyperv_debug_open mode=kernel and the returned attach string."); + steps.Add("Evaluation images expire 90 days after first boot and must activate online: keep the first boot on a NAT or internet switch."); + return steps; + } + + internal static IReadOnlyList LinuxNextSteps(ImageBuildPlan plan) + { + var l = plan.Linux!; + return + [ + $"Keep {plan.VhdPath} unchanged as the template: give each VM a differencing child or a full copy, and give each VM its own seed ISO (a new instance-id) when cloning.", + "Create the VM with hyperv_vm_create: generation=2, vhdPath=, memoryMb=2048, processors=2, switchName=.", + "Before the first start: Secure Boot On with the MicrosoftUEFICertificateAuthority template (Set-VMFirmware -SecureBootTemplate MicrosoftUEFICertificateAuthority; the Windows template rejects the Linux shim); checkpointType=Standard; automatic checkpoints off.", + $"Attach the seed as a DVD: hyperv_disk action=attach_iso path={plan.SeedIsoPath}.", + $"Start the VM. cloud-init creates {l.SshUser}, grows the root file system and installs {(l.Packages.Count == 0 ? "no packages" : string.Join(", ", l.Packages))}; linux-cloud-tools-virtual lets hyperv_vm_info report the guest IP (KVP). Then: ssh {l.SshUser}@." + ]; + } } + +internal sealed record ImageBuildToolsCopy(string GuestPath, int Files, long Bytes); +internal sealed record ImageBuildOutputs(string VhdPath, string SidecarPath, string? SeedIsoPath, string? CacheDirectory); +internal sealed record ImageBuildCredentials(string AdminUser, string? Password, bool PasswordGenerated, string Warning); +internal sealed record ImageBuildLinuxCredentials(string SshUser, int SshKeys, bool PasswordSet, string Note); +internal sealed record ImageBuildKernelDebugStart(string Transport, string? HostIp, int? Port, int? ComPort, string? KernelAttachString, bool KeyGenerated, string Note); + +/// The hyperv_image_build result: the job snapshot plus anything returned only once (a generated password, the KDNET key). +internal sealed record ImageBuildStart(bool Ok, string? Error, string? ErrorType, JobInfo Job, ImageBuildOutputs Outputs, object? Credentials, object? KernelDebug, string Next); diff --git a/src/HyperVControl/Services/ImageBuildUnattend.cs b/src/HyperVControl/Services/ImageBuildUnattend.cs new file mode 100644 index 0000000..eb5a8fb --- /dev/null +++ b/src/HyperVControl/Services/ImageBuildUnattend.cs @@ -0,0 +1,171 @@ +using System.Text; +using System.Xml; + +namespace HyperVControl.Services; + +/// Values written into a Windows image's Windows\Panther\unattend.xml. +internal sealed record UnattendSettings(string ComputerName, string AdminUser, string AdminPassword, bool EnableRdp, bool AutoLogon, + bool PreventDeviceEncryption, string InputLocale, string SystemLocale, string UiLanguage, string UserLocale) +{ + public override string ToString() => $"UnattendSettings {{ ComputerName = {ComputerName}, AdminUser = {AdminUser}, AdminPassword = }}"; +} + +/// +/// Builds the answer file for an offline-applied image. Only the specialize and oobeSystem passes run (Setup's +/// windowsPE pass never does): computer name, RDP and its firewall group, device-encryption prevention, BypassNRO, +/// locales, hidden OOBE pages, one local administrator, autologon, and first-logon commands that turn off the +/// Windows Hello-only sign-in, stop password expiry and delete the answer file (it holds the password). +/// An escapes every value, so passwords and names are always data. +/// +internal static class ImageBuildUnattend +{ + internal const string Namespace = "urn:schemas-microsoft-com:unattend"; + internal const string WcmNamespace = "http://schemas.microsoft.com/WMIConfig/2002/State"; + internal const string RemoteDesktopFirewallGroup = "@FirewallAPI.dll,-28752"; + + private sealed class Utf8StringWriter : StringWriter + { + public override Encoding Encoding => new UTF8Encoding(false); + } + + public static string Build(UnattendSettings s) + { + using var text = new Utf8StringWriter(); + using (var w = XmlWriter.Create(text, new XmlWriterSettings { Indent = true, IndentChars = " ", NewLineChars = "\r\n", Encoding = new UTF8Encoding(false) })) + { + w.WriteStartDocument(); + w.WriteStartElement("unattend", Namespace); + w.WriteAttributeString("xmlns", "wcm", null, WcmNamespace); + + w.WriteStartElement("settings", Namespace); + w.WriteAttributeString("pass", "specialize"); + Component(w, "Microsoft-Windows-Shell-Setup", () => w.WriteElementString("ComputerName", Namespace, s.ComputerName)); + if (s.EnableRdp) + { + Component(w, "Microsoft-Windows-TerminalServices-LocalSessionManager", () => w.WriteElementString("fDenyTSConnections", Namespace, "false")); + Component(w, "Networking-MPSSVC-Svc", () => + { + w.WriteStartElement("FirewallGroups", Namespace); + w.WriteStartElement("FirewallGroup", Namespace); + w.WriteAttributeString("action", WcmNamespace, "add"); + w.WriteAttributeString("keyValue", WcmNamespace, "RemoteDesktop"); + w.WriteElementString("Active", Namespace, "true"); + w.WriteElementString("Group", Namespace, RemoteDesktopFirewallGroup); + w.WriteElementString("Profile", Namespace, "all"); + w.WriteEndElement(); + w.WriteEndElement(); + }); + } + var specializeCommands = new List(); + if (s.PreventDeviceEncryption) + specializeCommands.Add("reg.exe add \"HKLM\\SYSTEM\\CurrentControlSet\\Control\\BitLocker\" /v PreventDeviceEncryption /t REG_DWORD /d 1 /f"); + // Best effort for the network page; the local account below is the supported way past OOBE. + specializeCommands.Add("reg.exe add \"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE\" /v BypassNRO /t REG_DWORD /d 1 /f"); + Component(w, "Microsoft-Windows-Deployment", () => + { + w.WriteStartElement("RunSynchronous", Namespace); + for (var i = 0; i < specializeCommands.Count; i++) + { + w.WriteStartElement("RunSynchronousCommand", Namespace); + w.WriteAttributeString("action", WcmNamespace, "add"); + w.WriteElementString("Order", Namespace, (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + w.WriteElementString("Path", Namespace, specializeCommands[i]); + w.WriteEndElement(); + } + w.WriteEndElement(); + }); + w.WriteEndElement(); + + w.WriteStartElement("settings", Namespace); + w.WriteAttributeString("pass", "oobeSystem"); + Component(w, "Microsoft-Windows-International-Core", () => + { + w.WriteElementString("InputLocale", Namespace, s.InputLocale); + w.WriteElementString("SystemLocale", Namespace, s.SystemLocale); + w.WriteElementString("UILanguage", Namespace, s.UiLanguage); + w.WriteElementString("UserLocale", Namespace, s.UserLocale); + }); + Component(w, "Microsoft-Windows-Shell-Setup", () => + { + w.WriteStartElement("OOBE", Namespace); + w.WriteElementString("HideEULAPage", Namespace, "true"); + w.WriteElementString("HideOnlineAccountScreens", Namespace, "true"); + w.WriteElementString("HideWirelessSetupInOOBE", Namespace, "true"); + w.WriteElementString("ProtectYourPC", Namespace, "3"); + w.WriteEndElement(); + + w.WriteStartElement("UserAccounts", Namespace); + w.WriteStartElement("LocalAccounts", Namespace); + w.WriteStartElement("LocalAccount", Namespace); + w.WriteAttributeString("action", WcmNamespace, "add"); + w.WriteElementString("Name", Namespace, s.AdminUser); + w.WriteElementString("Group", Namespace, "Administrators"); + Password(w, s.AdminPassword); + w.WriteEndElement(); + w.WriteEndElement(); + w.WriteEndElement(); + + if (s.AutoLogon) + { + w.WriteStartElement("AutoLogon", Namespace); + w.WriteElementString("Enabled", Namespace, "true"); + w.WriteElementString("Username", Namespace, s.AdminUser); + w.WriteElementString("LogonCount", Namespace, "9999999"); + Password(w, s.AdminPassword); + w.WriteEndElement(); + } + + string[] firstLogon = + [ + // Turn off "only allow Windows Hello sign-in" so Enhanced Session (RDP) shows a password prompt. + "reg.exe add \"HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\PasswordLess\\Device\" /v DevicePasswordLessBuildVersion /t REG_DWORD /d 0 /f", + "net.exe accounts /maxpwage:unlimited", + // The answer file holds the password: delete it once the first sign-in has consumed it. + "cmd.exe /c del /q %WINDIR%\\Panther\\unattend.xml" + ]; + w.WriteStartElement("FirstLogonCommands", Namespace); + for (var i = 0; i < firstLogon.Length; i++) + { + w.WriteStartElement("SynchronousCommand", Namespace); + w.WriteAttributeString("action", WcmNamespace, "add"); + w.WriteElementString("Order", Namespace, (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + w.WriteElementString("CommandLine", Namespace, firstLogon[i]); + w.WriteEndElement(); + } + w.WriteEndElement(); + }); + w.WriteEndElement(); + + w.WriteEndElement(); + w.WriteEndDocument(); + } + return text.ToString() + "\r\n"; + } + + /// Writes the answer file as UTF-8 with a byte order mark, matching Windows System Image Manager output. + public static void Write(string path, UnattendSettings settings) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, Build(settings), new UTF8Encoding(true)); + } + + private static void Component(XmlWriter w, string name, Action body) + { + w.WriteStartElement("component", Namespace); + w.WriteAttributeString("name", name); + w.WriteAttributeString("processorArchitecture", "amd64"); + w.WriteAttributeString("publicKeyToken", "31bf3856ad364e35"); + w.WriteAttributeString("language", "neutral"); + w.WriteAttributeString("versionScope", "nonSxS"); + body(); + w.WriteEndElement(); + } + + private static void Password(XmlWriter w, string password) + { + w.WriteStartElement("Password", Namespace); + w.WriteElementString("Value", Namespace, password); + w.WriteElementString("PlainText", Namespace, "true"); + w.WriteEndElement(); + } +} diff --git a/tests/HyperVControl.Tests/ImageBuildTests.cs b/tests/HyperVControl.Tests/ImageBuildTests.cs new file mode 100644 index 0000000..5d7ed50 --- /dev/null +++ b/tests/HyperVControl.Tests/ImageBuildTests.cs @@ -0,0 +1,829 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using HyperVControl.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace HyperVControl.Tests; + +/// +/// Golden image builds against the recording mock: input validation, unattend and NoCloud content, script rendering and +/// ordering for both pipelines, cleanup on failure, resumable downloads (loopback HTTP), the sidecar and the tool contract. +/// The IMAPI2 seed ISO is built for real (inbox COM, no administrator rights needed). +/// +internal static class ImageBuildTests +{ + private const string Store = @"S:\EFI\Microsoft\Boot\BCD"; + private const string Password = "p<&>\"w0rd'!"; + private const string Key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHyperVControlTestKeyOnlyForUnitTests lab@\"host\""; + + internal static async Task RunAsync(Action check) + { + var roots = new List(); + string Temp(string label) + { + var directory = Path.Combine(Path.GetTempPath(), "HyperVControl-imagebuild-" + label + "-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(directory); + roots.Add(directory); + return directory; + } + try + { + CloudInitAndCrypt(check); + Unattend(check); + ScriptRendering(check); + await Downloads(check, Temp); + await Validation(check, Temp); + await WindowsPipeline(check, Temp); + await LinuxPipeline(check, Temp); + await ToolContract(check, Temp); + } + finally + { + foreach (var root in roots) + try { Directory.Delete(root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + private static void CloudInitAndCrypt(Action check) + { + check(ImageBuildCloudInit.Sha512Crypt("Hello world!", "saltstring") == "$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJuesI68u4OTLiBFdcbYEdFCoEOfaS35inz1" + && ImageBuildCloudInit.Sha512Crypt("p<&>\"w0rd! $x", "Ab1/.xyz") == "$6$Ab1/.xyz$/wvQYkrJKc9FgzPAlOVXsfyUmx3RPSB50f7eD6ZBWLHcisbdOBuKpUICcvvdWJwNhFJKmdI4IpCc7kwBL94CT0", + "SHA-512-crypt matches Drepper's published vector and an OpenSSL 3.5 reference"); + var first = ImageBuildCloudInit.Sha512Crypt("same"); + check(first.StartsWith("$6$", StringComparison.Ordinal) && first.Split('$')[2].Length == 16 && first != ImageBuildCloudInit.Sha512Crypt("same"), + "Linux passwords get a random 16-character salt"); + + var userData = ImageBuildCloudInit.UserData("lab", [Key], null, "lab-ubuntu", ["linux-cloud-tools-virtual"]); + check(userData == "#cloud-config\nhostname: \"lab-ubuntu\"\nusers:\n - name: \"lab\"\n groups: [sudo]\n shell: /bin/bash\n sudo: \"ALL=(ALL) NOPASSWD:ALL\"\n" + + " lock_passwd: true\n ssh_authorized_keys:\n - \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHyperVControlTestKeyOnlyForUnitTests lab@\\\"host\\\"\"\n" + + "ssh_pwauth: false\npackage_update: true\npackages:\n - \"linux-cloud-tools-virtual\"\n", + "user-data: cloud-config with the SSH user, quoted key, KVP package, LF line endings"); + var hash = ImageBuildCloudInit.Sha512Crypt("secret", "abcdefgh"); + var passwordOnly = ImageBuildCloudInit.UserData("lab", [], hash, "h1", []); + check(passwordOnly.Contains(" lock_passwd: false\n passwd: \"" + hash + "\"\n", StringComparison.Ordinal) && passwordOnly.Contains("ssh_pwauth: true\n", StringComparison.Ordinal) + && !passwordOnly.Contains("secret", StringComparison.Ordinal) && !passwordOnly.Contains("packages", StringComparison.Ordinal), + "Password-only user-data carries a SHA-512-crypt hash, enables SSH passwords and omits empty package lists"); + check(ImageBuildCloudInit.MetaData("iid-123", "lab-ubuntu") == "instance-id: \"iid-123\"\nlocal-hostname: \"lab-ubuntu\"\n", "meta-data carries instance-id and local-hostname"); + } + + private static void Unattend(Action check) + { + XNamespace u = ImageBuildUnattend.Namespace, wcm = ImageBuildUnattend.WcmNamespace; + var xml = ImageBuildUnattend.Build(new UnattendSettings("HVC-&", "labadmin", Password, true, true, true, "en-US", "de-DE", "en-US", "fr-FR")); + var doc = XDocument.Parse(xml); + var settings = doc.Root!.Elements(u + "settings").ToList(); + check(doc.Root.Name == u + "unattend" && settings.Select(s => (string?)s.Attribute("pass")).SequenceEqual(["specialize", "oobeSystem"]) + && doc.Descendants(u + "component").All(c => (string?)c.Attribute("processorArchitecture") == "amd64" && (string?)c.Attribute("publicKeyToken") == "31bf3856ad364e35"), + "Unattend has only specialize and oobeSystem passes with amd64 components"); + var passwords = doc.Descendants(u + "Password").Select(p => (p.Element(u + "Value")?.Value, p.Element(u + "PlainText")?.Value)).ToList(); + check(xml.Contains("<", StringComparison.Ordinal) && xml.Contains("&", StringComparison.Ordinal) && !xml.Contains(Password, StringComparison.Ordinal) + && doc.Descendants(u + "ComputerName").Single().Value == "HVC-&" && passwords.Count == 2 && passwords.All(p => p == (Password, "true")), + "Password and computer name are XML-escaped and round-trip exactly (account and autologon)"); + var specialize = settings[0]; + var commands = specialize.Descendants(u + "RunSynchronousCommand").Select(c => (c.Element(u + "Order")!.Value, c.Element(u + "Path")!.Value)).ToList(); + var firewall = specialize.Descendants(u + "FirewallGroup").Single(); + check(specialize.Descendants(u + "fDenyTSConnections").Single().Value == "false" + && (string?)firewall.Attribute(wcm + "action") == "add" && (string?)firewall.Attribute(wcm + "keyValue") == "RemoteDesktop" + && firewall.Element(u + "Group")!.Value == "@FirewallAPI.dll,-28752" && firewall.Element(u + "Profile")!.Value == "all" + && commands.Count == 2 && commands[0].Item1 == "1" && commands[0].Item2.Contains("PreventDeviceEncryption /t REG_DWORD /d 1", StringComparison.Ordinal) + && commands[1].Item1 == "2" && commands[1].Item2.Contains("BypassNRO", StringComparison.Ordinal), + "specialize enables RDP and its firewall group, prevents device encryption and sets BypassNRO"); + var oobe = settings[1]; + var account = oobe.Descendants(u + "LocalAccount").Single(); + var autoLogon = oobe.Descendants(u + "AutoLogon").Single(); + var firstLogon = oobe.Descendants(u + "SynchronousCommand").Select(c => c.Element(u + "CommandLine")!.Value).ToList(); + check(oobe.Descendants(u + "InputLocale").Single().Value == "en-US" && oobe.Descendants(u + "SystemLocale").Single().Value == "de-DE" + && oobe.Descendants(u + "UserLocale").Single().Value == "fr-FR" && oobe.Descendants(u + "HideOnlineAccountScreens").Single().Value == "true" + && oobe.Descendants(u + "ProtectYourPC").Single().Value == "3" && account.Element(u + "Name")!.Value == "labadmin" && account.Element(u + "Group")!.Value == "Administrators" + && autoLogon.Element(u + "Enabled")!.Value == "true" && autoLogon.Element(u + "Username")!.Value == "labadmin" && autoLogon.Element(u + "LogonCount")!.Value == "9999999" + && firstLogon.Count == 3 && firstLogon[0].Contains("DevicePasswordLessBuildVersion /t REG_DWORD /d 0", StringComparison.Ordinal) + && firstLogon[1] == "net.exe accounts /maxpwage:unlimited" && firstLogon[2] == "cmd.exe /c del /q %WINDIR%\\Panther\\unattend.xml", + "oobeSystem sets locales, hides OOBE pages, creates the local admin with autologon and deletes the answer file at first logon"); + var minimal = XDocument.Parse(ImageBuildUnattend.Build(new UnattendSettings("LAB", "admin", "x", false, false, false, "en-US", "en-US", "en-US", "en-US"))); + check(!minimal.Descendants(u + "fDenyTSConnections").Any() && !minimal.Descendants(u + "FirewallGroups").Any() && !minimal.Descendants(u + "AutoLogon").Any() + && minimal.Descendants(u + "RunSynchronousCommand").Select(c => c.Element(u + "Path")!.Value).Single().Contains("BypassNRO", StringComparison.Ordinal), + "enableRdp, autoLogon and preventDeviceEncryption set to false remove their unattend settings"); + } + + private static void ScriptRendering(Action check) + { + var disk = ImageBuildScripts.CreateDisk(@"D:\Images\lab'1.vhdx", 64).Body; + int At(string text) => disk.IndexOf(text, StringComparison.Ordinal); + int[] order = [At("New-VHD -Path 'D:\\Images\\lab''1.vhdx' -SizeBytes ([long]64*1GB) -Dynamic:$true"), At("Mount-VHD -Path 'D:\\Images\\lab''1.vhdx' -Passthru:$true"), + At("Initialize-Disk -Number $diskNumber -PartitionStyle 'GPT'"), At("Remove-Partition -DiskNumber $diskNumber"), + At("$system = New-Partition -DiskNumber $diskNumber -Size ([long]300*1MB) -GptType '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}'"), + At("Format-Volume -Partition $system -FileSystem 'FAT32' -NewFileSystemLabel 'System' -Force:$true -Confirm:$false"), + At("Set-Partition -DiskNumber $diskNumber -PartitionNumber $system.PartitionNumber -GptType '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}'"), + At("Add-PartitionAccessPath -DiskNumber $diskNumber -PartitionNumber $system.PartitionNumber -AssignDriveLetter:$true"), + At("New-Partition -DiskNumber $diskNumber -Size ([long]16*1MB) -GptType '{e3c9e316-0b5c-4db8-817d-f92df00215ae}'"), + At("$windows = New-Partition -DiskNumber $diskNumber -UseMaximumSize:$true -GptType '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}'"), + At("Format-Volume -Partition $windows -FileSystem 'NTFS' -NewFileSystemLabel 'Windows'"), + At("Add-PartitionAccessPath -DiskNumber $diskNumber -PartitionNumber $windows.PartitionNumber -AssignDriveLetter:$true"), + At("$systemLetter = [string](Get-Partition")]; + check(order.All(i => i >= 0) && order.Zip(order.Skip(1)).All(p => p.First < p.Second) + && disk.Contains("if ([string]::IsNullOrWhiteSpace($env:HYPERV_CONTROL_MODULE_PATH)) {", StringComparison.Ordinal) + && disk.Contains("'Storage', 'Storage.psd1'", StringComparison.Ordinal) && !disk.Contains("'Dism', 'Dism.psd1'", StringComparison.Ordinal), + "Disk script: GPT, MSR removed, 300 MB ESP formatted FAT32 then retyped, 16 MB MSR, NTFS Windows; letters read back; Storage imported by path outside mock mode"); + var apply = ImageBuildScripts.ApplyImage(@"M:\sources\install.esd", 3, 'W').Body; + check(apply.Contains("Expand-WindowsImage -ImagePath 'M:\\sources\\install.esd' -Index 3 -ApplyPath 'W:\\'", StringComparison.Ordinal) + && apply.Contains("'Dism', 'Dism.psd1'", StringComparison.Ordinal), "Apply script: Expand-WindowsImage with the selected index; DISM imported by path"); + var boot = ImageBuildScripts.BcdBoot('W', 'S').Body; + check(boot.Contains("$arguments = @('W:\\Windows', '/s', 'S:', '/f', 'UEFI', '/v')", StringComparison.Ordinal) && boot.Contains("if ($offline) { $arguments += '/offline' }", StringComparison.Ordinal) + && boot.Contains("function Invoke-ImageBuildNative", StringComparison.Ordinal), "bcdboot script: /s system partition (no NVRAM entry), UEFI, /offline when supported"); + var net = ImageBuildScripts.BcdDebug('S', new ImageBuildKernelDebug(true, "net", "192.0.2.10", 50005, "1a.2b.3c.4d")).Body; + var serial = ImageBuildScripts.BcdDebug('S', new ImageBuildKernelDebug(true, "serial", null, 50000, null, 2)).Body; + check(net.Contains("$store = 'S:\\EFI\\Microsoft\\Boot\\BCD'", StringComparison.Ordinal) + && net.Contains("Invoke-ImageBuildBcd 'debug on' @('/set', '{default}', 'debug', 'on')", StringComparison.Ordinal) + && net.Contains("Invoke-ImageBuildBcd 'dbgsettings net' @('/dbgsettings', 'net', 'hostip:192.0.2.10', 'port:50005', 'key:1a.2b.3c.4d')", StringComparison.Ordinal) + && serial.Contains("@('/dbgsettings', 'serial', 'debugport:2', 'baudrate:115200')", StringComparison.Ordinal), + "Kernel-debug script: offline bcdedit /store debug on plus net or serial dbgsettings; failures name the step, not the key"); + var steps = ImageBuildScripts.BcdStoreSteps('W', 'S', "en-US", Guid.Empty, Guid.Empty); + check(steps.Count == 31 && steps.Any(s => s.Arguments.SequenceEqual(["/create", "{00000000-0000-0000-0000-000000000000}", "/d", "Windows", "/application", "osloader"])) + && steps.Any(s => s.Arguments.SequenceEqual(["/set", "{00000000-0000-0000-0000-000000000000}", "path", "\\Windows\\system32\\winload.efi"])) + && steps.Any(s => s.Arguments.SequenceEqual(["/set", "{bootmgr}", "device", "partition=S:"])), + "Fallback BCD store steps create the Windows Boot Loader, its resume object and the boot manager entries"); + var convert = ImageBuildScripts.QemuConvert(@"C:\q\qemu-img.exe", @"D:\c\ubuntu.img", "qcow2", @"D:\o\lab.vhdx").Body; + check(convert.Contains("-ArgumentList @('convert', '-f', 'qcow2', '-O', 'vhdx', '-o', 'subformat=dynamic', 'D:\\c\\ubuntu.img', 'D:\\o\\lab.vhdx')", StringComparison.Ordinal), + "qemu-img converts with an explicit source format to a dynamic VHDX"); + var iso = ImageBuildScripts.BuildIso(@"C:\seed dir", "CIDATA", @"D:\o\lab-seed.iso").Body; + check(iso.Contains("IMAPI2FS.MsftFileSystemImage", StringComparison.Ordinal) && iso.Contains("$fsi.FileSystemsToCreate = 3", StringComparison.Ordinal) + && iso.Contains("$fsi.VolumeName = 'CIDATA'", StringComparison.Ordinal) && iso.Contains("System.IO.FileMode.CreateNew", StringComparison.Ordinal), + "Seed ISO script: IMAPI2 ISO 9660 + Joliet, label CIDATA, never overwrites"); + } + + private static async Task Downloads(Action check, Func temp) + { + using var server = new LoopbackHttpServer(); + var payload = RandomNumberGenerator.GetBytes(3 * 1024 * 1024 + 123); + var hash = Convert.ToHexStringLower(SHA256.HashData(payload)); + server.Add("/images/disk.img", payload, "\"v1\""); + server.Add("/images/SHA256SUMS", Encoding.ASCII.GetBytes($"{hash} *disk.img\n{new string('a', 64)} other.img\n")); + server.Add("/other/data.bin", Encoding.ASCII.GetBytes("unexpected content")); + server.Redirect("/fwlink?id=1", "/images/disk.img"); + var client = ImageBuildDownloader.CreateClient(); + var downloader = new ImageBuildDownloader(client) { RetryDelay = TimeSpan.FromMilliseconds(50) }; + var url = new Uri(server.Base, "images/disk.img"); + + check(await downloader.PublishedSha256Async(url, default) == hash + && ImageBuildDownloader.ParseSha256Sums($"{hash} disk.img\n", "disk.img") == hash && ImageBuildDownloader.ParseSha256Sums($"{hash} *disk.img", "other.img") is null, + "SHA256SUMS next to the image is parsed for the exact file name (binary and text forms)"); + + var cache = temp("cache"); + server.FailOnce("/images/disk.img", 1_000_000); + var downloaded = await downloader.DownloadAsync(url, cache, hash, null, "test", default); + check(downloaded.Sha256 == hash && File.ReadAllBytes(downloaded.Path).SequenceEqual(payload) && !downloaded.Reused + && server.Requests.Any(r => r.Method == "GET" && r.Range is { } range && range.StartsWith("bytes=", StringComparison.Ordinal) && !range.StartsWith("bytes=0-", StringComparison.Ordinal)) + && !File.Exists(downloaded.Path + ".partial"), + "An interrupted download resumes with an HTTP range request and is verified against SHA-256"); + var gets = server.Requests.Count(r => r.Method == "GET"); + var reused = await downloader.DownloadAsync(url, cache, hash, null, "test", default); + check(reused.Reused && reused.Path == downloaded.Path && server.Requests.Count(r => r.Method == "GET") == gets, "A verified cached download is reused without transferring again"); + + var badCache = temp("bad"); + var mismatch = await Expect(() => downloader.DownloadAsync(new Uri(server.Base, "other/data.bin"), badCache, new string('0', 64), null, "test", default)); + check(mismatch is not null && Directory.GetFiles(badCache).Length == 0, "A SHA-256 mismatch fails the download and deletes it"); + + var redirected = await downloader.DownloadAsync(new Uri(server.Base, "fwlink?id=1"), temp("redirect"), null, null, "test", default); + check(Path.GetFileName(redirected.Path) == "disk.img" && redirected.FinalUrl.AbsolutePath == "/images/disk.img" && redirected.Sha256 == hash, + "A redirecting link caches the file under the final URL's name and records its SHA-256"); + } + + private static async Task Validation(Action check, Func temp) + { + using var mock = new MockHyperV(new { vms = Array.Empty(), switches = Array.Empty() }); + using var service = new ControlService(); + var build = service.ImageBuild; + var root = temp("validation"); + var output = Path.Combine(root, "out"); + var iso = Path.Combine(root, "win.iso"); + File.WriteAllBytes(iso, [1]); + var wim = Path.Combine(root, "install.wim"); + File.WriteAllBytes(wim, [1]); + var image = Path.Combine(root, "ubuntu.img"); + File.WriteAllBytes(image, [1]); + ImageBuildRequest Win(Func? change = null) => + (change ?? (r => r))(new ImageBuildRequest { OsFamily = "windows", Name = "lab", OutputDirectory = output, IsoPath = iso }); + ImageBuildRequest Lin(Func? change = null) => + (change ?? (r => r))(new ImageBuildRequest { OsFamily = "linux", Name = "lab", OutputDirectory = output, ImagePath = Path.ChangeExtension(image, ".vhdx"), SshPublicKey = Key }); + File.WriteAllBytes(Path.ChangeExtension(image, ".vhdx"), [1]); + + (string Case, ImageBuildRequest Request, string Expected)[] invalid = + [ + ("os family", Win(r => r with { OsFamily = "macos" }), "osFamily must be"), + ("path-like name", Win(r => r with { Name = "..\\evil" }), "name must be"), + ("device name", Win(r => r with { Name = "CON" }), "reserved Windows device name"), + ("relative output", Win(r => r with { OutputDirectory = "images" }), "absolute local path"), + ("dot-dot output", Win(r => r with { OutputDirectory = root + "\\..\\x" }), "absolute local path"), + ("UNC output", Win(r => r with { OutputDirectory = @"\\server\share\images" }), "absolute local path"), + ("no windows source", Win(r => r with { IsoPath = null }), "exactly one Windows source"), + ("two windows sources", Win(r => r with { Source = "eval" }), "exactly one Windows source"), + ("iso extension", Win(r => r with { IsoPath = wim }), "must be a .iso file"), + ("missing iso", Win(r => r with { IsoPath = Path.Combine(root, "missing.iso") }), "does not exist"), + ("unknown source", Win(r => r with { IsoPath = null, Source = "retail" }), "source must be eval"), + ("image index", Win(r => r with { ImageIndex = 0 }), "imageIndex must be"), + ("index and edition", Win(r => r with { ImageIndex = 1, Edition = "Pro" }), "not both"), + ("long computer name", Win(r => r with { ComputerName = "THIS-NAME-IS-TOO-LONG" }), "computerName must be"), + ("numeric computer name", Win(r => r with { ComputerName = "12345" }), "computerName must be"), + ("built-in admin", Win(r => r with { AdminUser = "Administrator" }), "built-in Windows account"), + ("blank password", Win(r => r with { AdminPassword = " " }), "printable characters"), + ("control character password", Win(r => r with { AdminPassword = "a\nb" }), "printable characters"), + ("kdnet without host", Win(r => r with { KernelDebug = new(true) }), "kernelDebug.hostIp"), + ("kdnet port", Win(r => r with { KernelDebug = new(true, "net", "192.0.2.1", 80) }), "kernelDebug.port"), + ("kdnet key", Win(r => r with { KernelDebug = new(true, "net", "192.0.2.1", 50000, "not-a-key") }), "kernelDebug.key"), + ("serial port", Win(r => r with { KernelDebug = new(true, "serial", null, 50000, null, 3) }), "kernelDebug.comPort"), + ("transport", Win(r => r with { KernelDebug = new(true, "usb") }), "kernelDebug.transport"), + ("relative tools", Win(r => r with { ToolsDirectory = "tools" }), "absolute local path"), + ("missing tools", Win(r => r with { ToolsDirectory = Path.Combine(root, "no-tools") }), "does not exist"), + ("drive root tools", Win(r => r with { ToolsDirectory = Path.GetPathRoot(root) }), "not a drive root"), + ("tools containing output", Win(r => r with { ToolsDirectory = root }), "must not contain the output directory"), + ("language", Win(r => r with { Language = "xx-NOPE" }), "language must be"), + ("disk size", Win(r => r with { DiskGb = 1 }), "diskGb must be"), + ("linux parameter on windows", Win(r => r with { SshPublicKey = Key }), "applies to osFamily=linux only"), + ("windows parameters on linux", Lin(r => r with { IsoPath = iso, AdminUser = "x" }), "isoPath, adminUser apply to osFamily=windows only"), + ("no key or password", Lin(r => r with { SshPublicKey = null }), "sshPublicKey is required unless password is given"), + ("bad key", Lin(r => r with { SshPublicKey = "ssh-dss AAAAB3NzaC1kc3M=" }), "OpenSSH public key"), + ("root user", Lin(r => r with { SshUser = "root" }), "sshUser must be"), + ("hostname", Lin(r => r with { Hostname = "-bad-" }), "hostname must be"), + ("package", Lin(r => r with { Packages = ["Bad Package"] }), "Invalid package name"), + ("url and path", Lin(r => r with { ImageUrl = "https://example.invalid/x.img" }), "not both"), + ("ftp url", Lin(r => r with { ImagePath = null, ImageUrl = "ftp://example.invalid/x.img" }), "https:// URL"), + ("http without hash", Lin(r => r with { ImagePath = null, ImageUrl = "http://example.invalid/x.vhdx" }), "needs sha256"), + ("bad hash", Lin(r => r with { Sha256 = "abc" }), "sha256 must be") + ]; + mock.ClearCalls(); + var failures = new List(); + foreach (var (name, request, expected) in invalid) + { + var error = await Expect(() => build.PrepareAsync(request, default)); + if (error is null || !error.Message.Contains(expected, StringComparison.Ordinal)) failures.Add($"{name}: {error?.Message ?? "accepted"}"); + } + check(failures.Count == 0 && mock.Calls().Count == 0 && !Directory.Exists(output), + $"{invalid.Length} invalid requests are rejected before any host call or file creation" + (failures.Count == 0 ? "" : ": " + string.Join(" / ", failures))); + + var sanitized = await build.PrepareAsync(Win(r => r with { Name = "My_Lab.Image-01" }), default); + check(sanitized.Windows!.ComputerName == "MY-LAB-IMAGE-01" && sanitized.Windows.AdminUser == "labadmin" && sanitized.Windows.PasswordGenerated + && sanitized.Windows.AdminPassword.Length == 20 && sanitized.DiskGb == 100 && sanitized.Windows.Language == "en-US" + && sanitized.VhdPath == Path.Combine(output, "My_Lab.Image-01.vhdx") && sanitized.SidecarPath == Path.Combine(output, "My_Lab.Image-01.image.json"), + "Defaults: computer name derived from name, labadmin, generated 20-character password, 100 GB, en-US, .vhdx and .image.json"); + + Directory.CreateDirectory(output); + File.WriteAllBytes(Path.Combine(output, "lab.vhdx"), [1]); + var existingVhd = await Expect(() => build.PrepareAsync(Win(), default)); + File.Delete(Path.Combine(output, "lab.vhdx")); + File.WriteAllText(Path.Combine(output, "lab.image.json"), "{}"); + var existingSidecar = await Expect(() => build.PrepareAsync(Win(), default)); + File.Delete(Path.Combine(output, "lab.image.json")); + File.WriteAllBytes(Path.Combine(output, "lab-seed.iso"), [1]); + var existingSeed = await Expect(() => build.PrepareAsync(Lin(), default)); + File.Delete(Path.Combine(output, "lab-seed.iso")); + check(new[] { existingVhd, existingSidecar, existingSeed }.All(e => e?.Message.Contains("already exists", StringComparison.Ordinal) == true), + "Existing outputs (VHDX, sidecar, seed ISO) are refused"); + + var savedPath = Environment.GetEnvironmentVariable("PATH"); + var savedQemu = Environment.GetEnvironmentVariable(ImageBuildService.QemuImgVariable); + HyperVOperationException? missing, wrong; + try + { + Environment.SetEnvironmentVariable("PATH", root); + Environment.SetEnvironmentVariable(ImageBuildService.QemuImgVariable, null); + missing = await Expect(() => build.PrepareAsync(Lin(r => r with { ImagePath = image }), default)); + Environment.SetEnvironmentVariable(ImageBuildService.QemuImgVariable, Path.Combine(root, "nowhere", "qemu-img.exe")); + wrong = await Expect(() => build.PrepareAsync(Lin(r => r with { ImagePath = image }), default)); + } + finally + { + Environment.SetEnvironmentVariable("PATH", savedPath); + Environment.SetEnvironmentVariable(ImageBuildService.QemuImgVariable, savedQemu); + } + var qemuDefault = Environment.GetEnvironmentVariable(ImageBuildService.QemuImgVariable, EnvironmentVariableTarget.User) is null + && Environment.GetEnvironmentVariable(ImageBuildService.QemuImgVariable, EnvironmentVariableTarget.Machine) is null + && !File.Exists(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "qemu", "qemu-img.exe")); + check((!qemuDefault || missing?.ErrorType == "NotFound" && missing.Message.Contains("HYPERV_CONTROL_QEMU_IMG", StringComparison.Ordinal) + && missing.Message.Contains("qemu-img", StringComparison.Ordinal) && missing.Message.Contains("--elevate", StringComparison.Ordinal)) + && wrong?.ErrorType == "NotFound" && wrong.Message.Contains("HYPERV_CONTROL_QEMU_IMG (process scope)", StringComparison.Ordinal) && mock.Calls().Count == 0, + "A missing qemu-img fails before any download with an error naming HYPERV_CONTROL_QEMU_IMG; a wrong path is named as such"); + + var wait = await Expect(() => build.StartAsync(Win(), 5000, default)); + check(wait?.Message.Contains("waitSeconds", StringComparison.Ordinal) == true, "waitSeconds is bounded to 0..3600"); + } + + private static object ImageState(object? native = null, string? bcdbootHelp = null, string? failAt = null, string isoDriveLetter = "M") => new + { + vms = Array.Empty(), switches = Array.Empty(), + imageBuild = new + { + isoDriveLetter, diskNumber = 7, driveLetters = new[] { "S", "W" }, createFiles = true, native, bcdbootHelp, failAt, + images = new object[] + { + new { index = 1, name = "Windows 11 Enterprise N Evaluation", description = "N edition", size = 1L << 30, editionId = "EnterpriseNEval", architecture = 9, version = "10.0.26300.9457", installationType = "Client", languages = new[] { "en-US" } }, + new { index = 2, name = "Windows 11 Enterprise Evaluation", description = "Enterprise", size = 1L << 30, editionId = "EnterpriseEval", architecture = 9, version = "10.0.26300.9457", installationType = "Client", languages = new[] { "en-US" } } + } + } + }; + + private sealed class Recorder : IProgress + { + public readonly List Messages = []; + public void Report(JobProgress value) { lock (Messages) if (value.Message is { } message) Messages.Add(message); } + } + + private static string Tool(JsonElement p) => Path.GetFileName(p.GetProperty("FilePath").GetString() ?? "").ToLowerInvariant(); + // The base mock's logger unrolls one-element arrays to a scalar, so accept both shapes. + private static string[] Args(JsonElement p) => !p.TryGetProperty("ArgumentList", out var a) ? [] + : a.ValueKind == JsonValueKind.Array ? a.EnumerateArray().Select(x => x.GetString() ?? "").ToArray() + : a.ValueKind == JsonValueKind.String ? [a.GetString()!] : []; + private static string First(JsonElement value) => value.ValueKind == JsonValueKind.Array ? value[0].GetString() ?? "" : value.GetString() ?? ""; + + private static async Task WindowsPipeline(Action check, Func temp) + { + var root = temp("windows"); + var isoContent = Directory.CreateDirectory(Path.Combine(root, "iso", "sources")).Parent!.FullName; + File.WriteAllBytes(Path.Combine(isoContent, "sources", "install.wim"), [1, 2, 3]); + var iso = Path.Combine(root, "media", "win11.iso"); + Directory.CreateDirectory(Path.GetDirectoryName(iso)!); + File.WriteAllBytes(iso, Encoding.ASCII.GetBytes("not really an ISO")); + var esp = Directory.CreateDirectory(Path.Combine(root, "esp")).FullName; + var windows = Directory.CreateDirectory(Path.Combine(root, "win", "Windows", "System32")).Parent!.Parent!.FullName; + var tools = Directory.CreateDirectory(Path.Combine(root, "tools", "sub")).Parent!.FullName; + File.WriteAllText(Path.Combine(tools, "dbg.txt"), "tool"); + File.WriteAllBytes(Path.Combine(tools, "sub", "\u7a7a \u95f4.bin"), [9, 8, 7]); + File.WriteAllText(Path.Combine(tools, ".hidden"), "h"); + File.SetAttributes(Path.Combine(tools, ".hidden"), FileAttributes.Hidden); + var output = Path.Combine(root, "out"); + var isoHash = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(iso))); + + // 1. ISO source, default edition, KDNET, tools; bcdboot succeeds. + using (var mock = new MockHyperV(ImageState())) + using (var service = new ControlService()) + { + var build = service.ImageBuild; + build.DriveRoot = letter => letter switch { 'M' => isoContent, 'S' => esp, 'W' => windows, _ => throw new InvalidOperationException("Unexpected drive " + letter) }; + var plan = await build.PrepareAsync(new ImageBuildRequest + { + OsFamily = "windows", Name = "lab-win", OutputDirectory = output, IsoPath = iso, DiskGb = 64, ComputerName = "HVC-LAB", AdminPassword = Password, + KernelDebug = new ImageBuildKernelDebug(true, "net", "192.0.2.10", 50005, "1a.2b.3c.4d"), ToolsDirectory = tools + }, default); + mock.ClearCalls(); + var progress = new Recorder(); + var result = (ImageBuildResult)(await build.ExecuteAsync(plan, progress, default))!; + var calls = mock.Calls().ToList(); + int At(string cmdlet, Func? match = null) => calls.FindIndex(c => c.Cmdlet == cmdlet && (match?.Invoke(c.Parameters) ?? true)); + int[] order = + [ + At("Mount-DiskImage", p => First(p.GetProperty("ImagePath")) == iso && p.GetProperty("Access").GetString() == "ReadOnly"), + At("Get-WindowsImage", p => !p.TryGetProperty("Index", out _) && p.GetProperty("ImagePath").GetString() == @"M:\sources\install.wim"), + At("Get-WindowsImage", p => p.TryGetProperty("Index", out var i) && i.GetInt32() == 2), + At("New-VHD", p => p.GetProperty("Path").GetString() == plan.VhdPath && p.GetProperty("SizeBytes").GetInt64() == 64L << 30 && p.GetProperty("Dynamic").GetBoolean()), + At("Mount-VHD", p => First(p.GetProperty("Path")) == plan.VhdPath), + At("Initialize-Disk", p => p.GetProperty("Number").GetInt32() == 7 && p.GetProperty("PartitionStyle").GetString() == "GPT"), + At("Remove-Partition", p => p.GetProperty("PartitionNumber").GetInt32() == 1), + At("New-Partition", p => p.GetProperty("Size").GetInt64() == 300L << 20 && p.GetProperty("GptType").GetString() == ImageBuildScripts.BasicDataGptType), + At("Format-Volume", p => p.GetProperty("PartitionNumber").GetInt32() == 1 && p.GetProperty("FileSystem").GetString() == "FAT32"), + At("Set-Partition", p => p.GetProperty("PartitionNumber").GetInt32() == 1 && p.GetProperty("GptType").GetString() == ImageBuildScripts.EspGptType), + At("Add-PartitionAccessPath", p => p.GetProperty("PartitionNumber").GetInt32() == 1 && p.GetProperty("AssignDriveLetter").GetBoolean()), + At("New-Partition", p => p.GetProperty("Size").GetInt64() == 16L << 20 && p.GetProperty("GptType").GetString() == ImageBuildScripts.MsrGptType), + At("New-Partition", p => p.TryGetProperty("UseMaximumSize", out var m) && m.GetBoolean() && p.GetProperty("GptType").GetString() == ImageBuildScripts.BasicDataGptType), + At("Format-Volume", p => p.GetProperty("PartitionNumber").GetInt32() == 3 && p.GetProperty("FileSystem").GetString() == "NTFS"), + At("Add-PartitionAccessPath", p => p.GetProperty("PartitionNumber").GetInt32() == 3), + At("Expand-WindowsImage", p => p.GetProperty("ImagePath").GetString() == @"M:\sources\install.wim" && p.GetProperty("Index").GetInt32() == 2 && p.GetProperty("ApplyPath").GetString() == @"W:\"), + At("Invoke-ImageBuildNative", p => Tool(p) == "bcdboot.exe" && Args(p).SequenceEqual(["/?"])), + At("Invoke-ImageBuildNative", p => Tool(p) == "bcdboot.exe" && Args(p).SequenceEqual([@"W:\Windows", "/s", "S:", "/f", "UEFI", "/v", "/offline"])), + At("Invoke-ImageBuildNative", p => Tool(p) == "bcdedit.exe" && Args(p).SequenceEqual(["/store", Store, "/set", "{default}", "debug", "on"])), + At("Invoke-ImageBuildNative", p => Tool(p) == "bcdedit.exe" && Args(p).SequenceEqual(["/store", Store, "/dbgsettings", "net", "hostip:192.0.2.10", "port:50005", "key:1a.2b.3c.4d"])), + At("Dismount-VHD", p => First(p.GetProperty("Path")) == plan.VhdPath), + At("Dismount-DiskImage", p => First(p.GetProperty("ImagePath")) == iso) + ]; + var bcdbootRuns = calls.Where(c => c.Cmdlet == "Invoke-ImageBuildNative" && Tool(c.Parameters) == "bcdboot.exe" && !Args(c.Parameters).Contains("/?")).ToList(); + var bcdeditRuns = calls.Where(c => c.Cmdlet == "Invoke-ImageBuildNative" && Tool(c.Parameters) == "bcdedit.exe").ToList(); + check(order.All(i => i >= 0) && order.Zip(order.Skip(1)).All(p => p.First < p.Second) + && bcdbootRuns.Count == 1 && bcdbootRuns.All(c => Args(c.Parameters).Contains("/s")) && bcdeditRuns.All(c => Args(c.Parameters).Take(2).SequenceEqual(["/store", Store])), + "Windows build runs mount, image selection, GPT layout, apply, bcdboot /s and offline bcdedit in order, then dismounts VHDX and ISO; host boot settings are never targeted" + + (order.All(i => i >= 0) ? "" : " (missing step " + Array.IndexOf(order, -1) + ")")); + + var unattendPath = Path.Combine(windows, "Windows", "Panther", "unattend.xml"); + var unattendBytes = File.ReadAllBytes(unattendPath); + XNamespace u = ImageBuildUnattend.Namespace; + var unattend = XDocument.Parse(Encoding.UTF8.GetString(unattendBytes, 3, unattendBytes.Length - 3)); + check(unattendBytes.Take(3).SequenceEqual(new byte[] { 0xEF, 0xBB, 0xBF }) && unattend.Descendants(u + "ComputerName").Single().Value == "HVC-LAB" + && unattend.Descendants(u + "Password").All(p => p.Element(u + "Value")!.Value == Password), + "Panther\\unattend.xml is written into the applied image with the computer name and the escaped password"); + check(File.ReadAllText(Path.Combine(windows, "Tools", "dbg.txt")) == "tool" && File.ReadAllBytes(Path.Combine(windows, "Tools", "sub", "\u7a7a \u95f4.bin")).SequenceEqual(new byte[] { 9, 8, 7 }) + && File.Exists(Path.Combine(windows, "Tools", ".hidden")) && result.ToolsCopied is ImageBuildToolsCopy { Files: 3, GuestPath: "C:\\Tools" }, + "toolsDirectory is copied to C:\\Tools including nested Unicode and hidden files"); + + var sidecarText = File.ReadAllText(plan.SidecarPath); + using var sidecar = JsonDocument.Parse(sidecarText); + var s = sidecar.RootElement; + check(s.GetProperty("name").GetString() == "lab-win" && s.GetProperty("osFamily").GetString() == "windows" && s.GetProperty("kind").GetString() == "template" + && s.GetProperty("config").GetProperty("secureBoot").GetBoolean() == false && s.GetProperty("config").GetProperty("secureBootTemplate").GetString() == "MicrosoftWindows" + && s.GetProperty("config").GetProperty("enhancedSessionTransportType").GetString() == "VMBus" && s.GetProperty("config").GetProperty("tpm").GetBoolean() + && s.GetProperty("config").GetProperty("staticMemory").GetBoolean() && s.GetProperty("config").GetProperty("checkpointType").GetString() == "Standard" + && s.GetProperty("adminUser").GetString() == "labadmin" && s.GetProperty("sshUser").ValueKind == JsonValueKind.Null + && s.GetProperty("builtFrom").GetProperty("kind").GetString() == "iso" && s.GetProperty("builtFrom").GetProperty("sha256").GetString() == isoHash + && s.GetProperty("image").GetProperty("index").GetInt32() == 2 && s.GetProperty("kernelDebug").GetProperty("transport").GetString() == "net" + && !s.GetProperty("kernelDebug").GetProperty("keyStored").GetBoolean() && s.TryGetProperty("builtAt", out _) + && s.GetProperty("warnings").EnumerateArray().Any(w => w.GetString()!.Contains("Secure Boot off", StringComparison.Ordinal)) + && !sidecarText.Contains("w0rd", StringComparison.Ordinal) && !sidecarText.Contains("1a.2b.3c.4d", StringComparison.Ordinal), + "Sidecar .image.json records template config (Secure Boot off for kernel debugging), admin user, source hash, image and warnings, never the password or KDNET key"); + var mockState = mock.State().GetProperty("imageBuild"); + check(result.BootConfiguration == "bcdboot /offline" && result.KernelDebugApplied == true && result.Partitions!.Select(p => p.GptType) + .SequenceEqual([ImageBuildScripts.EspGptType, ImageBuildScripts.MsrGptType, ImageBuildScripts.BasicDataGptType]) + && result.Warnings.Any(w => w.Contains("90 days", StringComparison.Ordinal)) && result.NextSteps.Any(n => n.Contains("hyperv_credential", StringComparison.Ordinal)) + && result.NextSteps.Any(n => n.Contains("Secure Boot Off", StringComparison.Ordinal)) && result.VhdVirtualBytes == 64L << 30 + && progress.Messages.Any(m => m.StartsWith("windows 5/10: applying image 2", StringComparison.Ordinal)) + && mockState.GetProperty("attachedVhds").GetArrayLength() == 0 && mockState.GetProperty("mountedImages").GetArrayLength() == 0, + "Result reports partitions, boot method, applied debug settings, evaluation warning, nextSteps and phase progress; nothing stays mounted"); + } + + // 2. install.wim source with an edition match; bcdboot copies files but cannot create the store (c0000035): bcdedit fallback. + var esp2 = Path.Combine(root, "esp2"); + Directory.CreateDirectory(Path.Combine(esp2, "EFI", "Microsoft", "Boot")); + Directory.CreateDirectory(Path.Combine(esp2, "EFI", "Boot")); + File.WriteAllBytes(Path.Combine(esp2, "EFI", "Boot", "bootx64.efi"), [1]); + File.WriteAllBytes(Path.Combine(esp2, "EFI", "Microsoft", "Boot", "bootmgfw.efi"), [1]); + File.WriteAllBytes(Path.Combine(esp2, "EFI", "Microsoft", "Boot", "BCD"), new byte[8192]); + File.WriteAllBytes(Path.Combine(esp2, "EFI", "Microsoft", "Boot", "BCD.LOG"), new byte[8192]); + var windows2 = Directory.CreateDirectory(Path.Combine(root, "win2", "Windows", "System32", "config")).Parent!.Parent!.Parent!.FullName; + File.WriteAllText(Path.Combine(windows2, "Windows", "System32", "config", "BCD-Template"), "TEMPLATE"); + var wim = Path.Combine(root, "install.wim"); + File.WriteAllBytes(wim, [4, 5, 6]); + using (var mock = new MockHyperV(ImageState(native: new Dictionary + { ["bcdboot.exe"] = new { exitCode = 183, stdout = "BFSVC: Copy files which lack a version\nBFSVC Error: Failed to create a new system store. Status = [c0000035]", stderr = "" } }))) + using (var service = new ControlService()) + { + var build = service.ImageBuild; + build.DriveRoot = letter => letter switch { 'S' => esp2, 'W' => windows2, _ => throw new InvalidOperationException("Unexpected drive " + letter) }; + var plan = await build.PrepareAsync(new ImageBuildRequest + { + OsFamily = "windows", Name = "lab-wim", OutputDirectory = output, ImagePath = wim, Edition = "Enterprise N", DiskGb = 32, + EnableRdp = false, AutoLogon = false, PreventDeviceEncryption = false + }, default); + mock.ClearCalls(); + var result = (ImageBuildResult)(await build.ExecuteAsync(plan, new Recorder(), default))!; + var calls = mock.Calls(); + var stores = calls.Where(c => c.Cmdlet == "Invoke-ImageBuildNative" && Tool(c.Parameters) == "bcdedit.exe").Select(c => Args(c.Parameters)).ToList(); + var bootDirectory = Path.Combine(esp2, "EFI", "Microsoft", "Boot"); + XNamespace u = ImageBuildUnattend.Namespace; + var unattend = XDocument.Load(Path.Combine(windows2, "Windows", "Panther", "unattend.xml")); + check(!calls.Any(c => c.Cmdlet is "Mount-DiskImage" or "Dismount-DiskImage") + && calls.Any(c => c.Cmdlet == "Expand-WindowsImage" && c.Parameters.GetProperty("ImagePath").GetString() == wim && c.Parameters.GetProperty("Index").GetInt32() == 1) + && File.ReadAllText(Path.Combine(bootDirectory, "BCD")) == "TEMPLATE" && !File.Exists(Path.Combine(bootDirectory, "BCD.LOG")) + && stores.Count == 31 && stores.All(a => a.Take(2).SequenceEqual(["/store", Store])) + && stores.Any(a => a.Length == 8 && a[2] == "/create" && a[4..].SequenceEqual(["/d", "Windows", "/application", "osloader"])) + && result.BootConfiguration == "bcdboot /offline (files) + bcdedit (store)" && result.Warnings.Any(w => w.Contains("c0000035", StringComparison.Ordinal)) + && !unattend.Descendants(u + "AutoLogon").Any() && !unattend.Descendants(u + "fDenyTSConnections").Any(), + "install.wim source with edition match; when bcdboot copies the boot files but cannot create the store (c0000035) the store is built from the image's BCD-Template with bcdedit"); + } + + // 3. bcdboot fails outright: the job fails with bcdboot's reason, dismounts and deletes the VHDX. + using (var mock = new MockHyperV(ImageState(native: new Dictionary + { ["bcdboot.exe"] = new { exitCode = 193, stdout = "BFSVC Error: Failed to validate boot manager checksum\nFailure when attempting to copy boot files.", stderr = "" } }))) + using (var service = new ControlService()) + { + var build = service.ImageBuild; + var esp3 = Directory.CreateDirectory(Path.Combine(root, "esp3")).FullName; + build.DriveRoot = letter => letter switch { 'M' => isoContent, 'S' => esp3, 'W' => windows, _ => throw new InvalidOperationException("Unexpected drive " + letter) }; + var plan = await build.PrepareAsync(new ImageBuildRequest { OsFamily = "windows", Name = "lab-fail", OutputDirectory = output, IsoPath = iso, DiskGb = 32 }, default); + mock.ClearCalls(); + var error = await Expect(() => build.ExecuteAsync(plan, new Recorder(), default)); + var calls = mock.Calls(); + check(error?.Message.StartsWith("bcdboot failed (exit 193): BFSVC Error: Failed to validate boot manager checksum | Failure when attempting to copy boot files.", StringComparison.Ordinal) == true + && calls.Any(c => c.Cmdlet == "Dismount-VHD") && calls.Any(c => c.Cmdlet == "Dismount-DiskImage") + && !File.Exists(plan.VhdPath) && !File.Exists(plan.SidecarPath) && mock.State().GetProperty("imageBuild").GetProperty("attachedVhds").GetArrayLength() == 0, + "A bcdboot failure fails the build with bcdboot's own reason; the VHDX and ISO are dismounted and the partial VHDX is deleted"); + } + + // 4. A failure inside DISM apply on a WIM source: dismount, delete, no ISO steps. + using (var mock = new MockHyperV(ImageState(failAt: "Expand-WindowsImage"))) + using (var service = new ControlService()) + { + var build = service.ImageBuild; + build.DriveRoot = letter => letter switch { 'S' => esp, 'W' => windows, _ => throw new InvalidOperationException("Unexpected drive " + letter) }; + var plan = await build.PrepareAsync(new ImageBuildRequest { OsFamily = "windows", Name = "lab-dism", OutputDirectory = output, ImagePath = wim, DiskGb = 32 }, default); + mock.ClearCalls(); + var error = await Expect(() => build.ExecuteAsync(plan, new Recorder(), default)); + var calls = mock.Calls(); + check(error?.Message.Contains("Mock failure injected at Expand-WindowsImage", StringComparison.Ordinal) == true && calls.Any(c => c.Cmdlet == "Dismount-VHD") + && !calls.Any(c => c.Cmdlet == "Dismount-DiskImage") && !calls.Any(c => c.Cmdlet == "Invoke-ImageBuildNative") && !File.Exists(plan.VhdPath), + "A DISM failure stops before bcdboot, dismounts the VHDX and deletes it"); + } + + // 5. source=eval-iot through a loopback server: cached download, install.esd on the mounted ISO. + using var server = new LoopbackHttpServer(); + var media = Encoding.ASCII.GetBytes(new string('x', 200_000)); + server.Add("/media/iot.iso", media, "\"iot\""); + server.Redirect("/fwlink?linkid=iot", "/media/iot.iso"); + var esdContent = Directory.CreateDirectory(Path.Combine(root, "esd", "sources")).Parent!.FullName; + File.WriteAllBytes(Path.Combine(esdContent, "sources", "install.esd"), [1]); + using (var mock = new MockHyperV(ImageState())) + using (var service = new ControlService()) + { + var build = service.ImageBuild; + build.EvaluationUrls = new Dictionary { ["eval-iot"] = new(server.Base, "fwlink?linkid=iot") }; + build.DriveRoot = letter => letter switch { 'M' => esdContent, 'S' => esp, 'W' => windows, _ => throw new InvalidOperationException("Unexpected drive " + letter) }; + File.Delete(Path.Combine(windows, "Windows", "Panther", "unattend.xml")); + Directory.Delete(Path.Combine(windows, "Tools"), true); + var plan = await build.PrepareAsync(new ImageBuildRequest { OsFamily = "windows", Name = "lab-eval", OutputDirectory = output, Source = "eval-iot", DiskGb = 32 }, default); + mock.ClearCalls(); + var result = (ImageBuildResult)(await build.ExecuteAsync(plan, new Recorder(), default))!; + var cached = Path.Combine(output, "cache", "iot.iso"); + var calls = mock.Calls(); + var from = result.Sidecar.BuiltFrom; + check(File.ReadAllBytes(cached).SequenceEqual(media) && calls.Any(c => c.Cmdlet == "Mount-DiskImage" && First(c.Parameters.GetProperty("ImagePath")) == cached) + && calls.Any(c => c.Cmdlet == "Get-WindowsImage" && c.Parameters.GetProperty("ImagePath").GetString() == @"M:\sources\install.esd") + && from.Kind == "eval-download" && from.EvalSource == "eval-iot" && from.Sha256 == Convert.ToHexStringLower(SHA256.HashData(media)) && !from.ChecksumVerified + && from.FinalUrl!.EndsWith("/media/iot.iso", StringComparison.Ordinal) && result.Warnings.Any(w => w.Contains("no checksum", StringComparison.Ordinal)) + && result.Warnings.Count(w => w.Contains("90 days", StringComparison.Ordinal)) == 1, + "source=eval-iot downloads the evaluation ISO into \\cache (kept), mounts it and applies install.esd; the unverified hash is recorded with a warning"); + } + + var listJson = JsonDocument.Parse("""[{"index":1,"name":"Windows 11 Pro N"},{"index":2,"name":"Windows 11 Pro"},{"index":3,"name":"Windows 11 Pro for Workstations"}]""").RootElement; + var ambiguous = Assert(() => ImageBuildService.SelectImage(listJson, null, "Pro")); + var unknown = Assert(() => ImageBuildService.SelectImage(listJson, null, "Home")); + var badIndex = Assert(() => ImageBuildService.SelectImage(listJson, 9, null)); + check(ImageBuildService.SelectImage(listJson, null, null) == 2 && ImageBuildService.SelectImage(listJson, null, "windows 11 pro") == 2 + && ImageBuildService.SelectImage(listJson, null, "Workstations") == 3 && ImageBuildService.SelectImage(listJson, 1, null) == 1 + && ambiguous?.ErrorType == "AmbiguousName" && unknown?.ErrorType == "NotFound" && badIndex?.ErrorType == "NotFound" && badIndex.Message.Contains("2: Windows 11 Pro;", StringComparison.Ordinal), + "Image selection: default skips N editions; edition matches exactly first, then one unique substring; ambiguity and misses list the images"); + } + + private static async Task LinuxPipeline(Action check, Func temp) + { + if (Type.GetTypeFromProgID("IMAPI2FS.MsftFileSystemImage") is null) + { + Console.WriteLine("SKIP Linux pipeline: the IMAPI2 file-system COM class is not registered on this machine."); + return; + } + var root = temp("linux"); + var output = Path.Combine(root, "out"); + var qemu = Path.Combine(root, "qemu", "qemu-img.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(qemu)!); + File.WriteAllBytes(qemu, [0]); + using var server = new LoopbackHttpServer(); + var cloudImage = Encoding.ASCII.GetBytes("QFI\u00fb fake qcow2 cloud image"); + var cloudHash = Convert.ToHexStringLower(SHA256.HashData(cloudImage)); + server.Add("/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img", cloudImage, "\"img\""); + server.Add("/releases/24.04/release/SHA256SUMS", Encoding.ASCII.GetBytes($"{new string('b', 64)} *ubuntu-24.04-server-cloudimg-amd64.vmdk\n{cloudHash} *ubuntu-24.04-server-cloudimg-amd64.img\n")); + var savedQemu = Environment.GetEnvironmentVariable(ImageBuildService.QemuImgVariable); + Environment.SetEnvironmentVariable(ImageBuildService.QemuImgVariable, qemu); + try + { + using var mock = new MockHyperV(new { vms = Array.Empty(), switches = Array.Empty() }); + using var service = new ControlService(); + var build = service.ImageBuild; + build.UbuntuImageUrl = new Uri(server.Base, "releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"); + var plan = await build.PrepareAsync(new ImageBuildRequest { OsFamily = "linux", Name = "lab-ubuntu", OutputDirectory = output, SshPublicKey = Key, DiskGb = 100 }, default); + mock.ClearCalls(); + var result = (ImageBuildResult)(await build.ExecuteAsync(plan, new Recorder(), default))!; + var cached = Path.Combine(output, "cache", "ubuntu-24.04-server-cloudimg-amd64.img"); + var calls = mock.Calls().ToList(); + int At(Func<(string Cmdlet, JsonElement Parameters), bool> match) => calls.FindIndex(c => match(c)); + int[] order = + [ + At(c => c.Cmdlet == "Invoke-ImageBuildNative" && c.Parameters.GetProperty("FilePath").GetString() == qemu && Args(c.Parameters).SequenceEqual(["info", "--output=json", cached])), + At(c => c.Cmdlet == "Invoke-ImageBuildNative" && Args(c.Parameters).SequenceEqual(["convert", "-f", "qcow2", "-O", "vhdx", "-o", "subformat=dynamic", cached, plan.VhdPath])), + At(c => c.Cmdlet == "Get-VHD"), + At(c => c.Cmdlet == "Resize-VHD" && c.Parameters.GetProperty("Path").GetString() == plan.VhdPath && c.Parameters.GetProperty("SizeBytes").GetInt64() == 100L << 30) + ]; + check(order.All(i => i >= 0) && order.Zip(order.Skip(1)).All(p => p.First < p.Second) && File.ReadAllBytes(cached).SequenceEqual(cloudImage), + "Linux build downloads and verifies the cloud image against SHA256SUMS, then runs qemu-img info, convert (QCOW2 to dynamic VHDX) and Resize-VHD in order"); + + var iso = File.ReadAllBytes(plan.SeedIsoPath!); + var (primary, label, joliet) = ReadIsoDescriptors(iso); + var text = Encoding.Latin1.GetString(iso); + var userDataAt = text.IndexOf("#cloud-config\n", StringComparison.Ordinal); + check(primary && label == "CIDATA" && joliet && ContainsUtf16BigEndian(iso, "user-data") && ContainsUtf16BigEndian(iso, "meta-data") + && userDataAt > 0 && !(iso[userDataAt - 3] == 0xEF && iso[userDataAt - 2] == 0xBB && iso[userDataAt - 1] == 0xBF) + && text.Contains("hostname: \"lab-ubuntu\"\nusers:\n - name: \"lab\"", StringComparison.Ordinal) && text.Contains(" - \"linux-cloud-tools-virtual\"\n", StringComparison.Ordinal) + && text.Contains("instance-id: \"iid-", StringComparison.Ordinal) && !text[userDataAt..(userDataAt + 400)].Contains('\r'), + "The seed ISO (built for real with IMAPI2) has an ISO 9660 primary volume descriptor at sector 16 labelled CIDATA, Joliet names user-data and meta-data, and LF-only content without a BOM"); + + using var sidecar = JsonDocument.Parse(File.ReadAllText(plan.SidecarPath)); + var s = sidecar.RootElement; + check(s.GetProperty("osFamily").GetString() == "linux" && s.GetProperty("sshUser").GetString() == "lab" && s.GetProperty("adminUser").ValueKind == JsonValueKind.Null + && s.GetProperty("seedIsoPath").GetString() == plan.SeedIsoPath && s.GetProperty("config").GetProperty("secureBoot").GetBoolean() + && s.GetProperty("config").GetProperty("secureBootTemplate").GetString() == "MicrosoftUEFICertificateAuthority" + && s.GetProperty("builtFrom").GetProperty("checksumVerified").GetBoolean() && s.GetProperty("builtFrom").GetProperty("sha256").GetString() == cloudHash + && s.GetProperty("builtFrom").GetProperty("checksumSource").GetString()!.EndsWith("/SHA256SUMS", StringComparison.Ordinal) + && result.SeedIsoBytes == iso.Length && result.NextSteps.Any(n => n.Contains("attach_iso", StringComparison.Ordinal)), + "Linux sidecar records the UEFI CA Secure Boot template, the seed ISO, the SSH user and the verified checksum"); + } + finally { Environment.SetEnvironmentVariable(ImageBuildService.QemuImgVariable, savedQemu); } + + // A .vhdx source needs no qemu-img: it is copied, never shrunk, and a password-only user gets SSH password login. + var vhdx = Path.Combine(root, "prebuilt.vhdx"); + File.WriteAllBytes(vhdx, Encoding.ASCII.GetBytes("vhdxfile")); + using (var mock = new MockHyperV(new { vms = Array.Empty(), switches = Array.Empty() })) + using (var service = new ControlService()) + { + var savedPath = Environment.GetEnvironmentVariable("PATH"); + ImageBuildPlan plan; + try + { + Environment.SetEnvironmentVariable("PATH", root); + plan = await service.ImageBuild.PrepareAsync(new ImageBuildRequest { OsFamily = "linux", Name = "lab-copy", OutputDirectory = output, ImagePath = vhdx, Password = "secret!", Hostname = "Copy-Host" }, default); + } + finally { Environment.SetEnvironmentVariable("PATH", savedPath); } + mock.ClearCalls(); + var result = (ImageBuildResult)(await service.ImageBuild.ExecuteAsync(plan, new Recorder(), default))!; + var text = Encoding.Latin1.GetString(File.ReadAllBytes(plan.SeedIsoPath!)); + check(plan.Linux!.QemuImg is null && File.ReadAllBytes(plan.VhdPath).SequenceEqual(File.ReadAllBytes(vhdx)) && !mock.Called("Resize-VHD") && !mock.Called("Invoke-ImageBuildNative") + && result.Warnings.Any(w => w.Contains("larger than diskGb=40", StringComparison.Ordinal)) && text.Contains("ssh_pwauth: true", StringComparison.Ordinal) + && text.Contains("passwd: \"$6$", StringComparison.Ordinal) && !text.Contains("secret!", StringComparison.Ordinal) && text.Contains("hostname: \"copy-host\"", StringComparison.Ordinal), + "A .vhdx source is copied without qemu-img and never shrunk; a password-only user gets a SHA-512-crypt hash and SSH password login"); + } + } + + private static async Task ToolContract(Action check, Func temp) + { + var root = temp("tool"); + var output = Path.Combine(root, "out"); + var wim = Path.Combine(root, "install.wim"); + File.WriteAllBytes(wim, [1]); + var esp = Directory.CreateDirectory(Path.Combine(root, "esp")).FullName; + var windows = Directory.CreateDirectory(Path.Combine(root, "win", "Windows")).Parent!.FullName; + using var mock = new MockHyperV(ImageState()); + using var service = new ControlService(); + service.ImageBuild.DriveRoot = letter => letter switch { 'S' => esp, 'W' => windows, _ => throw new InvalidOperationException("Unexpected drive " + letter) }; + var tools = new HyperVTools(service); + + var schema = McpServerTool.Create(typeof(HyperVTools).GetMethod(nameof(HyperVTools.ImageBuild))!, tools).ProtocolTool.InputSchema; + var properties = schema.GetProperty("properties"); + var kernel = properties.GetProperty("kernelDebug"); + var kernelProperties = (kernel.TryGetProperty("properties", out var direct) ? direct + : kernel.GetProperty("anyOf").EnumerateArray().First(a => a.TryGetProperty("properties", out _)).GetProperty("properties")); + check(schema.GetProperty("required").EnumerateArray().Select(r => r.GetString()).ToHashSet().SetEquals(["osFamily", "name"]) + && new[] { "enabled", "transport", "hostIp", "port", "key", "comPort" }.All(p => kernelProperties.TryGetProperty(p, out _)) + && properties.TryGetProperty("packages", out _) && properties.TryGetProperty("waitSeconds", out _) && properties.GetProperty("adminPassword").TryGetProperty("description", out _), + "hyperv_image_build schema: osFamily and name required, kernelDebug is an object {enabled, transport, hostIp, port, key, comPort}, parameters are described"); + + var refused = await tools.ImageBuild("linux", "lab", outputDirectory: output, sshPublicKey: Key, sshUser: "root"); + using (var document = JsonDocument.Parse(refused.Content.OfType().Single().Text)) + check(refused.IsError == true && !document.RootElement.GetProperty("ok").GetBoolean() && document.RootElement.GetProperty("errorType").GetString() == "ArgumentException", + "Tool validation failures return the uniform error envelope before a job starts"); + + var started = await tools.ImageBuild("windows", "tool-win", outputDirectory: output, imagePath: wim, diskGb: 32, waitSeconds: 120); + var text = started.Content.OfType().Single().Text; + using (var document = JsonDocument.Parse(text)) + { + var r = document.RootElement; + var password = r.GetProperty("credentials").GetProperty("password").GetString()!; + var job = r.GetProperty("job").GetRawText(); + check(started.IsError != true && r.GetProperty("ok").GetBoolean() && r.GetProperty("job").GetProperty("status").GetString() == "succeeded" + && r.GetProperty("credentials").GetProperty("passwordGenerated").GetBoolean() && password.Length == 20 + && r.GetProperty("credentials").GetProperty("warning").GetString()!.Contains("hyperv_credential", StringComparison.Ordinal) + && !job.Contains(password, StringComparison.Ordinal) && !File.ReadAllText(Path.Combine(output, "tool-win.image.json")).Contains(password, StringComparison.Ordinal) + && r.GetProperty("job").GetProperty("result").GetProperty("vhdPath").GetString() == Path.Combine(output, "tool-win.vhdx") + && r.GetProperty("outputs").GetProperty("sidecarPath").GetString() == Path.Combine(output, "tool-win.image.json"), + "hyperv_image_build returns the job; a generated password appears once in the start response, never in the job result or sidecar"); + } + var again = await tools.ImageBuild("windows", "tool-win", outputDirectory: output, imagePath: wim, diskGb: 32); + check(again.IsError == true && again.Content.OfType().Single().Text.Contains("already exists", StringComparison.Ordinal), + "A second build with the same name is refused because its outputs exist"); + } + + private static (bool Primary, string Label, bool Joliet) ReadIsoDescriptors(byte[] iso) + { + bool primary = false, joliet = false; + var label = ""; + for (var sector = 16; (sector + 1) * 2048 <= iso.Length; sector++) + { + var o = sector * 2048; + if (Encoding.ASCII.GetString(iso, o + 1, 5) != "CD001") break; + if (iso[o] == 1) { primary = sector == 16; label = Encoding.ASCII.GetString(iso, o + 40, 32).TrimEnd(); } + if (iso[o] == 2 && iso[o + 88] == 0x25 && iso[o + 89] == 0x2F && iso[o + 90] is 0x40 or 0x43 or 0x45) joliet = true; + if (iso[o] == 255) break; + } + return (primary, label, joliet); + } + + private static bool ContainsUtf16BigEndian(byte[] haystack, string value) + { + var needle = Encoding.BigEndianUnicode.GetBytes(value); + return haystack.AsSpan().IndexOf(needle) >= 0; + } + + private static async Task Expect(Func action) where T : Exception + { + try { await action(); return null; } + catch (T ex) { return ex; } + } + + private static HyperVOperationException? Assert(Action action) + { + try { action(); return null; } + catch (HyperVOperationException ex) { return ex; } + } +} + +/// +/// A minimal HTTP/1.1 file server on 127.0.0.1 for download tests: HEAD and GET, byte ranges, ETags, redirects, and +/// connections dropped mid-body once on request. No URL ACLs or administrator rights are needed. +/// +internal sealed class LoopbackHttpServer : IDisposable +{ + private readonly TcpListener listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource stop = new(); + private readonly ConcurrentDictionary files = new(); + private readonly ConcurrentDictionary redirects = new(); + private readonly ConcurrentDictionary dropAfter = new(); + public ConcurrentQueue<(string Method, string Path, string? Range)> Requests { get; } = new(); + public Uri Base { get; } + + public LoopbackHttpServer() + { + listener.Start(); + Base = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/"); + _ = AcceptAsync(); + } + + public void Add(string path, byte[] body, string? etag = null) => files[path] = (body, etag); + public void Redirect(string path, string target) => redirects[path] = target; + public void FailOnce(string path, long bytes) => dropAfter[path] = bytes; + + private async Task AcceptAsync() + { + while (!stop.IsCancellationRequested) + { + TcpClient client; + try { client = await listener.AcceptTcpClientAsync(stop.Token); } + catch (Exception) { return; } + _ = Task.Run(() => ServeAsync(client)); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + try + { + var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, false, 1, leaveOpen: true); + while (true) + { + var requestLine = await reader.ReadLineAsync(); + if (string.IsNullOrEmpty(requestLine)) return; + string? range = null; + for (var header = await reader.ReadLineAsync(); !string.IsNullOrEmpty(header); header = await reader.ReadLineAsync()) + if (header.StartsWith("Range:", StringComparison.OrdinalIgnoreCase)) range = header[6..].Trim(); + var parts = requestLine.Split(' '); + var (method, path) = (parts[0], parts[1]); + Requests.Enqueue((method, path, range)); + if (redirects.TryGetValue(path, out var target)) + { + await WriteAsync(stream, $"HTTP/1.1 302 Found\r\nLocation: {target}\r\nContent-Length: 0\r\n\r\n"); + continue; + } + if (!files.TryGetValue(path, out var file)) + { + await WriteAsync(stream, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + continue; + } + long start = 0; + if (method == "GET" && range is not null && range.StartsWith("bytes=", StringComparison.Ordinal) && range.EndsWith('-')) + start = long.Parse(range[6..^1], System.Globalization.CultureInfo.InvariantCulture); + if (start >= file.Body.Length && start > 0) + { + await WriteAsync(stream, $"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{file.Body.Length}\r\nContent-Length: 0\r\n\r\n"); + continue; + } + var length = file.Body.Length - start; + var head = new StringBuilder(start > 0 ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n") + .Append("Content-Type: application/octet-stream\r\nAccept-Ranges: bytes\r\n").Append("Content-Length: ").Append(length).Append("\r\n"); + if (start > 0) head.Append("Content-Range: bytes ").Append(start).Append('-').Append(file.Body.Length - 1).Append('/').Append(file.Body.Length).Append("\r\n"); + if (file.ETag is not null) head.Append("ETag: ").Append(file.ETag).Append("\r\n"); + await WriteAsync(stream, head.Append("\r\n").ToString()); + if (method == "HEAD") continue; + if (dropAfter.TryRemove(path, out var cut) && cut < length) + { + await stream.WriteAsync(file.Body.AsMemory((int)start, (int)cut)); + await stream.FlushAsync(); + client.Client.Shutdown(SocketShutdown.Both); + return; + } + await stream.WriteAsync(file.Body.AsMemory((int)start, (int)length)); + await stream.FlushAsync(); + } + } + catch (IOException) { } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private static Task WriteAsync(NetworkStream stream, string text) => stream.WriteAsync(Encoding.ASCII.GetBytes(text)).AsTask(); + + public void Dispose() + { + stop.Cancel(); + listener.Stop(); + stop.Dispose(); + } +} diff --git a/tests/HyperVControl.Tests/MockHyperV/Hyper-V/imagebuild.functions.ps1 b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/imagebuild.functions.ps1 new file mode 100644 index 0000000..9b4ba9f --- /dev/null +++ b/tests/HyperVControl.Tests/MockHyperV/Hyper-V/imagebuild.functions.ps1 @@ -0,0 +1,213 @@ +# Image build cmdlets for HyperVControl unit tests: the Storage and DISM cmdlets and the native-tool helper +# (bcdboot, bcdedit, qemu-img) that ImageBuildScripts calls. Dot-sourced by Hyper-V.psm1; later definitions win. +# In mock mode the build scripts skip their Storage/DISM imports and their own Invoke-ImageBuildNative, so these apply. +# State (imageBuild in the state file): isoDriveLetter, diskNumber, driveLetters[], images[], native{ '.exe' = { exitCode, +# stdout, stderr } }, bcdbootHelp, qemuInfo, failAt (a cmdlet name that throws), createFiles, and the runtime lists +# mountedImages[], attachedVhds[] and partitions[]. The base New-VHD creates no file; with createFiles set, Mount-VHD +# materializes a placeholder so tests can assert that a failed build deletes its VHDX. + +function Get-ImageBuildMockState { + $state = Read-MockState + if ($null -eq $state.PSObject.Properties['imageBuild']) { $state | Add-Member -NotePropertyName imageBuild -NotePropertyValue ([pscustomobject]@{}) } + return $state +} + +function Set-ImageBuildMockValue($State, [string]$Name, $Value) { + if ($null -eq $State.imageBuild.PSObject.Properties[$Name]) { $State.imageBuild | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } + else { $State.imageBuild.$Name = $Value } +} + +function Get-ImageBuildMockList($State, [string]$Name) { return @(@($State.imageBuild.$Name) | Where-Object { $null -ne $_ }) } + +function Assert-ImageBuildMockStep([string]$Step) { + $failAt = [string](Get-ImageBuildMockState).imageBuild.failAt + if ($failAt -and $failAt -eq $Step) { throw [System.InvalidOperationException]::new("Mock failure injected at $Step.") } +} + +function Get-DiskImage { + [CmdletBinding()] param([Parameter(Position = 0)][string]$ImagePath) + Write-MockCall 'Get-DiskImage' $PSBoundParameters + $mounted = @(Get-ImageBuildMockList (Get-ImageBuildMockState) 'mountedImages') + [pscustomobject]@{ ImagePath = $ImagePath; Attached = ($mounted -contains $ImagePath); StorageType = 1 } +} + +function Mount-DiskImage { + [CmdletBinding()] param([Parameter(Position = 0)][string[]]$ImagePath, [string]$StorageType, [string]$Access, [switch]$PassThru, [switch]$NoDriveLetter) + Write-MockCall 'Mount-DiskImage' $PSBoundParameters + Assert-ImageBuildMockStep 'Mount-DiskImage' + $state = Get-ImageBuildMockState + Set-ImageBuildMockValue $state 'mountedImages' @(@(Get-ImageBuildMockList $state 'mountedImages') + $ImagePath[0]) + Write-MockState $state + if ($PassThru) { [pscustomobject]@{ ImagePath = $ImagePath[0]; Attached = $true; StorageType = 1 } } +} + +function Dismount-DiskImage { + [CmdletBinding()] param([Parameter(Position = 0)][string[]]$ImagePath, [string]$DevicePath) + Write-MockCall 'Dismount-DiskImage' $PSBoundParameters + $state = Get-ImageBuildMockState + Set-ImageBuildMockValue $state 'mountedImages' @(Get-ImageBuildMockList $state 'mountedImages' | Where-Object { $_ -ne $ImagePath[0] }) + Write-MockState $state + [pscustomobject]@{ ImagePath = $ImagePath[0]; Attached = $false } +} + +function Get-Volume { + [CmdletBinding()] param([Parameter(ValueFromPipeline)]$DiskImage, $Partition, [char[]]$DriveLetter, [string]$FilePath) + process { + Write-MockCall 'Get-Volume' $PSBoundParameters + if ($DiskImage) { + $letter = [string](Get-ImageBuildMockState).imageBuild.isoDriveLetter + [pscustomobject]@{ DriveLetter = $(if ($letter) { [char]$letter } else { [char]0 }); FileSystemLabel = 'MOCK_ISO'; FileSystem = 'UDF'; Path = '\\?\Volume{00000000-0000-0000-0000-0000000015f0}\' } + } + } +} + +function Get-WindowsImage { + [CmdletBinding()] param([string]$ImagePath, [int]$Index, [string]$Name, [string]$LogPath) + Write-MockCall 'Get-WindowsImage' $PSBoundParameters + Assert-ImageBuildMockStep 'Get-WindowsImage' + $images = @(Get-ImageBuildMockList (Get-ImageBuildMockState) 'images') + if ($PSBoundParameters.ContainsKey('Index')) { + $image = @($images | Where-Object { [int]$_.index -eq $Index })[0] + if (-not $image) { Write-Error -Message "The specified image index $Index does not exist." -Category ObjectNotFound -ErrorAction Stop } + return [pscustomobject]@{ ImageIndex = [int]$image.index; ImageName = [string]$image.name; ImageDescription = [string]$image.description; ImageSize = [long]$image.size + EditionId = [string]$image.editionId; Architecture = [uint32]$image.architecture; Version = [string]$image.version; InstallationType = [string]$image.installationType + Languages = @($image.languages); DefaultLanguageIndex = 0 } + } + foreach ($image in $images) { [pscustomobject]@{ ImageIndex = [int]$image.index; ImageName = [string]$image.name; ImageDescription = [string]$image.description; ImageSize = [long]$image.size } } +} + +function Expand-WindowsImage { + [CmdletBinding()] param([string]$ImagePath, [int]$Index, [string]$Name, [string]$ApplyPath, [switch]$CheckIntegrity, [switch]$Verify, [string]$LogPath, [string]$ScratchDirectory) + Write-MockCall 'Expand-WindowsImage' $PSBoundParameters + Assert-ImageBuildMockStep 'Expand-WindowsImage' + [pscustomobject]@{ ImagePath = $ImagePath; Index = $Index } +} + +function Mount-VHD { + [CmdletBinding()] param([Parameter(Position = 0)][string[]]$Path, [switch]$Passthru, [switch]$ReadOnly, [switch]$NoDriveLetter) + Write-MockCall 'Mount-VHD' $PSBoundParameters + Assert-ImageBuildMockStep 'Mount-VHD' + $state = Get-ImageBuildMockState + $number = if ($state.imageBuild.diskNumber) { [int]$state.imageBuild.diskNumber } else { 7 } + if ($state.imageBuild.createFiles -and -not (Test-Path -LiteralPath $Path[0])) { [System.IO.File]::WriteAllBytes($Path[0], [byte[]](0x76, 0x68, 0x64, 0x78)) } + Set-ImageBuildMockValue $state 'attachedVhds' @(@(Get-ImageBuildMockList $state 'attachedVhds') + $Path[0]) + Set-ImageBuildMockValue $state 'partitions' @() + Write-MockState $state + if ($Passthru) { [pscustomobject]@{ Path = $Path[0]; DiskNumber = $number; Attached = $true } } +} + +function Dismount-VHD { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(Position = 0)][string[]]$Path, [int]$DiskNumber, [switch]$Passthru) + Write-MockCall 'Dismount-VHD' $PSBoundParameters + $state = Get-ImageBuildMockState + $attached = @(Get-ImageBuildMockList $state 'attachedVhds') + if ($attached -notcontains $Path[0]) { throw [System.InvalidOperationException]::new("The virtual disk '$($Path[0])' is not attached.") } + Set-ImageBuildMockValue $state 'attachedVhds' @($attached | Where-Object { $_ -ne $Path[0] }) + Write-MockState $state +} + +function Initialize-Disk { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(Position = 0)][int]$Number, [string]$PartitionStyle, [switch]$PassThru) + Write-MockCall 'Initialize-Disk' $PSBoundParameters + Assert-ImageBuildMockStep 'Initialize-Disk' + $state = Get-ImageBuildMockState + # Windows adds a reserved partition to a newly initialized GPT disk; the build must remove it. + Set-ImageBuildMockValue $state 'partitions' @([pscustomobject]@{ diskNumber = $Number; partitionNumber = 1; size = [long]16759808; gptType = '{e3c9e316-0b5c-4db8-817d-f92df00215ae}'; driveLetter = $null; fileSystem = $null }) + Write-MockState $state +} + +function ConvertTo-MockPartition($Row) { + $type = switch ([string]$Row.gptType) { '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' { 'System' } '{e3c9e316-0b5c-4db8-817d-f92df00215ae}' { 'Reserved' } default { 'Basic' } } + [pscustomobject]@{ DiskNumber = [int]$Row.diskNumber; PartitionNumber = [int]$Row.partitionNumber; Size = [long]$Row.size; GptType = [string]$Row.gptType; Type = $type + DriveLetter = $(if ($Row.driveLetter) { [char][string]$Row.driveLetter } else { [char]0 }) } +} + +function Get-Partition { + [CmdletBinding()] param([int]$DiskNumber, [int]$PartitionNumber, [char]$DriveLetter) + Write-MockCall 'Get-Partition' $PSBoundParameters + $rows = @(Get-ImageBuildMockList (Get-ImageBuildMockState) 'partitions' | Where-Object { [int]$_.diskNumber -eq $DiskNumber }) + if ($PSBoundParameters.ContainsKey('PartitionNumber')) { $rows = @($rows | Where-Object { [int]$_.partitionNumber -eq $PartitionNumber }) } + if ($rows.Count -eq 0) { Write-Error -Message "No MSFT_Partition objects found with property 'DiskNumber' equal to '$DiskNumber'." -Category ObjectNotFound; return } + foreach ($row in $rows) { ConvertTo-MockPartition $row } +} + +function Remove-Partition { + [CmdletBinding(SupportsShouldProcess)] param([int]$DiskNumber, [int]$PartitionNumber) + Write-MockCall 'Remove-Partition' $PSBoundParameters + $state = Get-ImageBuildMockState + Set-ImageBuildMockValue $state 'partitions' @(Get-ImageBuildMockList $state 'partitions' | Where-Object { -not ([int]$_.diskNumber -eq $DiskNumber -and [int]$_.partitionNumber -eq $PartitionNumber) }) + Write-MockState $state +} + +function New-Partition { + [CmdletBinding()] param([int]$DiskNumber, [long]$Size, [switch]$UseMaximumSize, [string]$GptType, [switch]$AssignDriveLetter, [char]$DriveLetter, [long]$Offset) + Write-MockCall 'New-Partition' $PSBoundParameters + Assert-ImageBuildMockStep 'New-Partition' + $state = Get-ImageBuildMockState + $rows = @(Get-ImageBuildMockList $state 'partitions') + $number = 1 + [int](@(@($rows | ForEach-Object { [int]$_.partitionNumber }) + 0) | Measure-Object -Maximum).Maximum + $used = [long](@(@($rows | ForEach-Object { [long]$_.size }) + 0) | Measure-Object -Sum).Sum + $length = if ($UseMaximumSize) { [long](64GB - 2MB - $used) } else { $Size } + $row = [pscustomobject]@{ diskNumber = $DiskNumber; partitionNumber = $number; size = $length; driveLetter = $null; fileSystem = $null + gptType = $(if ($GptType) { $GptType } else { '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' }) } + Set-ImageBuildMockValue $state 'partitions' @($rows + $row) + Write-MockState $state + ConvertTo-MockPartition $row +} + +function Format-Volume { + [CmdletBinding(SupportsShouldProcess)] param([Parameter(ValueFromPipeline)]$Partition, [char[]]$DriveLetter, [string]$FileSystem, [string]$NewFileSystemLabel, [switch]$Force, [switch]$Full) + process { + Write-MockCall 'Format-Volume' ([ordered]@{ PartitionNumber = [int]$Partition.PartitionNumber; FileSystem = $FileSystem; NewFileSystemLabel = $NewFileSystemLabel; Force = [bool]$Force }) + Assert-ImageBuildMockStep 'Format-Volume' + $state = Get-ImageBuildMockState + foreach ($row in Get-ImageBuildMockList $state 'partitions') { if ([int]$row.partitionNumber -eq [int]$Partition.PartitionNumber) { $row.fileSystem = $FileSystem } } + Write-MockState $state + [pscustomobject]@{ FileSystem = $FileSystem; FileSystemLabel = $NewFileSystemLabel } + } +} + +function Set-Partition { + [CmdletBinding(SupportsShouldProcess)] param([int]$DiskNumber, [int]$PartitionNumber, [string]$GptType, [char]$NewDriveLetter, [bool]$IsActive, [bool]$IsHidden) + Write-MockCall 'Set-Partition' $PSBoundParameters + $state = Get-ImageBuildMockState + foreach ($row in Get-ImageBuildMockList $state 'partitions') { if ([int]$row.diskNumber -eq $DiskNumber -and [int]$row.partitionNumber -eq $PartitionNumber -and $GptType) { $row.gptType = $GptType } } + Write-MockState $state +} + +function Add-PartitionAccessPath { + [CmdletBinding()] param([int]$DiskNumber, [int]$PartitionNumber, [string]$AccessPath, [switch]$AssignDriveLetter, [switch]$PassThru) + Write-MockCall 'Add-PartitionAccessPath' $PSBoundParameters + Assert-ImageBuildMockStep 'Add-PartitionAccessPath' + $state = Get-ImageBuildMockState + $rows = @(Get-ImageBuildMockList $state 'partitions') + if ($AssignDriveLetter) { + $used = @($rows | Where-Object { $_.driveLetter } | ForEach-Object { [string]$_.driveLetter }) + $free = @(Get-ImageBuildMockList $state 'driveLetters' | Where-Object { $used -notcontains [string]$_ }) + foreach ($row in $rows) { if ([int]$row.diskNumber -eq $DiskNumber -and [int]$row.partitionNumber -eq $PartitionNumber -and $free.Count -gt 0) { $row.driveLetter = [string]$free[0] } } + } + Write-MockState $state +} + +# Replaces the real helper, which ImageBuildScripts defines only outside mock mode. Results come from imageBuild.native; +# qemu-img info/convert are simulated, bcdboot /? advertises /offline unless imageBuild.bcdbootHelp says otherwise. +function Invoke-ImageBuildNative { + [CmdletBinding()] param([string]$FilePath, [string[]]$ArgumentList) + Write-MockCall 'Invoke-ImageBuildNative' $PSBoundParameters + $tool = [System.IO.Path]::GetFileName($FilePath).ToLowerInvariant() + $config = (Get-ImageBuildMockState).imageBuild + if ($tool -eq 'bcdboot.exe' -and $ArgumentList -contains '/?') { + $help = if ($null -ne $config.bcdbootHelp) { [string]$config.bcdbootHelp } else { 'BCDBOOT [/l ] [/s [/f ]] [/v] [/offline]' } + return @{ exitCode = 87; stdout = $help; stderr = '' } + } + if ($tool -like 'qemu-img*') { + if ($ArgumentList[0] -eq 'info') { return @{ exitCode = 0; stdout = $(if ($config.qemuInfo) { [string]$config.qemuInfo } else { '{ "format": "qcow2", "virtual-size": 3758096384 }' }); stderr = '' } } + if ($ArgumentList[0] -eq 'convert') { [System.IO.File]::WriteAllBytes($ArgumentList[-1], [byte[]](0x76, 0x68, 0x64, 0x78)); return @{ exitCode = 0; stdout = ''; stderr = '' } } + } + $configured = if ($config.native) { $config.native.PSObject.Properties[$tool] } else { $null } + if ($configured) { return @{ exitCode = [int]$configured.Value.exitCode; stdout = [string]$configured.Value.stdout; stderr = [string]$configured.Value.stderr } } + if ($tool -eq 'bcdedit.exe' -and $ArgumentList -contains '/enum') { + return @{ exitCode = 0; stdout = "Windows Boot Loader`n-------------------`nidentifier {default}`ndebug Yes"; stderr = '' } + } + return @{ exitCode = 0; stdout = 'The operation completed successfully.'; stderr = '' } +} diff --git a/tests/HyperVControl.Tests/PhaseStubs.cs b/tests/HyperVControl.Tests/PhaseStubs.cs index 9f6d40c..e400375 100644 --- a/tests/HyperVControl.Tests/PhaseStubs.cs +++ b/tests/HyperVControl.Tests/PhaseStubs.cs @@ -17,12 +17,6 @@ internal static class ImageTests internal static Task RunAsync(Action check) => Task.CompletedTask; } -/// Golden image build unit tests (phase 3). -internal static class ImageBuildTests -{ - internal static Task RunAsync(Action check) => Task.CompletedTask; -} - /// Live validation of lifecycle v2 on throwaway VMs (phase 2). Requires an elevated host and explicit authorization. internal static class LifecycleLiveTests { From 58c2d0672d1885ea16d4510de400d02e303f0592 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:41:41 -0500 Subject: [PATCH 09/23] Check free space before image downloads and merge into C:\Tools Refuse a download up front when the cache volume cannot hold the rest of the file, instead of failing when the disk fills. Copy toolsDirectory into an existing C:\Tools of a custom image, but never overwrite a file the image already has. Clarify the failed-build hint and the seed-ISO sharing note in nextSteps. --- .../Services/ImageBuildDownloader.cs | 8 ++++++++ src/HyperVControl/Services/ImageBuildService.cs | 16 +++++++++++----- tests/HyperVControl.Tests/ImageBuildTests.cs | 12 ++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/HyperVControl/Services/ImageBuildDownloader.cs b/src/HyperVControl/Services/ImageBuildDownloader.cs index d7b3c58..9f18802 100644 --- a/src/HyperVControl/Services/ImageBuildDownloader.cs +++ b/src/HyperVControl/Services/ImageBuildDownloader.cs @@ -76,6 +76,14 @@ public async Task DownloadAsync(Uri url, string cacheDirectory, string? } } + if (head.Length is { } size) + { + var remaining = size - (File.Exists(partial) ? Math.Min(size, new FileInfo(partial).Length) : 0); + var drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(cacheDirectory))!); + if (drive.AvailableFreeSpace < remaining + (512L << 20)) + throw new IOException($"{drive.Name} has {drive.AvailableFreeSpace / (double)(1L << 30):0.0} GB free; downloading {fileName} needs {remaining / (double)(1L << 30):0.0} GB more. Free space or choose another outputDirectory."); + } + for (var attempt = 1; ; attempt++) { try diff --git a/src/HyperVControl/Services/ImageBuildService.cs b/src/HyperVControl/Services/ImageBuildService.cs index beed7a9..ae7ebff 100644 --- a/src/HyperVControl/Services/ImageBuildService.cs +++ b/src/HyperVControl/Services/ImageBuildService.cs @@ -95,7 +95,7 @@ internal static ImageBuildStart Describe(ImageBuildPlan plan, JobInfo job) credentials, kernelDebug, job.Status == JobStatus.Succeeded ? "Done: the job result lists the outputs, warnings and nextSteps." - : failed ? "The build failed; nothing was left mounted and its outputs were removed (downloads stay cached)." + : failed ? "The build failed. Its outputs were removed and nothing it attached stays mounted unless the error says otherwise; downloads stay cached for a retry." : $"Poll hyperv_jobs (action=wait or status, id={job.Id}) until the job finishes; its result lists the outputs, warnings and nextSteps."); } @@ -570,10 +570,13 @@ private static void GuardLetters(char system, char windows) e.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String && value.GetString() is [var c] && char.IsAsciiLetter(c) ? char.ToUpperInvariant(c) : null; - /// Copies a folder tree (hidden files included, reparse points skipped) into a directory that must not exist yet. + /// + /// Copies a folder tree (hidden files included, reparse points skipped) into the image. An existing folder there is + /// merged, but a file the image already has is never overwritten: that fails the build. + /// internal static ImageBuildToolsCopy CopyTree(string source, string destination, string guestPath) { - if (Directory.Exists(destination) || File.Exists(destination)) throw new HyperVOperationException($"{guestPath} already exists in the image.", "InvalidArgument"); + if (File.Exists(destination)) throw new HyperVOperationException($"{guestPath} is a file in the image.", "InvalidArgument"); var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }; Directory.CreateDirectory(destination); foreach (var directory in Directory.EnumerateDirectories(source, "*", options)) @@ -582,7 +585,10 @@ internal static ImageBuildToolsCopy CopyTree(string source, string destination, long bytes = 0; foreach (var file in Directory.EnumerateFiles(source, "*", options)) { - File.Copy(file, Path.Combine(destination, Path.GetRelativePath(source, file)), overwrite: false); + var relative = Path.GetRelativePath(source, file); + var target = Path.Combine(destination, relative); + if (File.Exists(target)) throw new HyperVOperationException($"{guestPath}\\{relative} already exists in the image; tools never overwrite image files.", "InvalidArgument"); + File.Copy(file, target, overwrite: false); files++; bytes += new FileInfo(file).Length; } @@ -617,7 +623,7 @@ internal static IReadOnlyList LinuxNextSteps(ImageBuildPlan plan) var l = plan.Linux!; return [ - $"Keep {plan.VhdPath} unchanged as the template: give each VM a differencing child or a full copy, and give each VM its own seed ISO (a new instance-id) when cloning.", + $"Keep {plan.VhdPath} unchanged as the template: give each VM a differencing child or a full copy. Children of the never-booted template can share the seed ISO (and so its hostname).", "Create the VM with hyperv_vm_create: generation=2, vhdPath=, memoryMb=2048, processors=2, switchName=.", "Before the first start: Secure Boot On with the MicrosoftUEFICertificateAuthority template (Set-VMFirmware -SecureBootTemplate MicrosoftUEFICertificateAuthority; the Windows template rejects the Linux shim); checkpointType=Standard; automatic checkpoints off.", $"Attach the seed as a DVD: hyperv_disk action=attach_iso path={plan.SeedIsoPath}.", diff --git a/tests/HyperVControl.Tests/ImageBuildTests.cs b/tests/HyperVControl.Tests/ImageBuildTests.cs index 5d7ed50..97b6733 100644 --- a/tests/HyperVControl.Tests/ImageBuildTests.cs +++ b/tests/HyperVControl.Tests/ImageBuildTests.cs @@ -543,6 +543,18 @@ private static async Task WindowsPipeline(Action check, Func\\cache (kept), mounts it and applies install.esd; the unverified hash is recorded with a warning"); } + var existingTools = Directory.CreateDirectory(Path.Combine(root, "image-with-tools", "Tools")).FullName; + File.WriteAllText(Path.Combine(existingTools, "vendor.txt"), "image file"); + var merged = ImageBuildService.CopyTree(tools, existingTools, "C:\\Tools"); + File.WriteAllText(Path.Combine(existingTools, "conflict.txt"), "image file"); + var conflictSource = Directory.CreateDirectory(Path.Combine(root, "conflict-tools")).FullName; + File.WriteAllText(Path.Combine(conflictSource, "conflict.txt"), "tool file"); + var conflict = Assert(() => ImageBuildService.CopyTree(conflictSource, existingTools, "C:\\Tools")); + check(merged.Files == 3 && File.ReadAllText(Path.Combine(existingTools, "vendor.txt")) == "image file" && File.Exists(Path.Combine(existingTools, "dbg.txt")) + && conflict?.Message.Contains("C:\\Tools\\conflict.txt already exists in the image", StringComparison.Ordinal) == true + && File.ReadAllText(Path.Combine(existingTools, "conflict.txt")) == "image file", + "An existing C:\\Tools in the image is merged, but an image file is never overwritten"); + var listJson = JsonDocument.Parse("""[{"index":1,"name":"Windows 11 Pro N"},{"index":2,"name":"Windows 11 Pro"},{"index":3,"name":"Windows 11 Pro for Workstations"}]""").RootElement; var ambiguous = Assert(() => ImageBuildService.SelectImage(listJson, null, "Pro")); var unknown = Assert(() => ImageBuildService.SelectImage(listJson, null, "Home")); From 42e0e8864a9434a88e2e748b2cd47ae54ff91850 Mon Sep 17 00:00:00 2001 From: JerryLinLinLin <61572787+JerryLinLinLin@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:41:41 -0500 Subject: [PATCH 10/23] Validate image builds live and document them for agents The image-build-live phase drives the published EXE without downloading Windows media or creating VMs. A synthetic WIM (placeholder hives, the host's boot files, BCD template and bootstr.dll, and a marker) in an IMAPI2 ISO runs the whole Windows pipeline into a 4 GB VHDX under D:\HyperVControl-validation\imagebuild, which is then mounted read-only to check the GPT layout, applied files, Panther unattend, C:\Tools and a copy of the offline BCD store (boot manager, winload, debug on, KDNET). A WIM without boot files must fail with bcdboot's reason and leave no outputs. For Linux, the missing-qemu-img refusal is checked with an unreachable URL, and a .vhdx source is copied, grown with Resize-VHD and paired with a seed ISO that Windows mounts as CIDATA. Elevated helpers prepare, verify and clean up; the run directory is detached and deleted in every outcome, and secrets are redacted from the evidence. references/image-build.md tells agents when to build, which parameters exist, what the Windows image contains, the one-time password rule, the Ubuntu seed workflow, the qemu-img dependency and evaluation expiry. --- .../hyper-v-control/references/image-build.md | 41 ++ .../ImageBuildLiveTests.cs | 361 ++++++++++++++++++ tests/HyperVControl.Tests/PhaseStubs.cs | 6 - 3 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 plugins/hyper-v-control/skills/hyper-v-control/references/image-build.md create mode 100644 tests/HyperVControl.Tests/ImageBuildLiveTests.cs diff --git a/plugins/hyper-v-control/skills/hyper-v-control/references/image-build.md b/plugins/hyper-v-control/skills/hyper-v-control/references/image-build.md new file mode 100644 index 0000000..ac47d54 --- /dev/null +++ b/plugins/hyper-v-control/skills/hyper-v-control/references/image-build.md @@ -0,0 +1,41 @@ +# Golden image builds + +`hyperv_image_build` turns OS media into a template VHDX that new VMs start from. It never creates a VM. Build once per OS release, keep the VHDX unchanged, and give each VM a differencing child (`New-VHD -Differencing -ParentPath