Winfield.psm1

# Copyright (c) Microsoft Corporation. All rights reserved.
# Winfield.psm1 1.0.0 2023-04-27 18:34:10
# ASZ-ArcA-Deploy users/sarathys/addtiming Debug-x64

#Requires -RunAsAdministrator
$global:WinfieldInitComplete = $false
$ErrorActionPreference = "Stop"

# This script contains all functions to Save/Restore Winfield from Azure Artifacts.
# The script is standalone so it can be easily copied to different envrionments without any other dependencies.
# By default checks for required software and hardware capacibilities are included.
# Save-Winfield and Import-Winfield are the primary methods exposed for use.

$artifactsOrganizationUri = "https://msazure.visualstudio.com/"
$artifactsFeedName = "AzureStackUniversalBuddy"

function Import-Artifact([string] $path, [PSCredential] $credential)
{
    $IPaddressPrefix = "10.0.50"
    $vmSwitch = "DevEnv-Internal"
    $hostIp = "$IPaddressPrefix.1"

    CreateVmNetwork -vmSwitch $vmSwitch -hostIp $hostIp -IPaddressPrefix $IPaddressPrefix

    $dirs = Get-ChildItem -Path $path -Directory # Convention is that every single directory in the artifact represents single VM aka Desired State
    $vmSet = Get-VMSet # List of currently running VMs represents current belief

    foreach ($dir in $dirs)
    {
        $vmName = $dir.Name
        if ((DoesVmExist -vmSet $vmSet -value $vmName) -ne $true)
        {
            Trace-Execution "Importing $vmName"

            $vmLocation = Join-Path -Path $path -ChildPath $vmName
            $vmcxLocation = Join-Path -Path $vmLocation -ChildPath 'Virtual Machines'
            $vmcxFileCount = (Get-ChildItem -Path $vmcxLocation).Count

            # Pick VMCX file and use it for importing VM into Hyper-V
            $vmcxName = (Get-ChildItem -Path $vmcxLocation -Filter "*.vmcx")[0].Name
            $vmcxFilePath = Join-Path -Path $vmcxLocation -ChildPath $vmcxName

            if ($vmName -match "IRVM")
            {
                $PhysicalRAM = (Get-CimInstance -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum
                $hostLogicalProcessorCount = (Get-CimInstance -ClassName Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum

                # Update VMCX if either RAM or cores available is below the saved image
                if ($PhysicalRAM -le 46GB -or $hostLogicalProcessorCount -lt 26)
                {
                    $irRAM = $PhysicalRAM - 4GB # 4gb for host OS
                    Write-Host "Updating $vmName ($vmcxFilePath) to use $hostLogicalProcessorCount cores, $($irRAM / 1MB)GB ram"

                    ModifyVmcx -vmcxPath $vmcxLocation -vmcxFilename $vmcxFilePath -ramMB ($irRAM/1MB) -coreCount $hostLogicalProcessorCount

                    # get exported guid
                    $updatedVmcx = (Get-ChildItem $vmcxLocation "*.vmcx")[0]
                    $vmcxFilePath = $updatedVmcx.FullName
                    Write-Host "Updated vmcx $vmcxFilePath"
                }
            }

            Import-VM -Path "$vmcxFilePath" | Out-Null
        }
    }

    # Start all non-running VMs
    $vmSet = Get-VMSet
    Start-VMSet $vmSet

    Trace-Execution "Waiting for VMs to start..."
    Start-Sleep -Seconds 120
}

function WaitForVMNetwork
{
    $vmSet = Get-VMSet
    foreach ($vm in $vmSet)
    {
            Trace-Execution "Updating DNS server forwarder on $($vm.Name) to $dnsIP"
            $retryUntil = (Get-Date).AddMinutes(5)
            while ($retryUntil -gt (Get-Date))
            {
                try
                {
                    Trace-Execution "Testing connection to $($vm.Name)..."
                    $testConnectionVM = $false
                    if(Test-Connection -ComputerName $vm.Name -Quiet)
                    {
                        Trace-Execution "Test-Connection to $($vm.Name) succeeded."
                        $testConnectionVM = $true
                    }

                    # test TCP connectivity to Portal
                    $serviceTcpConnection = $false
                    $portalTcpConnection = $false
                    $tcpConnectionTest = Test-NetConnection -ComputerName 'portal.devfabric.azs.microsoft.com' -Port 443
                    if($tcpConnectionTest.TcpTestSucceeded)
                    {
                        Trace-Execution "Test-NetConnection portal.devfabric.azs.microsoft.com:443 succeeded"
                        $portalTcpConnection = $true
                    }

                    # test TCP connectivity to SysConfig service http://169.254.53.25:8320
                    $sysCfgTcpConnection = $false
                    $tcpConnectionTest = Test-NetConnection -ComputerName '169.254.53.25' -Port 8320
                    if($tcpConnectionTest.TcpTestSucceeded)
                    {
                        Trace-Execution "Test-NetConnection 169.254.53.25:8320 succeeded"
                        $sysCfgTcpConnection = $true
                    }

                    $serviceTcpConnection = ($portalTcpConnection -and $sysCfgTcpConnection)

                    if($testConnectionVM -and $serviceTcpConnection)
                    {
                        Trace-Execution "TCP connectivity test to SysConfig service and Portal passed."
                        break
                    }
                    else
                    {
                        Trace-Execution "Wait for network: vm connectivity: $($testConnectionVM) portal connectivity: $($portalTcpConnection) syscfg connectivity: $($sysCfgTcpConnection)"
                        Start-Sleep -Seconds 15
                    }
                }
                catch
                {
                    Trace-Execution "Error checking connectivity to IRVM01 $_"
                    Start-Sleep -Seconds 15
                }
            }
    }
}

function Export-Artifact([string] $path, [string] $filterByRoleName = "IR", [PSCredential] $credential, [bool] $compactVhds = $false)
{
    $vmSet = Get-VMSet
    Remove-TemporaryFolder -vmSet $vmSet -credential $credential
    Stop-VMSet -vmSet $vmSet

    if ($compactVhds -eq $true)
    {
        Write-VhdSize -logPrefix "Before compaction"
        Compact-VHDs -vmSet $vmSet -filterByRoleName $filterByRoleName
        Write-VhdSize -logPrefix "After compaction"
    }

    Export-VMSet -vmSet $vmSet -path $path -filterByRoleName $filterByRoleName
    Start-VMSet -vmSet $vmSet
}

function Remove-TemporaryFolder($vmSet, [string] $filterByRoleName = "IR", [PSCredential] $credential) {
    $irvmSet = $vmSet | Where-Object {$_.Name -Match $filterByRoleName}
    foreach ($vm in $irvmSet) {
        Invoke-Command -VMName $vm.Name -Credential $credential -ScriptBlock {
            $users = Get-LocalUser | Where-Object {$_.Enabled -eq $true} | Select-Object -Property Name
            $temporaryFolders = @("C:\logs", "C:\agent\logs", "C:\Temp")
            foreach ($user in $users) {
                $temporaryFolders += "C:\Users\$($user.Name)\AppData\Local\Temp"
            }
            foreach ($folder in $temporaryFolders) {
                if (Test-Path $folder) {
                    Remove-Item -Path "$folder\*" -Recurse -Force -ErrorAction Continue
                }
            }
        }
    }
}

function Remove-PageFile([string] $irVHDPath) {
    try {
        $drives = Mount-VHD -Path $irVHDPath -Passthru -ErrorAction Stop| Get-Disk | Get-Partition | Get-Volume
        $osdrive = ($drives | Where-Object FileSystem -match NTFS ).DriveLetter
        $pageSysFile = $osdrive + ":\pagefile.sys"
        if (Test-Path $pageSysFile) {
            Trace-Execution "Deleting pagefile.sys in $irVHDPath"
            Remove-Item -Path $pageSysFile -Force -ErrorAction Stop
        }
    }
    catch {
        Trace-Execution "Failed to remove pagefile"
    }
    finally {
        Dismount-VHD -Path $irVHDPath
    }
}

function Defrag-VHD([string] $vhd) {
    try {
        $mountedVhd = Mount-VHD -Path $vhd -Passthru -ErrorAction Stop
        $volume = $mountedVhd | Get-Partition | Get-Volume
        if ($null -eq $volume) {
            throw "Failed to get volume"
        }
        $osdrive = ($volume | Where-Object FileSystem -match NTFS ).DriveLetter
        if ($null -eq $osdrive) {
            $i = Get-CimInstance -ClassName Win32_Volume | Where-Object {$_.DeviceID -eq $volume.UniqueId}
            $i | Set-CimInstance -Property @{ DriveLetter = "X:"}

            $volume = $mountedVhd | Get-Partition | Get-Volume
            $osdrive = ($volume | Where-Object FileSystem -match NTFS ).DriveLetter
            if ($null -eq $osdrive) {
                throw "Failed to set drive letter for volume, vhd: $vhd"
            }
        }
        $osdrive = "$($osdrive):"
        Trace-Execution "Defrag for vhd: $vhd"
        defrag $osdrive /x
        defrag $osdrive /k /l
        defrag $osdrive /x
        defrag $osdrive /k
    }
    catch {
        Trace-Execution $_
    }
    finally {
        Dismount-VHD -Path $vhd
    }
}

function Compact-VHDs($vmSet, [string] $filterByRoleName = "IR") {
    $vms = $vmSet | Where-Object {$_.Name -Match $filterByRoleName}
    foreach ($vm in $vms) {
        $hds = $vm.HardDrives
        Trace-Execution "Compact VHD for VM: $($vm.Name)"
        foreach ($hd in $hds) {
            if ($hd.Path -match "\\IRVM\d+\.vhdx") {
                Remove-PageFile -irVHDPath $hd.Path
                Defrag-VHD -vhd $hd.Path
            } elseif ($hd.Path -match "\\Docker_IRVM\d+") {
                Defrag-VHD -vhd $hd.Path
            }
            Optimize-VHD -Path $hd.Path -Mode Full -ErrorAction Continue
        }
    }
}


function FormatByteSize([float]$size)
{
    $formatRule = "{0:N3}"
    $formatStr = ""
    switch ($size) {
        {$_ -lt 1KB} { $formatStr = $formatRule -f $size + "B"; Break}
        {$_ -lt 1MB} { $formatStr = $formatRule -f ($size / 1KB) + "KB"; Break}
        {$_ -lt 1GB} { $formatStr = $formatRule -f ($size / 1MB) + "MB"; Break}
        {$_ -lt 1TB} { $formatStr = $formatRule -f ($size / 1GB) + "GB"; Break}
        Default { $formatStr = $formatRule -f ($size / 1TB) + "TB"}
    }

    return $formatStr
}

function GetVHDFileSize()
{
    param(
        [Parameter(Mandatory=$true)] [string] $vhdPath,
        [Parameter(Mandatory=$false)] [switch] $Recurse = $false
    )

    $vhd = Get-VHD -Path $vhdPath
    $fileSize = $vhd.FileSize

    $vhdSizes = @()
    $vhdSizes += [PSCustomObject]@{
        Path = $vhdPath
        Size = $fileSize
    }

    if ($Recurse -and $vhd.ParentPath)
    {
        $vhdSizes += GetVHDFileSize -vhdPath $vhd.ParentPath -Recurse
    }

    return $vhdSizes
}

function GetAllVHDSize()
{
    $vhdSizes = @()
    $vmSet = Get-VM | Where-Object { $_.Name -like "IRVM*" }
    foreach ($vm in $vmSet)
    {
        $recurse = $vm.Name -like 'IRVM*'

        $hds = $vm.HardDrives
        foreach ($hd in $hds)
        {
            $vhdSizes += GetVHDFileSize -VhdPath $hd.Path -Recurse:$recurse
        }
    }
    return $vhdSizes
}

function Write-VhdSize([string] $logPrefix)
{
    $vhdSizes = GetAllVHDSize
    $totalSize = 0
    foreach ($vhdSize in $vhdSizes)
    {
        $totalSize += $vhdSize.Size
        $formattedSize = FormatByteSize($vhdSize.Size)
        Trace-Execution "[$logPrefix] File Size of VHD: $($vhdSize.Path) is $formattedSize"
    }
    $totalFormattedSize = FormatByteSize($totalSize)
    Trace-Execution "[$logPrefix] Total File Size of VHDs is $totalFormattedSize"
    return $totalFormattedSize
}

function Save-Artifact([string] $path = ".", [string] $name, [string] $version = "1.0.0", [bool] $useArtifactTool = $false, [bool] $skipInit = $false)
{
    try
    {
        if ($useArtifactTool -eq $false)
        {
            Trace-Execution "Uploading $name using az cli"
            az artifacts universal publish --organization $artifactsOrganizationUri --feed $artifactsFeedName --name $name.ToLower() --version $version `
                --description "Winfield $($env:COMPUTERNAME) - $version" --path $path
        }
        else
        {
            Trace-Execution "Uploading $name using $($global.ArtifactToolExe)"
            & $global:ArtifactToolExe universal publish --service $artifactsOrganizationUri --feed $artifactsFeedName --package-name $name.ToLower() --package-version $version `
                --description "Winfield Archive After Completion on $($env:ComputerName)" --path $path --patvar UNIVERSAL_PAT
        }
    }
    catch
    {
        $ex = $_
        if ($ex.Exception -is [System.Management.Automation.RemoteException]) {
            $details = $ex.Exception.RemoteException
            Trace-Execution "RemoteException uploading '$name' - $details"
        }
        else {
            Trace-Execution "Exception uploading '$name' - $ex"
        }
    }
}

function Restore-Artifact([string] $path = ".", [string] $name = "arca.onenode.complete", [string] $version = "*", [bool] $useArtifactTool = $false, [bool] $skipInit = $false)
{
    Trace-Execution "Downloading artifact from ADO: $name..."
    $ErrorActionPreference = "Continue"

    if ($useArtifactTool -eq $false)
    {
        az artifacts universal download --organization $artifactsOrganizationUri --feed $artifactsFeedName --name $name --version $version --path $path
    }
    else
    {
        & $global:ArtifactToolExe universal download --service $artifactsOrganizationUri --feed $artifactsFeedName --package-name $name --package-version $version --path $path --patvar UNIVERSAL_PAT
    }

    $ErrorActionPreference = "Stop"
}

# cmdlet to download Winfield VM appliance from ADO or Blob storage account
function Import-Winfield() {
    param(
        [Parameter(Mandatory=$true)]
        [string] $path,
        [string] $name = "arca.onenode.complete",
        [string] $version = "*",
        [string] $code = "",
        [switch] $clean = $false,
        [switch] $bestFit = $false
    )

    # START Transcript
    $timestamp = [DateTime]::Now.ToString("yyyyMMdd-HHmmss")
    $logPath = (New-Item -Path "$env:ProgramData\Microsoft\Winfield\Logs" -ItemType Directory -Force).FullName
    $logFile = Join-Path -Path $logPath -ChildPath "ImportWinField_${timestamp}.txt"
    try { Start-Transcript -Path $logFile -Force | Out-String | Write-Verbose -Verbose } catch { Write-Warning -Message $_.Exception.Message }
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Trace-Execution "START: Import-Winfield"
    $deploySuccessful = $false
    try
    {
        # Download artifacts, import VM, networking setup after import
        Restore-WinfieldInternal -path $path -name $name -version $version -clean $clean -bestFit $bestFit -code $code
        NetworkSetupPostImport
        WaitForVMNetwork

        # After IRVM01 has been imported, get root cert public key from SysConfig service and install it in the local cert store
        Install-WinfieldCerts

        # Validate install
        Test-Winfield
        $deploySuccessful = $true
    }
    catch
    {
        Trace-Execution "Error Importing Winfield:`r`n$($_)"
    }
    finally
    {
        Show-PostInstall -DeployResult $deploySuccessful
        $sw.Stop()
        Trace-Execution "Import-Winfield completed execution in $($sw.Elapsed.TotalSeconds) sec."
        # STOP Transcript
        try { Stop-Transcript | Out-String | Write-Verbose -Verbose } catch { Write-Warning -Message $_.Exception.Message }
    }
    Trace-Execution "END: Import-Winfield"
    return $deploySuccessful
}

function Show-PostInstall($DeployResult)
{
    $art = @"
______ _ _ _ _ _____ _ _ ______ _____ _____ _ ______
| ___ \ (_) | | | | | |_ _| \ | || ___|_ _| ___| | | _ \
| |_/ / __ ___ _ ___ ___| |_ | | | | | | | \| || |_ | | | |__ | | | | | |
| __/ '__/ _ \| |/ _ \/ __| __| | |/\| | | | | . ` || _| | | | __|| | | | | |
| | | | | (_) | | __/ (__| |_ \ /\ /_| |_| |\ || | _| |_| |___| |___| |/ /
\_| |_| \___/| |\___|\___|\__| \/ \/ \___/\_| \_/\_| \___/\____/\_____/___/
              _/ |
             |__/
 
"@

    $fontColor = 'White'
    if($DeployResult)
    {
        $fontColor = 'Green'
    }
    Write-Host -ForegroundColor $fontColor "`r`n$art`r`n"
    Trace-Execution "DeployResult = $DeployResult"
    Trace-Execution "If automated install has failed, Winfield portal doesn't load, try to download Winfield manually and import the VM."
    Trace-Execution "Open browser at https://portal.devfabric.azs.microsoft.com to try out Winfield."
    Trace-Execution "Use Azure CLI to try out various user scenarios."
    Trace-Execution "Refer to user guide at: https://aka.ms/winfield-userguide"
    Trace-Execution "`r`n"
}

function Test-Winfield
{
    Trace-Execution "START: Validating Winfield installation"
    $adapters = Get-VM IRVM01 | Select-Object -ExpandProperty NetworkAdapters
    Trace-Execution "Winfield NICs: $($adapters | Out-String)"
    foreach($adapter in $adapters)
    {
        if($adapter.Status -ne 'Ok')
        {
            throw "Winfield NIC $($adapter.Name) status is not ok"
        }
    }

    # basic portal test
    $portalPingUrl = 'https://portal.devfabric.azs.microsoft.com/api/ping'
    $pingResponseFile = "$env:APPDATA\Winfield\pingresponse.json"
    DownloadWithRetry -url $portalPingUrl -downloadLocation $pingResponseFile -retries 30

    if(-not (Test-path $pingResponseFile))
    {
        throw "Portal /api/ping status is $($response.StatusCode) instead of http/200"
    }
    Trace-Execution "END: Validating Winfield installation"
}

function Install-WinfieldCerts
{
    Trace-Execution "START: Installing Winfield certs"
    $retries = 10
    $waitSec = 30

    for($attempt = 1; $attempt -le $retries; $attempt++)
    {
        try
        {
            Trace-Execution "Attempt: $attempt"
            $response = Invoke-RestMethod http://169.254.53.25:8320/PublicRootCertificate -UseBasicParsing -TimeoutSec 30 -Verbose
            if($response.Status -eq 'ok')
            {
                break;
            }
        }
        catch
        {
            Trace-Execution "Error: $($_)"
            Trace-Execution "Retry in $waitSec sec."
            Start-Sleep -Seconds $waitSec
        }
    }

    if($null -eq $response.certificate)
    {
        throw "Failed to download Winfield certificate from sys config endpoint"
    }

    New-Item -Path "$env:APPDATA\Winfield" -ItemType Directory -Force | Out-Null
    $certFile = "$env:APPDATA\Winfield\winfieldRoot.cer"
    # note this cert is already base64 encoded format
    $response.certificate | Out-File $certFile
    Import-Certificate -FilePath $certFile -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
    Import-Certificate -FilePath $certFile -CertStoreLocation Cert:\CurrentUser\Root  | Out-Null
    Trace-Execution  "$(Get-ChildItem "$env:APPDATA\Winfield" | Out-String)" -Verbose
    UpdatePythonCertStore
    Trace-Execution "END: Installing Winfield certs"
}


function UpdatePythonCertStore
{
    Trace-Execution "START: Updating CLI cert store"
    $cerFile = "$env:APPDATA\Winfield\WinfieldRoot.cer"
    Trace-Execution "Updating Python cert store with $cerFile"
    $pythonCertStore = "${env:ProgramFiles(x86)}\Microsoft SDKs\Azure\CLI2\Lib\site-packages\certifi\cacert.pem"
    Trace-Execution "Python cert store location $pythonCertStore"

    $root = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2

    if(Test-Path $cerFile)
    {
        $root.Import($cerFile)
        Trace-Execution "$(Get-Date) Extracting required information from the cert file"
        $md5Hash    = (Get-FileHash -Path $cerFile -Algorithm MD5).Hash.ToLower()
        $sha1Hash   = (Get-FileHash -Path $cerFile -Algorithm SHA1).Hash.ToLower()
        $sha256Hash = (Get-FileHash -Path $cerFile -Algorithm SHA256).Hash.ToLower()
        $issuerEntry  = [string]::Format("# Issuer: {0}", $root.Issuer)
        $subjectEntry = [string]::Format("# Subject: {0}", $root.Subject)
        $labelEntry   = [string]::Format("# Label: {0}", $root.Subject.Split('=')[-1])
        $serialEntry  = [string]::Format("# Serial: {0}", $root.GetSerialNumberString().ToLower())
        $md5Entry     = [string]::Format("# MD5 Fingerprint: {0}", $md5Hash)
        $sha1Entry    = [string]::Format("# SHA1 Fingerprint: {0}", $sha1Hash)
        $sha256Entry  = [string]::Format("# SHA256 Fingerprint: {0}", $sha256Hash)
        $certText = (Get-Content -Path $cerFile -Raw).ToString().Replace("`r`n","`n")
        $rootCertEntry = "`n" + $issuerEntry + "`n" + $subjectEntry + "`n" + $labelEntry + "`n" + `
                        $serialEntry + "`n" + $md5Entry + "`n" + $sha1Entry + "`n" + $sha256Entry + "`n" + $certText
        Trace-Execution "Adding the certificate content to Python Cert store"
        Add-Content $pythonCertStore $rootCertEntry
        Trace-Execution "Python Cert store was updated to allow the Azure Stack CA root certificate"
    }
    else
    {
        $errorMessage = "$cerFile required to update CLI was not found."
        Trace-Execution "ERROR: $errorMessage"
        throw "UpdatePythonCertStore: $errorMessage"
    }

    Trace-Execution "END: Updating CLI cert store"
}

function GetExpectedVmFiles
{
    param
    (
        [string] $lkgBlobUri,
        [string] $code,
        [string[]] $pattern,
        [int] $expectedFileCount
    )

    $output = Invoke-AzCopy -operation "blob list pattern: $pattern" -azCopyParameters @('list', "$lkgBlobUri$code")
    $vmFiles = @()
    $matchInfo = $output | Select-String -Pattern $pattern
    foreach($match in $matchInfo)
    {
       $vmFiles += $match.Line.Split(";")[0].Split(":")[1].Trim()
    }

    # e.g. @('86E04238-2615-4EF7-9769-8C472512FF6D.vmcx', '86E04238-2615-4EF7-9769-8C472512FF6D.vmgs', '86E04238-2615-4EF7-9769-8C472512FF6D.VMRS')
    Trace-Execution "Following VM files are located in blob location: $($vmFiles)"

    if($vmFiles.count -ne $expectedFileCount)
    {
        throw "Did not find expected VM files (guid.vmcx, guid.vmgs, guid.VMRS) at blob location. Check the SAS token used."
    }

    return $vmFiles
}

function Invoke-AzCopy
  {
      [CmdletBinding()]
      param
      (
          [string] $operation,
          [string[]] $azCopyParameters
      )
      Trace-Execution "[START][$operation]"
      $azCopyExe = DownloadAzCopy
      try
      {
          if($azCopyParameters.Length -lt 2)
          {
              throw "expected atleast 2 azcopy parameters"
          }
          $azCopyCommand = $azCopyParameters[0]
          Trace-Execution "Executing $azCopyExe $azCopyCommand"
          Start-Process -FilePath $azCopyExe -ArgumentList $azCopyParameters -RedirectStandardOutput "output.txt" -Wait -NoNewWindow
          if($LASTEXITCODE -ne 0)
          {
              throw "Error executing azcopy command $azCopyCommand. See azcopy logs for details."
          }
          else 
          {
              Trace-Execution "azcopy command $azCopyCommand executed successfully."
          }
          $output = Get-Content -Path "output.txt"
          Trace-Execution "azcopy output: $output"
      }
      catch 
      {
          if($ErrorActionPreference -eq 'Continue')
          {
              Trace-Execution "Ignoring error $($_)"
          }
          else 
          {
              throw $_.Exception
          }
      }
      Trace-Execution "[END][$operation]"
      return $output
  }
function Resolve-BlobUrl {
    param 
    (
        [string] $blobUrl,
        [string] $code
    )
    # download and read control data file
    mkdir "$env:APPDATA\Winfield" -Force | Out-Null
    $downloadedControlDataFile = Join-Path -Path "$env:APPDATA\Winfield" -ChildPath "localControl.json"
    $output = Invoke-AzCopy -operation "cp $blobUrl/control/control.json" -azCopyParameters @('cp', "$blobUrl/control/control.json$code", $downloadedControlDataFile)
    Trace-Execution "downloadedControlDataFile contents:`r`n$(Get-Content $downloadedControlDataFile)`r`n"
    $controlData = Get-Content $downloadedControlDataFile | ConvertFrom-Json
    if($controlData.CopyInProgress)
    {
        Trace-Execution "LKG build copy is in progress."
        $lkgBlobContainerName = $controlData.PreviousLKGContainer
        Trace-Execution "Use previous LKG container: $lkgBlobContainerName"
    }
    else 
    {
        $lkgBlobContainerName = $controlData.LKGContainer
    }
    $resolvedBlobUrl = "$blobUrl/lkg-$lkgBlobContainerName"
    Trace-Execution "BlobUrl resolved to: $resolvedBlobUrl"
    return $resolvedBlobUrl
}
function DownloadArtifactFromBlob
{
    param
    (
        [string] $blobUrl,
        [string] $code,
        [string[]] $artifacts,
        [string] $downloadFolder,
        [string] $destination
    )

    Trace-Execution "START: DownloadArtifactFromBlob: $blobUrl"


    # download or copy VHDX files
    foreach($artifact in $artifacts)
    {
        if(Test-Path -Path "$localFolder\$artifact")
        {
            # it was previously downloaded
            Trace-Execution "$artifact was previously downloaded, moving it to $destination"
            Move-Item -Path "$downloadFolder\$artifact" -Destination $destination
        }
        else
        {
            $artifactInBlob = "$blobUrl/$artifact$code"
            $artifactDestination = "$destination\$artifact"

            if(Test-Path $artifactDestination)
            {
                Trace-Execution "$artifactDestination exists, skip download"
            }
            else
            {
                Trace-Execution "Invoking AzCopy to download from $blobUrl/$artifact to $downloadFolder ..."
                $output = Invoke-AzCopy -operation "copy $artifact" -azCopyParameters @('cp', $artifactInBlob, $downloadFolder)
                Trace-Execution "Moving $artifact to destination: $artifactDestination"
                Move-Item -Path "$downloadFolder\$artifact" -Destination $artifactDestination -Verbose
            }
        }
    }
    Trace-Execution "END: DownloadArtifactFromBlob"
}

function Restore-WinfieldInternal() {
    param(
        [Parameter(Mandatory=$true)]
        [string] $path,
        [string] $name = "arca.onenode.complete",
        [string] $version = "*",
        [bool] $clean,
        [bool] $bestFit,
        [string] $code
    )

    InstallCredMgr
    $cred = StoreAdminPassword
    Trace-Execution "Retrieved IRVM01 credential"

    InitEnv -skipInit $false -code $code

    if (-not (Test-Path $path))
    {
        New-Item -ItemType Directory -Path $path -ErrorAction Ignore -Force | Out-Null
    }

    Trace-Execution "Checking hardware requirements: minimum 8 vCPU, 24GB RAM, install path 300GB SSD drive space, Hyper-V path 50GB drive space. Recommended: 24 vCPU, 48GB RAM"

    # Check proc count
    $processorInformation = Get-WmiObject -Class Win32_processor
    # NumberOfLogicalProcessors in Win32_processor is an array for each socket in the case
    # of multi socket systems. In the case of single socket systems it is an Int32.
    $hostLogicalProcessorCount = [Int32]($processorInformation.NumberOfLogicalProcessors | Measure-Object -Sum).Sum
    if ($hostLogicalProcessorCount -lt 8 -and -not $bestFit)
    {
        throw "This machine has $hostLogicalProcessorCount cores. Winfield requires a minimum of 8 cores."
    }

    # Check disk space
    $drive = (Get-Item $path).Root.FullName
    if ($drive.StartsWith("\\"))
    {
        throw "You cannot restore Winfield to a network share."
    }
    $driveLetter = $drive.Substring(0, 1)
    $volume = Get-Volume -DriveLetter $driveLetter
    if ($volume.SizeRemaining -lt 300GB)
    {
        $available = [int] ($volume.SizeRemaining / 1GB)
        Trace-Execution "The path '$path' has $($available)GB available."
        throw "Winfield requires at least 300GB space on the drive you restore to."
    }

    # Check disk is an SSD - fails on lab env
# if (-not (IsSsdDrive -path $path))
# {
# throw "Winfield requires an installation path on an SSD drive."
# }

    # Check Hyper-V default storage location disk space
    Import-Module Hyper-V -ErrorAction Stop
    $vmHost = Get-VMHost
    $hvDrive = $vmHost.VirtualMachinePath.Substring(0, 1)
    $hvVolume = Get-Volume -DriveLetter $hvDrive
    if ($hvVolume.SizeRemaining -lt 50GB)
    {
        $available = [int] ($hvVolume.SizeRemaining / 1GB)
        Trace-Execution "The current Hyper-V default save location '$($vmHost.VirtualMachinePath)' has $($available)GB available."
        Trace-Execution "The default save location can be changed with the command 'Set-VMHost -VirtualMachinePath <path>'."
        throw "Winfield requires the Hyper-V default save location to have at least 50GB available."
    }

    # Check RAM
    $PhysicalRAM = (Get-WMIObject -class Win32_PhysicalMemory |Measure-Object -Property capacity -Sum | ForEach-Object {[Math]::Round(($_.sum / 1GB),2)})
    if ($PhysicalRAM -lt 24)
    {
        if ($bestFit)
        {
            Trace-Execution "Attempting to restore using available RAM. This deployment may encounter stability/health issues due to using below the recommeded specs."
        }
        else
        {
            throw "Winfield is recommended to run with a minimum of 24GB RAM. Add '-bestFit' to attempt to restore using available RAM."
        }
    }

    $restoreSpace = [int] ($volume.SizeRemaining / 1GB)
    $defaultSpace = [int] ($hvVolume.SizeRemaining / 1GB)
    Trace-Execution "Hardware check passed - $hostLogicalProcessorCount proc, $($PhysicalRam)GB RAM, restore space $($restoreSpace)GB, Hyper-V space $($defaultSpace)GB"

    $isClean = $true
    $existingVMs = Get-VMSet
    if ($null -ne $existingVMs -and $existingVMs.Count -gt 0)
    {
        if (-not $clean)
        {
            $notCleanMessage = "Remove any existing Winfield VMs prior to restoring or add '-clean' to the Import-Winfield command."
            Trace-Execution "$notCleanMessage"
            $isClean = $false
            throw "$notCleanMessage"
        }
        else
        {
            Trace-Execution "Removing existing Winfield virtual machines..."
            RemoveVMs $existingVMs
        }
    }

    if ($isClean)
    {
        $guid = [guid]::NewGuid()
        $importVmPath = Join-Path $path $guid
        New-Item -ItemType Directory -Path $importVmPath -ErrorAction Ignore -Force | Out-Null

        if(-not [string]::IsNullOrEmpty($code))
        {
            $blobBaseUrl = "https://winfieldartifacts.blob.core.windows.net"
            $blobUrl = Resolve-BlobUrl -blobUrl $blobBaseUrl -code $code
            Trace-Execution "Winfield LKG Blob URL: $blobUrl"
            # create fixed local folder for downloading the artifact
            $localFolder = "deab9cc9-962e-4bc2-aee2-5b5f438d09ee\IRVM01"
            $path = Join-Path $path $localFolder
            New-Item -ItemType Directory -Path $path -ErrorAction Ignore -Force | Out-Null

            # create destination folder layout
            New-Item -ItemType Directory -Path "$path\Snapshots" -ErrorAction Ignore -Force | Out-Null
            New-Item -ItemType Directory -Path "$path\Virtual Hard Disks" -ErrorAction Ignore -Force | Out-Null
            New-Item -ItemType Directory -Path "$path\Virtual Machines" -ErrorAction Ignore -Force | Out-Null

            Trace-Execution "Download artifacts from $blobUrl"
            <#
            $expectedVhdxFiles = @('IRVM01.vhdx',
                                   'ArcA_EphemeralData_IRVM01_1.vhdx',
                                   'ArcA_LocalData_IRVM01_1.vhdx',
                                   'ArcA_SharedData_IRVM01.vhdx',
                                   'Docker_IRVM01_1.vhdx'
                                 )
            #>

            $expectedVhdxFiles = GetExpectedVmFiles -lkgBlobUri $blobUrl -code $code -pattern @(".vhdx") -expectedFileCount 5
            $expectedVmFiles = GetExpectedVmFiles -lkgBlobUri $blobUrl -code $code -pattern @(".VMRS",".vmcx",".vmgs") -expectedFileCount 3

            # download artifacts from LKG Blob
            DownloadArtifactFromBlob -blobUrl $blobUrl -artifacts $expectedVhdxFiles -code $code -downloadFolder $localFolder -destination "$path\Virtual Hard Disks"
            DownloadArtifactFromBlob -blobUrl $blobUrl -artifacts $expectedVmFiles -code $code -downloadFolder $localFolder -destination "$path\Virtual Machines"

            $importVmPath = (Get-Item -Path $localFolder).Parent.FullName
            Trace-Execution "Winfield artifacts are in $importVmPath for import VM"
            Trace-Execution "$((Get-ChildItem $path -Recurse).FullName | Out-String)" -Verbose
            $result = $true
        }
        else
        {
            $result = Restore-Artifact -path $importVmPath -name $name -version $version
        }

        if ($null -ne $result)
        {
            Import-Artifact -path $importVmPath -credential $cred -bestFit $bestFit
        }
        else
        {
           throw "Download of Winfield appliance failed."
        }
    }
}

function Save-Winfield([string] $path, [string] $name = "Winfield-$($env:COMPUTERNAME)", [string] $version = "1.0.0", [bool] $useArtifactTool = $false, [bool] $skipInit = $false, [PSCredential] $credential, [bool] $compactVhds = $false)
{
    if ($null -eq $credential)
    {
        $credential = Get-Credential -Message "Enter the well known Administrator password for Winfield." -UserName "Administrator"
    }
    InitEnv -skipInit $skipInit -code $null

    Export-Artifact -path $path -credential $credential -compactVhds $compactVhds
    Save-Artifact -path $path -name "$name" -version "$version" -useArtifactTool $useArtifactTool -skipInit $skipInit
}

function Trace-Execution([string] $message)
{
    $caller = (Get-PSCallStack)[1]
    Write-Host "[$(([DateTime]::UtcNow).ToString())][$($caller.Command)] $message"
}

function Get-VMSet() {
    $vmSet = Get-VM | Where-Object { $_.Name -like "IRVM*" }
    return $vmSet
}

function Start-VMSet($vmSet)
{
    Trace-Execution "Starting all VMs"

    foreach ($vm in $vmSet) {
        $name = $vm.Name
        Trace-Execution "Start: start VM $name"
        if (($vm.State -eq "Off") -or ($vm.State -eq "Saved")) {
            Start-VM -Name $name
        }
        Trace-Execution "Complete: start VM $name"
    }
}

function Stop-VMSet($vmSet, [bool]$turnOff = $false)
{
    foreach ($vm in $vmSet) {
        $name = $vm.Name
        Trace-Execution "Stopping $name..."
        if ($vm.State -eq "Running") {

            # Check if we should use graceful shutdown/stop
            if ($turnOff -eq $false)
            {
                try {
                    Stop-VM -Name $name -Force
                }
                catch {
                    Trace-Execution "Failed to save VM; $_"
                }

                # Hyper-V will wait up to 5 minutes for guest to shutdown
                $stopWaitTime = (Get-Date).AddMinutes(6)
                while (($vm.State -ne "Off" -or $vm.OperationalStatus -match "MergingDisks") -and $stopWaitTime -gt (Get-Date)) {
                    Trace-Execution "Waiting for VM to stop / merge to complete, current state $($vm.State)"
                    Start-Sleep -Seconds 30
                    $vm = Get-VM -Name $name
                }

                # Wait up to 5 additional minutes if merging
                $stopWaitTime = (Get-Date).AddMinutes(5)
                while (($vm.OperationalStatus -match "MergingDisks") -and $stopWaitTime -gt (Get-Date))
                {
                    Trace-Execution "Waiting for VM merge to complete..."
                    Start-Sleep -Seconds 30
                    $vm = Get-VM -Name $name
                }
            }

            # If guest is still running force power off
            if ($vm.State -ne "Off") {
                Trace-Execution "Force turning off $name in state $($vm.State)"
                Stop-VM -Name $name -TurnOff -Force
            }
        }
        Trace-Execution "Stopped $name"
    }
}

function Export-VMSet($vmSet, [string] $path, [string] $filterByRoleName = "IR")
{
    $vms = $vmSet | Where-Object Name -match $filterByRoleName
    foreach ($vm in $vms) {
        $name = $vm.Name
        Trace-Execution "Start: export VM $name"
        Set-VMProcessor $name -CompatibilityForMigrationEnabled $true
        Export-VM -Name $name -Path $path
        $vmObject = Get-VM -Name $name
        Trace-Execution "Complete: export VM $name"
    }
}

function DoesVmExist($vmSet, [string]$value)
{
    foreach ($vm in $vmSet)
    {
        if ($vm.Name -eq $value)
        {
            return $true
        }
    }
    return $false
}

function CreateVmNetwork([string] $vmSwitch, [string] $hostIp, [string]$IPaddressPrefix)
{
    Trace-Execution "Cleaning up existing Winfield VMSwitches"
    Get-VMSwitch "winfield*" | Remove-VMSwitch -Force -Verbose -ErrorAction SilentlyContinue
    Get-VMSwitch "*devenv*" | Remove-VMSwitch -Force -Verbose -ErrorAction SilentlyContinue

    Trace-Execution "Creating vmswitch Winfield-Ingress"
    New-VMSwitch -SwitchName "Winfield-Ingress" -SwitchType Internal | Out-Null

    #todo: remove
    New-VMSwitch -Name 'DevEnv-Internal' -SwitchType Internal | Out-Null

    Trace-Execution "Setting new New-NetIPAddress on adapter Winfield-Ingress"
    $adapter = Get-NetAdapter -Name "*(Winfield-Ingress)"
    New-NetIPAddress -IPAddress 10.0.50.1 -PrefixLength 24 -InterfaceIndex $adapter.ifIndex -ErrorAction SilentlyContinue | Out-Null
    Trace-Execution "Waiting for network changes...."
    Start-Sleep -Seconds 10

    Trace-Execution "Creating NAT Winfield-Ingress-NAT"
    Get-NetNat | Remove-NetNat -Confirm:$false -ErrorAction SilentlyContinue -Verbose
    New-NetNat -Name "Winfield-Ingress-NAT" -InternalIPInterfaceAddressPrefix 10.0.50.0/24 -ErrorAction SilentlyContinue | Out-Null
    Trace-Execution "Waiting for network connection...."
    Start-Sleep -Seconds 30

    Trace-Execution "Creating vmswitch Winfield-Management"
    New-VMSwitch -SwitchName "Winfield-Management" -SwitchType Internal | Out-Null

    Trace-Execution "Setting IP address on Winfield-Management"
    $managementAdapter = Get-NetAdapter -Name "*(Winfield-Management)"

    Trace-Execution "Cleanup NetIPAddress if it exists"
    Get-NetIPAddress -IPAddress 169.254.53.20 -ErrorAction SilentlyContinue | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue

    Trace-Execution "Setting IP address 169.254.53.20 on interface $($managementAdapter.Name)"
    New-NetIPAddress -IPAddress 169.254.53.20 -PrefixLength 16 -InterfaceIndex $managementAdapter.ifIndex | Out-Null

    #Rename-VMSwitch -Name "Winfield-Ingress" -NewName "DevEnv-Internal"
    # todo: validate network settings
}

function NetworkSetupPostImport
{
    Trace-Execution "START: Post VM import networking setup"
    Trace-Execution "Connecting IRVM01 ingress network adapter to Winfield-Ingress switch."
    Get-VM -Name "IRVM01" | Get-VMNetworkAdapter -Name "Winfield-Ingress" | Connect-VMNetworkAdapter -SwitchName "Winfield-Ingress"

    Trace-Execution "Connecting IRVM01 management network adapter to Winfield-Management switch."
    Get-VM -Name "IRVM01" | Get-VMNetworkAdapter -Name "Winfield-Management" | Connect-VMNetworkAdapter -SwitchName "Winfield-Management"
    Trace-Execution "END: Post VM import networking setup"
}

function DownloadWithRetry([string] $url, [string] $downloadLocation, [int] $retries)
{
    while($true)
    {
        try
        {
            Invoke-WebRequest $url -OutFile $downloadLocation -Verbose
            break
        }
        catch
        {
            $exceptionMessage = $_.Exception.Message
            Trace-Execution "Failed to download '$url': $exceptionMessage"
            if ($retries -gt 0) {
                $retries--
                Trace-Execution "Waiting 10 seconds before retrying. Retries left: $retries"
                Start-Sleep -Seconds 10
            }
            else
            {
                $exception = $_.Exception
                throw $exception
            }
        }
    }
}

function Modify-Host($hostName, $ip = "127.0.0.1") {
    $hostsPath = "$env:windir\System32\drivers\etc\hosts";
    $hosts = Get-Content $hostsPath;
    $escapedHost = $hostName -replace "[.]", "\.";
    $exists = $false;
    $hosts = $hosts | ForEach-Object {
        if ($_ -match $escapedHost)
        {
            $exists = $true;
            "$ip`t$hostName";
        }
        else
        {
            $_
        }
    }

    if ($exists -eq $false) {
        $hosts += "$ip`t$hostName";
    }

    $hosts | Out-File $hostsPath -enc ascii
}

function InstallServerFeature($name)
{
    $r = Install-WindowsFeature -Name $name
    if ($r.RestartNeeded -ne "No")
    {
        throw "Restart your machine to complete installing $name before re-running this script. $($r.RestartNeeded)"
    }
}

function InstallClientFeature($name)
{
    $f = Get-WindowsOptionalFeature -FeatureName $name -Online
    if ($f.State -ne "Enabled")
    {
        $r = Enable-WindowsOptionalFeature -FeatureName $name -NoRestart -Online
        if ($r.RestartNeeded -ne "No")
        {
            throw "Restart your machine to complete installing $name before re-running this script. $($r.RestartNeeded)"
        }
    }
}

function InitEnv([bool] $skipInit = $false, [string]$code)
{
    if ($global:WinfieldInitComplete -ne $true)
    {
        if ($skipInit -eq $false)
        {
            $osName = (Get-WMIObject win32_operatingsystem).name
            if ($osName -match 'Windows Server')
            {
                Trace-Execution "Checking prereqs..."
                InstallServerFeature "Hyper-V-Tools"
                InstallServerFeature "Hyper-V-PowerShell"
            }
            elseif ($osName -match "Windows 11 Pro")
            {
                Trace-Execution "Checking prereqs..."
                InstallClientFeature "Microsoft-Hyper-V"
                InstallClientFeature "Microsoft-Hyper-V-Tools-All"
            }

            $installed = Get-Command "az.cmd" -ErrorAction "SilentlyContinue"
            if (-not $installed)
            {
                Trace-Execution "Install/Update Azure-CLI"
                DownloadWithRetry -url "https://aka.ms/installazurecliwindows" -downloadLocation ".\AzureCLI.msi" -retries 6
                Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet'
                $env:Path += ";c:\Program Files (x86)\Microsoft SDKs\Azure\CLI2\wbin"
            }

            $devopsInstalled = az.cmd extension list --output json | ConvertFrom-Json | Where-Object {$_.name -ieq "azure-devops"}
            if (-not $devopsInstalled)
            {
                Trace-Execution "Install/Update Azure-Devops"
                az.cmd extension add --name azure-devops
            }

            $hostEntries = @(
                @{name = "irvm01"; ip = "10.0.50.4"},
                @{name = "login.azs"; ip = "10.0.50.4"},
                @{name = "graph.azs"; ip = "10.0.50.4"},
                @{name = "his.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "login.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "hosting.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "portal.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "graph.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "armmanagement.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "adminmanagement.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "catalogapi.devfabric.azs.microsoft.com"; ip = "10.0.50.4"},
                @{name = "artifacts.blob.azs.microsoft.com"; ip = "10.0.50.4"}
            )

            foreach ($entry in $hostEntries)
            {
                Trace-Execution "$(Get-Date) : Adding $($entry.ip) $($entry.name)"
                Modify-Host $entry.name $entry.ip | Out-Null
            }

            if([string]::IsNullOrEmpty($code))
            {
                Trace-Execution "CLI login is required to download artifacts. Test CLI login..."
                $output = (az account show)
                if ($null -eq $output)
                {
                    Trace-Execution "Logging in to Azure..."
                    az login --use-device-code --allow-no-subscriptions | Out-Null
                }

                # verify if user is logged in
                Trace-Execution "Verifying if logged into Azure using CLI..."
                try
                {
                    $subscriptions = az rest -u 'https://management.azure.com/subscriptions?api-version=2022-12-01'
                    if($null -eq $subscriptions)
                    {
                        Trace-Execution "Logging in to Azure..."
                        az login --use-device-code --allow-no-subscriptions | Out-Null
                    }
                    else
                    {
                        Trace-Execution "CLI login sucessful. List of subscriptions: $subscriptions"
                    }
                }
                catch
                {
                    Trace-Execution "Logging in to Azure..."
                    az login --use-device-code --allow-no-subscriptions | Out-Null
                }
            }
            else
            {
                Trace-Execution "code entered, will download artifiacts from blob location."
            }
        }

        $global:WinfieldInitComplete = $true
    }
}

function RemoveVMs($vms)
{
    $vhds = $vms | ForEach-Object VMID | Get-VHD
    Stop-VMSet -vmSet $vms -turnOff $true
    $vms | ForEach-Object { $_ | Remove-VM -Force }
    $vhds | ForEach-Object { Remove-Item -Path $_.Path -Force }
}

function SetCredential([string]$vmName, [string]$vmIP, [PSCredential]$credential)
{
    Trace-Execution "Setting network credential for $vmName [$vmIP]"
    $ErrorActionPreference = "Continue"
    $pwd = $credential.GetNetworkCredential().Password
    & cmdkey /add:$vmName /user:$credential.UserName /pass:$pwd
    & cmdkey /add:$vmIP /user:$credential.UserName /pass:$pwd
    $ErrorActionPreference = "Stop"
}

function ProcessResult($result, $successString, $failureString)
{
    #Return success if the return value is "0"
    if ($result.ReturnValue -eq 0) {
        Write-host $successString
    #If the return value is not "0" or "4096" then the operation failed
    } elseif ($result.ReturnValue -ne 4096) {
        Write-Host $failureString " Error value:" $result.ReturnValue
    } else {
        #Get the job object
        $job=[WMI]$result.job

        #Provide updates if the jobstate is "3" (starting) or "4" (running)
        while ($job.JobState -eq 3 -or $job.JobState -eq 4) {
            Write-Host $job.PercentComplete "% complete"
            Start-Sleep 1

            #Refresh the job object
            $job=[WMI]$result.job
        }

        #A jobstate of "7" means success
        if ($job.JobState -eq 7) {
            Write-Host $successString
        } else {
            Write-host $failureString
            Write-host "ErrorCode:" $job.ErrorCode
            Write-host "ErrorDescription:" $job.ErrorDescription
        }
    }
}

function ModifyVmcx([string] $vmcxPath, [string] $vmcxFilename, [int] $ramMB, [int] $coreCount = 0)
{
    #Retrieve the virtual system management service
    $VSMS = Get-WmiObject -Namespace root\virtualization\v2 -Class Msvm_VirtualSystemManagementService

    # Import the VM, referencing the VM configuration
    # Second parameter is the snapshot folder - but we are not editing snapshots so set it to null
    # Third parameter says whether to generate a new VM ID or not
    $importResult = $VSMS.ImportSystemDefinition($vmcxFilename, $null, $true)

    ProcessResult $importResult "Virtual machine configuration loaded into memory." `
                                "Failed to load virtual machine configuration into memory."

    #Retrieve the object referencing the planned VM (in memory VM)
    $plannedVM = [WMI]$importResult.ImportedSystem

    #Retrieve the setting data for the planned VM
    $PVSD = ($plannedVM.GetRelated("Msvm_VirtualSystemSettingData", `
        "Msvm_SettingsDefineState", `
        $null, `
        $null, `
        "SettingData", `
        "ManagedElement", `
        $false, $null) | ForEach-Object {$_})

    #Modify the memory setting of the VM
    $MemSetting = $PVSD.getRelated("Msvm_MemorySettingData") | Select-Object -First 1
    $MemSetting.DynamicMemoryEnabled = 0
    $MemSetting.Reservation = $ramMB
    $MemSetting.VirtualQuantity = $ramMB
    $MemSetting.Limit = $ramMB
    $MemSetting.Weight = 100

    $memoryChangeResult = $VSMS.ModifyResourceSettings($MemSetting.GetText(1))
    ProcessResult $memoryChangeResult "Memory settings have been updated to $ramMB." "Failed to update memory settings."

    if ($coreCount -gt 0)
    {
        $ProcSetting = $PVSD.getRelated("Msvm_ProcessorSettingData") | Select-Object -First 1
        $ProcSetting.VirtualQuantity = $coreCount

        $procChangeResult = $VSMS.ModifyResourceSettings($ProcSetting.GetText(1))
        ProcessResult $procChangeResult "Processor settings have been updated to $coreCount." "Failed to update processor settings."
    }

    # Edit the Msvm_VirtualSystemExportSettingData to make sure we export only the VM configuration
    $VMExportSD = ($plannedVM.GetRelated("Msvm_VirtualSystemExportSettingData",`
                                        "Msvm_SystemExportSettingData", `
                                        $null, $null, $null, $null, $false, $null)`
                                        | ForEach-Object {$_})
    #CopySnapshotConfiguration - 1: ExportNoSnapshots - No snapshots will be exported with the VM.
    $VMExportSD.CopySnapshotConfiguration = 1
    #Indicates whether the VM runtime information will be copied when the VM is exported. (i.e. saved state)
    $VMExportSD.CopyVmRuntimeInformation = $false
    #Indicates whether the VM storage will be copied when the VM is exported. (i.e. VHDs/VHDx files)
    $VMExportSD.CopyVmStorage = $false
    #Indicates whether a subdirectory with the name of the VM will be created when the VM is exported.
    $VMExportSD.CreateVmExportSubdirectory = $false

    Remove-Item $vmcxFilename -Force -ErrorAction Ignore
    Remove-Item (Join-Path $vmcxPath "*.vm*") -Force -ErrorAction Ignore

    #Export the edited virtual machine to a new file.
    $exportResult = $VSMS.ExportSystemDefinition($plannedVM, $vmcxPath, $VMExportSD.GetText(1))

    ProcessResult $exportResult "Created new virtual machine confguration file." `
                                "Failed to create new virtual machine confguration file."

    #Export places vm* files in a subdir, move them up one level
    Copy-Item (Join-Path $vmcxPath "Virtual Machines\*") $vmcxPath
    Remove-Item (Join-Path $vmcxPath "Virtual Machines") -Force -Recurse

    Write-Host "Virtual machine exported to $($vmcxPath)"
}

function IsSsdDrive([string] $path)
{
    $driveLetter = $path[0]
    foreach ($drive in Get-PhysicalDisk) {
        if (($drive | Get-Disk | Get-Partition).DriveLetter -Contains $driveLetter) {
            Return $drive.MediaType -eq 'SSD'
        }
    }
}

function DownloadAzCopy
{
    param
    (
        $DownloadURL = "https://aka.ms/downloadazcopy-v10-windows",
        $OutputPath = "C:\AzCopy\"
    )

    Trace-Execution "START: Installing AzCopy"
    $azCopyExe = Join-Path -Path $OutputPath -ChildPath "azcopy.exe"

    if(Test-Path $azCopyExe)
    {
        Trace-Execution "Azcopy is already installed"
    }
    else
    {
        Remove-Item -Path $OutputPath -Recurse -Force -ErrorAction SilentlyContinue
        New-Item -Path $OutputPath -ItemType Directory -Force -Verbose | Out-Null
        $azcopyZipFile = Join-Path -Path $OutputPath -ChildPath "AzCopy.zip"

        # Download AzCopy and extract the zip file
        Trace-Execution "Downloading Azcopy.exe..."
        DownloadWithRetry -url $DownloadURL -downloadLocation $azcopyZipFile -retries 6
        Expand-Archive -Path $azcopyZipFile -DestinationPath $OutputPath -Force | Out-Null

        # Rename the AzCopy executable to "AzCopy.exe"
        $azCopyExeExtracted = Get-ChildItem -Path $OutputPath -Filter "azcopy.exe" -Recurse
        Move-Item -Path $azCopyExeExtracted.FullName -Destination $OutputPath | Out-Null

        if(-not (Test-Path $azCopyExe))
        {
            throw "$OutputPath\azcopy.exe not found."
        }
    }
    Trace-Execution "END: Installing AzCopy"
    return $azCopyExe
}

function InstallCredMgr
{
    Install-Module CredentialManager -Confirm:$false -Force -Verbose
    Trace-Execution "Done installing CredentialManager module"
}

function StoreAdminPassword
{
    Import-Module CredentialManager -ErrorAction Stop
    $irvmAdminPassword = Get-StrongPassword
    New-StoredCredential -Target 'Winfield' -UserName 'Administrator' -Password $irvmAdminPassword -Comment "irvm01 password created on $(Get-Date -Format o)"  | Out-Null
    $cred = Get-StoredCredential -Target 'Winfield'
    Trace-Execution "Winfield credential stored in Windows Credential Manager"
    return $cred
}


<#
    .SYNOPSIS
    Verifies connectivity to management endpoint and gets the current WinfieldAppliance state - current vs desired.
 
    .PARAMETER endpointIp
    IP address for the management endpoint
 
    .PARAMETER endpointPort
    Port for the management endpoint
    .OUTPUTS
    Object. The current appliance state grouped in functional areas.
#>

function Get-WinfieldApplianceState {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [string]
        $endpointIp = '169.254.53.25',
        [Parameter(Position = 1, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [string]
        $endpointPort = '8320'
    )
    begin {
        $test = Test-NetConnection -ComputerName $endpointIp -Port $endpointPort
        if (!$test.TcpTestSucceeded) {
            Write-Error "Unable to connect to configuration endpoint $endpointIp on port $endpointPort!"
            Write-Error "Make sure you are on a VM connected to the management network and there is no firewall blocking "
            exit 1;
        }
        $systemConfigServiceUri = "http://$($endpointIp):$($endpointPort)/SystemConfiguration"
        $observabilityUri = "http://$($endpointIp):$($endpointPort)/ObservabilityConfiguration"
    }
    process {
        try {
            Write-Verbose "Getting configuration for observability.. "
            $diagnostics = Invoke-RestMethod -Method Get $observabilityUri -ContentType "application/json"
        } 
        catch {
            $diagnostics = @{}
        }
        try {
            Write-Verbose "Getting configuration for network.. "
            $network = Invoke-RestMethod -Method get $systemConfigServiceUri -ContentType "application/json"
        }
        catch {
            $network = @{}
        }
        
    }
    end {
        return @{
            "NetworkSettings" = $network;
            "Diagnostics"     = $diagnostics
        }
    }

}
<#
    .SYNOPSIS
    Verifies connectivity to management endpoint and set the current WinfieldAppliance desired state based on config or settings file
 
    .PARAMETER path
    Path to settings file (Json)
 
    .PARAMETER config
    Alternative config object containing settings for the desired state.
 
    .OUTPUTS
    Object. The returned values from the management endpoint.
#>

function Set-WinfieldApplianceDesiredState {
    [CmdletBinding(DefaultParameterSetName = "FromConfigObject", ConfirmImpact = "High")]
    param (
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, ParameterSetName = "FromFile")]
        [string]
        $path,

        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, ParameterSetName = "FromConfigObject")]
        [object]
        $configuration,

        [Parameter(Position = 1, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [switch]
        $skipDiagnostics,

        [Parameter(Position = 2, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [string]
        $endpointIp = '169.254.53.25',
        [Parameter(Position = 3, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [string]
        $endpointPort = '8320'
    )
    begin {
        if ($PSBoundParameters.ContainsKey('Path')) {
            $configuration = Get-WinfieldSettings -Path $path
        }
        $valid = Test-WinfieldSettings -configuration $configuration -skipDiagnosticsValidation:$skipDiagnostics.IsPresent;
        if (!$valid) {
            Write-Error "Settings is invalid! Unable to finalize configuration"
            exit 1;
        }
        $systemConfigServiceUri = "http://$($endpointIp):$($endpointPort)/SystemConfiguration"
        $observabilityUri = "http://$($endpointIp):$($endpointPort)/ObservabilityConfiguration"



        $test = Test-NetConnection -ComputerName $endpointIp -Port $endpointPort
        if (!$test.TcpTestSucceeded) {
            Write-Error "Unable to connect to configuration endpoint $endpointIp on port $endpointPort!"
            Write-Error "Make sure you are on a VM connected to the management network and there is no firewall blocking "
            exit 1;
        }
    }
    process {
        if(!$skipDiagnostics.IsPresent){
            Write-Verbose "Applying configuration for observability.. "
            $diagnostics = Invoke-RestMethod -Method Put $observabilityUri -ContentType "application/json" -Body ($configuration.Diagnostics | ConvertTo-Json)
        }

        Write-Verbose "Applying configuration for network.. "
        $network = Invoke-RestMethod -Method Put $systemConfigServiceUri -ContentType "application/json" -Body ($configuration.NetworkSettings | ConvertTo-Json)
    }
    end {
        if(!$skipDiagnostics.IsPresent){
            return @($diagnostics, $network)
        } else {
            return @($network)
        }

    }
}


<#
    .SYNOPSIS
    Fills in the Settings config object in an interactive fashion
 
    .PARAMETER config
    The inital configuration with defaults
 
    .OUTPUTS
    Object. The configuration with settings set by interactive mode.
#>

function  Get-WinfieldInteractiveSettings {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [Object]
        $configuration
    )
    $skipDiagnostics = $false;
    $newConfig = $configuration.Clone();
    do {
        $i = Read-Host -Prompt "[Networking] DnsForwarderIpAddress [$($newConfig.NetworkSettings.DnsForwarderIpAddress)]"
        if ($i.Length) {
            $newConfig.NetworkSettings.DnsForwarderIpAddress = $i;
        }
        $i = Read-Host -Prompt "[Networking] IngressNICDefaultGateway [$($newConfig.NetworkSettings.IngressNICDefaultGateway)]"
        if ($i.Length) {
            $newConfig.NetworkSettings.IngressNICDefaultGateway = $i;
        }
        $i = Read-Host -Prompt "[Networking] IngressNICIPAddress [$($newConfig.NetworkSettings.IngressNICIPAddress)]"
        if ($i.Length) {
            $newConfig.NetworkSettings.IngressNICIPAddress = $i;
        }
        $i = Read-Host -Prompt "[Networking] IngressNICPrefixLength [$($newConfig.NetworkSettings.IngressNICPrefixLength)]"
        if ($i.Length) {
            $newConfig.NetworkSettings.IngressNICPrefixLength = $i;
        }
        $i = Read-Host -Prompt "[Networking] IsTelemetryOptOut [$($newConfig.NetworkSettings.IsTelemetryOptOut)]"
        if ($i.Length -gt 4) {
            $newConfig.NetworkSettings.IsTelemetryOptOut = $i;
        }
        $i = Read-Host -Prompt "Would you like to configure diagnostics? (Y/N)"
        if($i -and $i.Length -gt 0 -and $i -eq "y"){
            $skipDiagnostics = $false
            $i = Read-Host -Prompt "[Diagnostics] ResourceGroup [$($newConfig.Diagnostics.ResourceGroup)]"
            if ($i.Length) {
                $newConfig.Diagnostics.ResourceGroup = $i;
            }
            $i = Read-Host -Prompt "[Diagnostics] TenantId [$($newConfig.Diagnostics.TenantId)]"
            if ($i.Length) {
                $newConfig.Diagnostics.TenantId = $i;
            }
            $i = Read-Host -Prompt "[Diagnostics] Location [$($newConfig.Diagnostics.Location)]"
            if ($i.Length) {
                $newConfig.Diagnostics.Location = $i;
            }
            $i = Read-Host -Prompt "[Diagnostics] SubscriptionId [$($newConfig.Diagnostics.SubscriptionId)]"
            if ($i.Length) {
                $newConfig.Diagnostics.SubscriptionId = $i;
            }
            $i = Read-Host -Prompt "[Diagnostics] ServicePrincipalId [$($newConfig.Diagnostics.ServicePrincipalId)]"
            if ($i.Length) {
                $newConfig.Diagnostics.ServicePrincipalId = $i;
            }
            $i = Read-Host -Prompt "[Diagnostics] ServicePrincipalSecret [*******]" -MaskInput
            if ($i.Length) {
                $newConfig.Diagnostics.ServicePrincipalSecret = $i;
            }
        } else {
            $skipDiagnostics = $true
        }
    } while (-not (Test-WinfieldSettings -configuration $newConfig -skipDiagnosticsValidation:$skipDiagnostics -ErrorAction Continue));

    return $newConfig;
}

<#
    .SYNOPSIS
    Creates a Winfield settings file or configuration object - that can be exported to a file (as json)
 
    .PARAMETER path
    Path to export as settings file (Json). If not specified, returns settings objects
 
    .OUTPUTS
    Object. The returned settings object that can be edited, used to configure the appliance or exported as a file.
#>

function New-WinfieldSettings {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, ParameterSetName = "ExportToFile")]
        [string]
        $Path,

        [Parameter(Position = 1, Mandatory = $false)]
        [switch]
        $interactive
    )
    $networkSettings = @{
        "DnsForwarderIpAddress"    = "10.50.10.50";
        "IngressNICDefaultGateway" = "10.0.50.1";
        "IngressNICIPAddress"      = "10.0.50.4";
        "IngressNICPrefixLength"   = 24;
        "IsTelemetryOptOut"        = $false;
    }
    $observabilitySettings = @{
        "ResourceGroup"          = "WinfieldPreview";
        "TenantId"               = "<REPLACE ME>";
        "Location"               = "westus";
        "SubscriptionId"         = "<REPLACE ME>";
        "ServicePrincipalId"     = "<REPLACE ME>";
        "ServicePrincipalSecret" = "<REPLACE ME>";
    }

    $format = @{
        "NetworkSettings" = $networkSettings;
        "Diagnostics"     = $observabilitySettings;
    }

    if ($interactive.IsPresent) {
        $format = Get-WinfieldInteractiveSettings -configuration $format
    }

    if ($PSBoundParameters.ContainsKey('Path')) {
        Write-Verbose "Writing settings to file $path"
        $format | ConvertTo-Json | Set-Content -Path $path
        return $format
    }
    else {
        return $format
    }
}

<#
    .SYNOPSIS
    Gets a Winfield settings configuration object from file or default settings
 
    .PARAMETER path
    Path to settings file (Json).
 
    .OUTPUTS
    Object. The returned settings object that can be edited, used to configure the appliance or exported as a file.
#>


function Get-WinfieldSettings {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [string]
        $Path
    )

    if ($PSBoundParameters.ContainsKey('path')) {
        if (-not (Test-Path $path)) {
            Write-Error "Settings file does not exist"
            exit 1;
        }
        $rawContent = Get-Content -Path $path -raw
        if ($rawContent.Length -lt 2) {
            Write-Error "Empty config file"
            exit 1;
        }
        $config = $rawContent | ConvertFrom-Json
        if (!$?) {
            Write-Error "Invalid JSON format"
            exit 1;
        }

        return $config
    }
    else {
        Write-Verbose "Path not specified - returning default settings object"
        return New-WinfieldSettings
    }
}

<#
    .SYNOPSIS
    Verifies that the settings object has values within the range
 
    .PARAMETER path
    Path to settings file (Json)
 
    .PARAMETER configuration
    Alternative config object containing settings for the desired state.
 
    .OUTPUTS
    Boolean - true if configuration is valid. False if there is any issue.
#>

function Test-WinfieldSettings {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [object]
        $configuration,
        [Parameter(Position = 1, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [switch]
        $skipDiagnosticsValidation
    )
    $valid = $true
    $network = $configuration.NetworkSettings
    if (!$network) {
        Write-Error "Network settings not present"
        $valid = $false;
    }
    else {
        if (!([ipaddress]$network.DnsForwarderIpAddress)) {
            Write-Error "Network setting DnsForwarderIpAddress not valid IP Address"
            $valid = $false;
        }
        if (!([ipaddress]$network.IngressNICDefaultGateway)) {
            Write-Error "Network setting IngressNICDefaultGateway not valid IP"
            $valid = $false;
        }
        if (!([ipaddress]$network.IngressNICIPAddress)) {
            Write-Error "Network setting IngressNICIPAddress not valid IP"
            $valid = $false;
        }
        if ($network.IngressNICPrefixLength -lt 8 -or $network.IngressNICPrefixLength -gt 31) {
            Write-Error "Network setting IngressNICPrefixLength must be > 8 and < 32"
            $valid = $false;
        }
        $t = $false;
        if(!$network.IsTelemetryOptOut.GetType() -eq [bool]){
            if ([bool]::TryParse($network.IsTelemetryOptOut, [ref]$t)) {
                Write-Error "Network setting IsTelemetryOptOut must be true or false (is : $($network.IsTelemetryOptOut))"
                $valid = $false;
            }
        }
    }
    if (!$skipDiagnosticsValidation.IsPresent) {
        # [ipaddress]
        $diagnostics = $configuration.Diagnostics

        if (!$diagnostics) {
            Write-Error "Diagnostics settings not present"
            $valid = $false;
        }
        else {
            $g = [guid]::NewGuid();
            
            if (!$diagnostics.ResourceGroup -or $diagnostics.ResourceGroup.Length -lt 1) {
                Write-Error "Diagnostics settings - resource group is invalid"
                $valid = $false;
            }
            if (!$diagnostics.TenantId -or $diagnostics.TenantId -eq '<REPLACE ME>' -or ![guid]::tryParse($diagnostics.TenantId, [ref]$g)) {
                Write-Error "Diagnostics settings - TenantId is invalid. Must be set and must be a guid "
                $valid = $false;
            }
            if (!$diagnostics.Location -or $diagnostics.Location.Length -lt 5) {
                Write-Error "Diagnostics settings - Location is invalid. Must be set to a valid Azure location, e.g. westus "
                $valid = $false;
            }
            if (!$diagnostics.SubscriptionId -or $diagnostics.SubscriptionId -eq '<REPLACE ME>' -or ![guid]::tryParse($diagnostics.SubscriptionId, [ref]$g)) {
                Write-Error "Diagnostics settings - SubscriptionId is invalid. Must be set and must be a guid "
                $valid = $false;
            }
            if (!$diagnostics.ServicePrincipalId -or $diagnostics.ServicePrincipalId -eq '<REPLACE ME>' -or ![guid]::tryParse($diagnostics.ServicePrincipalId, [ref]$g)) {
                Write-Error "Diagnostics settings - ServicePrincipalId is invalid. Must be set and must be a guid "
                $valid = $false;
            }
            if (!$diagnostics.ServicePrincipalSecret -or $diagnostics.ServicePrincipalSecret -eq '<REPLACE ME>') {
                Write-Error "Diagnostics settings - ServicePrincipalSecret is invalid. Secret must be provided "
                $valid = $false;
            }  
        }
    }
    return $valid;
}

Write-Host "Use 'Import-Winfield -path <local-dir>' to setup the latest Winfield build."

Export-ModuleMember Import-Artifact
Export-ModuleMember Export-Artifact
Export-ModuleMember Restore-Artifact
Export-ModuleMember Save-Artifact
Export-ModuleMember Import-Winfield
Export-ModuleMember Save-Winfield
Export-ModuleMember Write-VhdSize
Export-ModuleMember Install-WinfieldCerts
Export-ModuleMember Test-Winfield

# Added module members for operator experience
Export-ModuleMember Get-WinfieldApplianceState
Export-ModuleMember Set-WinfieldApplianceDesiredState
Export-ModuleMember Get-WinfieldInteractiveSettings
Export-ModuleMember New-WinfieldSettings
Export-ModuleMember Get-WinfieldSettings
Export-ModuleMember Test-WinfieldSettings