Winfield.psm1
# Copyright (c) Microsoft Corporation. All rights reserved. # Winfield.psm1 0.99.0 2023-08-12 16:46:57 # ASZ-ArcA-Deploy main Debug-x64 #requires -Version 5.1 #requires -RunAsAdministrator $global:WinfieldInitComplete = $false $ErrorActionPreference = "Stop" # This script contains all functions to Restore Winfield from Azure Artifacts. # The script is standalone so it can be easily copied to different environments without any other dependencies. # By default checks for required software and hardware capabilities are included. # Import-Winfield and operator cmdlets are the primary methods exposed for use. $artifactsOrganizationUri = "https://msazure.visualstudio.com/" $artifactsFeedName = "AzureStackUniversalBuddy" function Trace-Execution([string] $message) { $caller = (Get-PSCallStack)[1] Write-Verbose -Message "[$([DateTime]::UtcNow.ToString('u'))][$($caller.Command)] $message" -Verbose } function Import-Artifact([string] $path, [PSCredential] $credential) { $IPaddressPrefix = "10.0.50" $vmSwitch = "Winfield-Ingress" $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 from path: $path" $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 $irVMRequiredRAM = 45GB $hostMinRAM = 4GB $requiredRAM = $irVMRequiredRAM + $hostMinRAM # Update VMCX if RAM available is below the saved image plus min required for host OS if ($PhysicalRAM -le $requiredRAM) { $irRAM = $PhysicalRAM - 4GB # 4gb for host OS Trace-Execution "Updating $vmName ($vmcxFilePath) to use $hostLogicalProcessorCount cores, $($irRAM / 1MB)GB ram" ModifyVmcx -vmcxPath $vmcxLocation -vmcxFilename $vmcxFilePath -coreCount $hostLogicalProcessorCount -ramMB ($irRAM/1MB) # get exported guid $updatedVmcx = (Get-ChildItem $vmcxLocation "*.vmcx")[0] $vmcxFilePath = $updatedVmcx.FullName Trace-Execution "Updated vmcx $vmcxFilePath" } $irVMRequiredCPU = 24 $hostMinCPU = 2 $requiredCPUCount = $irVMRequiredCPU + $hostMinCPU # Update VMCX if number of logical processors is less than required for the saved image if($hostLogicalProcessorCount -lt $requiredCPUCount) { $irRAM = 45Gb $irCPUCount = $hostLogicalProcessorCount - $hostMinCPU Trace-Execution "Updating $vmName ($vmcxFilePath) to use $irCPUCount cores, $($irRAM / 1MB)GB ram" ModifyVmcx -vmcxPath $vmcxLocation -vmcxFilename $vmcxFilePath -coreCount $irCPUCount -ramMB ($irRAM/1MB) # get exported guid $updatedVmcx = (Get-ChildItem $vmcxLocation "*.vmcx")[0] $vmcxFilePath = $updatedVmcx.FullName Trace-Execution "Updated vmcx $vmcxFilePath" } } # Windows Server release information # https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info $osVersionMin = [Version]'10.0.17763' # Windows Server 2019 $osVersion = ([Environment]::OSVersion).Version Trace-Execution "OS Version: $osVersion" if ($osVersion -ge $osVersionMin) { Trace-Execution "[START] Import-VM" Import-VM -Path "$vmcxFilePath" | Out-Null Trace-Execution "[END] Import-VM" } else { $unsupportedOSMsg = "Import-Winfield supported on Windows Server 2019 and later editions." Trace-Execution "$unsupportedOSMsg`r`nSee https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info for Windows Server releases." throw [PlatformNotSupportedException]::new("Error Importing Artifact: $unsupportedOSMsg") } } } # Set VM Processor CompatibilityForMigrationEnabled and start all non-running VMs $vmSet = Get-VMSet Set-VMProcessorCompatibility -vmSet $vmSet -compatibilityForMigration $false Start-VMSet $vmSet $vmStartWaitDurationSec = 120 Trace-Execution "Waiting for $vmStartWaitDurationSec sec for VMs to start..." Start-Sleep -Seconds 120 Trace-Execution "Done." } 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 and SysConfig $serviceTcpConnection = $false # NOTE: Parameterized to avoid PSScriptAnalyzer error PSAvoidUsingComputerNameHardcoded $netConnectionList = @" "Name","ComputerName","Port","Result" "Portal","portal.autonomous.cloud.private","443" "SysConfig","169.254.53.25","8320" "@ | ConvertFrom-Csv foreach ($netConnectionItem in $netConnectionList) { $netConnectionItem.Result = Test-NetConnection -ComputerName $netConnectionItem.ComputerName -Port $netConnectionItem.Port if ($netConnectionItem.Result.TcpTestSucceeded) { Write-Verbose -Message "Test-NetConnection $($netConnectionItem.ComputerName):$($netConnectionItem.Port) succeeded" } } $serviceTcpConnection = ($netConnectionList[0].Result.TcpTestSucceeded -and $netConnectionList[1].Result.TcpTestSucceeded) 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 Get-LKGVersionFromFeed { param ( [string] $ViewName ) $lkgVersion = "*" # Sign in with a personal access token (PAT) # https://learn.microsoft.com/en-us/azure/devops/cli/log-in-via-pat?view=azure-devops&tabs=windows $pat = [System.Environment]::GetEnvironmentVariable('AZURE_DEVOPS_EXT_PAT') if([string]::IsNullOrEmpty($pat)) { $ViewName = $ViewName.ToLower() $lkgBlobUri = "https://winfieldartifacts.blob.core.windows.net/control/control$ViewName.json" Trace-Execution "Obtain LKG version from $lkgBlobUri" $lkgContainer = (Invoke-RestMethod -uri $lkgBlobUri -UseBasicParsing).LKGContainer $lkgVersion = $lkgContainer.Replace("-",".") } else { $B64Pat = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("`:$pat")) $headers = @{Authorization = "Basic $B64Pat"; "Content-Type" = "application/json"} $FeedId = 'AzureStackUniversalBuddy' $packageName = 'arca.onenode.complete' $uri = "https://feeds.dev.azure.com/msazure/_apis/packaging/Feeds/$FeedId/Packages?packageNameQuery=$packageName&protocol%20Type=nuget&api-version=5.1-preview.1&includeAllVersions=true" $response = Invoke-WebRequest -Uri $uri -UseBasicParsing -Headers $headers -Verbose $data = ($response.Content | ConvertFrom-Json).value | Where-Object name -EQ $PackageName | Select-Object -First 1 $lkgLatest = $data.versions | Where-Object { $_.views.name -eq $ViewName }| Select-Object version, publishDate, id, storageId | Sort-Object publishDate -Descending | Select-Object -First 1 Trace-Execution "LKG build version for ViewName: $ViewName = $($lkgLatest.version)" $lkgVersion = $lkgLatest.version } Trace-Execution "LKG version for View $ViewName = $lkgVersion" return $lkgVersion } <# .SYNOPSIS Downloads artifacts from Azure DevOps Universal Feed #> function Restore-Artifact([string] $path, [string] $name = "arca.onenode.complete", [string] $ViewName, [string] $version, [bool] $useArtifactTool = $false, [bool] $skipInit = $false) { Trace-Execution "Path = $path ViewName = $ViewName Version = $version" Trace-Execution "[START] Downloading artifact from ADO: $name..." $ErrorActionPreference = "Continue" az cloud set --name 'AzureCloud' if ($useArtifactTool -eq $false) { Trace-Execution "az artifacts universal download --organization $artifactsOrganizationUri --feed $artifactsFeedName --name $name --version $version --path $path" az artifacts universal download --organization $artifactsOrganizationUri --feed $artifactsFeedName --name $name --version $version --path $path } else { Trace-Execution "ArtifactToolExe universal download --service $artifactsOrganizationUri --feed $artifactsFeedName --package-name $name --package-version $version --path $path --patvar UNIVERSAL_PAT" & $global:ArtifactToolExe universal download --service $artifactsOrganizationUri --feed $artifactsFeedName --package-name $name --package-version $version --path $path --patvar UNIVERSAL_PAT } $ErrorActionPreference = "Stop" Trace-Execution "[END] Download from ADO" } <# .SYNOPSIS Verifies MD5 checksum of downloaded Winfield artifacts. .PARAMETER Path Path to artifacthash.json .EXAMPLE Test-WinfieldCheckSum -Path "\\su1fileserver\SU1_Infrastructure_1\arca\0714\Winfield\IRVM01\artifacthash.json" #> function Test-WinfieldCheckSum { [OutputType([bool])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [ValidateScript({Test-Path -Path $_ -PathType Leaf})] [ValidatePattern('artifacthash[.]json$')] [string] $Path ) Trace-Execution "[START] Test-WinfieldCheckSum Path: $Path" $sw = [System.Diagnostics.Stopwatch]::StartNew() $artifactHashInfo = (Get-Content -Path $Path | ConvertFrom-Json).ArtifactHashInfo $artifactFolder = Split-Path -Parent $Path foreach($artifactInfo in $artifactHashInfo) { # TODO: Handle storage.json if ($artifactInfo.FileName -eq 'storage.json') { continue } $expectedMd5 = $artifactInfo.MD5 $localArtifact = (Get-ChildItem -Path $artifactFolder -Include $artifactInfo.FileName -Recurse).FullName $actualMD5 = (Get-FileHash -Path $localArtifact -Algorithm MD5).Hash Trace-Execution "File: $($artifactInfo.FileFullName) expected checksum: $expectedMd5 actual checksum: $actualMD5" if($expectedMd5 -ne $actualMD5) { $md5CheckFailedMessage = "$($artifactInfo.FileName) MD5 checksum does not match with expected checksum.`r`n" Trace-Execution "Download or checksum validation error.`r`n$md5CheckFailedMessage" $sw.Stop() Trace-Execution "[END] Test-WinfieldCheckSum completed with ERROR in $($sw.Elapsed.TotalSeconds) seconds." throw [System.InvalidOperationException]::new($md5CheckFailedMessage) } } $sw.Stop() Trace-Execution "[END] Test-WinfieldCheckSum completed with SUCCESS in $($sw.Elapsed.TotalSeconds) seconds." return $true } function Test-ImportWinfieldParameters { param ( $InputParameters, $InputArgs ) Trace-Execution "Following input parameters were specified:" foreach($key in $InputParameters.Keys) { $parameterStr = "[$Key] = " if($Key -eq 'code') { $parameterStr += "*" * 8 } else { $parameterStr += $InputParameters[$Key] } Trace-Execution "$parameterStr" } $i = 0 foreach($inputArg in $InputArgs) { Trace-Execution "$i $inputArg" $i++ } if($InputParameters.ContainsKey("code")) { Trace-Execution "Validating input code" $sasToken = $InputParameters["code"] Invoke-WebRequest -Uri "https://winfieldartifacts.blob.core.windows.net/control/controlrelease.json$sasToken" -UseBasicParsing | Out-Null Trace-Execution "validated input code" } if($InputParameters.ContainsKey("version")) { $version = [Version] $InputParameters["version"] Trace-Execution "Validated version format = $version" } $pathInfo = [System.Uri] $InputParameters["Path"] if($pathInfo.IsUnc) { $errMsg = "UNC paths are not yet supported." Trace-Execution "$($pathInfo.OriginalString)`: $errMsg" throw [System.NotSupportedException]::new($errMsg) } } function Get-WinfieldHostInfo { Trace-Execution "[START] Get-ComputerInfo" $computerInfo = Get-ComputerInfo Trace-Execution "Computer Info: $($computerInfo | Out-String)" Trace-Execution "[END] Get-ComputerInfo" } 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.autonomous.cloud.private 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" } <# .SYNOPSIS Connects to system config endpoint to retrieve system application health state. Retries every 30s until specified $TimeoutSec. #> function Wait-SystemReady { param ( [Parameter(Mandatory = $false)] [int] $TimeoutSec = 300 ) Trace-Execution "[START] Wait-SystemReady" $sw = [System.Diagnostics.Stopwatch]::StartNew() $intervalSec = 30 $iterations = [Math]::Ceiling($TimeoutSec / $intervalSec) for($i = 1; $i -le $iterations; $i++) { Trace-Execution "[CHECK][Attempt $i] System readiness..." try { $healthState = Get-WinfieldHealthState Trace-Execution "$($healthState | Out-String)" if($healthState.ReadinessStatusDetails.Services -eq 100) { Trace-Execution "System is ready to use." $sw.Stop() Trace-Execution "[END] Wait-SystemReady completed in $($sw.Elapsed.TotalSeconds) seconds." return $true } else { Trace-Execution "[WAIT] for $intervalSec sec before retry" Start-Sleep -Seconds $intervalSec } } catch { Trace-Execution "[ERROR] connecting to system config endpoint. $($_) `r`nWaiting for $intervalSec sec before retry." Start-Sleep -Seconds $intervalSec } } Trace-Execution "[ERROR] System hasn't converged in $TimeoutSec seconds." $sw.Stop() Trace-Execution "[END] Wait-SystemReady completed with error in $($sw.Elapsed.TotalSeconds) seconds." return $false } <# .SYNOPSIS Validates Winfield installation. #> function Test-Winfield { param ( [Parameter(Mandatory = $false)] [int] $TimeoutSec = 300 ) 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 [System.InvalidOperationException]::new("Winfield NIC $($adapter.Name) status is not ok") } } $systemReady = Wait-SystemReady -TimeoutSec $TimeoutSec if(-not $systemReady) { throw [System.TimeoutException]::new("System has not converged in $TimeoutSec. Check diagnostic logs for details.") } # basic portal test Trace-Execution "Testing if Portal is accessible" $portalPingUrl = 'https://portal.autonomous.cloud.private/api/ping' $pingResponseFile = "$env:APPDATA\Winfield\pingresponse.json" Remove-Item -Path $pingResponseFile -Force -ErrorAction SilentlyContinue DownloadWithRetry -url $portalPingUrl -downloadLocation $pingResponseFile -retries 30 if(-not (Test-path $pingResponseFile)) { throw [System.InvalidOperationException]::new("Portal /api/ping status is $($response.StatusCode) instead of http/200") } Trace-Execution "END: Validating Winfield installation" } function Show-SystemConfiguration { try { New-Item -Path "$env:APPDATA\Winfield" -ItemType Directory -Force | Out-Null $sysCfgFile = "$env:APPDATA\Winfield\WinfieldVersion.json" DownloadWithRetry -url 'http://169.254.53.25:8320/SystemConfiguration' -downloadLocation $sysCfgFile $sysCfgContent = Get-Content -Path $sysCfgFile Trace-Execution "System Configuration: $sysCfgContent" } catch { Trace-Execution "Error calling http://169.254.53.25:8320/SystemConfiguration: $_`r`n$($_.Exception)." } } function Set-ObsSettingsInternal { param ( [Parameter(Mandatory = $true)] $webRequestParams ) try { $response = Invoke-WebRequest @webRequestParams } catch { if($null -ne $_.Exception.Response) { $responseStatusCode = [int] $_.Exception.Response.StatusCode } Trace-Execution "Error setting observability configuration. $($_) Status code: http/$responseStatusCode" throw $_.Exception } if($response.StatusCode -ne 202) { $obsErr = "Error setting observability configuration. Status code: $(response.StatusCode)" Trace-Execution "$obsErr" throw [System.InvalidOperationException]::new($obsErr) } } <# .SYNOPSIS Set default Winfield Observability configuration #> function Set-DefaultObservabilityConfiguration { [CmdletBinding()] param ( ) $webRequestParams = @{ Uri = "http://169.254.53.25:8320/ObservabilityConfiguration" Method = 'PUT' Body = (@{} | ConvertTo-Json) ContentType = 'application/json' UseBasicParsing = $true TimeOutSec = 30 } Trace-Execution "[START] Set Default Observability configuration: $($webRequestParams)" Set-ObsSettingsInternal -webRequestParams $webRequestParams Trace-Execution "[END] Set Set Observability configuration" } <# .SYNOPSIS Set Winfield Observability configuration #> function Set-ObservabilityConfiguration { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ResouceGroupName, [Parameter(Mandatory = $true)] [string] $TenantId, [Parameter(Mandatory = $true)] [string] $Location, [Parameter(Mandatory = $true)] [string] $SubscriptionId, [Parameter(Mandatory = $true)] [string] $ServicePrincipalId, [Parameter(Mandatory = $true)] [securestring] $ServicePrincipalSecret ) $obsCfgParams = @{ ResourceGroup = $ResouceGroupName TenantId = $TenantId Location = $Location SubscriptionId = $SubscriptionId ServicePrincipalId = $ServicePrincipalId ServicePrincipalSecret = ([System.Net.NetworkCredential]::new("", $ServicePrincipalSecret).Password) } $webRequestParams = @{ Uri = "http://169.254.53.25:8320/ObservabilityConfiguration" Method = 'PUT' Body = ($obsCfgParams | ConvertTo-Json) ContentType = 'application/json' UseBasicParsing = $true } Trace-Execution "[START] Set Observability configuration" Set-ObsSettingsInternal -webRequestParams $webRequestParams Trace-Execution "[END] Set Observability configuration" } <# .SYNOPSIS Get Winfield Observability configuration #> function Get-ObservabilityConfiguration { [CmdletBinding()] param ( ) $obsCfg = (Invoke-WebRequest -Uri "http://169.254.53.25:8320/ObservabilityConfiguration" -Method 'Get' -UseBasicParsing -TimeoutSec 30).Content Trace-Execution "Observability configuration: $obsCfg" return $obsCfg } <# .SYNOPSIS Exports Winfield root certificate .EXAMPLE Export-WinfieldRootCert -FilePath c:\winfield\winfieldRoot.cer #> function Export-WinfieldRootCert { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string] [ValidateScript({(-not [String]::IsNullOrEmpty([System.IO.Path]::GetFileName($_)))})] $FilePath = "$env:APPDATA\Winfield\winfieldRoot.cer" ) Trace-Execution "[START] Export Winfield root cert to $FilePath" $retries = 20 $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 seconds." Start-Sleep -Seconds $waitSec } } if($null -eq $response.certificate) { throw "Failed to download Winfield certificate from sys config endpoint" } $certFolder = [System.IO.Path]::GetDirectoryName($FilePath) New-Item -Path $certFolder -ItemType Directory -Force | Out-Null # note this cert is already base64 encoded format $response.certificate | Out-File $FilePath Trace-Execution "Winfield root cert exported to $FilePath" Trace-Execution "[END] Export Winfield root cert" } <# .SYNOPSIS Imports Winfield certificates into local cert store and Python cert store (if Azure CLI is installed) .EXAMPLE Import-WinfieldRootCert -FilePath c:\winfield\winfieldRoot.cer #> function Import-WinfieldRootCert { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string] [ValidateScript({Test-Path $_})] $FilePath = "$env:APPDATA\Winfield\winfieldRoot.cer" ) Trace-Execution "[START] Import Winfield root cert" Import-Certificate -FilePath $FilePath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null Import-Certificate -FilePath $FilePath -CertStoreLocation Cert:\CurrentUser\Root | Out-Null Trace-Execution "$(Get-ChildItem "$env:APPDATA\Winfield" | Out-String)" -Verbose if (Test-CliInstalled) { UpdatePythonCertStore -WinfieldRootCertPath $FilePath } Trace-Execution "[END] Import Winfield cert" } function UpdatePythonCertStore { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string] [ValidateScript({Test-Path $_})] $WinfieldRootCertPath = "$env:APPDATA\Winfield\winfieldRoot.cer" ) Trace-Execution "[START] Updating CLI cert store with Winfield root cert at $WinfieldRootCertPath" $cerFile = $WinfieldRootCertPath 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 ) # azcopy list # https://learn.microsoft.com/en-us/azure/storage/common/storage-ref-azcopy-list # Adding these [flags] to support automation scenarios. # --skip-version-check Do not perform the version check at startup. Intended for automation scenarios & airgapped use. # --machine-readable Lists file sizes in bytes. $output = Invoke-AzCopy -operation "blob list pattern: $pattern" -azCopyParameters @('list', "$lkgBlobUri$code", '--skip-version-check', '--machine-readable') $vmFiles = @() $matchInfo = $output | Select-String -Pattern $pattern foreach($match in $matchInfo) { # INFO: ArcA_ABData.vhdx; Content Length: 2.54 GiB # INFO: ArcA_ABData.vhdx; Content Length: 2722103296 (--machine-readable) $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:`r`n$($vmFiles | ConvertTo-Json)" 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" $azcliOutputFile = "azcli_out.txt" $azcliErrFile = "azcli_err.txt" Start-Process -FilePath $azCopyExe -ArgumentList $azCopyParameters -RedirectStandardOutput $azcliOutputFile -RedirectStandardError $azcliErrFile -Wait -NoNewWindow Trace-Execution "LASTEXITCODE = $LASTEXITCODE." $azcliOutput = Get-Content -Path $azcliOutputFile -ErrorAction SilentlyContinue $azcliError = Get-Content -Path $azcliErrFile -ErrorAction SilentlyContinue Trace-Execution "AzCLI output: $azcliOutput" if(-not [string]::IsNullOrEmpty($azcliError)) { Trace-Execution "AzCLI error: $azcliError" throw "Error executing azcopy command $azCopyCommand. See azcopy logs in $env:HOMEDRIVE\$env:HOMEPATH\.azcopy for details." } else { Trace-Execution "azcopy command $azCopyCommand executed successfully." } # if($LASTEXITCODE -ne 0) # { # throw "Error executing azcopy command $azCopyCommand.`r`n`r`nSee azcopy logs for details." # } # else # { # Trace-Execution "azcopy command $azCopyCommand executed successfully." # } } catch { if($ErrorActionPreference -eq 'Continue') { Trace-Execution "Ignoring error $($_)" } else { throw $_.Exception } } Trace-Execution "[END][$operation]" return $azcliOutput } function Resolve-BlobUrl { param ( [string] $blobUrl, [string] $ViewName = 'release', [string] $version, [string] $code ) $blobContainer = "" if([string]::IsNullOrEmpty($version)) { # download and read control data file New-Item -Path "$env:APPDATA\Winfield" -ItemType Directory -Force | Out-Null $downloadedControlDataFile = Join-Path -Path "$env:APPDATA\Winfield" -ChildPath "localControl.json" $controlDataBlobUrl = "$blobUrl/control/control$ViewName.json" Trace-Execution "Get blob container info from: $controlDataBlobUrl" $output = Invoke-AzCopy -operation "cp $controlDataBlobUrl" -azCopyParameters @('cp', "$controlDataBlobUrl$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." $blobContainer = $controlData.PreviousLKGContainer Trace-Execution "Use previous LKG container: $blobContainer" } else { $blobContainer = $controlData.LKGContainer } } else { Trace-Execution "Version $version specified" $blobContainer = $version.Replace('.','-') Trace-Execution "Will download from blob container $blobContainer" } $resolvedBlobUrl = "$blobUrl/$blobContainer" 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 Set-VMHostVMPath { param ( [string] $path ) Import-Module Hyper-V -ErrorAction Stop Trace-Execution "Setting VMHost VM Path location = $path" Set-VMHost -VirtualMachinePath $path } function Test-Hardware { param ( [string] $path ) Trace-Execution "[START] Hardware check" Trace-Execution "Checking hardware requirements: minimum 8 vCPU, 24GB RAM, install path 200GB SSD drive space, Hyper-V path 50GB drive space. Recommended: 24 vCPU, 48GB RAM" # Check Processor Trace-Execution "[START] Checking 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 = (Get-CimInstance -ClassName Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum if ($hostLogicalProcessorCount -lt 8 -and -not $bestFit) { throw "This machine has $hostLogicalProcessorCount cores. Winfield requires a minimum of 8 cores." } Trace-Execution "[END] Checking processor" # Check RAM Trace-Execution "[START] Checking RAM" $PhysicalRAM = (Get-CimInstance -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum if ($PhysicalRAM -lt 24GB) { 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." } } Trace-Execution "[END] Checking RAM" # Check disk space Trace-Execution "[START] Checking 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 200GB) { $available = [int] ($volume.SizeRemaining / 1GB) Trace-Execution "The path '$path' has $($available)GB available." throw "Winfield requires at least 200GB 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 $restoreSpace = [int] ($volume.SizeRemaining / 1GB) $defaultSpace = [int] ($hvVolume.SizeRemaining / 1GB) Trace-Execution "[END] Checking Disk space" Trace-Execution "Hardware check passed - $hostLogicalProcessorCount proc, $($PhysicalRam)GB RAM, restore space $($restoreSpace)GB, Hyper-V space $($defaultSpace)GB" Trace-Execution "[END] Hardware check" return $true } <# .SYNOPSIS Downloads Winfield Appliance .EXAMPLE #To download latest Winfield release artifacts from blob storage to the current working directory. Invoke-DownloadWinfieldAppliance -path . -code 'SAS_TOKEN' .EXAMPLE #To download latest Winfield release artifacts from ADO universal feed to the current working directory. #Download from ADO universal feed option requires Azure CLI to be installed, and logged into Azure CLI. #Optionally, set AZURE_DEVOPS_EXT_PAT environment variable. See Example 4 for details. Invoke-DownloadWinfieldAppliance -path . .EXAMPLE #To download latest Winfield prerelease artifacts from blob storage to the f:\winfield. Folder will be created if it does not exist. Invoke-DownloadWinfieldAppliance -path f:\winfield -code 'SAS_TOKEN' -ViewName 'prerelease' .EXAMPLE #To download specific Winfield release version from ADO universal feed to the current working directory. #If AZURE_DEVOPS_EXT_PAT environment variable is set, there's no need to log into Azure CLI. $env:AZURE_DEVOPS_EXT_PAT = 'ADO_PAT' Invoke-DownloadWinfieldAppliance -path . -version '...' #> function Invoke-DownloadWinfieldAppliance { param ( [Parameter(Mandatory=$true, ParameterSetName = 'DefaultSet')] [Parameter(Mandatory=$true, ParameterSetName = 'Code')] [ValidateScript({Test-Path $_})] [string] $Path, [Parameter(Mandatory=$false, ParameterSetName = 'DefaultSet')] [Parameter(Mandatory=$false, ParameterSetName = 'Code')] [ValidateNotNullOrEmpty()] [ValidateSet('release', 'prerelease')] [string] $ViewName = "release", [Parameter(Mandatory=$false, ParameterSetName = 'DefaultSet')] [Parameter(Mandatory=$false, ParameterSetName = 'Code')] [ValidateNotNullOrEmpty()] [string] $version, [Parameter(Mandatory=$true, ParameterSetName = 'Code', HelpMessage = 'Specify Azure Blob container SAS token')] [ValidateNotNullOrEmpty()] [string] $code ) Trace-Execution "[START] Invoke-DownloadWinfieldAppliance" Initialize-Download -code $code $sw = [System.Diagnostics.Stopwatch]::StartNew() $downloadFolder = $Path New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null Trace-Execution "Winfield will be downloaded to $downloadFolder" if(-not [string]::IsNullOrEmpty($code)) { $blobBaseUrl = "https://winfieldartifacts.blob.core.windows.net" $blobUrl = Resolve-BlobUrl -blobUrl $blobBaseUrl -code $code -ViewName $ViewName Trace-Execution "Winfield LKG Blob URL for ViewName $ViewName = $blobUrl" # create fixed local folder for downloading the artifact $irvmFolder = Join-Path -Path $downloadFolder -ChildPath "IRVM01" New-Item -ItemType Directory -Path $irvmFolder -Force | Out-Null Trace-Execution "Winfield artifacts will be downloaded from blob container $blobBaseUrl to $downloadFolder and moved to $irvmFolder" # create destination folder layout New-Item -ItemType Directory -Path "$irvmFolder\Snapshots" -Force | Out-Null New-Item -ItemType Directory -Path "$irvmFolder\Virtual Hard Disks" -Force | Out-Null New-Item -ItemType Directory -Path "$irvmFolder\Virtual Machines" -Force | Out-Null <# $expectedVhdxFiles = @('IRVM01.vhdx', 'ArcA_EphemeralData_IRVM01_1.vhdx', 'ArcA_LocalData_IRVM01_1.vhdx', 'ArcA_SharedData_IRVM01.vhdx', 'Docker_IRVM01_1.vhdx' ) #> # TODO: determine list from storage.json $expectedVhdxFiles = GetExpectedVmFiles -lkgBlobUri $blobUrl -code $code -pattern @(".vhdx") -expectedFileCount 12 $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 $downloadFolder -destination "$irvmFolder\Virtual Hard Disks" DownloadArtifactFromBlob -blobUrl $blobUrl -artifacts $expectedVmFiles -code $code -downloadFolder $downloadFolder -destination "$irvmFolder\Virtual Machines" DownloadArtifactFromBlob -blobUrl $blobUrl -artifacts "artifacthash.json" -code $code -downloadFolder $downloadFolder -destination "$irvmFolder" DownloadArtifactFromBlob -blobUrl $blobUrl -artifacts "Version.json" -code $code -downloadFolder $downloadFolder -destination "$irvmFolder" $importVmPath = (Get-Item -Path $irvmFolder).Parent.FullName Trace-Execution "Winfield artifacts are in $importVmPath" Trace-Execution "$((Get-ChildItem $importVmPath -Recurse).FullName | Out-String)" -Verbose # TODO: wire up validate downloaded files #$artifactHashFile = (Resolve-Path -Path "$path\artifacthash.json").Path #Trace-Execution "Validating MD5 checksum of downloaded files using $artifactHashFile" #$result = Test-WinfieldCheckSum -Path $artifactHashFile $result = $true Trace-Execution "Validation result: $result" } else { Trace-Execution "Downloading from ADO artifact feed." $name = "arca.onenode.complete" Trace-Execution "Winfield artifacts will be downloaded from ADO artifact feed $name to $downloadFolder" if([string]::IsNullOrEmpty($version)) { Trace-Execution "Version number not specified, get LKG version from ADO Universal Feed for ViewName = $ViewName" $version = Get-LKGVersionFromFeed -ViewName $ViewName } Trace-Execution "Using version: $version" $result = Restore-Artifact -path $downloadFolder -name $name -ViewName $ViewName -version $version } $sw.Stop() Trace-Execution "Download result: $result" Trace-Execution "Download completed in $($sw.Elapsed.TotalSeconds) seconds." Trace-Execution "[END] Invoke-DownloadWinfieldAppliance" return $result } <# .SYNOPSIS Helper function to cleanup existing VMs #> function Invoke-CleanupVM { param ( [bool] $clean ) Trace-Execution "[START] Invoke-CleanupVM" $isClean = $false $existingVMs = Get-VMSet if ($null -ne $existingVMs -and $existingVMs.Count -gt 0) { Trace-Execution "Appliance VM exists" if (-not $clean) { $notCleanMessage = "Remove any existing Winfield VMs prior to restoring or add '-clean' to the Import-Winfield command." Trace-Execution "$notCleanMessage" throw "$notCleanMessage" } else { Trace-Execution "Removing existing Winfield virtual machines..." RemoveVMs $existingVMs Trace-Execution "Appliance VM removed" $isClean = $true } } else { Trace-Execution "Appliance VM does not exist" $isClean = $true } Trace-Execution "Environment IsClean: $isClean" Trace-Execution "[END] Invoke-CleanupVM" return $isClean } function Test-DownloadWinfield { param ( [Parameter(Mandatory=$true)] $Path, [Parameter(Mandatory=$false)] [bool] $VerifyCheckSum ) Trace-Execution "[START] Test-DownloadWinfield folder: $Path" $downloadValid = $false if(-not (Test-Path -Path (Join-Path -Path $Path -ChildPath "IRVM01"))) { Trace-Execution "[ERROR] IRVM01 folder not found under $Path, specify path to folder that contains IRVM01." } else { $expectedVmFiles = @('*.VMCX', '*.VMGS', '*.VMRS') # TODO: verify from storage.json file which will be included soon $expectedVhdxFiles = @( 'ArcA_ABData.vhdx' 'ArcA_EphemeralData_A.vhdx' 'ArcA_EphemeralData_B.vhdx' 'ArcA_LocalData_A.vhdx' 'ArcA_LocalData_B.vhdx' 'ArcA_SharedData_A.vhdx' 'ArcA_SharedData_B.vhdx' 'BCDR_1.vhdx' 'OSAndDocker_A.vhdx' 'OSAndDocker_B.vhdx' 'Reserved_2.vhdx' 'Reserved_3.vhdx' ) foreach($expectedVmFile in $expectedVmFiles) { $vmFilePath = Join-Path -Path $Path -ChildPath "IRVM01\Virtual Machines\$expectedVmFile" Trace-Execution "Checking for $vmFilePath" if(-not (Test-Path -Path $vmFilePath)) { Trace-Execution "[ERROR] Did not find expected VM File $expectedVmFile under $Path" return $false } } foreach($expectedVhdxFile in $expectedVhdxFiles) { $vhdxFilePath = Join-Path -Path $Path -ChildPath "IRVM01\Virtual Hard Disks\$expectedVhdxFile" Trace-Execution "Checking for $vhdxFilePath" if(-not (Test-Path -Path $vhdxFilePath)) { Trace-Execution "[ERROR] Did not find expected VHD File $expectedVhdxFile under $Path" return $false } } $downloadValid = $true } if($VerifyCheckSum) { $artifacthashPath = (Get-ChildItem -Path $Path -Filter 'artifacthash.json' -Recurse).FullName Trace-Execution "ArtifactHashFile path = $artifacthashPath" if($null -eq $artifacthashPath) { Trace-Execution "[ERROR] Could not locate artifacthash.json under $Path" } else { Trace-Execution "Note: Checksum verification can take upto 30 min to complete." $checkSumResult = Test-WinfieldCheckSum -Path $artifacthashPath Trace-Execution "Checksum result: $checkSumResult" } $downloadValid = $checkSumResult } else { Trace-Execution "[WARN] Consider specifying -VerifyCheckSum flag to verify checksum. Currently only supported for blob downloads." } if($downloadValid) { Trace-Execution "[SUCCESS] Winfield artifacts at $Path verified." } else { $errorMsg = "[ERROR] Winfield artifacts at $Path is not valid download." Trace-Execution "$errorMsg" } Trace-Execution "[END] Test-DownloadWinfield folder: $Path" return $downloadValid } <# .SYNOPSIS Imports Winfield and completes the installation process that includes networking setup, installing root cert, and validating install. .EXAMPLE #Installs Winfield appliance using files at f:\winfield directory. This this the top level folder that contains IRVM01 folder. Install-WinfieldAppliance -path f:\winfield .EXAMPLE #Installs Winfield appliance using files in the current folder and cleanup existing VM Install-WinfieldAppliance -path . -clean .EXAMPLE #Installs Winfield appliance using files in the current folder and cleanup existing VM and use bestfit option. Use bestfit option if recommended hardware spec aren't available. Install-WinfieldAppliance -path . -clean -bestfit #> function Install-WinfieldAppliance { param ( [Parameter(Mandatory=$true)] [string] $Path, [switch] $VerifyCheckSum, [switch] $clean, [switch] $bestFit, [int] $TimeoutSec = 1800 ) # START Transcript $timestamp = [DateTime]::UtcNow.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 } Trace-Execution "[START] Install-Winfield" Test-ImportWinfieldParameters -InputParameters $PsBoundParameters -InputArgs $args $sw = [System.Diagnostics.Stopwatch]::StartNew() $installSuccessful = $false try { # Verify downloaded artifacts $validDownload = Test-DownloadWinfield -Path $Path -VerifyCheckSum $VerifyCheckSum.IsPresent if(-not $validDownload) { $errorMsg = "Error verifying Winfield artificats. Check logs for details." throw [System.InvalidOperationException]::new($errorMsg) } # Initialize VM Host Initialize-VMHost # Update hosts file Update-HostsFile # Get host info Get-WinfieldHostInfo # Import Import-WinfieldAppliance -path $Path -clean $clean.IsPresent -bestFit $bestFit.IsPresent # Networking setup NetworkSetupPostImport WaitForVMNetwork # Install CLI Install-AzCLI # Get root cert public key from SysConfig service and install it in the local cert store and python cert store $rootCertPath = "$env:APPDATA\Winfield\winfieldRoot.cer" Export-WinfieldRootCert -FilePath $rootCertPath Import-WinfieldRootCert -FilePath $rootCertPath # Validate install Test-Winfield -TimeoutSec $TimeoutSec $installSuccessful = $true } catch { Trace-Execution "Error Installing Winfield:`r`n$($_)" } finally { Show-SystemConfiguration Show-PostInstall -DeployResult $installSuccessful $sw.Stop() Trace-Execution "Install-WinfieldAppliance completed execution in $($sw.Elapsed.TotalSeconds) seconds." # STOP Transcript try { Stop-Transcript | Out-String | Write-Verbose -Verbose } catch { Write-Warning -Message $_.Exception.Message } } Trace-Execution "[END] Install-Winfield" return $installSuccessful } <# .SYNOPSIS Imports the downloaded Winfield appliance VM to local hyper-v server #> function Import-WinfieldAppliance { param ( [Parameter(Mandatory=$true)] [string] $Path, [bool] $clean, [bool] $bestFit ) Trace-Execution "[START] Import-WinfieldAppliance" Test-ImportWinfieldParameters -InputParameters $PSBoundParameters -InputArgs $args $importSuccessful = $false $sw = [System.Diagnostics.Stopwatch]::StartNew() # cleanup prior deployment if(Invoke-CleanupVM -clean $clean) { Trace-Execution "Winfield appliance doesn't exist, proceeding with hardware pre-req test." } else { throw "Cleanup failed, please remove prior appliance VM manually." } # hardware pre-req test $vmHostVMPath = (Resolve-Path -Path $Path).Path Set-VMHostVMPath -path $vmHostVMPath $hwTestResult = Test-Hardware -path $Path if($hwTestResult) { Trace-Execution "Hardware test passed." } else { throw "Hardware test failed" } # import VM Import-Artifact -path $vmHostVMPath -bestFit $bestFit $importSuccessful = $true $sw.Stop() Trace-Execution "Import success: $importSuccessful" Trace-Execution "Imported appliance VM in $($sw.Elapsed.TotalSeconds) seconds." Trace-Execution "[END] Import-WinfieldAppliance" return $importSuccessful } function Get-VMSet() { $vmSet = Get-VM | Where-Object { $_.Name -like "IRVM*" } return $vmSet } function Set-VMProcessorCompatibility($vmSet, $compatibilityForMigration) { Trace-Execution "Setting VM Processor CompatibilityForMigrationEnabled = $compatibilityForMigration" foreach ($vm in $vmSet) { $name = $vm.Name Trace-Execution "Start: Set VM Processor CompatibilityForMigrationEnabled $name" if (($vm.State -eq "Off")) { Get-VM -name $name | Set-VMProcessor -CompatibilityForMigrationEnabled $compatibilityForMigration } Trace-Execution "Complete: Set VM Processor CompatibilityForMigrationEnabled $name" } } 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 DoesVmExist($vmSet, [string]$value) { foreach ($vm in $vmSet) { if ($vm.Name -eq $value) { return $true } } return $false } function Show-NetIPAddressInfo { $netIpCfgs = Get-NetIPConfiguration | Where-Object{$_.InterfaceDescription -inotlike "*hyper-v*"} foreach($netIpCfg in $netIpCfgs) { Trace-Execution "$($netIpCfg | Out-String)" Trace-Execution "NetIpAddress: $(Get-NetIPAddress -InterfaceIndex $netIpCfg.InterfaceIndex | Out-String)" } } function CreateVmNetwork([string] $vmSwitch, [string] $hostIp, [string]$IPaddressPrefix) { Trace-Execution "[BEFORE] CreateVmNetwork" Show-NetIPAddressInfo 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 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 Trace-Execution "[AFTER] CreateVmNetwork" Show-NetIPAddressInfo } function NetworkSetupPostImport { Trace-Execution "[BEFORE] NetworkSetupPostImport" Show-NetIPAddressInfo 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" -ErrorAction SilentlyContinue 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" -ErrorAction SilentlyContinue $networkAdapters = Get-VMNetworkAdapter -VMName 'IRVM01' Trace-Execution "IRVM01 network adapters: $($networkAdapters | Out-String)" foreach($networkAdapter in $networkAdapters) { if(($null -eq $networkAdapter.SwitchName) -or ($networkAdapter.Name -ne $networkAdapter.SwitchName)) { throw "IRVM01 network adapter $($networkAdapter.Name) is not connected to Switch or doesn't match switch name." } } Trace-Execution "[END] Post VM import networking setup" Trace-Execution "[AFTER] NetworkSetupPostImport" Show-NetIPAddressInfo } 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 Add-HostsFileEntry { param ( [string] $hostName, [string] $ip = "127.0.0.1" ) $hostsPath = "$env:windir\System32\drivers\etc\hosts" $hosts = Get-Content -Path $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 -FilePath $hostsPath -Encoding 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)" } } } # Installs HyperV features on the VM Host where Winfield appliance will be imported function Initialize-VMHost { param ( ) # Microsoft Windows 10 Enterprise # Microsoft Azure Stack HCI Trace-Execution "[START] Check Hyper-V pre-req" $osName = (Get-CimInstance -ClassName Win32_OperatingSystem).Caption 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" } Trace-Execution "[END] Check Hyper-V pre-req" } # Updates hosts file on the VM Host where Winfield appliance will be imported function Update-HostsFile { Trace-Execution "[START] Update hosts file" $hostEntries = @( @{name = "irvm01"; ip = "10.0.50.4"}, @{name = "his.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "login.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "hosting.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "portal.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "graph.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "armmanagement.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "adminmanagement.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "catalogapi.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "artifacts.blob.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "acrmanagedaccount0.blob.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "azgns-dev-autonomous-00.servicebus.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "mycontainerregistry.edgeacr.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "guestnotificationservice.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "autonomous.dp.kubernetesconfiguration.autonomous.cloud.private"; ip = "10.0.50.4"}, @{name = "agentserviceapi.autonomous.cloud.private"; ip = "10.0.50.4"} ) foreach ($entry in $hostEntries) { Trace-Execution "Adding hosts entry: $($entry.ip) $($entry.name)" Add-HostsFileEntry -hostName $entry.name -ip $entry.ip | Out-Null } Trace-Execution "[END] Update hosts file" } function Test-CliInstalled { $installed = Get-Command "az.cmd" -ErrorAction "SilentlyContinue" return ($null -ne $installed) } # Installs Azure CLI and azure-devops CLI extension function Install-AzCLI { param ( ) if (-not (Test-CliInstalled)) { Trace-Execution "Install/Update Azure-CLI" DownloadWithRetry -url "https://aka.ms/install-winfield-cli-windows" -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" } else { Trace-Execution "Azure CLI is already installed." } $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 } else { Trace-Execution "Azure DevOps CLI extension is already installed." } } function Initialize-Download([bool] $skipInit = $false, [string]$code) { if ($global:WinfieldInitComplete -ne $true) { if ($skipInit -eq $false) { if([string]::IsNullOrEmpty($code)) { Trace-Execution "CLI login is required to download artifacts. Install CLI if needed ..." Install-AzCLI $pat = [System.Environment]::GetEnvironmentVariable('AZURE_DEVOPS_EXT_PAT') if([string]::IsNullOrEmpty($pat)) { Trace-Execution "Switch cloud to AzureCloud" az cloud set -n AzureCloud Trace-Execution "Verify CLI login works ..." $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 "AZURE_DEVOPS_EXT_PAT will be used for downloading from ADO Universal feed." } } 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 ProcessResult($result, $successString, $failureString) { #Return success if the return value is "0" if ($result.ReturnValue -eq 0) { Trace-Execution "$successString" #If the return value is not "0" or "4096" then the operation failed } elseif ($result.ReturnValue -ne 4096) { Trace-Execution "$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) { Trace-Execution "$($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) { Trace-Execution "$successString" } else { Trace-Execution "$failureString" Trace-Execution "ErrorCode: $($job.ErrorCode)" Trace-Execution "ErrorDescription: $($job.ErrorDescription)" } } } <# .SYNOPSIS Modifies Core and Memory configuration of Virtual Machine (VM). #> function ModifyVmcx { param ( [string] $vmcxPath, [ValidatePattern('[.]vmcx$')] [string] $vmcxFilename, [int] $coreCount = 0, [int] $ramMB = 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 -result $importResult ` -successString "Virtual machine configuration loaded into memory." ` -failureString "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 -result $memoryChangeResult ` -successString "Memory settings have been updated to $ramMB." ` -failureString "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 -result $procChangeResult ` -successString "Processor settings have been updated to $coreCount." ` -failureString "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 -result $exportResult ` -successString "Created new virtual machine confguration file." ` -failureString "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 Trace-Execution "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 } # Currently only desktop edition is supported. specifically, PSEdition = Core version is not supported function Test-PSEdition { $versionTable = $PSVersionTable Trace-Execution "PSVersionTable:`r`n $($versionTable | Out-String)`r`n" if($versionTable.PSEdition -ne 'Desktop') { $errorMsg = 'Winfield module only supports PowerShell Desktop Edition.' throw [System.InvalidOperationException]::new($errorMsg) } } <# .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-WinfieldApplianceSettings { [CmdletBinding()] [OutputType([Hashtable])] 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 Set the current WinfieldAppliance desired state based on config or settings file (if path is given) .PARAMETER path Path to settings file (Json) .PARAMETER config Alternative config object containing settings for the desired state. .EXAMPLE Create a settings object using interactive mode and set the state $settings = New-WinfieldSettings -interactive Set-WinfieldApplianceDesiredState -configuration $settings -verbose .EXAMPLE Import settings from file and configure the appliance $settings = New-WinfieldSettings -path c:\winfield\applianceConfig.json Set-WinfieldApplianceDesiredState -configuration $settings -verbose .OUTPUTS Object. The returned values from the management endpoint. #> function Set-WinfieldApplianceDesiredState { [CmdletBinding(DefaultParameterSetName = "FromConfigObject", ConfirmImpact = "High")] [OutputType([PSCustomObject[]])] 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 = Import-WinfieldSettingsFromFile -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" } 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 Get the Winfield appliance health state (and convergence state) .OUTPUTS Json Object containing the health state and system convergence. #> function Get-WinfieldHealthState { [OutputType([PSCustomObject[]])] 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' ) $endpoint = "http://$($endpointIp):$($endpointPort)/SystemReadiness" $result = Invoke-RestMethod -Method Get -Uri $endpoint -ContentType 'application/json' return $result } <# .SYNOPSIS Creates a Winfield 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()] [OutputType([Hashtable])] param ( [Parameter(Position = 0, 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 } return $format } <# .SYNOPSIS Exports the Winfield settings configuration object to file .PARAMETER config Configuration object .PARAMETER path Path to settings file (Json). .OUTPUTS Object. The exported config object #> function Export-WinfieldSettingsToFile { [CmdletBinding()] param ( [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [object] $configuration, [Parameter(Position = 1, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [string] $Path ) Write-Verbose "Writing settings to file $path" $configuration | ConvertTo-Json | Set-Content -Path $path return $configuration } <# .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 Import-WinfieldSettingsFromFile { [CmdletBinding()] param ( [Parameter(Position = 0, Mandatory = $true, 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-Error "Path not specified - returning default settings object" exit 1; } } <# .SYNOPSIS Verifies that the configuration settings are valid. .PARAMETER configuration Configuration object containing settings to validate. .PARAMETER skipDiagnosticsValidation Skips diagnostics validation. .OUTPUTS Boolean. true if configuration is valid. False if there is any issue. #> function Test-WinfieldSettings { [CmdletBinding()] [OutputType([Boolean])] 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; } <# .SYNOPSIS Gets the cloud configurations required for Azure CLI's "az cloud register" command. .PARAMETER ArmEndpoint The Azure Resource Management endpoint used for retrieving the environment's various endpoints. .PARAMETER OutputFolder The optional output folder path to output. .PARAMETER ApiVersion The API version of the ARM metadata endpoints to get. .OUTPUTS String. cloud configuration string to be used for the "az cloud register". If OutputFolder was passed, it also creates a "cloudconfig.json" file in that location. .EXAMPLE PS> Get-AzCliCloudConfig -ArmEndpoint "<ARM ENDPOINT>" { "suffixes": { "keyvaultDns": "...", "storageEndpoint": "...", "acrLoginServerEndpoint": "..." }, "endpoints": { "activeDirectory": "...", "activeDirectoryGraphResourceId": "...", "resourceManager": "...", "microsoftGraphResourceId": "...", "activeDirectoryResourceId": "..." } } #> function Get-AzCliCloudConfig { [CmdletBinding()] [OutputType([String])] param ( [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [string] $ArmEndpoint, [Parameter(Position = 1, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [string] $OutputFolder, [Parameter(Position = 2, Mandatory = $false, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [string] $ApiVersion = "2022-09-01" ) $armMetadataUrl = "$($armEndpoint.TrimEnd('/'))/metadata/endpoints?api-version=${ApiVersion}" try { $response = Invoke-WebRequest $armMetadataUrl ` -Method 'GET' ` -ContentType "application/json" ` -UseBasicParsing } catch { Write-Error "Failed to get ARM metadata endpoints at '$armMetadataUrl'." throw $_ } $cloudEndpoints = $response.Content | ConvertFrom-Json $cloudConfig = @{ endpoints = @{ activeDirectory = "$($cloudEndpoints.authentication.loginEndpoint.TrimEnd('/'))/adfs" activeDirectoryGraphResourceId = $cloudEndpoints.graph activeDirectoryResourceId = $cloudEndpoints.authentication.audiences[0] resourceManager = $cloudEndpoints.resourceManager microsoftGraphResourceId = $cloudEndpoints.graph } suffixes = @{ storageEndpoint = $cloudEndpoints.suffixes.storage keyvaultDns = $cloudEndpoints.suffixes.keyvaultDns acrLoginServerEndpoint = $cloudEndpoints.suffixes.acrLoginServer } } $cloudConfigJson = $cloudConfig | ConvertTo-Json if ($OutputFolder) { $cloudConfigJson | Set-Content -Path "$OutputFolder\cloudconfig.json" } return $cloudConfigJson } Test-PSEdition Write-Host "Use Invoke-DownloadWinfieldAppliance to download Winfield and Install-WinfieldAppliance to install Winfield." # Installation cmdlets Export-ModuleMember Invoke-DownloadWinfieldAppliance Export-ModuleMember Install-WinfieldAppliance Export-ModuleMember Export-WinfieldRootCert Export-ModuleMember Import-WinfieldRootCert Export-ModuleMember Test-Winfield Export-ModuleMember Test-WinfieldCheckSum Export-ModuleMember Test-DownloadWinfield # Added module members for operator experience Export-ModuleMember Get-WinfieldApplianceSettings Export-ModuleMember Set-WinfieldApplianceDesiredState Export-ModuleMember Get-WinfieldHealthState Export-ModuleMember New-WinfieldSettings Export-ModuleMember Import-WinfieldSettingsFromFile Export-ModuleMember Test-WinfieldSettings Export-ModuleMember Get-WinfieldSettings Export-ModuleMember Test-WinfieldSettings Export-ModuleMember Get-ObservabilityConfiguration Export-ModuleMember Set-ObservabilityConfiguration Export-ModuleMember Set-DefaultObservabilityConfiguration Export-ModuleMember Get-AzCliCloudConfig Export-ModuleMember Copy-WinfieldDiagnosticData Export-ModuleMember Get-ObservabilityStampId Export-ModuleMember Send-WinfieldDiagnosticData # SIG # Begin signature block # MIIoKgYJKoZIhvcNAQcCoIIoGzCCKBcCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCk0YikllHb3bgF # J1v60wbxL7Ur0aTn5f7Mhr1lK59RFaCCDXYwggX0MIID3KADAgECAhMzAAADTrU8 # esGEb+srAAAAAANOMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjMwMzE2MTg0MzI5WhcNMjQwMzE0MTg0MzI5WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQDdCKiNI6IBFWuvJUmf6WdOJqZmIwYs5G7AJD5UbcL6tsC+EBPDbr36pFGo1bsU # p53nRyFYnncoMg8FK0d8jLlw0lgexDDr7gicf2zOBFWqfv/nSLwzJFNP5W03DF/1 # 1oZ12rSFqGlm+O46cRjTDFBpMRCZZGddZlRBjivby0eI1VgTD1TvAdfBYQe82fhm # WQkYR/lWmAK+vW/1+bO7jHaxXTNCxLIBW07F8PBjUcwFxxyfbe2mHB4h1L4U0Ofa # +HX/aREQ7SqYZz59sXM2ySOfvYyIjnqSO80NGBaz5DvzIG88J0+BNhOu2jl6Dfcq # jYQs1H/PMSQIK6E7lXDXSpXzAgMBAAGjggFzMIIBbzAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUnMc7Zn/ukKBsBiWkwdNfsN5pdwAw # RQYDVR0RBD4wPKQ6MDgxHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEW # MBQGA1UEBRMNMjMwMDEyKzUwMDUxNjAfBgNVHSMEGDAWgBRIbmTlUAXTgqoXNzci # tW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3JsMGEG # CCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDovL3d3dy5taWNyb3NvZnQu # Y29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3J0 # MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIBAD21v9pHoLdBSNlFAjmk # mx4XxOZAPsVxxXbDyQv1+kGDe9XpgBnT1lXnx7JDpFMKBwAyIwdInmvhK9pGBa31 # TyeL3p7R2s0L8SABPPRJHAEk4NHpBXxHjm4TKjezAbSqqbgsy10Y7KApy+9UrKa2 # kGmsuASsk95PVm5vem7OmTs42vm0BJUU+JPQLg8Y/sdj3TtSfLYYZAaJwTAIgi7d # hzn5hatLo7Dhz+4T+MrFd+6LUa2U3zr97QwzDthx+RP9/RZnur4inzSQsG5DCVIM # pA1l2NWEA3KAca0tI2l6hQNYsaKL1kefdfHCrPxEry8onJjyGGv9YKoLv6AOO7Oh # JEmbQlz/xksYG2N/JSOJ+QqYpGTEuYFYVWain7He6jgb41JbpOGKDdE/b+V2q/gX # UgFe2gdwTpCDsvh8SMRoq1/BNXcr7iTAU38Vgr83iVtPYmFhZOVM0ULp/kKTVoir # IpP2KCxT4OekOctt8grYnhJ16QMjmMv5o53hjNFXOxigkQWYzUO+6w50g0FAeFa8 # 5ugCCB6lXEk21FFB1FdIHpjSQf+LP/W2OV/HfhC3uTPgKbRtXo83TZYEudooyZ/A # Vu08sibZ3MkGOJORLERNwKm2G7oqdOv4Qj8Z0JrGgMzj46NFKAxkLSpE5oHQYP1H # tPx1lPfD7iNSbJsP6LiUHXH1MIIHejCCBWKgAwIBAgIKYQ6Q0gAAAAAAAzANBgkq # hkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x # EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv # bjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 # IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEwOTA5WjB+MQswCQYDVQQG # EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG # A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYDVQQDEx9NaWNyb3NvZnQg # Q29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC # CgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+laUKq4BjgaBEm6f8MMHt03 # a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc6Whe0t+bU7IKLMOv2akr # rnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4Ddato88tt8zpcoRb0Rrrg # OGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+lD3v++MrWhAfTVYoonpy # 4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nkkDstrjNYxbc+/jLTswM9 # sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6A4aN91/w0FK/jJSHvMAh # dCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmdX4jiJV3TIUs+UsS1Vz8k # A/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL5zmhD+kjSbwYuER8ReTB # w3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zdsGbiwZeBe+3W7UvnSSmn # Eyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3T8HhhUSJxAlMxdSlQy90 # lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS4NaIjAsCAwEAAaOCAe0w # ggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRIbmTlUAXTgqoXNzcitW2o # ynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYD # VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBDuRQFTuHqp8cx0SOJNDBa # BgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2Ny # bC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3JsMF4GCCsG # AQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3J0MIGfBgNV # HSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEFBQcCARYzaHR0cDovL3d3 # dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1hcnljcHMuaHRtMEAGCCsG # AQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkAYwB5AF8AcwB0AGEAdABl # AG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn8oalmOBUeRou09h0ZyKb # C5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7v0epo/Np22O/IjWll11l # hJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0bpdS1HXeUOeLpZMlEPXh6 # I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/KmtYSWMfCWluWpiW5IP0 # wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvyCInWH8MyGOLwxS3OW560 # STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBpmLJZiWhub6e3dMNABQam # ASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJihsMdYzaXht/a8/jyFqGa # J+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYbBL7fQccOKO7eZS/sl/ah # XJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbSoqKfenoi+kiVH6v7RyOA # 9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sLgOppO6/8MO0ETI7f33Vt # Y5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtXcVZOSEXAQsmbdlsKgEhr # /Xmfwb1tbWrJUnMTDXpQzTGCGgowghoGAgEBMIGVMH4xCzAJBgNVBAYTAlVTMRMw # EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN # aWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNp # Z25pbmcgUENBIDIwMTECEzMAAANOtTx6wYRv6ysAAAAAA04wDQYJYIZIAWUDBAIB # BQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEO # MAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIFvgMYjUA/oxfqt9mWN4I30e # LkHWtCO2Zym8KVnsmBjVMEIGCisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8A # cwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEB # BQAEggEANKqppAEg3oRr0QSmxex3anXUuQfBpn/NydHvFM/SA1+SJpAph2s8p0RA # X6/1VsQOSwd7g+8gLetatSiSid6zn8K3ZF6y61pUgZk8V2UM++YrAA5XkhXleeWc # +nQ7nojSIU/3jOeJTVMJFyHXcCutvO+g1YbB2t0o1857chSQmftLb5YL2g9ZvAai # Zr8uLjiOebXgqZaeCSskepxpnaNdSpxE3G8+IYBJLv/UcbREXW0TZArYpeON2zjF # iJQnMvE+YW7hEUbA1OyfajkB3oGiGbemGNtKSSAUOZg7lx4TPYyfg6u/sjcm1Yr+ # ljsHCGp5hFuUZmgFAqcpD4lQaJ47HaGCF5QwgheQBgorBgEEAYI3AwMBMYIXgDCC # F3wGCSqGSIb3DQEHAqCCF20wghdpAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFSBgsq # hkiG9w0BCRABBKCCAUEEggE9MIIBOQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFl # AwQCAQUABCBnSIx+qqz7BPSuM/1FllIFX2enjOiISXaV8jqRG1RzNwIGZNT7QQN0 # GBMyMDIzMDgxMjE2NDg0OC42NTNaMASAAgH0oIHRpIHOMIHLMQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1l # cmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTYwMC0w # NUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2Wg # ghHqMIIHIDCCBQigAwIBAgITMwAAAdj8SzOlHdiFFQABAAAB2DANBgkqhkiG9w0B # AQsFADB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYD # VQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAeFw0yMzA1MjUxOTEy # NDBaFw0yNDAyMDExOTEyNDBaMIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2Fz # aGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENv # cnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25z # MScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNV # BAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQDNeOsp0fXgAz7GUF0N+/0EHcQFri6wliTbmQNmFm8D # i0CeQ8n4bd2td5tbtzTsEk7dY2/nmWY9kqEvavbdYRbNc+Esv8Nfv6MMImH9tCr5 # Kxs254MQ0jmpRucrm3uHW421Cfva0hNQEKN1NS0rad1U/ZOme+V/QeSdWKofCThx # f/fsTeR41WbqUNAJN/ml3sbOH8aLhXyTHG7sVt/WUSLpT0fLlNXYGRXzavJ1qUOe # Pzyj86hiKyzQJLTjKr7GpTGFySiIcMW/nyK6NK7Rjfy1ofLdRvvtHIdJvpmPSze3 # CH/PYFU21TqhIhZ1+AS7RlDo18MSDGPHpTCWwo7lgtY1pY6RvPIguF3rbdtvhoyj # n5mPbs5pgjGO83odBNP7IlKAj4BbHUXeHit3Da2g7A4jicKrLMjo6sGeetJoeKoo # j5iNTXbDwLKM9HlUdXZSz62ftCZVuK9FBgkAO9MRN2pqBnptBGfllm+21FLk6E3v # VXMGHB5eOgFfAy84XlIieycQArIDsEm92KHIFOGOgZlWxe69leXvMHjYJlpo2VVM # tLwXLd3tjS/173ouGMRaiLInLm4oIgqDtjUIqvwYQUh3RN6wwdF75nOmrpr8wRw1 # n/BKWQ5mhQxaMBqqvkbuu1sLeSMPv2PMZIddXPbiOvAxadqPkBcMPUBmrySYoLTx # wwIDAQABo4IBSTCCAUUwHQYDVR0OBBYEFPbTj0x8PZBLYn0MZBI6nGh5qIlWMB8G # A1UdIwQYMBaAFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCG # Tmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUy # MFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4w # XAYIKwYBBQUHMAKGUGh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2Vy # dHMvTWljcm9zb2Z0JTIwVGltZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwG # A1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQD # AgeAMA0GCSqGSIb3DQEBCwUAA4ICAQCunA6aSP48oJ1VD+SMF1/7SFiTGD6zyLC3 # Ju9HtLjqYYq1FJWUx10I5XqU0alcXTUFUoUIUPSvfeX/dX0MgofUG+cOXdokaHHS # lo6PZIDXnUClpkRix9xCN37yFBpcwGLzEZlDKJb2gDq/FBGC8snTlBSEOBjV0eE8 # ICVUkOJzIAttExaeQWJ5SerUr63nq6X7PmQvk1OLFl3FJoW4+5zKqriY/PKGssOa # A5ZjBZEyU+o7+P3icL/wZ0G3ymlT+Ea4h9f3q5aVdGVBdshYa/SehGmnUvGMA8j5 # Ct24inx+bVOuF/E/2LjIp+mEary5mOTrANVKLym2kW3eQxF/I9cj87xndiYH55Xf # rWMk9bsRToxOpRb9EpbCB5cSyKNvxQ8D00qd2TndVEJFpgyBHQJS/XEK5poeJZ5q # gmCFAj4VUPB/dPXHdTm1QXJI3cO7DRyPUZAYMwQ3KhPlM2hP2OfBJIr/VsDsh3sz # LL2ZJuerjshhxYGVboMud9aNoRjlz1Mcn4iEota4tam24FxDyHrqFm6EUQu/pDYE # DquuvQFGb5glIck4rKqBnRlrRoiRj0qdhO3nootVg/1SP0zTLC1RrxjuTEVe3PKr # ETbtvcODoGh912Xrtf4wbMwpra8jYszzr3pf0905zzL8b8n8kuMBChBYfFds916K # Tjc4TGNU9TCCB3EwggVZoAMCAQICEzMAAAAVxedrngKbSZkAAAAAABUwDQYJKoZI # hvcNAQELBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw # DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # MjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAy # MDEwMB4XDTIxMDkzMDE4MjIyNVoXDTMwMDkzMDE4MzIyNVowfDELMAkGA1UEBhMC # VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV # BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRp # bWUtU3RhbXAgUENBIDIwMTAwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC # AQDk4aZM57RyIQt5osvXJHm9DtWC0/3unAcH0qlsTnXIyjVX9gF/bErg4r25Phdg # M/9cT8dm95VTcVrifkpa/rg2Z4VGIwy1jRPPdzLAEBjoYH1qUoNEt6aORmsHFPPF # dvWGUNzBRMhxXFExN6AKOG6N7dcP2CZTfDlhAnrEqv1yaa8dq6z2Nr41JmTamDu6 # GnszrYBbfowQHJ1S/rboYiXcag/PXfT+jlPP1uyFVk3v3byNpOORj7I5LFGc6XBp # Dco2LXCOMcg1KL3jtIckw+DJj361VI/c+gVVmG1oO5pGve2krnopN6zL64NF50Zu # yjLVwIYwXE8s4mKyzbnijYjklqwBSru+cakXW2dg3viSkR4dPf0gz3N9QZpGdc3E # XzTdEonW/aUgfX782Z5F37ZyL9t9X4C626p+Nuw2TPYrbqgSUei/BQOj0XOmTTd0 # lBw0gg/wEPK3Rxjtp+iZfD9M269ewvPV2HM9Q07BMzlMjgK8QmguEOqEUUbi0b1q # GFphAXPKZ6Je1yh2AuIzGHLXpyDwwvoSCtdjbwzJNmSLW6CmgyFdXzB0kZSU2LlQ # +QuJYfM2BjUYhEfb3BvR/bLUHMVr9lxSUV0S2yW6r1AFemzFER1y7435UsSFF5PA # PBXbGjfHCBUYP3irRbb1Hode2o+eFnJpxq57t7c+auIurQIDAQABo4IB3TCCAdkw # EgYJKwYBBAGCNxUBBAUCAwEAATAjBgkrBgEEAYI3FQIEFgQUKqdS/mTEmr6CkTxG # NSnPEP8vBO4wHQYDVR0OBBYEFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMFwGA1UdIARV # MFMwUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWlj # cm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0bTATBgNVHSUEDDAK # BggrBgEFBQcDCDAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMC # AYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvX # zpoYxDBWBgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20v # cGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYI # KwYBBQUHAQEETjBMMEoGCCsGAQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNydDANBgkqhkiG # 9w0BAQsFAAOCAgEAnVV9/Cqt4SwfZwExJFvhnnJL/Klv6lwUtj5OR2R4sQaTlz0x # M7U518JxNj/aZGx80HU5bbsPMeTCj/ts0aGUGCLu6WZnOlNN3Zi6th542DYunKmC # VgADsAW+iehp4LoJ7nvfam++Kctu2D9IdQHZGN5tggz1bSNU5HhTdSRXud2f8449 # xvNo32X2pFaq95W2KFUn0CS9QKC/GbYSEhFdPSfgQJY4rPf5KYnDvBewVIVCs/wM # nosZiefwC2qBwoEZQhlSdYo2wh3DYXMuLGt7bj8sCXgU6ZGyqVvfSaN0DLzskYDS # PeZKPmY7T7uG+jIa2Zb0j/aRAfbOxnT99kxybxCrdTDFNLB62FD+CljdQDzHVG2d # Y3RILLFORy3BFARxv2T5JL5zbcqOCb2zAVdJVGTZc9d/HltEAY5aGZFrDZ+kKNxn # GSgkujhLmm77IVRrakURR6nxt67I6IleT53S0Ex2tVdUCbFpAUR+fKFhbHP+Crvs # QWY9af3LwUFJfn6Tvsv4O+S3Fb+0zj6lMVGEvL8CwYKiexcdFYmNcP7ntdAoGokL # jzbaukz5m/8K6TT4JDVnK+ANuOaMmdbhIurwJ0I9JZTmdHRbatGePu1+oDEzfbzL # 6Xu/OHBE0ZDxyKs6ijoIYn/ZcGNTTY3ugm2lBRDBcQZqELQdVTNYs6FwZvKhggNN # MIICNQIBATCB+aGB0aSBzjCByzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hp # bmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jw # b3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEn # MCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOjk2MDAtMDVFMC1EOTQ3MSUwIwYDVQQD # ExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQBI # p++xUJ+f85VrnbzdkRMSpBmvL6CBgzCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w # IFBDQSAyMDEwMA0GCSqGSIb3DQEBCwUAAgUA6IIcszAiGA8yMDIzMDgxMjE0NTg1 # OVoYDzIwMjMwODEzMTQ1ODU5WjB0MDoGCisGAQQBhFkKBAExLDAqMAoCBQDoghyz # AgEAMAcCAQACAg/YMAcCAQACAhLCMAoCBQDog24zAgEAMDYGCisGAQQBhFkKBAIx # KDAmMAwGCisGAQQBhFkKAwKgCjAIAgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZI # hvcNAQELBQADggEBAFoe+kCiMHCxa+OcLGzebLQn/AM74r6mE2CaRsd6RRr2VaTF # UhKZ0z6CyCPx1LLSpUqZMwzfCgNeDg1eUgi+GsSgnD3qLRIK0AzpnCOfB+PnSa+J # oaO5pD1g2vZgrch+wSx2gW/J3wGtVgPxRa3sNx8E1vD0zosXgWN/zkfaEL0yq0lW # gRFSb93Q5VrKSSXdOew3MpRg14dwDJeRFx5eHCtGDMK5mL0gDznQ351ufaJYo2Se # A/koONIbhJg8Sbjas8xAh8UMhs5HMnOLeBl/1bLvo4Tj3kosgAho16KwPGLprCBN # dL9pn/fguc/ap/VofV1f+IPpVQ7glpCzhX4YJCExggQNMIIECQIBATCBkzB8MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNy # b3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAAdj8SzOlHdiFFQABAAAB2DAN # BglghkgBZQMEAgEFAKCCAUowGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8G # CSqGSIb3DQEJBDEiBCA3y89o1k6XsJOIrAKhfdKSpcPsaJj7IC+w7VSz8YANkzCB # +gYLKoZIhvcNAQkQAi8xgeowgecwgeQwgb0EIDrjIX/8CZN3RTABMNt5u73Mi3o3 # fmvq2j8Sik+2s75UMIGYMIGApH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldh # c2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD # b3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIw # MTACEzMAAAHY/EszpR3YhRUAAQAAAdgwIgQgvOtgxDLd2V1H83Ves/5zK5EsZhKM # FR80wPDMjduK8JcwDQYJKoZIhvcNAQELBQAEggIAmONNs0t3nuztq6wHaviiqZ3L # hh8OR+2iat61SEaVTzECxH9pQ8yqkawIXxUDhNnqbaVyVinij/qt4gWgsNf+eu6h # +alVIC2ajvuN4+nN0ud/xPBXv1XW0LnkpqGYhYG9VpzyStTJ8s1mt9PgAoAuq5HB # /eJFTbVAFSZT5KzdUkCJTnmBOcBtR10fSlMi65ycEfaSmXhVohLbCcEhEF0nqwLw # WSU0UvY0zKuqRVWyuzITUXX5IrGZsNT16MkVTUVEr5+IBn8untQYWkh5+x2NzzaP # PLbHmVoTmp87bymIGNA2O+nYLzdu5XDzzDkVfE3oOipmo1fIIQqbR470FUzxNccB # lilKUAe/rpepMITDUJ8QzA1ZsmWZrs7WCnYhhM1NIDmGOgerBkLb7X7KtDZeod7I # PpnT0WeCR7F873NWHbj4z31sl67cKS9SweFcv0WO9Ja0e8aLzNaD2o8iPvQb88er # +a3xAk1EXiusAYQ19LYZ1QvKk5vFg4WTX0w94eJPTuQvnABD5S3uXDW3JM1wtlxd # iiDf4eOyRzIT+aGeBEKHHWrBbuP5WPlwTDHy6Il/a6nEWtZEWWUTeEVPu5D0ckxs # muatGU8W0P9QoATL8HSLzxBB2RuGScYJlG4BCBBPb/CPJ0vY8Oz7f/iJLD0bca87 # JyzFjdZzXsMo4SZZC8Q= # SIG # End signature block |