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 |