diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/DownloadOperationCancelledException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/DownloadOperationCancelledException.cs new file mode 100644 index 0000000000..b37b310c14 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/DownloadOperationCancelledException.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using HyperVExtension.Common; + +namespace HyperVExtension.Exceptions; + +internal sealed class DownloadOperationCancelledException : Exception +{ + public DownloadOperationCancelledException(IStringResource stringResource) + : base(stringResource.GetLocalized("DownloadOperationCancelled")) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/DownloadOperationFailedException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/DownloadOperationFailedException.cs new file mode 100644 index 0000000000..2c8f3ff265 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/DownloadOperationFailedException.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using HyperVExtension.Common; + +namespace HyperVExtension.Exceptions; + +internal sealed class DownloadOperationFailedException : Exception +{ + public DownloadOperationFailedException(IStringResource stringResource) + : base(stringResource.GetLocalized("DownloadOperationFailed")) + { + } + + public DownloadOperationFailedException(string message) + : base(message) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/NoVMImagesAvailableException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/NoVMImagesAvailableException.cs new file mode 100644 index 0000000000..0ca74d2c0f --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/NoVMImagesAvailableException.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using HyperVExtension.Common; + +namespace HyperVExtension.Exceptions; + +internal sealed class NoVMImagesAvailableException : Exception +{ + public NoVMImagesAvailableException(IStringResource stringResource) + : base(stringResource.GetLocalized("NoImagesFoundError")) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/OperationInProgressException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/OperationInProgressException.cs new file mode 100644 index 0000000000..ba4947b2df --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/OperationInProgressException.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using HyperVExtension.Common; + +namespace HyperVExtension.Exceptions; + +internal sealed class OperationInProgressException : Exception +{ + public OperationInProgressException(IStringResource stringResource) + : base(stringResource.GetLocalized("OperationInProgressError")) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs b/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs index 215ddf5c49..fef1a4c66e 100644 --- a/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs +++ b/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using HyperVExtension.Models; +using HyperVExtension.Models.VirtualMachineCreation; using HyperVExtension.Providers; using HyperVExtension.Services; using Microsoft.Extensions.DependencyInjection; @@ -18,15 +19,20 @@ public static IServiceCollection AddHyperVExtensionServices(this IServiceCollect services.AddTransient(); // Services + services.AddHttpClient(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Pattern to allow multiple non-service registered interfaces to be used with registered interfaces during construction. services.AddSingleton(psService => ActivatorUtilities.CreateInstance(psService, new PowerShellSession())); - services.AddSingleton(sp => psObject => ActivatorUtilities.CreateInstance(sp, psObject)); + services.AddSingleton(serviceProvider => psObject => ActivatorUtilities.CreateInstance(serviceProvider, psObject)); + services.AddSingleton(serviceProvider => parameters => ActivatorUtilities.CreateInstance(serviceProvider, parameters)); return services; } diff --git a/HyperVExtension/src/HyperVExtension/Extensions/StreamExtensions.cs b/HyperVExtension/src/HyperVExtension/Extensions/StreamExtensions.cs new file mode 100644 index 0000000000..a1af58afa9 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Extensions/StreamExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Extensions; + +public static class StreamExtensions +{ + /// + /// Copies the contents of a source stream to a destination stream and reports the progress of the operation. + /// + /// The source stream that data will be copied from + /// The destination stream the data will be copied into + /// The object that progress will be reported to + /// The size of the buffer which is used to read data from the source stream and write it to the destination stream + /// A cancellation token that will allow the caller to cancel the operation + public static async Task CopyToAsync(this Stream source, Stream destination, IProgress progressProvider, int bufferSize, CancellationToken cancellationToken) + { + var buffer = new byte[bufferSize]; + long totalRead = 0; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var bytesRead = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + + if (bytesRead == 0) + { + break; + } + + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalRead += bytesRead; + + // Report the progress of the operation. + progressProvider.Report(totalRead); + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs b/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs index ae650f2007..41621ae0cb 100644 --- a/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs +++ b/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs @@ -1,12 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Globalization; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; namespace HyperVExtension.Helpers; public static class BytesHelper { + private const int ByteToStringBufferSize = 16; + public const decimal OneKbInBytes = 1ul << 10; public const decimal OneMbInBytes = 1ul << 20; @@ -18,7 +24,6 @@ public static class BytesHelper /// /// Converts bytes represented by a long value to its human readable string /// equivalent in either megabytes, gigabytes or terabytes. - /// Note: this is only for internal use and is not localized. /// public static string ConvertFromBytes(ulong size) { @@ -30,7 +35,46 @@ public static string ConvertFromBytes(ulong size) { return $"{(size / OneGbInBytes).ToString("F", CultureInfo.InvariantCulture)} GB"; } + else if (size >= OneMbInBytes) + { + return $"{(size / OneMbInBytes).ToString("F", CultureInfo.InvariantCulture)} MB"; + } + + return $"{(size / OneKbInBytes).ToString("F", CultureInfo.InvariantCulture)} KB"; + } - return $"{(size / OneMbInBytes).ToString("F", CultureInfo.InvariantCulture)} MB"; + /// + /// Converts from a ulong amount of bytes to a localized string representation of that byte size in gigabytes. + /// Relies on StrFormatByteSizeEx to convert to localized. + /// + /// + /// If succeeds internally return localized size in gigabytes, otherwise falls back to community toolkit + /// implementation which is in English. + /// + public static string ConvertBytesToString(ulong sizeInBytes) + { + unsafe + { + // 15 characters + null terminator. + var buffer = new string(' ', ByteToStringBufferSize); + fixed (char* tempPath = buffer) + { + var result = + PInvoke.StrFormatByteSizeEx( + sizeInBytes, + SFBS_FLAGS.SFBS_FLAGS_TRUNCATE_UNDISPLAYED_DECIMAL_DIGITS, + tempPath, + ByteToStringBufferSize); + if (result != HRESULT.S_OK) + { + // Fallback to using non localized version which is in english. + return ConvertFromBytes(sizeInBytes); + } + else + { + return buffer.Trim().Trim('\0'); + } + } + } } } diff --git a/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs b/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs index abd76b2524..dbe8cd76f5 100644 --- a/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs +++ b/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs @@ -41,6 +41,9 @@ public static class HyperVStrings public const string VMSnapshotId = "VMSnapshotId"; public const string VMSnapshotName = "VMSnapshotName"; public const string Size = "Size"; + public const string VirtualMachinePath = "VirtualMachinePath"; + public const string VirtualHardDiskPath = "VirtualHardDiskPath"; + public const string LogicalProcessorCount = "LogicalProcessorCount"; // Hyper-V PowerShell commands strings public const string GetModule = "Get-Module"; @@ -59,6 +62,10 @@ public static class HyperVStrings public const string RemoveVMSnapshot = "Remove-VMSnapshot"; public const string CreateVMCheckpoint = "Checkpoint-VM"; public const string RestartVM = "Restart-VM"; + public const string GetVMHost = "Get-VMHost"; + public const string NewVM = "New-VM"; + public const string SetVM = "Set-VM"; + public const string SetVMFirmware = "Set-VMFirmware"; // Hyper-V PowerShell command parameter strings public const string ListAvailable = "ListAvailable"; @@ -68,6 +75,20 @@ public static class HyperVStrings public const string Save = "Save"; public const string TurnOff = "TurnOff"; public const string PassThru = "PassThru"; + public const string NewVHDPath = "NewVHDPath"; + public const string NewVHDSizeBytes = "NewVHDSizeBytes"; + public const string EnableSecureBoot = "EnableSecureBoot"; + public const string EnhancedSessionTransportType = "EnhancedSessionTransportType"; + public const string Generation = "Generation"; + public const string VHDPath = "VHDPath"; + public const string MemoryStartupBytes = "MemoryStartupBytes"; + public const string VM = "VM"; + public const string SwitchName = "SwitchName"; + public const string DefaultSwitchName = "Default Switch"; + public const string ParameterOnState = "On"; + public const string ParameterOffState = "Off"; + public const string ParameterHvSocket = "HvSocket"; + public const string ParameterVmBus = "VMBus"; // Hyper-V psObject property values public const string CanStopService = "CanStop"; diff --git a/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj b/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj index 1e45238da5..f2a314425d 100644 --- a/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj +++ b/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj @@ -23,6 +23,7 @@ + diff --git a/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachineHost.cs b/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachineHost.cs new file mode 100644 index 0000000000..de8b3b98e9 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachineHost.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; +using HyperVExtension.Helpers; + +namespace HyperVExtension.Models; + +/// +/// Represents the Hyper-V virtual machine host. For the Hyper-V Extension this is the local computer. +/// +public class HyperVVirtualMachineHost +{ + private readonly PsObjectHelper _psObjectHelper; + + public uint LogicalProcessorCount => _psObjectHelper.MemberNameToValue(HyperVStrings.LogicalProcessorCount); + + public string VirtualHardDiskPath => _psObjectHelper.MemberNameToValue(HyperVStrings.VirtualHardDiskPath) ?? string.Empty; + + public string VirtualMachinePath => _psObjectHelper.MemberNameToValue(HyperVStrings.VirtualMachinePath) ?? string.Empty; + + public HyperVVirtualMachineHost(PSObject psObject) + { + _psObjectHelper = new PsObjectHelper(psObject); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/JsonSourceGenerationContext.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/JsonSourceGenerationContext.cs new file mode 100644 index 0000000000..373220a73f --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/JsonSourceGenerationContext.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using HyperVExtension.Models.VirtualMachineCreation; + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Used to generate the source code for the classes when we deserialize the Json recieved from the VM gallery +/// and any associated json. +/// .Net 8 requires a JsonSerializerContext to be used with the JsonSerializerOptions when +/// deserializing JSON into objects. +/// See : https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation?pivots=dotnet-8-0 +/// for more information +/// +[JsonSerializable(typeof(VMGalleryItemWithHashBase))] +[JsonSerializable(typeof(VMGalleryConfig))] +[JsonSerializable(typeof(VMGalleryDetail))] +[JsonSerializable(typeof(VMGalleryDisk))] +[JsonSerializable(typeof(VMGalleryImage))] +[JsonSerializable(typeof(VMGalleryImageList))] +[JsonSerializable(typeof(VMGalleryLogo))] +[JsonSerializable(typeof(VMGalleryRequirements))] +[JsonSerializable(typeof(VMGallerySymbol))] +[JsonSerializable(typeof(VMGalleryThumbnail))] +[JsonSerializable(typeof(VMGalleryCreationUserInput))] +public sealed partial class JsonSourceGenerationContext : JsonSerializerContext +{ +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryConfig.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryConfig.cs new file mode 100644 index 0000000000..5b7905a10d --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryConfig.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'config' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryConfig +{ + public string SecureBoot { get; set; } = string.Empty; + + public string EnhancedSessionTransportType { get; set; } = string.Empty; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryDetail.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryDetail.cs new file mode 100644 index 0000000000..b80a60fda1 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryDetail.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'detail' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryDetail +{ + public string Name { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryDisk.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryDisk.cs new file mode 100644 index 0000000000..f8078cfcfb --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryDisk.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'disk' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryDisk : VMGalleryItemWithHashBase +{ + public string ArchiveRelativePath { get; set; } = string.Empty; + + public long SizeInBytes { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryImage.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryImage.cs new file mode 100644 index 0000000000..08ceec3376 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryImage.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the an image json object in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryImage +{ + public string Name { get; set; } = string.Empty; + + public string Publisher { get; set; } = string.Empty; + + public DateTime LastUpdated { get; set; } + + public string Version { get; set; } = string.Empty; + + public string Locale { get; set; } = string.Empty; + + public List Description { get; set; } = new List(); + + public VMGalleryConfig Config { get; set; } = new VMGalleryConfig(); + + public VMGalleryRequirements Requirements { get; set; } = new VMGalleryRequirements(); + + public VMGalleryDisk Disk { get; set; } = new VMGalleryDisk(); + + public VMGalleryLogo Logo { get; set; } = new VMGalleryLogo(); + + public VMGallerySymbol Symbol { get; set; } = new VMGallerySymbol(); + + public VMGalleryThumbnail Thumbnail { get; set; } = new VMGalleryThumbnail(); + + public List Details { get; set; } = new List(); +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryImageList.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryImageList.cs new file mode 100644 index 0000000000..10988055c3 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryImageList.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents a list of image json objects in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryImageList +{ + public List Images { get; set; } = new List(); +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryItemWithHashBase.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryItemWithHashBase.cs new file mode 100644 index 0000000000..38250a3a42 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryItemWithHashBase.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents a VM gallery item that contains a uri and a hash. +/// +public abstract class VMGalleryItemWithHashBase +{ + public string Uri { get; set; } = string.Empty; + + public string Hash { get; set; } = string.Empty; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryLogo.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryLogo.cs new file mode 100644 index 0000000000..9ed8820a27 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryLogo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'Logo' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryLogo : VMGalleryItemWithHashBase +{ +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryRequirements.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryRequirements.cs new file mode 100644 index 0000000000..518d2f100b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryRequirements.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'requirements' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryRequirements +{ + public string DiskSpace { get; set; } = string.Empty; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGallerySymbol.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGallerySymbol.cs new file mode 100644 index 0000000000..9bb47f10fc --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGallerySymbol.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'symbol' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGallerySymbol : VMGalleryItemWithHashBase +{ + public string Base64Image { get; set; } = string.Empty; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryThumbnail.cs b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryThumbnail.cs new file mode 100644 index 0000000000..2b1106c781 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VMGalleryJsonToClasses/VMGalleryThumbnail.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VMGalleryJsonToClasses; + +/// +/// Represents the 'thumbnail' json object of an image in the VM Gallery. See Gallery Json "https://go.microsoft.com/fwlink/?linkid=851584" +/// +public sealed class VMGalleryThumbnail : VMGalleryItemWithHashBase +{ +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/ArchiveExtractionReport.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/ArchiveExtractionReport.cs new file mode 100644 index 0000000000..c5c5657e11 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/ArchiveExtractionReport.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VirtualMachineCreation; + +/// +/// Represents an operation to extract an archive file. +/// +public sealed class ArchiveExtractionReport : IOperationReport +{ + public ReportKind ReportKind => ReportKind.ArchiveExtraction; + + public string LocalizationKey => "ExtractingFile"; + + public ulong BytesReceived { get; private set; } + + public ulong TotalBytesToReceive { get; private set; } + + public ArchiveExtractionReport(ulong bytesReceived, ulong totalBytesToReceive) + { + BytesReceived = bytesReceived; + TotalBytesToReceive = totalBytesToReceive; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/DotNetZipArchiveProvider.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/DotNetZipArchiveProvider.cs new file mode 100644 index 0000000000..8c76087c49 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/DotNetZipArchiveProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Compression; +using HyperVExtension.Extensions; +using Windows.Storage; + +namespace HyperVExtension.Models.VirtualMachineCreation; + +/// +/// Provides methods to extract a zip archive. This class uses the .NET class to extract the contents of the archive file. +/// is used by Hyper-V Manager's VM Quick Create feature. This gives us parity, but in the future it is expected that faster/more efficient +/// archive extraction libraries will be added. +/// +public sealed class DotNetZipArchiveProvider : IArchiveProvider +{ + // Same buffer size used by Hyper-V Manager's VM gallery feature. + private readonly int _transferBufferSize = 4096; + + /// + public async Task ExtractArchiveAsync(IProgress progressProvider, StorageFile archivedFile, string destinationAbsoluteFilePath, CancellationToken cancellationToken) + { + using var zipArchive = ZipFile.OpenRead(archivedFile.Path); + + // Expect only one entry in the zip file, which would be the virtual disk. + var zipArchiveEntry = zipArchive.Entries.First(); + var totalBytesToExtract = zipArchiveEntry.Length; + using var outputFileStream = File.OpenWrite(destinationAbsoluteFilePath); + using var zipArchiveEntryStream = zipArchiveEntry.Open(); + + var fileExtractionProgress = new Progress(bytesCopied => + { + progressProvider.Report(new ArchiveExtractionReport((ulong)bytesCopied, (ulong)totalBytesToExtract)); + }); + + outputFileStream.SetLength(totalBytesToExtract); + await zipArchiveEntryStream.CopyToAsync(outputFileStream, fileExtractionProgress, _transferBufferSize, cancellationToken); + File.SetLastWriteTime(destinationAbsoluteFilePath, zipArchiveEntry.LastWriteTime.DateTime); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/DownloadOperationReport.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/DownloadOperationReport.cs new file mode 100644 index 0000000000..abec4f6301 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/DownloadOperationReport.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VirtualMachineCreation; + +public class DownloadOperationReport : IOperationReport +{ + public ReportKind ReportKind => ReportKind.Download; + + public string LocalizationKey => "DownloadInProgress"; + + public ulong BytesReceived { get; private set; } + + public ulong TotalBytesToReceive { get; private set; } + + public DownloadOperationReport(ulong bytesReceived, ulong totalBytesToReceive) + { + BytesReceived = bytesReceived; + TotalBytesToReceive = totalBytesToReceive; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IArchiveProvider.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IArchiveProvider.cs new file mode 100644 index 0000000000..e1f3aa8f9a --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IArchiveProvider.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Windows.Storage; + +namespace HyperVExtension.Models.VirtualMachineCreation; + +/// +/// Represents an interface that all archive providers can inherit from. +/// +public interface IArchiveProvider +{ + /// + /// Extracts the contents of the archive file into the destination folder. + /// + /// The provider who progress should be reported back to + /// An archive file on the file system that can be extracted + /// The absolute file path in the file system that the archive file will be extracted to + /// A token that can allow the operation to be cancelled while it is running + public Task ExtractArchiveAsync(IProgress progressProvider, StorageFile archivedFile, string destinationAbsoluteFilePath, CancellationToken cancellationToken); +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IOperationReport.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IOperationReport.cs new file mode 100644 index 0000000000..bf6bd3eb17 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IOperationReport.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VirtualMachineCreation; + +public enum ReportKind +{ + ArchiveExtraction, + Download, +} + +public interface IOperationReport +{ + public ReportKind ReportKind { get; } + + public string LocalizationKey { get; } + + public ulong BytesReceived { get; } + + public ulong TotalBytesToReceive { get; } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IVMGalleryVMCreationOperation.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IVMGalleryVMCreationOperation.cs new file mode 100644 index 0000000000..e8e105010b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/IVMGalleryVMCreationOperation.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Windows.DevHome.SDK; + +namespace HyperVExtension.Models.VirtualMachineCreation; + +/// +/// Represents an operation to quickly create a virtual machine using the VM Gallery. +/// +public interface IVMGalleryVMCreationOperation : ICreateComputeSystemOperation, IProgress +{ +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/VMGalleryCreationUserInput.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/VMGalleryCreationUserInput.cs new file mode 100644 index 0000000000..51d52cba28 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/VMGalleryCreationUserInput.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models.VirtualMachineCreation; + +/// +/// Represents the user input for a VM gallery creation operation. +/// +public sealed class VMGalleryCreationUserInput +{ + public string NewVirtualMachineName { get; set; } = string.Empty; + + public int SelectedImageListIndex { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/VMGalleryVMCreationOperation.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/VMGalleryVMCreationOperation.cs new file mode 100644 index 0000000000..56b8de23c5 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreation/VMGalleryVMCreationOperation.cs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Common; +using HyperVExtension.Exceptions; +using HyperVExtension.Helpers; +using HyperVExtension.Models.VMGalleryJsonToClasses; +using HyperVExtension.Providers; +using HyperVExtension.Services; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; +using Windows.Storage; + +namespace HyperVExtension.Models.VirtualMachineCreation; + +public delegate VMGalleryVMCreationOperation VmGalleryCreationOperationFactory(VMGalleryCreationUserInput parameters); + +/// +/// Class that represents the VM gallery VM creation operation. +/// +public sealed class VMGalleryVMCreationOperation : IVMGalleryVMCreationOperation +{ + private const string ComponentName = nameof(VMGalleryVMCreationOperation); + + private readonly IArchiveProviderFactory _archiveProviderFactory; + + private readonly IHyperVManager _hyperVManager; + + private readonly IDownloaderService _downloaderService; + + private readonly string _tempFolderSaveLocation = Path.GetTempPath(); + + private readonly IStringResource _stringResource; + + private readonly IVMGalleryService _vmGalleryService; + + private readonly VMGalleryCreationUserInput _userInputParameters; + + public CancellationTokenSource CancellationTokenSource { get; private set; } = new(); + + private readonly object _lock = new(); + + public bool IsOperationInProgress { get; private set; } + + public bool IsOperationCompleted { get; private set; } + + public CreateComputeSystemResult? ComputeSystemResult { get; private set; } + + public StorageFile? ArchivedFile { get; private set; } + + public VMGalleryImage Image { get; private set; } = new(); + + public event TypedEventHandler? ActionRequired = (s, e) => { }; + + public event TypedEventHandler? Progress; + + public VMGalleryVMCreationOperation( + IStringResource stringResource, + IVMGalleryService vmGalleryService, + IDownloaderService downloaderService, + IArchiveProviderFactory archiveProviderFactory, + IHyperVManager hyperVManager, + VMGalleryCreationUserInput parameters) + { + _stringResource = stringResource; + _vmGalleryService = vmGalleryService; + _userInputParameters = parameters; + _archiveProviderFactory = archiveProviderFactory; + _hyperVManager = hyperVManager; + _downloaderService = downloaderService; + } + + /// + /// Reports the progress of an operation. + /// + /// The archive extraction operation returned by the progress handler which extracts the archive file + public void Report(IOperationReport value) + { + var displayText = Image.Name; + + if (value.ReportKind == ReportKind.ArchiveExtraction) + { + displayText = $"{ArchivedFile!.Name} ({Image.Name})"; + } + + UpdateProgress(value, value.LocalizationKey, displayText); + } + + /// + /// Starts the VM gallery operation. + /// + /// A result that contains information on whether the operation succeeded or failed + public IAsyncOperation StartAsync() + { + return Task.Run(async () => + { + try + { + lock (_lock) + { + if (IsOperationInProgress) + { + var exception = new OperationInProgressException(_stringResource); + return new CreateComputeSystemResult(exception, exception.Message, exception.Message); + } + else if (IsOperationCompleted) + { + return ComputeSystemResult; + } + + IsOperationInProgress = true; + } + + var imageList = await _vmGalleryService.GetGalleryImagesAsync(); + if (imageList.Images.Count == 0) + { + throw new NoVMImagesAvailableException(_stringResource); + } + + Image = imageList.Images[_userInputParameters.SelectedImageListIndex]; + + await DownloadImageAsync(); + var virtualMachineHost = _hyperVManager.GetVirtualMachineHost(); + var absoluteFilePathForVhd = GetUniqueAbsoluteFilePath(virtualMachineHost.VirtualHardDiskPath); + + // extract the archive file to the destination file. + var archiveProvider = _archiveProviderFactory.CreateArchiveProvider(ArchivedFile!.FileType); + + await archiveProvider.ExtractArchiveAsync(this, ArchivedFile!, absoluteFilePathForVhd, CancellationTokenSource.Token); + var virtualMachineName = MakeFileNameValid(_userInputParameters.NewVirtualMachineName); + + // Use the Hyper-V manager to create the VM. + UpdateProgress(_stringResource.GetLocalized("CreationInProgress", virtualMachineName)); + var creationParameters = new VirtualMachineCreationParameters( + _userInputParameters.NewVirtualMachineName, + GetVirtualMachineProcessorCount(), + absoluteFilePathForVhd, + Image.Config.SecureBoot, + Image.Config.EnhancedSessionTransportType); + + ComputeSystemResult = new CreateComputeSystemResult(_hyperVManager.CreateVirtualMachineFromGallery(creationParameters)); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError(ComponentName, "Operation to create compute system failed", ex); + ComputeSystemResult = new CreateComputeSystemResult(ex, ex.Message, ex.Message); + } + + IsOperationCompleted = true; + IsOperationInProgress = false; + + return ComputeSystemResult; + }).AsAsyncOperation(); + } + + private void UpdateProgress(IOperationReport report, string localizedKey, string fileName) + { + var bytesReceivedSoFar = BytesHelper.ConvertBytesToString(report.BytesReceived); + var totalBytesToReceive = BytesHelper.ConvertBytesToString(report.TotalBytesToReceive); + var progressPercentage = (uint)((report.BytesReceived / (double)report.TotalBytesToReceive) * 100D); + var displayString = _stringResource.GetLocalized(localizedKey, fileName, $"{bytesReceivedSoFar}/{totalBytesToReceive}"); + Progress?.Invoke(this, new CreateComputeSystemProgressEventArgs(displayString, progressPercentage)); + } + + private void UpdateProgress(string localizedString, uint percentage = 0u) + { + Progress?.Invoke(this, new CreateComputeSystemProgressEventArgs(localizedString, percentage)); + } + + /// + /// Downloads the disk image from the Hyper-V VM gallery. + /// + private async Task DownloadImageAsync() + { + var downloadUri = new Uri(Image.Disk.Uri); + var archivedFileName = _vmGalleryService.GetDownloadedArchiveFileName(Image); + var archivedFileAbsolutePath = Path.Combine(_tempFolderSaveLocation, archivedFileName); + + // If the file already exists and has the correct hash, we don't need to download it again. + if (File.Exists(archivedFileAbsolutePath)) + { + ArchivedFile = await StorageFile.GetFileFromPathAsync(archivedFileAbsolutePath); + if (await _vmGalleryService.ValidateFileSha256Hash(ArchivedFile)) + { + return; + } + + // hash is not valid, so we'll delete/overwrite the file and download it again. + Logging.Logger()?.ReportInfo(ComponentName, "File already exists but hash is not valid. Deleting file and downloading again."); + await DeleteFileIfExists(ArchivedFile!); + } + + await _downloaderService.StartDownloadAsync(this, downloadUri, archivedFileAbsolutePath, CancellationTokenSource.Token); + + // Create the file to save the downloaded archive image to. + ArchivedFile = await StorageFile.GetFileFromPathAsync(archivedFileAbsolutePath); + + // Download was successful, we'll check the hash of the file, and if it's valid, we'll extract it. + if (!await _vmGalleryService.ValidateFileSha256Hash(ArchivedFile)) + { + await ArchivedFile.DeleteAsync(); + throw new DownloadOperationFailedException(_stringResource.GetLocalized("DownloadOperationFailedCheckingHash")); + } + } + + private async Task DeleteFileIfExists(StorageFile file) + { + try + { + await file.DeleteAsync(); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError(ComponentName, $"Failed to delete file {file.Path}", ex); + } + } + + private string MakeFileNameValid(string originalName) + { + const string escapeCharacter = "_"; + return string.Join(escapeCharacter, originalName.Split(Path.GetInvalidFileNameChars())); + } + + private string GetUniqueAbsoluteFilePath(string defaultVirtualDiskPath) + { + var extension = Path.GetExtension(Image.Disk.ArchiveRelativePath); + var expectedExtractedFileLocation = Path.Combine(defaultVirtualDiskPath, $"{_userInputParameters.NewVirtualMachineName}{extension}"); + var appendedNumber = 1u; + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(expectedExtractedFileLocation); + + // If the extracted virtual hard disk file doesn't exist, we'll extract it to the temp folder. + // If it does exist we'll need to extract the archive file and append a number to the file + // as it will be a new file within the temp directory. + while (File.Exists(expectedExtractedFileLocation)) + { + expectedExtractedFileLocation = Path.Combine(defaultVirtualDiskPath, $"{fileNameWithoutExtension} ({appendedNumber++}){extension}"); + } + + return expectedExtractedFileLocation; + } + + private int GetVirtualMachineProcessorCount() + { + // We'll use half the number of processors for the processor count of the VM just like VM gallery in Windows. + return Math.Max(1, Environment.ProcessorCount / 2); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreationParameters.cs b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreationParameters.cs new file mode 100644 index 0000000000..2f3577546a --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VirtualMachineCreationParameters.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Helpers; + +namespace HyperVExtension.Models; + +public class VirtualMachineCreationParameters +{ + public string Name { get; private set; } = string.Empty; + + // 4Gb in bytes. We use the same default value as the Hyper-V Managers Quick Create feature. + public long MemoryStartupBytes { get; private set; } = 4096L << 20; + + public string VHDPath { get; private set; } = string.Empty; + + // Virtual machine generation. + public short Generation => 2; + + public int ProcessorCount { get; private set; } + + public string SecureBoot { get; private set; } = HyperVStrings.ParameterOffState; + + public string EnhancedSessionTransportType { get; private set; } = string.Empty; + + public VirtualMachineCreationParameters(string name, int processorCount, string vhdPath, string secureBoot, string enhanceSessionType) + { + Name = name; + ProcessorCount = processorCount; + VHDPath = vhdPath; + SecureBoot = secureBoot.Equals("true", StringComparison.OrdinalIgnoreCase) ? HyperVStrings.ParameterOnState : HyperVStrings.ParameterOffState; + EnhancedSessionTransportType = enhanceSessionType.Equals(HyperVStrings.ParameterHvSocket, StringComparison.OrdinalIgnoreCase) ? HyperVStrings.ParameterHvSocket : HyperVStrings.ParameterVmBus; + } +} diff --git a/HyperVExtension/src/HyperVExtension/NativeMethods.txt b/HyperVExtension/src/HyperVExtension/NativeMethods.txt index fc7b5a2b8d..539d71be02 100644 --- a/HyperVExtension/src/HyperVExtension/NativeMethods.txt +++ b/HyperVExtension/src/HyperVExtension/NativeMethods.txt @@ -3,3 +3,6 @@ WIN32_ERROR E_FAIL E_ABORT S_OK +MAX_PATH +SFBS_FLAGS +StrFormatByteSizeEx \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs b/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs index 2b0fb6b460..ad424e0b7f 100644 --- a/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs +++ b/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using HyperVExtension.Common; using HyperVExtension.Helpers; +using HyperVExtension.Models.VirtualMachineCreation; using HyperVExtension.Services; using Microsoft.Windows.DevHome.SDK; using Windows.Foundation; @@ -18,13 +20,16 @@ public class HyperVProvider : IComputeSystemProvider private readonly IHyperVManager _hyperVManager; + private readonly VmGalleryCreationOperationFactory _vmGalleryCreationOperationFactory; + // Temporary will need to add more error strings for different operations. public string OperationErrorString => _stringResource.GetLocalized(errorResourceKey); - public HyperVProvider(IHyperVManager hyperVManager, IStringResource stringResource) + public HyperVProvider(IHyperVManager hyperVManager, IStringResource stringResource, VmGalleryCreationOperationFactory vmGalleryCreationOperationFactory) { _hyperVManager = hyperVManager; _stringResource = stringResource; + _vmGalleryCreationOperationFactory = vmGalleryCreationOperationFactory; } /// Gets or sets the default compute system properties. @@ -44,20 +49,7 @@ public HyperVProvider(IHyperVManager hyperVManager, IStringResource stringResour /// won't be supported. public ComputeSystemProviderOperations SupportedOperations => ComputeSystemProviderOperations.CreateComputeSystem; - public Uri? Icon - { - get => new(Constants.ExtensionIcon); - set => throw new NotSupportedException("Setting the icon is not supported"); - } - - /// Creates a new Hyper-V compute system. - /// Optional string with parameters that the Hyper-V provider can recognize - public ICreateComputeSystemOperation? CreateComputeSystem(IDeveloperId developerId, string options) - { - // This is temporary until we have a proper implementation for this. - Logging.Logger()?.ReportError($"creation not supported yet for hyper-v"); - return null; - } + public Uri Icon => new(Constants.ExtensionIcon); /// Gets a list of all Hyper-V compute systems. The developerId is not used by the Hyper-V provider public IAsyncOperation GetComputeSystemsAsync(IDeveloperId developerId) @@ -92,6 +84,22 @@ public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForComputeSystem return new ComputeSystemAdaptiveCardResult(notImplementedException, OperationErrorString, notImplementedException.Message); } - // This will be implemented in a future release, but will be available for Dev Environments 1.0. - public ICreateComputeSystemOperation CreateCreateComputeSystemOperation(IDeveloperId developerId, string inputJson) => throw new NotImplementedException(); + /// Creates an operation that will create a new Hyper-V virtual machine. + public ICreateComputeSystemOperation? CreateCreateComputeSystemOperation(IDeveloperId? developerId, string inputJson) + { + try + { + var deserializedObject = JsonSerializer.Deserialize(inputJson, typeof(VMGalleryCreationUserInput)); + var inputForGalleryOperation = deserializedObject as VMGalleryCreationUserInput ?? throw new InvalidOperationException($"Json deserialization failed for input Json: {inputJson}"); + return _vmGalleryCreationOperationFactory(inputForGalleryOperation); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to create a new virtual machine on: {DateTime.Now}", ex); + + // Dev Home will handle null values as failed operations. We can't throw because this is an out of proc + // COM call, so we'll lose the error information. We'll log the error and return null. + return null; + } + } } diff --git a/HyperVExtension/src/HyperVExtension/Scripts/WindowsPSModulePath.ps1 b/HyperVExtension/src/HyperVExtension/Scripts/WindowsPSModulePath.ps1 new file mode 100644 index 0000000000..2f38f71f5b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Scripts/WindowsPSModulePath.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + +Appends the existing Windows PowerShell PSModulePath to existing PSModulePath. +This PowerShell module has been taken from https://www.powershellgallery.com/packages/WindowsPSModulePath/1.0.0 +which Microsoft owns the Copywrite to. It is functionally the same but We do not need to download it. + +.DESCRIPTION + +If the current PSModulePath does not contain the Windows PowerShell PSModulePath, it will +be appended to the end. +#> + +function Add-WindowsPSModulePath +{ + + if (! $IsWindows) + { + throw "This cmdlet is only supported on Windows" + } + + $WindowsPSModulePath = [System.Environment]::GetEnvironmentVariable("psmodulepath", [System.EnvironmentVariableTarget]::Machine) + if (-not ($env:PSModulePath).Contains($WindowsPSModulePath)) + { + $env:PSModulePath += ";${env:userprofile}\Documents\WindowsPowerShell\Modules;${env:programfiles}\WindowsPowerShell\Modules;${WindowsPSModulePath}" + } +} \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtension/Services/ArchiveProviderFactory.cs b/HyperVExtension/src/HyperVExtension/Services/ArchiveProviderFactory.cs new file mode 100644 index 0000000000..7567e32fe7 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/ArchiveProviderFactory.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models.VirtualMachineCreation; + +namespace HyperVExtension.Services; + +/// +/// Provides a factory for creating archive providers based on the archive file extension. +/// +public sealed class ArchiveProviderFactory : IArchiveProviderFactory +{ + public IArchiveProvider CreateArchiveProvider(string extension) + { + if (extension.Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + return new DotNetZipArchiveProvider(); + } + + throw new ArgumentException($"Unsupported archive extension {extension}"); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Services/DownloaderService.cs b/HyperVExtension/src/HyperVExtension/Services/DownloaderService.cs new file mode 100644 index 0000000000..9a86fec523 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/DownloaderService.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Extensions; +using HyperVExtension.Models.VirtualMachineCreation; + +namespace HyperVExtension.Services; + +/// +/// A service to download files from the web. +/// +public class DownloaderService : IDownloaderService +{ + // Use the same default buffer size as the DefaultCopyBufferSize variable in the .Nets System.IO.Stream class + // See: https://github.com/dotnet/runtime/blob/f0117c96ace4d475af63bce80d8afa31a740b836/src/libraries/System.Private.CoreLib/src/System/IO/Stream.cs#L128C46-L128C52 + // For comments on why this size was chosen. + private const int _transferBufferSize = 81920; + + private readonly IHttpClientFactory _httpClientFactory; + + public DownloaderService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + /// + public async Task StartDownloadAsync(IProgress progressProvider, Uri sourceWebUri, string destinationFile, CancellationToken cancellationToken) + { + var httpClient = _httpClientFactory.CreateClient(); + var totalBytesToReceive = GetTotalBytesToReceive(await httpClient.GetAsync(sourceWebUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)); + var webFileStream = await httpClient.GetStreamAsync(sourceWebUri, cancellationToken); + using var outputFileStream = File.OpenWrite(destinationFile); + outputFileStream.SetLength(totalBytesToReceive); + + var downloadProgress = new Progress(bytesCopied => + { + var percentage = (uint)(bytesCopied / (double)totalBytesToReceive * 100D); + progressProvider.Report(new DownloadOperationReport((ulong)bytesCopied, (ulong)totalBytesToReceive)); + }); + + await webFileStream.CopyToAsync(outputFileStream, downloadProgress, _transferBufferSize, cancellationToken); + } + + /// + public async Task DownloadStringAsync(string sourceWebUri, CancellationToken cancellationToken) + { + var httpClient = _httpClientFactory.CreateClient(); + return await httpClient.GetStringAsync(sourceWebUri, cancellationToken); + } + + /// + public async Task DownloadByteArrayAsync(string sourceWebUri, CancellationToken cancellationToken) + { + var httpClient = _httpClientFactory.CreateClient(); + return await httpClient.GetByteArrayAsync(sourceWebUri, cancellationToken); + } + + private long GetTotalBytesToReceive(HttpResponseMessage response) + { + if (response.Content.Headers.ContentLength.HasValue) + { + return response.Content.Headers.ContentLength.Value; + } + + // We should be able to get the content length from the response headers from the Microsoft servers. + throw new InvalidOperationException("The content length of the response is not known."); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs b/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs index 4b23e6b950..8e01f302e1 100644 --- a/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs +++ b/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.ServiceProcess; +using System.Xml.Linq; using DevHome.Logging; using HyperVExtension.Common.Extensions; using HyperVExtension.Exceptions; @@ -711,6 +712,77 @@ public bool IsUserInHyperVAdminGroup() return wasHyperVSidFound ?? false; } + /// + public HyperVVirtualMachineHost GetVirtualMachineHost() + { + StartVirtualMachineManagementService(); + + // Start building command line statement to get the virtual machine host. + var statementBuilder = new StatementBuilder().AddCommand(HyperVStrings.GetVMHost).Build(); + var result = _powerShellService.Execute(statementBuilder, PipeType.None); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to get Local Hyper-V host: {result.CommandOutputErrorMessage}"); + } + + // return the host object. It should be the only object in the list. + return new HyperVVirtualMachineHost(result.PsObjects.First()); + } + + /// + public HyperVVirtualMachine CreateVirtualMachineFromGallery(VirtualMachineCreationParameters parameters) + { + StartVirtualMachineManagementService(); + + // Start building command line statement to create the VM. + var statementBuilderForNewVm = new StatementBuilder() + .AddCommand(HyperVStrings.NewVM) + .AddParameter(HyperVStrings.VM, parameters.Name) + .AddParameter(HyperVStrings.Generation, parameters.Generation) + .AddParameter(HyperVStrings.VHDPath, parameters.VHDPath) + .AddParameter(HyperVStrings.SwitchName, HyperVStrings.DefaultSwitchName) + .Build(); + + var result = _powerShellService.Execute(statementBuilderForNewVm, PipeType.None); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to create the virtual machine: {result.CommandOutputErrorMessage}"); + } + + var returnedPsObject = result.PsObjects.First(); + var virtualMachine = _hyperVVirtualMachineFactory(returnedPsObject); + + // Now we can set the processor count. + var statementBuilderForVmProperties = new StatementBuilder() + .AddCommand(HyperVStrings.SetVM) + .AddParameter(HyperVStrings.VM, returnedPsObject.BaseObject) + .AddParameter(HyperVStrings.ProcessorCount, parameters.ProcessorCount) + .AddParameter(HyperVStrings.EnhancedSessionTransportType, parameters.EnhancedSessionTransportType); + + // Now we can set the startup memory. + if (virtualMachine.MemoryMinimum < parameters.MemoryStartupBytes && parameters.MemoryStartupBytes < virtualMachine.MemoryMaximum) + { + statementBuilderForVmProperties.AddParameter(HyperVStrings.MemoryStartupBytes, parameters.MemoryStartupBytes); + } + + // Now we can set the secure boot. + statementBuilderForVmProperties.AddCommand(HyperVStrings.SetVMFirmware) + .AddParameter(HyperVStrings.VM, returnedPsObject.BaseObject) + .AddParameter(HyperVStrings.EnableSecureBoot, parameters.SecureBoot); + + result = _powerShellService.Execute(statementBuilderForVmProperties.Build(), PipeType.None); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + // don't throw. If we can't set the processor count, we'll just log it. The VM was still created with the default processor count of 1, + // and The VM is still created with the default memory size of 512MB. The user can change this later. + Logging.Logger()?.ReportError($"Unable to set VM properties count: {parameters.ProcessorCount} and startUpBytes: {parameters.MemoryStartupBytes} for VM {virtualMachine}: {result.CommandOutputErrorMessage}"); + } + + // return the created VM object + return virtualMachine; + } + private bool AreStringsTheSame(string? stringA, string? stringB) { return stringA?.Equals(stringB, StringComparison.OrdinalIgnoreCase) ?? false; diff --git a/HyperVExtension/src/HyperVExtension/Services/IArchiveProviderFactory.cs b/HyperVExtension/src/HyperVExtension/Services/IArchiveProviderFactory.cs new file mode 100644 index 0000000000..97453c3512 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/IArchiveProviderFactory.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using HyperVExtension.Models.VirtualMachineCreation; +using Windows.Storage; + +namespace HyperVExtension.Services; + +public interface IArchiveProviderFactory +{ + public IArchiveProvider CreateArchiveProvider(string extension); +} diff --git a/HyperVExtension/src/HyperVExtension/Services/IDownloaderService.cs b/HyperVExtension/src/HyperVExtension/Services/IDownloaderService.cs new file mode 100644 index 0000000000..55704fe976 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/IDownloaderService.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models.VirtualMachineCreation; + +namespace HyperVExtension.Services; + +/// +/// Interface for a service that can download files from the web. +/// +public interface IDownloaderService +{ + /// + /// Starts file a download operation asynchronously from the web. + /// + /// The provider who progress should be reported back to + /// The web uri that points to the location of the file + /// The file path that the downloaded file should be downloaded into + /// A token that can allow the operation to be cancelled while it is running + /// A Task to start the download operation + public Task StartDownloadAsync(IProgress progressProvider, Uri sourceWebUri, string destinationFile, CancellationToken cancellationToken); + + /// + /// Downloads a string from the web asynchronously. + /// + /// The web uri that points to the location of the file + /// A token that can allow the operation to be cancelled while it is running + /// String content returned by web server + public Task DownloadStringAsync(string sourceWebUri, CancellationToken cancellationToken); + + /// + /// Downloads a byte array from the web asynchronously. + /// + /// The web uri that points to the location of the file + /// A token that can allow the operation to be cancelled while it is running + /// Content returned by web server represented as an array of bytes + public Task DownloadByteArrayAsync(string sourceWebUri, CancellationToken cancellationToken); +} diff --git a/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs b/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs index 8f95938808..43be65449f 100644 --- a/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs +++ b/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs @@ -98,4 +98,13 @@ public interface IHyperVManager /// The path to the virtual disk. /// The size in bytes of the virtual disk. public ulong GetVhdSize(string diskPath); + + /// Gets the host information for the Hyper-V host. + /// An object that represents the host information for the Hyper-V host. + public HyperVVirtualMachineHost GetVirtualMachineHost(); + + /// Creates a new virtual machine from the Hyper-V VM Gallery + /// The parameters for creating a new virtual machine. + /// A new virtual machine object. + public HyperVVirtualMachine CreateVirtualMachineFromGallery(VirtualMachineCreationParameters parameters); } diff --git a/HyperVExtension/src/HyperVExtension/Services/IVMGalleryService.cs b/HyperVExtension/src/HyperVExtension/Services/IVMGalleryService.cs new file mode 100644 index 0000000000..b3776b973b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/IVMGalleryService.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models.VMGalleryJsonToClasses; +using Windows.Storage; + +namespace HyperVExtension.Services; + +/// +/// Interface for creating a service that will handle the VM gallery functionality. +/// +public interface IVMGalleryService +{ + /// + /// Gets the Hyper-V virtual machine gallery data from the web. + /// + /// An object that represents a list of virtual machine images retrieved from the gallery + public Task GetGalleryImagesAsync(); + + /// + /// Used to get the file name of the downloaded archive file that contains the virtual disk. We use the hash of the archive as the file name. + /// The provided hash is the SHA256 hash of the archive in the form of "sha256:09C50382D496C5DF2C96034EB69F20D456B25308B3F672257D55FD8202DDF84B" + /// + /// An object that represents the VM gallery Image json item + /// a string with the SHA256 hex values as the name and the appropriate extension based on the web location of the archive file + public string GetDownloadedArchiveFileName(VMGalleryImage image); + + /// + /// Validates that the file has the correct SHA256 hash and has not been tampered with. + /// + /// File on the file system that we will compare the hash to + /// True if the hash validation was successful and false otherwise + public Task ValidateFileSha256Hash(StorageFile file); + + /// + /// Validates that the file has the correct SHA256 hash and has not been tampered with. + /// + /// ByteArray received from the web that we will compare the hash to + /// True if the hash validation was successful and false otherwise + public bool ValidateFileSha256Hash(byte[] byteArray, string hashOfGalleryItem); +} diff --git a/HyperVExtension/src/HyperVExtension/Services/VMGalleryService.cs b/HyperVExtension/src/HyperVExtension/Services/VMGalleryService.cs new file mode 100644 index 0000000000..6775c4740b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/VMGalleryService.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text.Json; +using HyperVExtension.Models.VMGalleryJsonToClasses; +using HyperVExtension.Providers; +using Windows.Storage; + +namespace HyperVExtension.Services; + +/// +/// Service that performs operations specific to the VM gallery. +/// +public sealed class VMGalleryService : IVMGalleryService +{ + private const string ComponentName = "VMGalleryService"; + + private readonly IDownloaderService _downloaderService; + + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = JsonSourceGenerationContext.Default, + AllowTrailingCommas = true, + }; + + public VMGalleryService(IDownloaderService downloaderService) + { + _downloaderService = downloaderService; + } + + private static readonly Uri VmGalleryUrl = new("https://go.microsoft.com/fwlink/?linkid=851584"); + + private VMGalleryImageList _imageList = new(); + + /// + public async Task GetGalleryImagesAsync() + { + // If we have already downloaded the image list before, return it. + if (_imageList.Images.Count > 0) + { + return _imageList; + } + + var emptyList = new VMGalleryImageList(); + try + { + var cancellationTokenSource = new CancellationTokenSource(); + + // This should be quick as the file is around 28.0 KB, so a 5 minute timeout should be ok on the worst network. + cancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(5)); + + // get the JSON data + var resultJson = await _downloaderService.DownloadStringAsync(VmGalleryUrl.AbsoluteUri, cancellationTokenSource.Token); + _imageList = JsonSerializer.Deserialize(resultJson, typeof(VMGalleryImageList), _jsonOptions) as VMGalleryImageList ?? emptyList; + + // Now we need to download the base64 images for the symbols (icons). So they can be used within an adaptive card. + foreach (var image in _imageList.Images) + { + if (!string.IsNullOrEmpty(image.Symbol.Uri)) + { + var byteArray = await _downloaderService.DownloadByteArrayAsync(image.Symbol.Uri, cancellationTokenSource.Token); + if (!ValidateFileSha256Hash(byteArray, image.Symbol.Hash)) + { + Logging.Logger()?.ReportError(ComponentName, $"Symbol Hash '{image.Symbol.Hash}' validation failed for image with name '{image}'. Symbol uri: '{image.Symbol}'"); + continue; + } + + image.Symbol.Base64Image = Convert.ToBase64String(byteArray); + } + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError(ComponentName, $"Unable to retrieve VM gallery images", ex); + } + + return _imageList; + } + + /// + public string GetDownloadedArchiveFileName(VMGalleryImage image) + { + var hash = image.Disk.Hash; + var hexValues = hash.Split(':').Last(); + + // Now get the file extension from the web uri. The web uri is the location of the zipped image on the web. + // we can expect this last segment to be the file name plus the extension. + var webUri = new Uri(image.Disk.Uri); + var fileExtension = webUri.Segments.Last().Split('.').Last(); + return $"{hexValues}.{fileExtension}"; + } + + /// + /// Validates that the file has the correct SHA256 hash and has not been tampered with. + /// + /// File on the file system that we will compare the hash to + /// True if the hash validation was successful and false otherwise + public async Task ValidateFileSha256Hash(StorageFile file) + { + var fileStream = await file.OpenStreamForReadAsync(); + var hashedFileStream = SHA256.HashData(fileStream); + var hashedFileString = BitConverter.ToString(hashedFileStream).Replace("-", string.Empty); + + // For our usage the file name before the extension is the original hash of the file. + // We'll compare that to the current hash of the file. + return string.Equals(hashedFileString, file.Name.Split('.').First(), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Validates that the file has the correct SHA256 hash and has not been tampered with. + /// + /// ByteArray received from the web that we will compare the hash to + /// True if the hash validation was successful and false otherwise + public bool ValidateFileSha256Hash(byte[] byteArray, string hashOfGalleryItem) + { + var hashedByteArray = SHA256.HashData(byteArray); + var hashedString = BitConverter.ToString(hashedByteArray).Replace("-", string.Empty); + + // remove the sha256: prefix from the hash + var hashOfGalleryItemWithoutPrefix = hashOfGalleryItem.Split(':').Last(); + + // For our usage the file name before the extension is the original hash of the file. + // We'll compare that to the current hash of the file. + return string.Equals(hashedString, hashOfGalleryItemWithoutPrefix, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw b/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw index 3c3a2c0d1b..731c42e27e 100644 --- a/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw +++ b/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw @@ -117,14 +117,54 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Creating: {0} + Locked="{0}" text to tell the user that we're currently creating the virtual machine. {0} is the name of the virtual machine + Current Checkpoint Label text to display in front of the users current checkpoint name. + + File {0} already exists. We'll use this file to create the virtual machine + Locked="{0}" text to tell the user that a file exists and we do not need to download it again. {0} is a previously download file. We show the file name in {0}. + + + Downloading {0}. {1} + Locked="{0}" text to tell the user that we are downloading a file from the web. {0} is the file we're downloading. {1} the progress in the form of "bytes received / total bytes needed". E.g "10 Mb / 400 Mb" + + + Unable to download disk image file because the operation was cancelled + Error text to tell the user that we weren't able to download the disk file because it was cancelled. + + + Downloading the disk image file failed. Check the Hyper-V extension logs for more information + Error text for when we fail to download the disk image file the user selected from the internet + + + There was a hash mismatch when attempting to compare the Sha256 hash of the to the expected hash value + Error text for when comparing to Sha256 hashes fail + + + Failed to create the file needed to download the disk image into + Error text for when we fail to create the file that the disk image will be downloaded into + Unable to perform the requested operation. Check the Dev Home Hyper-V extension's log files for more information. Error text for when the hyper-v extension is unable to perform an operation the user requests + + Extracting file {0}. {1} + Locked="{0}" text to tell the user that we're extracting a zip file into a location on their computer. {0} is the zip file we're extracting. {1} the progress in the form of "bytes extracted / total bytes needed". E.g "10 Mb / 400 Mb" + + + Unable to find any disk images in the Hyper-V virtual machine gallery + Error text to tell the user there was an issue retrieving the virtual machine disk images from the VM gallery. + + + Unable to start the operation because it is already in progress + Error text to tell the user that the process to create the virtual machine is already in progress. + Cancel diff --git a/HyperVExtension/test/HyperVExtension/Assets/6CFDC8E5163679E32B9886CEEACEB95F8919B20799CA8E5A6207B9F72EFEFD40.zip b/HyperVExtension/test/HyperVExtension/Assets/6CFDC8E5163679E32B9886CEEACEB95F8919B20799CA8E5A6207B9F72EFEFD40.zip new file mode 100644 index 0000000000..75b47a15a3 Binary files /dev/null and b/HyperVExtension/test/HyperVExtension/Assets/6CFDC8E5163679E32B9886CEEACEB95F8919B20799CA8E5A6207B9F72EFEFD40.zip differ diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj b/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj index 9247fc3d65..47f57ea447 100644 --- a/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj +++ b/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj @@ -36,4 +36,7 @@ + + + diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs index 7674335db8..97a0aaf1ce 100644 --- a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs @@ -1,14 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.Globalization; +using System.IO; using System.Management.Automation; using System.Net; using System.Text; +using System.Text.Json; +using System.Threading; using HyperVExtension.Common; using HyperVExtension.Common.Extensions; using HyperVExtension.Helpers; using HyperVExtension.Models; +using HyperVExtension.Models.VirtualMachineCreation; +using HyperVExtension.Models.VMGalleryJsonToClasses; using HyperVExtension.Providers; using HyperVExtension.Services; using HyperVExtension.UnitTest.Mocks; @@ -16,6 +22,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Windows.DevHome.SDK; using Moq; +using Windows.Storage; namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; @@ -25,6 +32,12 @@ namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; [TestClass] public class HyperVExtensionIntegrationTest { + private readonly string tempFileNameToWriteProgress = "HyperVVMCreationProgress"; + + private readonly object _testFileWriteLock = new(); + + public uint PreviousPercentage { get; private set; } + protected Mock? MockedStringResource { get; set; @@ -70,16 +83,21 @@ private IHost CreateTestHost() .ConfigureServices(services => { // Services + services.AddHttpClient(); services.AddSingleton(MockedStringResource!.Object); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Pattern to allow multiple non-service registered interfaces to be used with registered interfaces during construction. services.AddSingleton(psService => ActivatorUtilities.CreateInstance(psService, new PowerShellSession())); - services.AddSingleton(sp => psObject => ActivatorUtilities.CreateInstance(sp, psObject)); + services.AddSingleton(serviceProvider => psObject => ActivatorUtilities.CreateInstance(serviceProvider, psObject)); + services.AddSingleton(serviceProvider => parameters => ActivatorUtilities.CreateInstance(serviceProvider, parameters)); services.AddTransient(); }).Build(); @@ -359,4 +377,136 @@ public void TestDevSetupAgentDeployment() Assert.IsNotNull(psObject); Assert.AreEqual(psObject!.Properties["Status"].Value, "Running"); } + + /// + /// This test will attempt to create a new VM on the computer using the VMGalleryService. + /// It should be ran normally, not in a CI/CD pipeline until we have pipeline machines + /// that have Hyper-V enabled and running. + /// + [TestMethod] + public async Task TestVirtualMachineCreationFromVmGallery() + { + var vmGalleryService = TestHost!.GetService(); + var hyperVProvider = TestHost!.GetService(); + var expectedVMName = "New Windows 11 VM for Integration test"; + var imageList = await vmGalleryService.GetGalleryImagesAsync(); + var smallestImageIndex = await GetIndexOfImageWithSmallestRequiredSpace(imageList); + var inputJson = JsonSerializer.Serialize(new VMGalleryCreationUserInput() + { + NewVirtualMachineName = expectedVMName, + + // Get Image with the smallest size from gallery, we'll use it to create a VM. + SelectedImageListIndex = smallestImageIndex, + }); + + var createComputeSystemOperation = hyperVProvider.CreateCreateComputeSystemOperation(null, inputJson); + createComputeSystemOperation!.Progress += OnProgressReceived; + + // Act + var createComputeSystemResult = await createComputeSystemOperation!.StartAsync(); + createComputeSystemOperation!.Progress -= OnProgressReceived; + + // Assert + Assert.AreEqual(ProviderOperationStatus.Success, createComputeSystemResult.Result.Status); + Assert.AreEqual(expectedVMName, createComputeSystemResult.ComputeSystem.DisplayName); + CleanUpVirtualMachine(createComputeSystemResult.ComputeSystem, imageList.Images[smallestImageIndex], expectedVMName); + } + + public void CleanUpVirtualMachine(IComputeSystem computeSystem, VMGalleryImage image, string virtualMachineName) + { + try + { + var hyperVManager = TestHost!.GetService(); + var virtualMachine = computeSystem as HyperVVirtualMachine; + var temp = Path.GetTempPath(); + var archiveFileExtension = Path.GetExtension(image.Disk.ArchiveRelativePath); + var fileName = image.Disk.Hash.Split(":").Last(); + var archiveFilePath = Path.Combine(temp, $"{fileName}.{archiveFileExtension}"); + + try + { + // remove extracted VHD file + var hardDisk = virtualMachine!.GetHardDrives().First(); + File.Delete(hardDisk!.Path!); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to stop virtual machine with name VHD from default Hyper-V path: {ex}"); + } + + try + { + // remove archive file + File.Delete(archiveFilePath); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to remove archive file from {temp} {virtualMachineName}: {ex}"); + } + + // remove virtual machine + hyperVManager.RemoveVirtualMachine(Guid.Parse(computeSystem.Id)); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to clean up virtual machine with name {virtualMachineName}: {ex}"); + } + } + + public async Task GetIndexOfImageWithSmallestRequiredSpace(VMGalleryImageList imageList) + { + var smallestImageIndex = 0; + var smallestSize = ulong.MaxValue; + var httpClient = TestHost!.GetService()!.CreateClient(); + for (var i = 0; i < imageList.Images.Count; i++) + { + var sourceWebUri = new Uri(imageList.Images[i].Disk.Uri); + var response = await httpClient.GetAsync(sourceWebUri, HttpCompletionOption.ResponseHeadersRead); + var totalSizeOfDisk = response.Content.Headers.ContentLength ?? 0L; + if (ulong.TryParse(imageList.Images[i].Requirements.DiskSpace, CultureInfo.InvariantCulture, out var requiredDiskSpace)) + { + // The Hype-V Quick Create feature in the Hyper-V Manager in Windows uses the size of the archive file and the size of the required disk space + // value in the VM gallery Json to determin the size of the download. We'll use the same logic here to determine the smallest size. + // I'm not sure why this is done in this way, but we'll do the same here. In the Quick Create window the 'Download' text shows the size of both + // the archive file to be downloaded and the required disk space value added together. + if ((requiredDiskSpace + (ulong)totalSizeOfDisk) < smallestSize) + { + smallestSize = requiredDiskSpace + (ulong)totalSizeOfDisk; + smallestImageIndex = i; + } + } + } + + return smallestImageIndex; + } + + public void OnProgressReceived(ICreateComputeSystemOperation operation, CreateComputeSystemProgressEventArgs progressArgs) + { + lock (_testFileWriteLock) + { + // Only write to the file if the percentage has changed + if (progressArgs.PercentageCompleted == PreviousPercentage) + { + return; + } + + var temp = Path.GetTempPath(); + var progressFilePath = Path.Combine(temp, $"{tempFileNameToWriteProgress}.txt"); + var progressData = $"{progressArgs.Status}: percentage: {progressArgs.PercentageCompleted}%"; + PreviousPercentage = progressArgs.PercentageCompleted; + + // Write the string array to a new file named "HyperVVMCreationProgress.txt". + if (!Path.Exists(progressFilePath)) + { + using var newFile = new FileStream(progressFilePath, FileMode.Create, FileAccess.ReadWrite); + using var writer = new StreamWriter(newFile); + writer.WriteLine(progressData); + } + else + { + using var outputFile = File.AppendText(progressFilePath); + outputFile.WriteLine(progressData); + } + } + } } diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs index 3f231dc4f7..d0da3274b9 100644 --- a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs @@ -3,9 +3,11 @@ using System.Collections.ObjectModel; using System.Management.Automation; +using System.Net; using System.ServiceProcess; using HyperVExtension.Common; using HyperVExtension.Models; +using HyperVExtension.Models.VirtualMachineCreation; using HyperVExtension.Providers; using HyperVExtension.Services; using HyperVExtension.UnitTest.Mocks; @@ -14,14 +16,29 @@ using Microsoft.Windows.DevHome.SDK; using Moq; using Moq.Language; +using Moq.Protected; namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; /// /// Base class that can be used to test services throughout the HyperV extension. /// -public class HyperVExtensionTestsBase +public class HyperVExtensionTestsBase : IDisposable { + private readonly Mock mockFactory = new(); + + private HttpClient mockHttpClient = new(); + + public Mock HttpHandler { get; set; } = new(MockBehavior.Strict); + + // Arbitrary 9 byte array to use for testing the retrieval of a byte array from the web. + public byte[] GallerySymbolByteArray => new byte[9] { 137, 80, 78, 71, 13, 10, 26, 10, 0 }; + + // hash of the Window 11 gallery disk object from our test gallery json below. + // Note: this hash should be the name of the file located in the HyperVExtension.UnitTest\Assets folder for testing purposes. + // It is the actual sha256 hash of the zip file that contains a test virtual disk. + public string GalleryDiskHash => "6CFDC8E5163679E32B9886CEEACEB95F8919B20799CA8E5A6207B9F72EFEFD40"; + protected Mock? MockedStringResource { get; set; } protected Mock? MockedPowerShellSession { get; set; } @@ -48,6 +65,37 @@ public void TestInitialize() MockedPowerShellSession! .Setup(pss => pss.GetErrorMessages()) .Returns(() => { return string.Empty; }); + + // Create an HttpClient using the mocked handler + mockHttpClient = new HttpClient(HttpHandler.Object); + + mockFactory.Setup(f => f.CreateClient(It.IsAny())) + .Returns(mockHttpClient); + } + + public void UpdateHttpClientResponseMock(List returnList) + { + var handlerSequence = HttpHandler + .Protected() + .SetupSequence>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()); + + foreach (var item in returnList) + { + handlerSequence = AddResponse(handlerSequence, item); + } + } + + private ISetupSequentialResult> AddResponse(ISetupSequentialResult> handlerSequence, HttpContent content) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content, + }; + + return handlerSequence.ReturnsAsync(response); } /// @@ -109,11 +157,16 @@ private IHost CreateTestHost() .ConfigureServices(services => { // Services + services.AddSingleton(mockFactory.Object); + services.AddSingleton(mockHttpClient); services.AddSingleton(MockedStringResource!.Object); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Pattern to allow multiple non-service registered interfaces to be used with registered interfaces during construction. services.AddSingleton(psService => @@ -122,7 +175,89 @@ private IHost CreateTestHost() services.AddTransient(controller => ActivatorUtilities.CreateInstance(controller, VirtualMachineManagementServiceStatus)); - services.AddSingleton(sp => psObject => ActivatorUtilities.CreateInstance(sp, psObject)); + services.AddSingleton(serviceProvider => psObject => ActivatorUtilities.CreateInstance(serviceProvider, psObject)); + services.AddSingleton(serviceProvider => parameters => ActivatorUtilities.CreateInstance(serviceProvider, parameters)); }).Build(); } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + public void SetupGalleryHttpContent() + { + var contentList = new List + { + new StringContent(TestGallery), + new ByteArrayContent(GallerySymbolByteArray), + }; + + UpdateHttpClientResponseMock(contentList); + } + + // This is a sample gallery json that is used to test the VMGalleryService. + // The symbol hash is a sha256 hash of a 9 byte array specified in the _gallerySymbolByteArray so we can test it. + // The disk hash is the sha256 hash of the zip file located in the 'HyperVExtension.UnitTest\Assets' folder. + public string TestGallery => """ + { + "images": [ + { + "name": "Windows 11 dev environment", + "publisher": "Microsoft", + "lastUpdated": "2024-01-12T12:00:00Z", + "version": "10.0.22621", + "locale": "en-US", + "description": [ + "This evaluation copy of Windows 11 21H2 will enable you to try out Windows 11 development with an evaluation copy of Windows. ", + "\r\n\r\n", + "This evaluation copy will expire after a pre-determined amount of time. ", + "\r\n\r\n", + "The license terms for the Windows 11 VMs supersede any conflicting Windows license terms. ", + "\r\n\r\n", + "By using the virtual machine, you are accepting the EULAs for all the installed products. ", + "\r\n\r\n", + "Please see https://aka.ms/windowsdevelopervirtualmachineeula for more information. " + ], + "config": { + "secureBoot": "true" + }, + "requirements": { + "diskSpace": "20000000000" + }, + "disk": { + "uri": "https://download.microsoft.com/download/f/4/f/f4f4b60a-1842-4666-8692-d03daa03f8f7/WinDev2401Eval.HyperV.zip", + "hash": "sha256:6CFDC8E5163679E32B9886CEEACEB95F8919B20799CA8E5A6207B9F72EFEFD40", + "archiveRelativePath": "WinDev2401Eval.vhdx" + }, + "logo": { + "uri": "https://download.microsoft.com/download/c/f/5/cf5b587c-98bf-4cfc-9844-a2a7d8c96d83/Windows11_Logo.png", + "hash": "sha256:5E583CE95340BE9FF1CB56FD39C5DDDF3B3341B93E417144D361C3F29A5A7395" + }, + "symbol": { + "uri": "https://download.microsoft.com/download/c/f/5/cf5b587c-98bf-4cfc-9844-a2a7d8c96d83/Windows11_Symbol.png", + "hash": "sha256:843AC23B1736B4487EC81CF7C07DDD9BB46AE5B7818C2C3843D99D62FA75F3C9" + }, + "thumbnail": { + "uri": "https://download.microsoft.com/download/c/f/5/cf5b587c-98bf-4cfc-9844-a2a7d8c96d83/Windows11_Thumbnail.png", + "hash": "sha256:E7BF96E18754D71E41B32A8EDCDE9E2F1DBAF47C7CD05D6DE0764CD4E4EA5066" + }, + "details": [ + { + "name": "Edition", + "value": "Windows 11 Enterprise" + }, + { + "name": "Copyright", + "value": "Copyright (c) Microsoft Corporation. All rights reserved." + }, + { + "name": "License", + "value": "By using the virtual machine, you are accepting the EULAs for all the installed products." + } + ] + } + ] + } + """; } diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVProviderTests.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVProviderTests.cs new file mode 100644 index 0000000000..68ca35480d --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVProviderTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.ServiceProcess; +using System.Text.Json; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Helpers; +using HyperVExtension.Models.VirtualMachineCreation; +using HyperVExtension.Providers; +using HyperVExtension.UnitTest.Mocks; +using Microsoft.Windows.DevHome.SDK; +using Windows.Storage; +using static System.Net.Mime.MediaTypeNames; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +[TestClass] +public class HyperVProviderTests : HyperVExtensionTestsBase +{ + private readonly uint _logicalProcessorCount = 2; + + private readonly string _virtualHardDiskPath = "C:\\Users\\Public\\Downloads"; + + private readonly string _virtualMachinePath = "C:\\Users\\Public\\Downloads"; + + private readonly string _expectedVmName = "New Windows 11 VM"; + + // 1024 * 10 to simulate 10GB + public long MemoryMaximum => 10240; + + // Simulate 512MB + public long MemoryMinimum => 512; + + private readonly string _tempFolderSaveLocation = Path.GetTempPath(); + + public void OnProgressReceived(ICreateComputeSystemOperation operation, CreateComputeSystemProgressEventArgs progressArgs) + { + } + + [TestMethod] + public async Task HyperVProvider_Can_Create_VirtualMachine() + { + // Arrange Hyper-V manager and powershell service + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + + // setup powershell session to return HyperVVirtualMachineHost object + var objectForVirtualMachineHost = CreatePSObjectCollection( + new PSCustomObjectMock() + { + VirtualHardDiskPath = _virtualHardDiskPath, + VirtualMachinePath = _virtualMachinePath, + }); + + // setup powershell session to return HyperVVirtualMachine object + var objectForVirtualMachine = CreatePSObjectCollection( + new PSCustomObjectMock() + { + Name = _expectedVmName, + MemoryMaximum = MemoryMaximum, + MemoryMinimum = MemoryMinimum, + LogicalProcessorCount = _logicalProcessorCount, + }); + + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) // first call to load HyperV module + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) // second call to load HyperV module + .Returns(() => { return objectForVirtualMachineHost; }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock()); }) // call CreateVirtualMachineFromGallery which initially tries to load hyperV module + .Returns(() => { return objectForVirtualMachine; }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock()); }) // Calls Set-VMMemory to set VM startup memory but we don't need to check it + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock()); }); // Calls Set-VMProcessor to set VM processor count but we don't need to check it + + // Arrange VMGalleryService HttpContent + SetupGalleryHttpContent(); + + var hyperVProvider = TestHost!.GetService(); + var inputJson = JsonSerializer.Serialize(new VMGalleryCreationUserInput() + { + NewVirtualMachineName = _expectedVmName, + SelectedImageListIndex = 0, // Our test gallery image list Json only has one image + }); + + var createComputeSystemOperation = hyperVProvider.CreateCreateComputeSystemOperation(null, inputJson); + createComputeSystemOperation!.Progress += OnProgressReceived; + + // Act + var createComputeSystemResult = await createComputeSystemOperation!.StartAsync(); + createComputeSystemOperation!.Progress -= OnProgressReceived; + + // Assert + Assert.AreEqual(ProviderOperationStatus.Success, createComputeSystemResult.Result.Status); + Assert.AreEqual(_expectedVmName, createComputeSystemResult.ComputeSystem.DisplayName); + } + + [TestCleanup] + public async Task Cleanup() + { + try + { + // Clean up temp folder + var zipFileInTempFolder = await StorageFile.GetFileFromPathAsync($@"{_tempFolderSaveLocation}{GalleryDiskHash}.zip"); + await zipFileInTempFolder?.DeleteAsync(); + + // cleanup public downloads folder + var virtualHardDisk = await StorageFile.GetFileFromPathAsync($@"{_virtualHardDiskPath}\{_expectedVmName}.vhdx"); + await virtualHardDisk?.DeleteAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"Error in Cleanup for Hyper-V provider test: {ex.Message}"); + } + } +} diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/VMGalleryServiceTests.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/VMGalleryServiceTests.cs new file mode 100644 index 0000000000..147e022f6d --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/VMGalleryServiceTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Models.VMGalleryJsonToClasses; +using HyperVExtension.Services; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +[TestClass] +public class VMGalleryServiceTests : HyperVExtensionTestsBase +{ + // SHA256 hash of the gallery symbol byte array + private readonly string _gallerySymbolByteArrayHash = "843AC23B1736B4487EC81CF7C07DDD9BB46AE5B7818C2C3843D99D62FA75F3C9"; + + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = JsonSourceGenerationContext.Default, + AllowTrailingCommas = true, + }; + + [TestMethod] + public void VMGalleryService_Can_Retrieve_VMGalleryJson() + { + // Arrange + var vmGalleryService = TestHost!.GetService(); + var expectedJsonObj = JsonSerializer.Deserialize(TestGallery, typeof(VMGalleryImageList), _jsonOptions) as VMGalleryImageList; + SetupGalleryHttpContent(); + + // Act + var vmGalleryImageList = vmGalleryService.GetGalleryImagesAsync().Result; + + // Assert + Assert.IsNotNull(vmGalleryImageList); + var symbolHash = vmGalleryImageList.Images[0].Symbol.Hash.Split(":").Last(); + + Assert.IsTrue(string.Equals(_gallerySymbolByteArrayHash, symbolHash, StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(expectedJsonObj?.Images.Count, vmGalleryImageList.Images.Count); + Assert.AreEqual(expectedJsonObj?.Images[0].Name, vmGalleryImageList.Images[0].Name); + } + + [TestMethod] + public void VMGalleryService_ReturnsCorrectDiskFileNameWithExtension() + { + // Arrange + var vmGalleryService = TestHost!.GetService(); + SetupGalleryHttpContent(); + + // Act + var vmGalleryImageList = vmGalleryService.GetGalleryImagesAsync().Result; + var actualFileName = vmGalleryService.GetDownloadedArchiveFileName(vmGalleryImageList.Images[0]); + var expectedFileName = $"{GalleryDiskHash}.zip"; + + Assert.IsTrue(string.Equals(expectedFileName, actualFileName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/HyperVExtension/test/HyperVExtension/Mocks/DownloaderServiceMock.cs b/HyperVExtension/test/HyperVExtension/Mocks/DownloaderServiceMock.cs new file mode 100644 index 0000000000..06dc15a7ac --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Mocks/DownloaderServiceMock.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models.VirtualMachineCreation; +using HyperVExtension.Services; +using Windows.Storage; + +namespace HyperVExtension.UnitTest.Mocks; + +public class DownloaderServiceMock : IDownloaderService +{ + private readonly int _totalIterations = 4; + + private readonly ulong _totalBytesToReceive = 1000; + + private readonly ulong _bytesReceivedEachIteration = 250; + + private readonly IHttpClientFactory _httpClientFactory; + + public DownloaderServiceMock(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public async Task StartDownloadAsync(IProgress progressProvider, Uri sourceWebUri, string destinationFile, CancellationToken cancellationToken) + { + var bytesReceivedSoFar = 0UL; + for (var i = 0; i < _totalIterations; i++) + { + await Task.Delay(100, cancellationToken); + bytesReceivedSoFar += _bytesReceivedEachIteration; + progressProvider.Report(new DownloadOperationReport(bytesReceivedSoFar, _totalBytesToReceive)); + } + + var zipFile = await GetTestZipFileInPackage(); + var destinationFolder = await StorageFolder.GetFolderFromPathAsync(Path.GetDirectoryName(destinationFile)); + var newDownloadedFile = await destinationFolder.CreateFileAsync(zipFile.Name, CreationCollisionOption.ReplaceExisting); + await zipFile.CopyAndReplaceAsync(newDownloadedFile); + } + + public async Task GetTestZipFileInPackage() + { + var currentDirectory = Directory.GetCurrentDirectory(); + + return await StorageFile.GetFileFromPathAsync($@"{currentDirectory}\HyperVExtension.UnitTest\Assets\6CFDC8E5163679E32B9886CEEACEB95F8919B20799CA8E5A6207B9F72EFEFD40.zip"); + } + + public async Task DownloadStringAsync(string sourceWebUri, CancellationToken cancellationToken) + { + var httpClient = _httpClientFactory.CreateClient(); + return await httpClient.GetStringAsync(sourceWebUri, cancellationToken); + } + + public async Task DownloadByteArrayAsync(string sourceWebUri, CancellationToken cancellationToken) + { + var httpClient = _httpClientFactory.CreateClient(); + return await httpClient.GetByteArrayAsync(sourceWebUri, cancellationToken); + } +} diff --git a/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs b/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs index 144164fc5c..3c0298ad69 100644 --- a/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs +++ b/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ServiceProcess; +using HyperVExtension.Helpers; + namespace HyperVExtension.UnitTest.Mocks; public enum HyperVState @@ -34,4 +37,14 @@ public class PSCustomObjectMock public string Date { get; set; } = string.Empty; public bool IsDeleted { get; set; } + + public uint LogicalProcessorCount { get; set; } + + public string VirtualHardDiskPath { get; set; } = string.Empty; + + public string VirtualMachinePath { get; set; } = string.Empty; + + public long MemoryMaximum { get; set; } + + public long MemoryMinimum { get; set; } }