AVDManagementFramework.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\AVDManagementFramework.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName AVDManagementFramework.Import.DoDotSource -Fallback $false if ($AVDManagementFramework_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName AVDManagementFramework.Import.IndividualFiles -Fallback $false if ($AVDManagementFramework_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE PS C:\> . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { . Import-ModuleFile -Path $path } # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { . Import-ModuleFile -Path $path } # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'AVDManagementFramework' -Language 'en-US' function New-AVDMFResourceName { <# .SYNOPSIS This function generates resource names as per the naming convention. .DESCRIPTION The function reads naming conventions and abbreviations from configuration files and outputs resource names to use. .EXAMPLE TODO: Add Examples PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = "Does not change any states")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [Parameter()] [string] $ResourceType, [string] $DeploymentStage = $script:DeploymentStage, [string] $ResourceCategory, #This is part of the ABV category [string] $NameSuffix, [string] $AccessLevel, # Enterprise, Specialist, Privileged [string] $HostPoolType, # Shared, Dedicated [string] $Location = $script:Location, [string] $HostPoolInstance, [string] $ParentName, [string] $AddressPrefix, [Int] $InstanceNumber, [string] $UniqueNameString # For resources that require global name uniqueness (Storage Accounts / FunctionApps) #TODO: Change parameters to overloads so we don't have to provide them. (Except deployment stage?) ) Write-PSFMessage -Level Debug -Message "Calculating a name for a resource of type {0}" -StringValues $ResourceType # Selecting a naming style $namingStyle = $script:NamingStyles | Where-Object { $_.ResourceType -eq $ResourceType } if (-not $namingStyle) { $namingStyle = $script:NamingStyles | Where-Object { $_.ResourceType -eq 'Default' } } Write-PSFMessage -Level Debug -Message "Resource type '{0}' is configured to use '{1}' naming style" -StringValues $ResourceType, $namingStyle.ResourceType [array] $nameArray = foreach ($component in $namingStyle.NameComponents) { if ($component -like "*Abv") { $componentName = $component -replace "Abv", "" $componentNC = $component -replace "Abv", "NC" #Check if component naming convention is available # TODO: Use hashtable for naming conventions instead of dynamic variable names! # Assumption - hashtable stored in $script:namingConvention try { Get-Variable -Name $componentNC -ErrorAction Stop | Out-Null } catch { throw "Could not find a naming convention for component: $componentName. It should be supplied in configuration as .\NamingConvention\Components\$($componentName).json" } # Default or custom abbreviation $componentNCmembers = (Get-Variable -Name $componentNC).value | Get-Member -MemberType NoteProperty | Where-Object Name -NE $componentName $abbreviationMarker = ($componentNCmembers | Where-Object Name -EQ ("{0}Abv" -f $ResourceType)).Name if (-Not $abbreviationMarker) { $abbreviationMarker = "Abbreviation" } if ($componentName -eq 'Subscription') { $namingConvention = (Get-Variable -Name $componentNC -Scope Script).Value $filterScript = [ScriptBlock]::Create("`$_.DeploymentStage -eq `$DeploymentStage") } elseif ($componentName -eq 'Location') { $namingConvention = (Get-Variable -Name $componentNC -Scope Script).Value $filterScript = [ScriptBlock]::Create("`$_.Location -eq `$Script:Location") } else { $namingConvention = (Get-Variable -Name $componentNC -Scope Script).Value $filterScript = [ScriptBlock]::Create("`$_.$componentName -eq `$$componentName") } #FRED: $script:namingConvention[$componentName].$abbreviationMarker $abv = ($namingConvention | Where-Object -FilterScript $filterScript).$abbreviationMarker if (-not $abv) { $errorMessage = "Resource Type: {0} - Naming Style: {1} - Could not find abbreviation for $componentName`: $((Get-Variable -Name $componentName).Value)" -f $ResourceType,$namingStyle.ResourceType Write-PSFMessage -Level Error -Message $errorMessage throw $errorMessage } $abv } if ($component -like "static_*") { $component -replace "static_", "" } if ($component -in ('-', '_')) { $component } if ($component -eq 'ParentName') { $ParentName } if ($component -eq 'NameSuffix') { $NameSuffix } if ($component -eq 'AddressPrefix') { $AddressPrefix -replace "/", "-" } if ($component -eq 'HostPoolInstance') { $HostPoolInstance } } $resourceName = $nameArray -join "" -replace "-All", "" -replace "All", "" if ($namingStyle.LowerCase) { $resourceName = $resourceName.ToLower() } if ($namingStyle.NameComponents -contains 'InstanceNumber') { #TODO: Move this part to the main loop. if ($InstanceNumber) { $resourceName = "{0}{1:D2}" -f $resourceName, $InstanceNumber } else { $scriptResourceType = (Get-Variable -Name "$($ResourceType)s" -Scope Script).Value $filterScript = [ScriptBlock]::Create("`$_ -like `"$resourceName*`"") $count = ($scriptResourceType.Keys | Where-Object -FilterScript $filterScript).Count #TODO: Fix this once we have resource name attribute for all resource if ($count -eq 0) { $count = ($scriptResourceType.GetEnumerator() | ForEach-Object { $_.Value.ResourceName } | Where-Object -FilterScript $filterScript).count } $resourceName = "{0}{1:D2}" -f $resourceName, ($Count + 1) } } if ($namingStyle.NameComponents -contains 'UniqueNameString') { $resourceName = "{0}{1}" -f $resourceName, $UniqueNameString } if ($namingStyle.NameComponents -contains 'FillUnique') { $subscriptionIdNoDash = $script:AzSubscriptionId -replace "-", "" $resourceName = "{0}x{1}" -f $resourceName, $subscriptionIdNoDash.substring(0, ($namingStyle.MaxLength - $resourceName.length - 1)) } if ($resourceName.length -gt $namingStyle.MaxLength) { throw "Resulting resource name is longer than $($namingStyle.MaxLength) characters '$resourceName'" } $resourceName } function Convert-HashtableToArray { [OutputType('System.Array')] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline=$true )] [Hashtable] $InputObject ) process { $output = foreach ($key in $InputObject.Keys){ $object = @{ Name = $key } $Members = $InputObject[$key] | Get-Member -MemberType NoteProperty # TODO: $hash['h104p01-vnet-pv-01'].psobject.Properties.name $Members | ForEach-Object {$object[$_.Name] = $InputObject[$key].($_.Name)} $object } ,$output # "The comma makes it output an array ALWAYS, that's it" -Fred! } } function Get-AVDMFResourceInfo { [CmdletBinding()] param ( [string] $ResourceId ) $pattern = '^\/subscriptions\/(?<SubscriptionId>.+)\/resourceGroups\/(?<ResourceGroupName>.+)\/providers.+\/(?<ResourceName>.+$)' if($ResourceId -match $pattern){ [PSCustomObject]@{ SubscriptionId = $Matches.SubscriptionId ResourceGroupName = $Matches.ResourceGroupName ResourceName = $Matches.ResourceName } } else {throw "Resource ID is not valid: $ResourceId"} } function Get-RandomPassword { #Link: https://gist.github.com/onlyann/00d9bb09d4b1338ffc88a213509a6caf param( [Parameter(Mandatory = $false)] [ValidateRange(12, 256)] [int] $length = 14 ) $symbols = '!@#$%^&*'.ToCharArray() $characterList = 'a'..'z' + 'A'..'Z' + '0'..'9' + $symbols do { $password = "" for ($i = 0; $i -lt $length; $i++) { $randomIndex = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32(0, $characterList.Length) $password += $characterList[$randomIndex] } [int]$hasLowerChar = $password -cmatch '[a-z]' [int]$hasUpperChar = $password -cmatch '[A-Z]' [int]$hasDigit = $password -match '[0-9]' [int]$hasSymbol = $password.IndexOfAny($symbols) -ne -1 } until (($hasLowerChar + $hasUpperChar + $hasDigit + $hasSymbol) -ge 3) $password #| ConvertTo-SecureString -AsPlainText } function New-AVDMFSubnetRange { [cmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = "Does not change any states")] param( # Address Space for the subnet, this can be any of the address spaces created under the vNet in the format X.X.X.X/X [Parameter(Mandatory = $true)][string]$AddressSpace, # Mask bits of the new subnet, written as XX (example 27) [Parameter(Mandatory = $true)][int]$NewSubnetMaskBits ) #region: functions function ConvertFrom-DecimalIPtoBinary ([string]$DecimalIPAddress) { #Create an empty variable $Binary = $null #Extract octets from IP Address $Octets = $DecimalIPAddress.Split('.') #Convert each octet to Binary and add to the variable $Binary # Here we use ToString with '2' as the base, 2 means binary # We are also using padleft to make sure each octet is 8 bits long with leading zeros if needed $Octets | ForEach-Object { $Binary += ([convert]::ToString($_, 2)).PadLeft(8, "0") } return $Binary } function ConvertFrom-BinaryIPtoDecimal ([string]$BinaryIPAddress) { #Create an empty string $Decimal = $null #Split Binary address into 4 octets - And convert to decimal # Again, 2 is for the base. $Octets = for ($i = 0; $i -lt 4; $i++) { [convert]::ToInt32($BinaryIPAddress.Substring($i * 8, 8), 2) } # Join the octets into one string with "." as delimeter $Decimal = $Octets -join "." return $Decimal } function Find-IPAddressesInRange ($FirstIPAddress, $LastIPAddress) { # First we confirm the IP Addresses to Binary, then to int64 $Int64IP1 = [convert]::Toint64((ConvertFrom-DecimalIPtoBinary $FirstIPAddress), 2) $Int64IP2 = [convert]::Toint64((ConvertFrom-DecimalIPtoBinary $LastIPAddress), 2) # Then we just create a loop of all the values in the range of int64 $IPAddresses = for ($i = $Int64IP1; $i -le $Int64IP2; $i++) { #Finally, we convert the int64 to binary then back to a decimal IP Address ConvertFrom-BinaryIPtoDecimal ([convert]::ToString($i, 2)).padleft(32, "0") } return $IPAddresses } function Get-SubnetDetails ($IPAddress, $MaskBits) { $BinaryIPAddress = ConvertFrom-DecimalIPtoBinary $IPAddress $SubnetID = ConvertFrom-BinaryIPtoDecimal $BinaryIPAddress.Substring(0, $MaskBits).PadRight(32, '0') $BroadcastIP = ConvertFrom-BinaryIPtoDecimal $BinaryIPAddress.Substring(0, $MaskBits).PadRight(32, '1') return [PSCustomObject]@{ SubnetID = $SubnetID BroadcastIP = $BroadcastIP AddressPrefix = "$SubnetID/$MaskBits" } } function Test-OverlappingSubnets ($SubnetA, $SubnetB) { $SubnetAIDDigital = [convert]::Toint64((ConvertFrom-DecimalIPtoBinary $SubnetA.SubnetID), 2) $SubnetABCDigital = [convert]::Toint64((ConvertFrom-DecimalIPtoBinary $SubnetA.BroadcastIP), 2) $SubnetBIDDigital = [convert]::Toint64((ConvertFrom-DecimalIPtoBinary $SubnetB.SubnetID), 2) $SubnetBBCDigital = [convert]::Toint64((ConvertFrom-DecimalIPtoBinary $SubnetB.BroadcastIP), 2) if ($SubnetAIDDigital -ge $SubnetBIDDigital -and $SubnetAIDDigital -le $SubnetBBCDigital) { $Overlap = $true } elseif ($SubnetABCDigital -ge $SubnetBIDDigital -and $SubnetABCDigital -le $SubnetBBCDigital) { $Overlap = $true } else { $Overlap = $false } return $Overlap } #endregion: functions #region: Analyzing Address Space Write-Verbose -Message "Analyzing Address Space" #Find the position of '/' in the provided address space. $AddressSpaceIndexOfMaskBits = $AddressSpace.IndexOf("/") $AddressSpaceID = $AddressSpace.Substring(0, $AddressSpaceIndexOfMaskBits) $AddressSpaceMaskBits = $AddressSpace.Substring($AddressSpaceIndexOfMaskBits + 1) Write-Verbose -Message "ID: $AddressSpaceID - MaskbBits: $AddressSpaceMaskBits" $AddressSpaceSize = [math]::Pow(2, 32 - $AddressSpaceMaskBits) Write-Verbose -Message "Address Space Size: $AddressSpaceSize" Write-Verbose -Message "Finished Analyzing Address Space" #endregion: Analyzing Address Space #region: Find all possible subnets Write-Verbose -Message "ENTER: Find all possible subnets" $NewSubnetSize = [math]::Pow(2, (32 - $NewSubnetMaskBits)) Write-Verbose -Message "New Subnet Size: $NewSubnetSize" $NumberOfPossibleSubnets = $AddressSpaceSize / $NewSubnetSize Write-Verbose -Message "Number of Possible Subnets: $NumberOfPossibleSubnets" $PossibleSubnetsArray = @(Get-SubnetDetails $AddressSpaceID $NewSubnetMaskBits) for ($i = 1; $i -lt $NumberOfPossibleSubnets; $i++) { $LastSubnetInt64 = ([convert]::Toint64((ConvertFrom-DecimalIPtoBinary $PossibleSubnetsArray[$i - 1].BroadcastIP), 2)) $NextSubnetID = ConvertFrom-BinaryIPtoDecimal ([convert]::ToString($LastSubnetInt64 + 1, 2)).padleft(32, '0') $PossibleSubnetsArray += Get-SubnetDetails $NextSubnetID $NewSubnetMaskBits } Write-Verbose -Message "Calculated $($PossibleSubnetsArray.Count) possible subnets." Write-Verbose -Message "Exit: Find all possible subnets" #endregion: Find all possible subnets #region: Collect vNet information Write-Verbose -Message "ENTER: Collect vNet information" $vNetSubnets = foreach ($key in $script:Subnets.Keys) { if ((ConvertFrom-DecimalIPtoBinary ($script:Subnets[$key].Properties.AddressPrefix.Substring(0, $script:Subnets[$key].Properties.AddressPrefix.IndexOf("/")))).Substring(0, $AddressSpaceMaskBits) ` -eq (ConvertFrom-DecimalIPtoBinary ($AddressSpaceID)).Substring(0, $AddressSpaceMaskBits)) { $script:Subnets[$key] } } Write-Verbose -Message "Found $($vNetSubnets.count) subnets in the vNet belonging to the address space $AddressSpace" $UtilizedAddressesArray = foreach ($Subnet in $vNetSubnets) { $IndexOfSubnetMask = $Subnet.Properties.AddressPrefix.indexOf("/") $SubnetID = $Subnet.Properties.AddressPrefix.Substring(0, $IndexOfSubnetMask) $MaskBits = $Subnet.Properties.AddressPrefix.Substring($IndexOfSubnetMask + 1) Get-SubnetDetails -IPAddress $SubnetID -MaskBits $MaskBits } Write-Verbose -Message "Calculated utilized addresses" Write-Verbose -Message "Exit: Collect vNet information" #endregion: Collect vNet information #region: Return the first free subnet Write-Verbose -Message "ENTER: Return the first free subnet" foreach ($PossibleSubnet in $PossibleSubnetsArray) { foreach ($ExistingSubnet in $UtilizedAddressesArray) { $Overlap = $false if (Test-OverlappingSubnets $PossibleSubnet $ExistingSubnet) { $Overlap = $true break } } if (!($Overlap)) { return $PossibleSubnet } } Write-Verbose -Message "Exit: Return the first free subnet" #endregion: Return the first free subnet # if we did not return any subnet, throw an error throw "Could not find any free subnets" } function Set-AVDMFNameMapping { <# .SYNOPSIS Takes a dataset and converts any %XXXX% into mapping. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [Hashtable] $Dataset ) foreach ($item in ($dataset.GetEnumerator() | Where-Object { $null -ne $_.Value } )){ if ($item.Value.GetType().Name -eq 'String'){ $stringMappings = ([regex]::Matches($item.Value, '%.+?%')).Value | ForEach-Object { if ($_) { $_ -replace "%", "" } } foreach ($mapping in $stringMappings) { $mappedValue = $script:NameMappings[$mapping] if($null -ne $mappedValue ){ $item.Value = $item.Value -replace "%$mapping%", $mappedValue } } $dataset[$item.Key] = $item.Value } if ($item.Value.GetType().Name -eq 'PSCustomObject') { $dataset[$item.Key] =[PSCustomObject] (Set-AVDMFNameMapping -Dataset ($item.Value | ConvertTo-PSFHashtable)) } if ($item.Value.GetType().Name -eq 'Object[]') { for($i=0;$i -lt $item.Value.Count;$i++){ if($item.Value[$i].GetType().Name -eq 'String'){ $stringMappings = ([regex]::Matches($item.Value[$i], '%.+?%')).Value | ForEach-Object { if ($_) { $_ -replace "%", "" } } foreach ($mapping in $stringMappings) { $mappedValue = $script:NameMappings[$mapping] $item.Value[$i] = $item.Value[$i] -replace "%$mapping%", $mappedValue } } if($item.Value[$i].GetType().Name -eq 'PSCustomObject' ){ $item.Value[$i] = [PSCustomObject] (Set-AVDMFNameMapping -Dataset ($item.Value[$i] | ConvertTo-PSFHashtable)) } } } } $Dataset } function Set-AVDMFStageEntries { <# .SYNOPSIS This function replaces "Stages" token in json objects depending on the current stage or a default one. .Example $json = @" { "SampleProperty": { "DeploymentStage": { "Development": 10, "Production": 5, "Default": 15 } } } "@ $dataset = $json | ConvertFrom-Json | ConvertTo-PSFHashtable Set-AVDMFStageEntries -Dataset $dataset Assuming the current stage name is "Development", the output will be that SampleProperty = 10 #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [Hashtable] $Dataset, [string] $DeploymentStage = $script:DeploymentStage, [string] $StageToken = "DeploymentStage" ) foreach ($key in ([array]$Dataset.Keys)) { if ($null -eq $Dataset[$key]) { continue } if ($Dataset[$key].GetType().Name -eq 'PSCustomObject') { if ($Dataset[$key] | Get-Member -MemberType NoteProperty -Name $StageToken) { # Get list of configured stages under the stage token $configuredStages = ($Dataset[$key].$StageToken | Get-Member -MemberType NoteProperty).Name if ( $configuredStages -contains $DeploymentStage) { $Dataset[$key] = $Dataset[$key].$StageToken.($DeploymentStage) Write-PSFMessage -Level Verbose -Message "Set $key to $DeploymentStage value: $($Dataset[$key])" } elseif ( $configuredStages -contains "Default" ) { $Dataset[$key] = $Dataset[$key].$StageToken.Default Write-PSFMessage -Level Verbose -Message "Set $key to Default value: $($Dataset[$key])" } else { throw "Could not resolve stage value ($DeploymentStage) for `r`n $($Dataset | Out-String)" } } else { # key is a PSCustomObject that does not have a stage token, maybe one of its children. $Dataset[$key] = [PSCustomObject] (Set-AVDMFStageEntries -Dataset ($Dataset[$key] | ConvertTo-PSFHashtable)) } } if ($Dataset[$key].GetType().Name -eq 'Object[]') { # Key is an array of objects (example, SessionHosts > RemoteAppGroups) $Dataset[$key] = $Dataset[$key] | ForEach-Object { if ($_.GetType().Name -ne 'string') { [PSCustomObject](Set-AVDMFStageEntries -Dataset ( $_ | ConvertTo-PSFHashtable) ) } else { $_ } } } } $Dataset } function Invoke-AVDMFConfiguration { [CmdletBinding()] param ( ) if($script:Offline){ throw "Cannot deploy when working offline. Please reload configuration without the offline switch." } # Create resource groups Write-PSFMessage -Level Host -Message "Invoking resource groups." foreach ($rg in $script:ResourceGroups.Keys) { $newAzResourceGroup = @{ Name = $rg Location = $script:Location Force = $true } if($script:ResourceGroups[$rg].Tags){ $newAzResourceGroup['Tags'] = $script:ResourceGroups[$rg].Tags } $null = New-AzResourceGroup @newAzResourceGroup } #TODO: Decide if we want to create RGs here or with deployment. decide on parallelism # Create network resources Write-PSFMessage -Level Host -Message "Invoking network resources." Invoke-AVDMFNetwork -ErrorAction Stop #Create storage resources Write-PSFMessage -Level Host -Message "Invoking Storage resources." Invoke-AVDMFStorage -ErrorAction Stop # Create Host Pools and Session Hosts Write-PSFMessage -Level Host -Message "Invoking Desktop Virtualization resources." Invoke-AVDMFDesktopVirtualization -ErrorAction Stop } function New-AVDMFConfiguration { [CmdletBinding()] param ( [Parameter()] [string] $Path = (Get-Location).Path, [switch] $Quiet ) $modulePath = Split-Path -Path $MyInvocation.MyCommand.Module.Path $zipPath = Join-Path -Path $modulePath -ChildPath 'SampleConfiguration.zip' if(Test-Path -Path (Join-Path -Path $Path -ChildPath 'AVDMFConfiguration') -PathType Container){ Stop-PSFFunction -Message "AVDMFConfiguration folder already exists. Please provide a different path." -EnableException $true -Category InvalidOperation } else{ Expand-Archive -Path $zipPath -DestinationPath $Path -ErrorAction Stop } if(-Not $Quiet){ $setPath = Join-PSFPath -Path $Path -Child 'AVDMFConfiguration','ConfigurationFiles' $newConfigurationWelcomeText = @" Welcome To AVD Management Framework. You just created a new configuration. The first step is to review the configuration and add users in the Host Pools. You can deploy the configuration by running Set-AVDMFConfiguration -ConfigurationPath '$setPath' then Invoke-AVDMFConfiguration to create the resources in Azure. Please make sure you connect to Azure using Add-AzAccount and Set-AzContext to your target subscription. For more information please review the documentation. Happy AVD :) "@ Write-Host $newConfigurationWelcomeText -ForegroundColor Cyan } } function Set-AVDMFConfiguration { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = "Does not change any states")] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationPath, [string] $AzSubscriptionId = (Get-AzContext).Subscription.Id, [switch] $Force, # Use this parameter for testing offline to avoid Id resolutions. [switch] $Offline ) #region: Initialize Variables $configurationVersion = '1.1.1' #endregion: Initialize Variables #region: Load Custom Environment Variables $environmentVariablesFilePath = Join-Path -Path $ConfigurationPath -ChildPath 'EnvironmentVariables.jsonc' if (Test-Path -Path $environmentVariablesFilePath) { Write-PSFMessage -Level Warning -Message "EnvironmentVariables.json file detected. This is not supposed to exist on DevOps. Please add it to .gitignore" $environmentVariables = Get-Content -Path $environmentVariablesFilePath | ConvertFrom-Json | ConvertTo-PSFHashtable $null = $environmentVariables.GetEnumerator() | ForEach-Object { New-Item -Path $_.Key -Value $_.Value -Force } } #endregion: Load Custom Environment Variables #region: Set DeploymentStage $script:DeploymentStage = $env:SYSTEM_STAGEDISPLAYNAME if ([string]::IsNullOrEmpty($DeploymentStage) -or [string]::IsNullOrWhiteSpace($DeploymentStage)) { throw "Deployment Stage is not defined, if running from local device create EnvironmentVariables.json file. Otherwise review environment variables." #TODO: Include environment variable name in error message. } #endregion: Set DeploymentStage #region: Offline Switch if($Offline){ $script:Offline = $true Write-PSFMessage -Level "Warning" -Message "Working offline. Deployment blocked." } #endregion: Offline Switch #region: Register Name Mappings $nameMappingConfigPath = Join-Path -Path $ConfigurationPath -ChildPath "NameMappings" if (Test-Path $nameMappingConfigPath) { foreach ($file in Get-ChildItem -Path $nameMappingConfigPath -Filter "*.json*") { foreach ($dataset in (Get-Content -Path $file.FullName | ConvertFrom-Json -ErrorAction Stop | ConvertTo-PSFHashtable )) { Register-AVDMFNameMapping @dataset } } } #endregion: Register Name Mappings #region: Populate Script Variables $script:AzSubscriptionId = $AzSubscriptionId $script:ConfigurationPath = $ConfigurationPath #endregion: Populate Script Variables if ($script:WVDConfigurationLoaded -and -not $Force) { throw "Configuration is already loaded. Use the force to reload." } if ($Force) { & "$moduleRoot\internal\scripts\variables.ps1" } #region: General Configuration $generalConfiguration = Get-Content -Path (Join-Path -Path $ConfigurationPath -ChildPath '\GeneralConfiguration\GeneralConfiguration.jsonc' -ErrorAction Stop ) | ConvertFrom-Json -ErrorAction Stop if ($generalConfiguration.ConfigurationVersion -ne $configurationVersion) { throw "current configuration version $($generalConfiguration.ConfigurationVersion) must match $configurationVersion." } Write-PSFMessage -Message "Configuration version: {0}" -StringValues $configurationVersion $script:Location = $GeneralConfiguration.Location $script:TimeZone = $generalConfiguration.TimeZone # TODO: Remove this variable from all files. #endregion #region: Naming Conventions $namingConventionsRoot = Join-Path -Path $ConfigurationPath -ChildPath NamingConventions $script:NamingStyles = Get-Content -Path $namingConventionsRoot\NamingStyles.jsonc -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $namingConventionsComponentsRoot = Join-Path -Path $namingConventionsRoot -ChildPath "Components" foreach ($componentNC in (Get-ChildItem -Path $namingConventionsComponentsRoot -Filter "*.json*")) { # We create a script variable for each component by adding 'NC' to the name of the file $NCContent = Get-Content -Path $componentNC.FullName -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop New-Variable -Scope 'script' -Name ("{0}NC" -f $componentNC.BaseName) -Value $NCContent } #endregion: Naming Conventions #region: Define Registrable Components $components = [ordered] @{ # Tags 'GlobalTags' = @{Command = (Get-Command -Name Register-AVDMFGlobalTag); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "GlobalTags") } # Network 'AddressSpaces' = @{Command = (Get-Command Register-AVDMFAddressSpace); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "Network\AddressSpaces") } 'RouteTables' = @{Command = (Get-Command Register-AVDMFRouteTable); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "Network\RouteTables") } 'NetworkSecurityGroups' = @{Command = (Get-Command Register-AVDMFNetworkSecurityGroup); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "Network\NetworkSecurityGroups") } 'VirtualNetworks' = @{Command = (Get-Command Register-AVDMFVirtualNetwork); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "Network\VirtualNetworks") } # Storage 'StorageAccounts' = @{Command = (Get-Command Register-AVDMFStorageAccount); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "Storage\StorageAccounts") } # Desktop Virtualization 'Workspaces' = @{Command = (Get-Command Register-AVDMFWorkspace); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\Workspaces") } 'VMTemplates' = @{Command = (Get-Command Register-AVDMFVMTemplate); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\VMTemplates") } 'RemoteAppTemplates' = @{Command = (Get-Command Register-AVDMFRemoteAppTemplate); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\RemoteAppTemplates") } 'ReplacementPlanTemplates' = @{Command = (Get-Command Register-AVDMFReplacementPlanTemplate); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\ReplacementPlanTemplates") } 'ScalingPlanScheduleTemplates' = @{Command = (Get-Command Register-AVDMFScalingPlanScheduleTemplate); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\ScalingPlanScheduleTemplates") } 'ScalingPlanTemplates' = @{Command = (Get-Command Register-AVDMFScalingPlanTemplate); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\ScalingPlanTemplates") } 'HostPools' = @{Command = (Get-Command Register-AVDMFHostPool); ConfigurationPath = (Join-Path -Path $ConfigurationPath -ChildPath "DesktopVirtualization\HostPools") } } #endregion: Define Registrable Components #region: Load Component Configuration foreach ($key in $components.Keys) { if (-not (Test-Path $components[$key].ConfigurationPath)) { continue } Write-PSFMessage -Level Verbose -Message "Loading configuration for $key" foreach ($file in Get-ChildItem -Path $components[$key].ConfigurationPath -Recurse -Filter "*.json*") { Write-PSFMessage -Level Verbose -Message "`tLoading $key from $($file.FullName)" foreach ($dataset in (Get-Content -Path $file.FullName | ConvertFrom-Json -ErrorAction Stop | ConvertTo-PSFHashtable -Include $($components[$key].Command.Parameters.Keys))) { Write-PSFMessage -Level Verbose -Message "`t`tRegistering dataset:`r`n $($dataset | Format-List | Out-String -Width 120)" $dataset = Set-AVDMFNameMapping -Dataset $dataset $dataset = Set-AVDMFStageEntries -Dataset $dataset & $components[$key].Command @dataset -ErrorAction Stop } } } #endregion: Load Component Configuration #region: Add Tags $taggedResources = @( 'ResourceGroup' 'VirtualNetwork' 'NetworkSecurityGroup' 'RouteTable' 'StorageAccount' 'PrivateLink' 'HostPool' 'ApplicationGroup' 'Workspace' 'SessionHost' ) foreach ($resourceType in $taggedResources) { $scriptVariable = Get-Variable -Scope script -Name "$($resourceType)s" -ValueOnly if (($script:GlobalTags.keys -contains $resourceType) -or ($script:GlobalTags.keys -contains 'All')) { $keys = [array] $scriptVariable.Keys foreach ($key in $keys) { $scriptVariable[$key] = Add-AVDMFTag -ResourceType $resourceType -ResourceObject $scriptVariable[$key] } } } #endregion: Add Tags $script:WVDConfigurationLoaded = $true } function Initialize-AVDMFDesktopVirtualization { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] [OutputType('System.Collections.Hashtable')] param ( [string] $ResourceGroupName, [string] $ResourceCategory ) switch ($ResourceCategory) { 'HostPool' { $filteredHostPools = @{} $script:HostPools.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredHostPools.Add($_.Key, $_.Value) } $filteredApplicationGroups = @{} $script:ApplicationGroups.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredApplicationGroups.Add($_.Key, $_.Value) } $filteredRemoteApps = @{} $script:RemoteApps.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredRemoteApps.Add($_.Key, $_.Value) } if ($null -eq ([array] ($filteredRemoteApps | Convert-HashtableToArray))) { $filteredRemoteApps = @() } else { $filteredRemoteApps = [array] ($filteredRemoteApps | Convert-HashtableToArray) } $filteredReplacementPlans = @{} $script:ReplacementPlans.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredReplacementPlans.Add($_.Key, $_.Value) } $filteredScalingPlans = @{} $script:ScalingPlans.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredScalingPlans.Add($_.Key, $_.Value) } $filteredTemplateSpecs = @{} $script:TemplateSpecs.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredTemplateSpecs.Add($_.Key, $_.Value) } $templateParams = @{ HostPools = [array] ($filteredHostPools | Convert-HashtableToArray) ApplicationGroups = [array] ($filteredApplicationGroups | Convert-HashtableToArray) RemoteApps = $filteredRemoteApps ScalingPlan = if ($filteredScalingPlans.Keys.Count) { ([array] ($filteredScalingPlans | Convert-HashtableToArray))[0] } else { @{} } #TODO: There can only be one, review the code here. ReplacementPlan = ([array] ($filteredReplacementPlans | Convert-HashtableToArray))[0] #TODO: There can only be one, review the code here. TemplateSpec = ([array] ($filteredTemplateSpecs | Convert-HashtableToArray))[0] #TODO: There can only be one, review the code here. ResourceGroupName = $ResourceGroupName Location = $script:Location } } 'Workspace' { $filteredWorkspaces = @{} $script:Workspaces.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredWorkspaces.Add($_.Key, $_.Value) } $templateParams = @{ Workspaces = [array] ($filteredWorkspaces | Convert-HashtableToArray) } } } $templateParams } function Invoke-AVDMFDesktopVirtualization { [CmdletBinding()] param ( ) if($script:Offline){ throw "Cannot deploy when working offline. Please reload configuration without the offline switch." } #region: Initialize Variables $bicepWorkspaces = "$($moduleRoot)\internal\Bicep\DesktopVirtualization\Workspaces.bicep" $bicepHostPools = "$($moduleRoot)\internal\Bicep\DesktopVirtualization\HostPools.bicep" #endregion: Initialize Variables # Host Pools $hostPoolJobs = @() foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'HostPool') { $templateParams = Initialize-AVDMFDesktopVirtualization -ResourceGroupName $rg -ResourceCategory 'HostPool' try { $null = Get-AzResourceGroup -Name $rg -ErrorAction Stop } catch { New-AzResourceGroup -Name $rg -Location $script:Location } $hostPoolJobs += New-AzSubscriptionDeployment -Location $script:Location -Name 'AVDMFHostPoolDeployment' -TemplateFile $bicepHostPools -TemplateParameterObject $templateParams #$hostPoolJobs += New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Incremental -TemplateFile $bicepHostPools @templateParams -ErrorAction Stop -Confirm:$false -Force -AsJob #TODO: We switched to incremental for the FunctionApp to take over host deployment. We need to add logic to remove RemoteApps, Application groups, that are no longer in configuration. #TODO: See if we can merge this into the main deployment } $dateTime = Get-Date } while ($hostPoolJobs.State -contains "Running") { Start-Sleep -Seconds 5 $timeSpan = New-TimeSpan -Start $dateTime -End (Get-Date) $count = ($hostPoolJobs | Where-Object { $_.State -eq "Running" }).count Write-PSFMessage -Level Host -Message "Waiting for $count hostpool deployments to complete - Been waiting for $($timeSpan.ToString())" } Write-PSFMessage -Level Host -Message "Hostpool jobs completed." #$hostPoolJobs | Receive-Job #region: Update SessionDesktop name #TODO: Check if there is a put method yet for 'Microsoft.DesktopVirtualization/applicationgroups/desktops' foreach ($item in ($script:ApplicationGroups.GetEnumerator() | Where-Object {$_.Value.ApplicationGroupType -eq 'Desktop'}) ) { Write-PSFMessage -Level Host -Message 'Updating SessionDesktop Friendly Name' $null = Update-AzWvdDesktop -ResourceGroupName $item.Value.ResourceGroupName -ApplicationGroupName $item.Key -Name 'SessionDesktop' -FriendlyName $item.Value.FriendlyName -ErrorAction Stop } #endregion # Workspaces Write-PSFMessage -Level Host -Message "Creating workspaces" foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'Workspace') { $templateParams = Initialize-AVDMFDesktopVirtualization -ResourceGroupName $rg -ResourceCategory 'Workspace' try { $null = Get-AzResourceGroup -Name $rg -ErrorAction Stop } catch { New-AzResourceGroup -Name $rg -Location $script:Location } New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Complete -TemplateFile $bicepWorkspaces @templateParams -ErrorAction Stop -Confirm:$false -Force } } } function Test-AVDMFDesktopVirtualization { [CmdletBinding()] param ( ) #region: Initialize Variables $bicepHostPools = "$($moduleRoot)\internal\Bicep\DesktopVirtualization\HostPools.bicep" $bicepWorkspaces = "$($moduleRoot)\internal\Bicep\DesktopVirtualization\Workspaces.bicep" #endregion: Initialize Variables # Host Pools foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'HostPool') { $templateParams = Initialize-AVDMFDesktopVirtualization -ResourceGroupName $rg -ResourceCategory 'HostPool' New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Complete -TemplateFile $bicepHostPools @templateParams -ErrorAction Stop -WhatIf } } # Workspaces foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'Workspace') { $templateParams = Initialize-AVDMFDesktopVirtualization -ResourceGroupName $rg -ResourceCategory 'Workspace' New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Complete -TemplateFile $bicepWorkspaces @templateParams -ErrorAction Stop -WhatIf } } } function Get-AVDMFApplicationGroup { $script:ApplicationGroups } function Register-AVDMFApplicationGroup { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolResourceId, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $Name, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $FriendlyName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [ValidateSet('Desktop', 'RemoteApp')] [string] $ApplicationGroupType, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string[]] $RemoteAppReference, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string[]] $Users, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [ValidateSet("AAD", "ADDS")] [string] $SessionHostJoinType = "ADDS", [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $resourceName = New-AVDMFResourceName -ResourceType 'ApplicationGroup' -ParentName $HostPoolName -NameSuffix $Name $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.DesktopVirtualization/applicationgroups/$ResourceName" $principalId = @() if ($Users.count -ge 1) { $principalId += foreach ($user in $Users) { try { if ($user -like "*@*" ) { Write-PSFMessage -Level Verbose -Message "Resolving Id for user: $user" if(-Not $script:Offline){ $id = (Get-AzADUser -UserPrincipalName $user -ErrorAction Stop).Id } else{ $id = 'XXXXXX-XXXX-XXXX-XXXX-OFFLINE' } } else { Write-PSFMessage -Level Verbose -Message "Resolving Id for group: $user" if(-Not $script:Offline){ $id = (Get-AzADGroup -DisplayName $user -ErrorAction Stop).Id } else{ $id = 'XXXXXX-XXXX-XXXX-XXXX-OFFLINE' } } if ($null -eq $id) { throw } $id } catch { throw "Could not resolve id for $user - If the name is correct then ensure the service principal used is assigned 'Directory readers' role." } } } else{ if($ApplicationGroupType -eq 'RemoteApp'){ Write-PSFMessage -Level Warning -Message "No users defined for Host Pool: {0} - RemoteApp: {1}. Review documentation for how to assign users or groups in AVDMF configuration." -StringValues $HostPoolName, $resourceName } else{ Write-PSFMessage -Level Warning -Message "No users defined for Host Pool: {0}. Review documentation for how to assign users or groups in AVDMF configuration." -StringValues $HostPoolName } } $script:ApplicationGroups[$resourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.ApplicationGroup' ResourceGroupName = $ResourceGroupName HostPoolId = $HostPoolResourceId ApplicationGroupType = $ApplicationGroupType FriendlyName = $FriendlyName PrincipalId = $principalId SessionHostJoinType = $SessionHostJoinType Tags = $Tags } # Link Application group to workspace $script:Workspaces.GetEnumerator() | Where-Object { $_.value.ReferenceName -eq $script:hostpools.$hostpoolname.WorkspaceReference } | ForEach-Object { $_.value.ApplicationGroupReferences += $resourceID } # Register remote Apps if ($RemoteAppReference) { Write-PSFMessage -Level Verbose -Message "Registering Remote Apps" foreach ($remoteApp in $RemoteAppReference) { if ($script:RemoteAppTemplates[$remoteApp]) { $registerRemoteAppParams = @{ ResourceGroupName = $resourceGroupName ApplicationGroupName = $resourceName RemoteAppTemplate = $script:RemoteAppTemplates[$remoteApp] } Register-AVDMFRemoteApp @registerRemoteAppParams } else { throw "Could not find RemoteApp Template: $remoteApp" } } } } } function Get-AVDMFHostPool { $script:HostPools } function Register-AVDMFHostPool { <# .SYNOPSIS A short one-line action-based description, e.g. 'Tests if a function is valid' .DESCRIPTION A longer description of the function, its purpose, common use cases, etc. .NOTES Information or caveats about the function e.g. 'This function is not supported in Linux' .LINK Specify a URI to a help page, this will show when Get-Help -Online is used. .EXAMPLE Application Groups for RemoteApp Host Pools { "Name": "Common Apps", "RemoteAppReference":[ "SAPAnalyzer", "SAPLogon" ], "Users":[ "BusinessAppGroup@oq.com" ] } #> [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $AccessLevel, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [ValidateSet("Personal", "Pooled", "RemoteApp")] [string] $PoolType, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $ReplacementPlan, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $ScalingPlan, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [int] $MaxSessionLimit, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [int] $NumberOfSessionHosts, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $FriendlyName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $WorkSpaceReference, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $VirtualNetworkReference, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $SubnetNSG, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $SubnetRouteTable, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $StorageAccountReference, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] $RemoteAppGroups, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string[]] $Users, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $VMTemplate, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [ValidateSet("AAD", "ADDS")] [string] $SessionHostJoinType = "ADDS", [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $UseAvailabilityZones = $false, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $StartVMOnConnect = $false, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $CustomRdpProperty = "drivestoredirect:s:;redirectwebauthn:i:0;redirectlocation:i:0;redirectclipboard:i:1;redirectprinters:i:0;devicestoredirect:s:;redirectcomports:i:0;redirectsmartcards:i:0;usbdevicestoredirect:s:;camerastoredirect:s:;autoreconnectionenabled:i:1;", [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { #Validate the value of Pooled only parameters against PoolType parameter $exclusiveParameters = @( @{Name = "ReplacementPlan"; PoolTypes = @("Pooled", "RemoteApp") } @{Name = "MaxSessionLimit"; PoolTypes = @("Pooled", "RemoteApp") } ) foreach ($parameter in $exclusiveParameters) { $parameterValue = (Get-Variable -Name $parameter.Name -ErrorAction SilentlyContinue).Value if ($PoolType -in $parameter.PoolTypes -and ( [string]::IsNullOrEmpty($parameterValue))) { $errorMessage = "Parameter ({0}) is required for {1} Host Pools" -f $parameter.Name, ($parameter.PoolTypes -join ' and ') Write-PSFMessage -Level Error -Message $errorMessage throw $errorMessage } elseif ($PoolType -notin $parameter.PoolTypes -and (-Not [string]::IsNullOrEmpty($parameterValue))) { $errorMessage = "Parameter ({0}) is not supported for {1} Host Pools" -f $parameter.Name, ($parameter.PoolTypes -join ' and ') Write-PSFMessage -Level Error -Message $errorMessage throw $errorMessage } } $ResourceName = New-AVDMFResourceName -ResourceType 'HostPool' -AccessLevel $AccessLevel -HostPoolType $PoolType $resourceGroupName = New-AVDMFResourceName -ResourceType "ResourceGroup" -ResourceCategory 'HostPool' -AccessLevel $AccessLevel -HostPoolType $PoolType Register-AVDMFResourceGroup -Name $resourceGroupName -ResourceCategory 'HostPool' $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.DesktopVirtualization/hostpools/$ResourceName" #Register Subnet $subnetParams = @{ Scope = $AccessLevel + 'Access' #TODO: Change the parameter name from scope to Access Level, also change it in subnet configurations NamePrefix = $resourceName VirtualNetworkName = $script:VirtualNetworks[$VirtualNetworkReference].ResourceName VirtualNetworkID = $script:VirtualNetworks[$VirtualNetworkReference].ResourceID NSGID = $script:NetworkSecurityGroups[$SubnetNSG].ResourceID RouteTableID = $script:RouteTables[$SubnetRouteTable].ResourceID } $subnetID = Register-AVDMFSubnet @subnetParams -PassThru # Pickup Storage Account #TODO: Change Storage Accounts into HashTables if ($StorageAccountReference) { $storageAccount = $script:StorageAccounts[$StorageAccountReference] Register-AVDMFFileShare -Name $resourceName.ToLower() -StorageAccountName $storageAccount.Name -ResourceGroupName $storageAccount.ResourceGroupName } # Get Azure Virtual Desktop App Object Id for permission assignment Write-PSFMessage -Level Verbose -Message "Getting Azure Virtual Desktop App (9cdead84-a844-4324-93f2-b2e6bb768d07) Object Id for permission assignment" if (-Not $script:Offline) { $avdAppObjectId = (Get-AzADServicePrincipal -ApplicationId '9cdead84-a844-4324-93f2-b2e6bb768d07').Id } else { $avdAppObjectId = 'XXXXXX-XXXX-XXXX-XXXX-OFFLINE' } Write-PSFMessage -Level Verbose -Message "Azure Virtual Desktop App Object Id is: {0}" -StringValues $avdAppObjectId $script:HostPools[$ResourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.HostPool' ResourceGroupName = $resourceGroupName ResourceID = $resourceID PoolType = $PoolType MaxSessionLimit = $MaxSessionLimit NumberOfSessionHosts = $NumberOfSessionHosts CustomRdpProperty = $CustomRdpProperty StartVMOnConnect = $StartVMOnConnect AVDAppObjectId = $avdAppObjectId WorkSpaceReference = $WorkSpaceReference SubnetID = $subnetID VMTemplate = $VMTemplate # SessionHostJoinType = $script:SessionHostJoinType Tags = $Tags } #TODO: Check if users are provided. if ($PoolType -eq "RemoteApp") { # We assume only Remote App AGs are used for RemoteApp Host Pools foreach ($applicationGroup in $RemoteAppGroups) { # Register each application group $applicationGroupParams = @{ HostPoolName = $resourceName ResourceGroupName = $resourceGroupName HostPoolResourceId = $resourceID Users = $applicationGroup.Users Name = $applicationGroup.Name FriendlyName = $applicationGroup.Name ApplicationGroupType = 'RemoteApp' RemoteAppReference = $applicationGroup.RemoteAppReference SessionHostJoinType = $SessionHostJoinType } #TODO: Add logic to check if all remote app references exist Register-AVDMFApplicationGroup @applicationGroupParams -ErrorAction Stop } } else { # This would apply for pooled and personal pools. Only creating one AG of type Desktop. $applicationGroupParams = @{ HostPoolName = $resourceName ResourceGroupName = $resourceGroupName HostPoolResourceId = $resourceID Users = $Users Name = "Desktop" FriendlyName = $FriendlyName ApplicationGroupType = 'Desktop' SessionHostJoinType = $SessionHostJoinType } Register-AVDMFApplicationGroup @applicationGroupParams } # Register Scaling Plan if (-Not [string]::IsNullOrEmpty($ScalingPlan)) { $scalingPlanParams = @{ ResourceGroupName = $resourceGroupName HostPoolName = $resourceName HostPoolId = $resourceID ScalingPlanTemplate = $script:ScalingPlanTemplates[$ScalingPlan] } Register-AVDMFScalingPlan @scalingPlanParams } # Register TemplateSpec $templateSpecParams = @{ ResourceGroupName = $resourceGroupName HostPoolName = $resourceName TemplateFileName = $script:VMTemplates[$VMTemplate].TemplateFileName } $templateSpecResourceId = Register-AVDMFTemplateSpec @templateSpecParams # Register Replacement Plan $hostPoolInstance = $ResourceName.Substring($ResourceName.Length - 2, 2) $sessionHostNamePrefix = New-AVDMFResourceName -ResourceType 'SessionHostPrefix' -AccessLevel $AccessLevel -HostPoolType $PoolType -HostPoolInstance $hostPoolInstance $hostPoolSessionHostParameters = $script:VMTemplates[$VMTemplate].Parameters | ConvertFrom-Json -Depth 99 -AsHashtable $hostPoolSessionHostParameters['SubnetID'] = $subnetID $replacementPlanParams = @{ ResourceGroupName = $resourceGroupName HostPoolName = $resourceName TargetSessionHostCount = $NumberOfSessionHosts SessionHostNamePrefix = $sessionHostNamePrefix SubnetId = $subnetID ReplacementPlanTemplate = $script:ReplacementPlanTemplates[$ReplacementPlan] SessionHostParameters = $hostPoolSessionHostParameters | ConvertTo-Json -Depth 99 -Compress SessionHostTemplate = $templateSpecResourceId } if (-Not [string]::IsNullOrEmpty($ScalingPlan)) { $replacementPlanParams.ScalingPlanExclusionTag = $script:ScalingPlanTemplates[$ScalingPlan].ExclusionTag } Register-AVDMFReplacementPlan @replacementPlanParams } } function Get-AVDMFRemoteApp { $script:RemoteApps } function Register-AVDMFRemoteApp { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ApplicationGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [object] $RemoteAppTemplate ) process { $resourceName = "$ApplicationGroupName/$($RemoteAppTemplate.RemoteAppName)" Write-PSFMessage -Level Verbose -Message "Registering Remote App: $resourceName" #TODO: Validate inputs would create a working remote app #register Remote App $script:RemoteApps[$resourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.RemoteApp' ResourceGroupName = $ResourceGroupName ApplicationGroupName = $ApplicationGroupName RemoteAppName = $RemoteAppTemplate.RemoteAppName RemoteAppProperties = $RemoteAppTemplate.RemoteAppProperties | ConvertTo-PSFHashtable } } } function Get-AVDMFRemoteAppTemplate { $script:RemoteAppTemplates } function Register-AVDMFRemoteAppTemplate { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $Name, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] $Properties ) process { #TODO: Validate inputs would create a working remote app #register Remote App Template $script:RemoteAppTemplates[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.RemoteAppTemplate' RemoteAppName = $Name RemoteAppProperties = $Properties } } } function Get-AVDMFReplacementPlan { $script:ReplacementPlans } function Register-AVDMFReplacementPlan { <# .SYNOPSIS This function registers a replacement plan for a host pool. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [int] $TargetSessionHostCount, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $SessionHostNamePrefix, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $ADOrganizationalUnitPath, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $SubnetId, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [object] $ReplacementPlanTemplate, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $ScalingPlanExclusionTag = "ScalingPlanExclusion", [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $UniqueNameString = "", [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $SessionHostTemplate, [PSCustomObject] $SessionHostParameters, [PSCustomObject] $Tags = [PSCustomObject]@{} ) $resourceName = New-AVDMFResourceName -ResourceType 'FunctionApp' -ParentName $HostPoolName -InstanceNumber 1 -UniqueNameString $UniqueNameString -NameSuffix $ReplacementPlanTemplate.ReplacementPlanNameSuffix $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/site/$ResourceName" $script:ReplacementPlans[$resourceName] = [PSCustomObject]@{ ResourceGroupName = $ResourceGroupName HostPoolName = $HostPoolName ResourceID = $resourceID TargetSessionHostCount = $TargetSessionHostCount SessionHostNamePrefix = $SessionHostNamePrefix SubnetId = $SubnetId SessionHostTemplate = $SessionHostTemplate SessionHostParameters = $SessionHostParameters TagScalingPlanExclusionTag = $ScalingPlanExclusionTag Tags = $Tags # Replacement plan template AllowDownsizing = $ReplacementPlanTemplate.AllowDownsizing AppPlanName = $ReplacementPlanTemplate.AppPlanName AppPlanTier = $ReplacementPlanTemplate.AppPlanTier DrainGracePeriodHours = $ReplacementPlanTemplate.DrainGracePeriodHours FixSessionHostTags = $ReplacementPlanTemplate.FixSessionHostTags FunctionAppZipUrl = $ReplacementPlanTemplate.FunctionAppZipUrl MaxSimultaneousDeployments = $ReplacementPlanTemplate.MaxSimultaneousDeployments ReplaceSessionHostOnNewImageVersion = $ReplacementPlanTemplate.ReplaceSessionHostOnNewImageVersion ReplaceSessionHostOnNewImageVersionDelayDays = $ReplacementPlanTemplate.ReplaceSessionHostOnNewImageVersionDelayDays SessionHostInstanceNumberPadding = $ReplacementPlanTemplate.SessionHostInstanceNumberPadding SHRDeploymentPrefix = $ReplacementPlanTemplate.SHRDeploymentPrefix TagDeployTimestamp = $ReplacementPlanTemplate.TagDeployTimestamp TagIncludeInAutomation = $ReplacementPlanTemplate.TagIncludeInAutomation TagPendingDrainTimestamp = $ReplacementPlanTemplate.TagPendingDrainTimestamp TargetVMAgeDays = $ReplacementPlanTemplate.TargetVMAgeDays RemoveAzureADDevice = $ReplacementPlanTemplate.RemoveAzureADDevice } } function Get-AVDMFReplacementPlanTemplate { $script:ReplacementPlanTemplates } function Register-AVDMFReplacementPlanTemplate { <# .SYNOPSIS This function registers a replacement plan template. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReplacementPlanNameSuffix, [PSCustomObject] $Tags = [PSCustomObject]@{}, ### This is generated from replacement plan parameters helper script [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $AllowDownsizing = $true, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $AppPlanName = 'Y1', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $AppPlanTier = 'Dynamic', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [int] $DrainGracePeriodHours = 24, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $FixSessionHostTags = $true, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $FunctionAppZipUrl = 'https://github.com/WillyMoselhy/AVDReplacementPlans/releases/download/v0.1.5/FunctionApp.zip', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [int] $MaxSimultaneousDeployments = 20, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $ReplaceSessionHostOnNewImageVersion = $true, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [int] $ReplaceSessionHostOnNewImageVersionDelayDays = 0, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [int] $SessionHostInstanceNumberPadding = 2, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $SHRDeploymentPrefix = 'AVDSessionHostReplacer', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $TagDeployTimestamp = 'AutoReplaceDeployTimestamp', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $TagIncludeInAutomation = 'IncludeInAutoReplace', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $TagPendingDrainTimestamp = 'AutoReplacePendingDrainTimestamp', [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [int] $TargetVMAgeDays = 45, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [bool] $RemoveAzureADDevice ) #register AVD Replacement Plan Template $script:ReplacementPlanTemplates[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.AVDReplacementPlanTemplate' ReplacementPlanNameSuffix = $ReplacementPlanNameSuffix Tags = $Tags ### This is generated from replacement plan parameters helper script AllowDownsizing = $AllowDownsizing AppPlanName = $AppPlanName AppPlanTier = $AppPlanTier DrainGracePeriodHours = $DrainGracePeriodHours FixSessionHostTags = $FixSessionHostTags FunctionAppZipUrl = $FunctionAppZipUrl MaxSimultaneousDeployments = $MaxSimultaneousDeployments ReplaceSessionHostOnNewImageVersion = $ReplaceSessionHostOnNewImageVersion ReplaceSessionHostOnNewImageVersionDelayDays = $ReplaceSessionHostOnNewImageVersionDelayDays SessionHostInstanceNumberPadding = $SessionHostInstanceNumberPadding SHRDeploymentPrefix = $SHRDeploymentPrefix TagDeployTimestamp = $TagDeployTimestamp TagIncludeInAutomation = $TagIncludeInAutomation TagPendingDrainTimestamp = $TagPendingDrainTimestamp TargetVMAgeDays = $TargetVMAgeDays RemoveAzureADDevice = $RemoveAzureADDevice } } function Get-AVDMFScalingPlan { $script:ScalingPlans } function Register-AVDMFScalingPlan { <# .SYNOPSIS This function registers a Scaling plan for a host pool. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolId, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [PSCustomObject] $ScalingPlanTemplate, [PSCustomObject] $Tags = [PSCustomObject]@{} ) $resourceName = New-AVDMFResourceName -ResourceType 'ScalingPlan' -ParentName $HostPoolName -InstanceNumber 1 $script:ScalingPlans[$resourceName] = [PSCustomObject]@{ ResourceGroupName = $ResourceGroupName HostPoolId = $HostPoolId Timezone = $ScalingPlanTemplate.Timezone Schedules = $ScalingPlanTemplate.Schedules ExclusionTag = $ScalingPlanTemplate.ExclusionTag Tags = $Tags } } function Get-AVDMFScalingPlanScheduleTemplate { $script:ScalingPlanScheduleTemplates } function Register-AVDMFScalingPlanScheduleTemplate { <# .SYNOPSIS This function registers a scaling plan schedule template. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [PSCustomObject] $Parameters ) #register AVD Replacement Plan Template $script:ScalingPlanScheduleTemplates[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.AVDScalingPlanScheduleTemplate' Parameters = $Parameters | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable } } function Get-AVDMFScalingPlanTemplate { $script:ScalingPlanTemplates } function Register-AVDMFScalingPlanTemplate { <# .SYNOPSIS This function registers a scaling plan template. .DESCRIPTION The Register-AVDMFScalingPlanTemplate function creates and registers a scaling plan template for Azure Virtual Desktop. It requires the time zone, schedules and a reference name as mandatory parameters. It also optionally allows for an exclusion tag and additional tags to be defined. .EXAMPLE Register-AVDMFScalingPlanTemplate -ReferenceName "MyTemplate" -Timezone "Pacific Standard Time" -Schedules @("Schedule1", "Schedule2") .NOTES The scaling plan template created by this function is stored in the ReplacementPlanTemplates array, using the provided reference name as the key. #> [CmdletBinding()] param ( # The name of the scaling plan template to be registered. This will be used as the key in the ReplacementPlanTemplates array. [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, # The time zone in which the scaling plan is to be implemented. Only accepts valid time zone names. [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [ValidateSet( ErrorMessage = 'Invalid Time Zone, Please use a valid Timezone from: https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/default-time-zones', 'Afghanistan Standard Time', 'Arab Standard Time', 'Arabian Standard Time', 'Arabic Standard Time', 'Argentina Standard Time', 'Atlantic Standard Time', 'AUS Eastern Standard Time', 'Azerbaijan Standard Time', 'Bangladesh Standard Time', 'Belarus Standard Time', 'Cape Verde Standard Time', 'Caucasus Standard Time', 'Central America Standard Time', 'Central Asia Standard Time', 'Central Europe Standard Time', 'Central European Standard Time', 'Central Pacific Standard Time', 'Central Standard Time (Mexico)', 'China Standard Time', 'E. Africa Standard Time', 'E. Europe Standard Time', 'E. South America Standard Time', 'Eastern Standard Time', 'Egypt Standard Time', 'Fiji Standard Time', 'FLE Standard Time', 'Georgian Standard Time', 'GMT Standard Time', 'Greenland Standard Time', 'Greenwich Standard Time', 'GTB Standard Time', 'Hawaiian Standard Time', 'India Standard Time', 'Iran Standard Time', 'Israel Standard Time', 'Jordan Standard Time', 'Korea Standard Time', 'Mauritius Standard Time', 'Middle East Standard Time', 'Montevideo Standard Time', 'Morocco Standard Time', 'Mountain Standard Time', 'Myanmar Standard Time', 'Namibia Standard Time', 'Nepal Standard Time', 'New Zealand Standard Time', 'Pacific SA Standard Time', 'Pacific Standard Time', 'Pakistan Standard Time', 'Paraguay Standard Time', 'Romance Standard Time', 'Russian Standard Time', 'SA Eastern Standard Time', 'SA Pacific Standard Time', 'SA Western Standard Time', 'Samoa Standard Time', 'SE Asia Standard Time', 'Singapore Standard Time', 'South Africa Standard Time', 'Sri Lanka Standard Time', 'Syria Standard Time', 'Taipei Standard Time', 'Tokyo Standard Time', 'Tonga Standard Time', 'Turkey Standard Time', 'Ulaanbaatar Standard Time', 'UTC', 'UTC+12', 'UTC-02', 'UTC-11', 'Venezuela Standard Time', 'W. Central Africa Standard Time', 'W. Europe Standard Time', 'West Asia Standard Time', 'West Pacific Standard Time' )] [string] $Timezone, # Array of schedule names that are associated with the scaling plan template. [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string[]] $Schedules, # Optional parameter. Defines a tag that, when assigned to a resource, will exclude it from the scaling plan. [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $ExclusionTag = 'ScalingPlanExclusion', # Optional parameter. Defines a set of additional tags that will be assigned to the scaling plan template. [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [PSCustomObject] $Tags = [PSCustomObject]@{} ) [array] $scheduleArray = ($Schedules | ForEach-Object { $script:ScalingPlanScheduleTemplates[$_] }).Parameters $script:ScalingPlanTemplates[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.AVDScalingPlanTemplate' Timezone = $Timezone Schedules = $scheduleArray ExclusionTag = $ExclusionTag Tags = $Tags } } function Get-AVDMFSessionHost { $script:SessionHosts } function Register-AVDMFSessionHost { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $AccessLevel, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolType, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolInstance, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [int] $InstanceNumber, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [object] $VMTemplate, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [object] $SubnetID, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [ValidateSet("AAD", "ADDS")] [string] $SessionHostJoinType, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $DomainName, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $OUPath, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $AvailabilityZone = '', [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $ResourceName = New-AVDMFResourceName -ResourceType 'VirtualMachine' -AccessLevel $AccessLevel -HostPoolType $HostPoolType -HostPoolInstance $HostPoolInstance -InstanceNumber $InstanceNumber $script:SessionHosts[$resourceName] = [PSCustomObject]@{ # TODO: Is it a good idea to switch this to hashtable not custom object? ResourceGroupName = $ResourceGroupName VMSize = $VMTemplate.VMSize TimeZone = $script:TimeZone SubnetID = $SubnetID AdminUsername = $VMTemplate.AdminUserName AdminPassword = $VMTemplate.AdminPassword ImageReference = $VMTemplate.ImageReference AcceleratedNetworking = $VMTemplate.AcceleratedNetworking Tags = $Tags AvailabilityZone = $AvailabilityZone PreJoinRunCommand = $VMTemplate.PreJoinRunCommand # Add Session Host WVDArtifactsURL = $VMTemplate.WVDArtifactsURL SessionHostJoinType = $SessionHostJoinType } # AAD or Domain Join switch ($SessionHostJoinType) { "AAD" { } "ADDS" { $script:SessionHosts[$resourceName] | Add-Member -MemberType NoteProperty -Name DomainName -Value $DomainName $script:SessionHosts[$resourceName] | Add-Member -MemberType NoteProperty -Name OUPath -Value $OUPath $script:SessionHosts[$resourceName] | Add-Member -MemberType NoteProperty -Name DomainJoinUserName -Value $script:DomainJoinUserName $script:SessionHosts[$resourceName] | Add-Member -MemberType NoteProperty -Name DomainJoinPassword -Value $script:DomainJoinPassword } } } } function Get-AVDMFTemplateSpec { $script:TemplateSpecs } function Register-AVDMFTemplateSpec { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $TemplateFileName ) $resourceName = New-AVDMFResourceName -ResourceType 'TemplateSpec' -ParentName $HostPoolName -InstanceNumber 1 $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Resources/templateSpecs/$resourceName" $templateFilePath = Join-PSFPath -Path $script:ConfigurationPath -Child 'DesktopVirtualization', 'VMTemplates', 'TemplateFiles', $TemplateFileName Write-PSFMessage -Level Verbose -Message "Loading bicep file from {0}" -StringValues $templateFilePath $templateJSON = [string] (bicep build $templateFilePath --stdout ) if ([string]::IsNullOrEmpty($templateJSON)) { Stop-PSFFunction -Message "Could not load VM Template file: $templateFilePath" -EnableException $true -Category InvalidData } $script:TemplateSpecs[$resourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.TemplateSpec' ResourceGroupName = $ResourceGroupName ResourceID = $resourceID TemplateFileName = $TemplateFileName TemplateJSON = $templateJSON } $resourceID } function Get-AVDMFVMTemplate { $script:VMTemplates } function Register-AVDMFVMTemplate { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [PSCustomObject] $Parameters, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $TemplateFileName ) process { $script:VMTemplates[$ReferenceName] = @{ Parameters = $Parameters | ConvertTo-Json -Depth 100 -Compress # Converting to JSON as this is how it is stored as a FunctipnApp Configuration. TemplateFileName = $TemplateFileName } } } function Get-AVDMFWorkspace { $script:Workspaces } function Register-AVDMFWorkspace { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $AccessLevel, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolType, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $FriendlyName, [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $ResourceName = New-AVDMFResourceName -ResourceType 'Workspace' -AccessLevel $AccessLevel -HostPoolType $HostPoolType $resourceGroupName = New-AVDMFResourceName -ResourceType "ResourceGroup" -ResourceCategory 'Workspace' -AccessLevel $AccessLevel -HostPoolType $HostPoolType Register-AVDMFResourceGroup -Name $resourceGroupName -ResourceCategory 'Workspace' $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.DesktopVirtualization/workspaces/$ResourceName" $script:Workspaces[$ResourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.DesktopVirtualization.Workspace' ResourceID = $resourceID ReferenceName = $ReferenceName ResourceGroupName = $resourceGroupName FriendlyName = $FriendlyName ApplicationGroupReferences = @() Tags = $Tags } } } function Register-AVDMFGlobalSettings { param ( # Stage [string] $Stage ) $script:AVDMFGlobalSettings = [PSCustomObject]@{ Stage = $Stage } } function Add-AVDMFTag { <# .SYNOPSIS Adds tags to resources #> [CmdletBinding()] param ( # ResourceType [Parameter(Mandatory = $true)] [string] $ResourceType, # Resource Object [Parameter(Mandatory = $true)] $ResourceObject ) # Tags that apply to all resources if($script:GlobalTags['All']){ $effectiveTags = $script:GlobalTags['All'].Clone() } # Tags that apply to all instaces of a specific resource type if($script:GlobalTags[$ResourceType]){ $resourceTypeTags = $script:GlobalTags[$ResourceType] foreach($item in $resourceTypeTags.GetEnumerator()) {$effectiveTags[$item.Key] = $item.Value} } if($ResourceObject.Tags){ $resourceSpecificTags = $ResourceObject.Tags | ConvertTo-PSFHashtable foreach($item in $resourceSpecificTags.GetEnumerator()) {$effectiveTags[$item.Key] = $item.Value} } if ($effectiveTags) { $ResourceObject | Add-Member -MemberType NoteProperty -Name Tags -Value $effectiveTags -Force } $ResourceObject } function Get-AVDMFGlobalTag { $script:GlobalTags } function Register-AVDMFGlobalTag { [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ResourceType, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [PSCustomObject] $Tags ) process { $Tags = $Tags | ConvertTo-PSFHashtable $script:GlobalTags[$ResourceType] = $Tags } } function Get-AVDMFNameMapping { $script:NameMappings } function Register-AVDMFNameMapping { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $Name, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $VariableName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [Object] $Value ) process { if($VariableName -like "$*"){ throw "Variable names in Name Mapping cannot start with '$'" } $variableNameValue = if($VariableName -like "env:*"){ (Get-Item -Path $VariableName).Value } else{ (Get-Variable -Name $VariableName).Value } $script:NameMappings[$Name] = $Value.$variableNameValue } } function Initialize-AVDMFNetwork { [CmdletBinding()] [OutputType('System.Collections.Hashtable')] param ( [string] $ResourceGroupName ) $filteredVirtualNetworks = @{} $filteredSubnets = @{} $filteredNetworkSecurityGroups = @{} $filteredRouteTables = @{} $script:VirtualNetworks.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } -PipelineVariable vNet | ForEach-Object { $filteredVirtualNetworks.Add($vNet.Key, $vNet.Value) $script:Subnets.GetEnumerator() | Where-Object { $_.value.VirtualNetworkName -eq $vNet.Value.ResourceName } | ForEach-Object { $filteredSubnets.Add($_.Key, $_.Value) } } $script:RouteTables.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } -PipelineVariable routeTable | ForEach-Object { $filteredRouteTables.Add($routeTable.Key, $routeTable.Value) } foreach ($nsg in $script:NetworkSecurityGroups.keys) { if ($script:NetworkSecurityGroups[$nsg].ResourceGroupName -eq $ResourceGroupName) { $filteredNetworkSecurityGroups[$nsg] = $script:NetworkSecurityGroups[$nsg] } } $templateParams = @{ VirtualNetworks = [array] ($filteredVirtualNetworks | Convert-HashtableToArray) Subnets = [array] ($filteredSubnets | Convert-HashtableToArray) NetworkSecurityGroups = [array] ($filteredNetworkSecurityGroups | Convert-HashtableToArray) RouteTables = [array] ($filteredRouteTables | Convert-HashtableToArray) } $templateParams } function Initialize-AVDMFRemotePeering { [CmdletBinding()] [OutputType('System.Collections.Hashtable')] $templateParams = @{ RemotePeerings = [array] ($script:RemotePeerings | Convert-HashtableToArray) } $templateParams } function Invoke-AVDMFNetwork { [CmdletBinding()] param ( [ValidateSet('All', 'DeployNetwork', 'RemotePeering')] [string[]] $Action = 'All' ) if($script:Offline){ throw "Cannot deploy when working offline. Please reload configuration without the offline switch." } if ($Action -contains 'All' -or $Action -contains 'DeployNetwork') { Write-PSFMessage -Level Verbose -Message "Starting Action: DeployNetwork" # TODO: Handle multiple peerings scenario #Initialize Variables $bicepVirtualNetwork = "$($moduleRoot)\internal\Bicep\Network\Network.bicep" foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'Network') { $templateParams = Initialize-AVDMFNetwork -ResourceGroupName $rg try { Write-PSFMessage -Level Verbose -Message "Checking if resource group exists: {0}" -StringValues $rg $null = Get-AzResourceGroup -Name $rg -ErrorAction Stop } catch { Write-PSFMessage -Level Verbose -Message "Creating resource group {0} in Location {1}" -StringValues $rg, $script:Location #TODO: This is a repeated message and should use the power of PSFramework New-AzResourceGroup -Name $rg -Location $script:Location } Write-PSFMessage -Level Verbose -Message "Deploying network resources in {0}" -StringValues $rg New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Complete -TemplateFile $bicepVirtualNetwork @templateParams -ErrorAction Stop -Confirm:$false -Force } } } if ($Action -contains 'All' -or $Action -contains 'RemotePeering') { # Create remote peerings if ($script:RemotePeerings.count) { Write-PSFMessage -Level Verbose -Message "Starting Action: RemotePeering" $templateParams = Initialize-AVDMFRemotePeering $currentSubscription = (Get-AzContext).Subscription.Id $targetSubscription = $templateParams.RemotePeerings.SubscriptionId Write-PSFMessage -Level Verbose -Message "Switching to remote network subscription context ({0})" -StringValues $targetSubscription $null = Set-AzContext -SubscriptionId $templateParams.RemotePeerings.SubscriptionId # We are not using Azure Deployment for remote peering so we limit the needed permissions on the hub network # WE only need network contributor permissions on the hyb vNet using this approach. $remoteVNet = Get-AzVirtualNetwork -Name $templateParams.RemotePeerings.RemoteVNetNAme -ResourceGroupName $templateParams.RemotePeerings.ResourceGRoupName try{ Add-AzVirtualNetworkPeering -Name $templateParams.RemotePeerings.Name -VirtualNetwork $remoteVNet -RemoteVirtualNetworkId $templateParams.RemotePeerings.LocalVNetResourceId -ErrorAction Stop } catch{ if($_.Exception.Message -eq 'Peering with the specified name already exists'){ Write-PSFMessage -Level Warning -Message "Peering with the specified name already exists." } else{ $peeringError = $_ } } finally{ Write-PSFMessage -Level Verbose -Message "Switching back to local subscription context ({0})" -StringValues $targetSubscription $null = Set-AzContext -SubscriptionId $currentSubscription if($peeringError) {throw $peeringError} } } } } function Test-AVDMFNetwork { [CmdletBinding()] param ( ) #region: Initialize Variables $bicepVirtualNetwork = "$($moduleRoot)\internal\Bicep\Network\Network.bicep" #endregion: Initialize Variables foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'Network') { $templateParams = Initialize-AVDMFNetwork -ResourceGroupName $rg New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Complete -TemplateFile $bicepVirtualNetwork @templateParams -ErrorAction Stop -WhatIf } } } function Get-AVDMFAddressSpace { $script:AddressSpaces } function Register-AVDMFAddressSpace { param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Scope, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $AddressSpace, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [int] $SubnetMask ) process { $Script:AddressSpaces += [PSCustomObject]@{ Scope = $Scope AddressSpace = $AddressSpace subnetMask = $SubnetMask } } } function Get-AVDMFNetworkSecurityGroup { $script:NetworkSecurityGroups } function Register-AVDMFNetworkSecurityGroup { param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ReferenceName, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [array] $SecurityRules, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $AccessLevel, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $HostPoolType, [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $resourceName = New-AVDMFResourceName -ResourceType 'NetworkSecurityGroup' -AccessLevel $AccessLevel -HostPoolType $HostPoolType #Register Resource Group if needed $resourceGroupName = New-AVDMFResourceName -ResourceType "ResourceGroup" -ResourceCategory 'Network' -AccessLevel $AccessLevel -HostPoolType $HostPoolType -InstanceNumber 1 # At the moment we do not have a reason for multiple network RGs. Register-AVDMFResourceGroup -Name $resourceGroupName -ResourceCategory 'Network' $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Network/networkSecurityGroups/$resourceName" $script:NetworkSecurityGroups[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Network.NetworkSecurityGroup' ResourceName = $resourceName ResourceGroupName = $resourceGroupName ResourceID = $resourceID ReferenceName = $ReferenceName SecurityRules = if($SecurityRules ) {@($SecurityRules | ForEach-Object { $_ | ConvertTo-PSFHashtable })} else {$null} Tags = $Tags } } } function Get-AVDMFRemotePeering { $script:RemotePeerings } function Register-AVDMFRemotePeering { param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $RemoteVNetResourceID, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $LocalVNetResourceId ) process { $remoteVNet = Get-AVDMFResourceInfo -ResourceId $RemoteVNetResourceID $localVNet = Get-AVDMFResourceInfo -ResourceId $LocalVNetResourceId $referenceName = "Peering_{0}_To_{1}" -f $RemoteVNet.ResourceName, $LocalVNet.ResourceName #this is used for the hashtable. $name = "Peering_To_{0}" -f $LocalVNet.ResourceName $script:RemotePeerings[$referenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Network.RemotePeering' Name = $name SubscriptionId = $remoteVNet.SubscriptionId #TODO: Implement Remote Subscription Support. ResourceGroupName = $remoteVNet.ResourceGroupName RemoteVNetName = $remoteVNet.ResourceName LocalVNetResourceId = $LocalVNetResourceId } } } function Get-AVDMFRouteTable { $script:RouteTables } function Register-AVDMFRouteTable { param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ReferenceName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [array] $Routes, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [Boolean] $DisableBgpRoutePropagation, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $AccessLevel, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $HostPoolType, [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $resourceName = New-AVDMFResourceName -ResourceType 'RouteTable' -AccessLevel $AccessLevel -HostPoolType $HostPoolType #Register Resource Group if needed $resourceGroupName = New-AVDMFResourceName -ResourceType "ResourceGroup" -ResourceCategory 'Network' -AccessLevel $AccessLevel -HostPoolType $HostPoolType -InstanceNumber 1 # At the moment we do not have a reason for multiple network RGs. Register-AVDMFResourceGroup -Name $resourceGroupName -ResourceCategory 'Network' $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Network/routeTables/$resourceName" $routesHashTable = @($Routes | ForEach-Object { $_ | ConvertTo-PSFHashtable }) foreach ($item in $routesHashTable) { $item.properties = $item.properties | ConvertTo-PSFHashtable } $script:RouteTables[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Network.RouteTable' ResourceName = $resourceName ResourceGroupName = $resourceGroupName ResourceID = $resourceID ReferenceName = $ReferenceName Routes = $routesHashTable #@($Routes | ForEach-Object { $_ | ConvertTo-PSFHashtable }) DisableBgpRoutePropagation = $DisableBgpRoutePropagation Tags = $Tags } } } function Get-AVDMFSubnet { $script:Subnets } function Register-AVDMFSubnet { param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Scope, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $NamePrefix, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $VirtualNetworkName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $VirtualNetworkID, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [bool] $PrivateLink = $false, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $NSGID , [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $RouteTableID , [switch] $PassThru ) process { #region: Calculate subnet range and prefix [array] $scope = ($Script:AddressSpaces | Where-Object { $_.Scope -eq $Scope }) if ($scope.count -gt 1) { throw "Found multiple scopes, please review address spaces configuration and avoid duplicates." } [string] $addressSpace = $scope.AddressSpace [int] $subnetMask = $scope.SubnetMask Write-Verbose "Will use the address space $addressSpace and subnet mask $subnetMask" if (-not ($addressSpace -match '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/\d{2}$')) { throw "$addressSpace is not a valid address space" } $addressPrefix = (New-AVDMFSubnetRange -AddressSpace $addressSpace -NewSubnetMaskBits $subnetMask -ErrorAction 'Stop').AddressPrefix #endregion: Calculate subnet range and prefix $resourceName = New-AVDMFResourceName -ResourceType 'Subnet' -ParentName $NamePrefix -AddressPrefix $addressPrefix $resourceID = "$VirtualNetworkID/subnets/$resourceName" #Build Subnet properties $properties = @{ addressPrefix = $addressPrefix privateEndpointNetworkPolicies = if ($PrivateLink) { "Disabled" } else { "Enabled" } } if ($NSGID) { $properties['networkSecurityGroup'] = @{id = $NSGID } } if ($RouteTableID) { $properties['routeTable'] = @{id = $RouteTableID } } $script:Subnets[$resourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Network.Subnet' VirtualNetworkName = $VirtualNetworkName ResourceID = $resourceID Properties = $properties } if ($PassThru) { $resourceID } } } function Get-AVDMFVirtualNetwork { $script:VirtualNetworks } function Register-AVDMFVirtualNetwork { param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ReferenceName, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string[]] $DNSServers, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [array] $DefaultSubnets, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [array] $VirtualNetworkPeerings, [string] $AccessLevel = 'All', [string] $HostPoolType = 'All', [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $resourceName = New-AVDMFResourceName -ResourceType 'VirtualNetwork' -AccessLevel $AccessLevel -HostPoolType $HostPoolType #Register Resource Group if needed $resourceGroupName = New-AVDMFResourceName -ResourceType "ResourceGroup" -ResourceCategory 'Network' -AccessLevel $AccessLevel -HostPoolType $HostPoolType -InstanceNumber 1 Register-AVDMFResourceGroup -Name $resourceGroupName -ResourceCategory 'Network' # At the moment we do not have a reason for multiple network RGs. $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Network/VirtualNetworks/$resourceName" #Register Virtual Networks [string]$addressSpace = ($Script:AddressSpaces | Where-Object Scope -EQ 'VirtualNetwork').AddressSpace if (-not ($addressSpace -match '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/\d{2}$')) { throw "$addressSpace is not a valid address space" } Write-PSFMessage -Level Verbose -Message 'Configuring peerings' # Configure Peerings $peerings = @(foreach ($peering in $VirtualNetworkPeerings) { $RemoteNetworkName = ($peering.RemoteVnetId -split "/")[-1] Write-PSFMessage -Level Verbose -Message "Configuring peering with $RemoteNetworkName" @{ Name = "PeeringTo_$RemoteNetworkName" RemoteNetworkID = $peering.RemoteVnetId UseRemoteGateways = [bool] $peering.useRemoteGateways } if ($peering.CreateRemotePeering) { Write-PSFMessage -Level Verbose -Message "Registering remote peering." Register-AVDMFRemotePeering -RemoteVNetResourceID $peering.RemoteVNetId -LocalVNetResourceId $resourceID } else { Write-PSFMessage -Level Warning -Message "Peering of Virtual Network '$ReferenceName ($resourceName)' to '$RemoteNetworkName' is not configured to create remote peering. You must manually create peering in the remote network." # Add link to help on website. } }) $script:VirtualNetworks[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Network.VirtualNetwork' ResourceName = $resourceName ResourceGroupName = $resourceGroupName ResourceID = $resourceID AddressSpace = $addressSpace DNSServers = $DNSServers VirtualNetworkPeerings = $peerings Tags = $Tags } #Register Default Subnets foreach ($subnet in $DefaultSubnets) { $paramRegisterAVDMFSubnet = @{ VirtualNetworkName = $resourceName VirtualNetworkID = $resourceID } if ($subnet.NSG) { $paramRegisterAVDMFSubnet['NSGID'] = $script:NetworkSecurityGroups[$subnet.NSG].ResourceID } if ($subnet.RouteTable) { $paramRegisterAVDMFSubnet['RouteTableID'] = $script:RouteTables[$subnet.RouteTable].ResourceID } $subnet | Register-AVDMFSubnet @paramRegisterAVDMFSubnet -ErrorAction Stop # TODO: Why do we have pipeline here? } } } function Get-AVDMFResourceGroup { $script:ResourceGroups } function Register-AVDMFResourceGroup { [CmdletBinding()] param ( [string] $Name, [string] $ResourceCategory ) $script:ResourceGroups[$Name] = [PSCustomObject]@{ PSTypeName = 'AVDMF.ResourceGroup' ResourceCategory = $ResourceCategory } } function Initialize-AVDMFStorage { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] [OutputType('System.Collections.Hashtable')] param ( [string] $ResourceGroupName ) $filteredStorageAccounts = @{} $filteredPrivateLinks = @{} $filteredFileShares = @{} $filteredFileShareAutoGrowLogicApps = @{} $script:StorageAccounts.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredStorageAccounts.Add($_.Key, $_.Value) } $script:PrivateLinks.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredPrivateLinks.Add($_.Key, $_.Value) } $script:FileShares.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredFileShares.Add($_.Key, $_.Value) } $script:FileShareAutoGrowLogicApps.GetEnumerator() | Where-Object { $_.value.ResourceGroupName -eq $ResourceGroupName } | ForEach-Object { $filteredFileShareAutoGrowLogicApps.Add($_.Key, $_.Value) } $templateParams = @{ StorageAccounts = [array] ($filteredStorageAccounts | Convert-HashtableToArray) PrivateLinks = [array] ($filteredPrivateLinks | Convert-HashtableToArray) FileShares = [array] ($filteredFileShares | Convert-HashtableToArray) FileShareAutoGrowLogicApps = [array] ($filteredFileShareAutoGrowLogicApps | Convert-HashtableToArray) } $templateParams } function Invoke-AVDMFStorage { [CmdletBinding()] param ( ) if($script:Offline){ throw "Cannot deploy when working offline. Please reload configuration without the offline switch." } #region: Initialize Variables $bicepStorage = "$($moduleRoot)\internal\Bicep\Storage\Storage.bicep" #endregion: Initialize Variables foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'Storage') { $templateParams = Initialize-AVDMFStorage -ResourceGroupName $rg try{ Get-AzResourceGroup -Name $rg -ErrorAction Stop | Out-Null } catch{ New-AzResourceGroup -Name $rg -Location $script:Location } New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Incremental -TemplateFile $bicepStorage @templateParams -ErrorAction Stop -Confirm:$false -Force # Cannot use Complete mode with Private links, see: https://feedback.azure.com/forums/217313-networking/suggestions/40395946-private-endpoint-arm-template-deployment-fix-comp } } } function Test-AVDMFStorage { [CmdletBinding()] param ( ) #region: Initialize Variables $bicepStorage = "$($moduleRoot)\internal\Bicep\Storage\Storage.bicep" #endregion: Initialize Variables foreach ($rg in $script:ResourceGroups.Keys) { if ($script:ResourceGroups[$rg].ResourceCategory -eq 'Storage') { $templateParams = Initialize-AVDMFStorage -ResourceGroupName $rg try{ Get-AzResourceGroup -Name $rg -ErrorAction Stop | Out-Null } catch{ Write-Warning -Message "Resourcegroup $rg does not exist. Skipping test for: `r`n$($templateParams.Values.ResourceID | out-string)" continue } New-AzResourceGroupDeployment -ResourceGroupName $rg -Mode Complete -TemplateFile $bicepStorage @templateParams -ErrorAction Stop -WhatIf } } } function Get-AVDMFFileShareAutoGrowLogicApp { $script:FileShareAutoGrowLogicApps } function Register-AVDMFFileShareAutoGrowLogicApp { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $StorageAccountResourceId, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [int] $TargetFreeSpaceGB, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $Enabled = $true, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [bool] $AllowShrink = $true, [PSCustomObject] $Tags = [PSCustomObject]@{} ) $storageAccountName = $StorageAccountResourceId | Split-Path -Leaf $resourceName = New-AVDMFResourceName -ResourceType 'LogicApp' -ParentName $StorageAccountName -NameSuffix "FileShareAutoGrow" $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Logic/workflows/$resourceName" $script:FileShareAutoGrowLogicApps[$resourceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Storage.FileShareAutoGrowLogicApp' ResourceGroupName = $resourceGroupName ResourceID = $resourceID Name = $ResourceName StorageAccountResourceId = $StorageAccountResourceId TargetFreeSpaceGB = $TargetFreeSpaceGB Enabled = $Enabled AllowShrink = $AllowShrink Tags = $Tags } } function Get-AVDMFFileShareQuotaLogicApp { $script:FileShareQuotaLogicApps } function Get-AVDMFFileShare { $script:FileShares } function Register-AVDMFFileShare { [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $StorageAccountName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ResourceGroupName ) process { $script:FileShares[$Name] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Storage.FileShare' ResourceName = $Name ResourceGroupName = $resourceGroupName StorageAccountName = $StorageAccountName } } } function Get-AVDMFPrivateLink { $script:PrivateLinks } function Register-AVDMFPrivateLink { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ResourceGroupName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $StorageAccountName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $StorageAccountID, [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $SubnetId = $script:subnets[($script:subnets.keys | Where-Object { $_ -like 'PrivateLinks*' })].ResourceId $resourceName = New-AVDMFResourceName -ResourceType 'PrivateLink' -ParentName $StorageAccountName $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Network/privateEndpoints/$ResourceName" $script:PrivateLinks[$resourceName]= [PSCustomObject]@{ PSTypeName = 'AVDMF.Storage.PrivateLink' ResourceGroupName = $resourceGroupName ResourceID = $resourceID StorageAccountID = $StorageAccountID SubnetID = $SubnetId Tags = $Tags } } } function Get-AVDMFStorageAccount { $script:StorageAccounts } function Register-AVDMFStorageAccount { [CmdletBinding()] param ( [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $AccessLevel, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $accountType, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $HostPoolType, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $Kind, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $ReferenceName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [int] $shareSoftDeleteRetentionDays, [Parameter(Mandatory = $false , ValueFromPipelineByPropertyName = $true )] [string] $UniqueNameString = "", [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $DirectoryServiceOptions, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $DomainName, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $DomainGuid, [Parameter(Mandatory = $true , ValueFromPipelineByPropertyName = $true )] [string] $DefaultSharePermission, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [PSCustomObject] $FileShareAutoGrow, [PSCustomObject] $Tags = [PSCustomObject]@{} ) process { $ResourceName = New-AVDMFResourceName -ResourceType 'StorageAccount' -AccessLevel $AccessLevel -HostPoolType $HostPoolType -UniqueNameString $UniqueNameString $resourceGroupName = New-AVDMFResourceName -ResourceType "ResourceGroup" -ResourceCategory 'Storage' -AccessLevel 'All' -HostPoolType 'All' -InstanceNumber 1 Register-AVDMFResourceGroup -Name $resourceGroupName -ResourceCategory 'Storage' # At the moment we do not have a reason for multiple storage RGs. $resourceID = "/Subscriptions/$script:AzSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Storage/storageAccounts/$ResourceName" $script:StorageAccounts[$ReferenceName] = [PSCustomObject]@{ PSTypeName = 'AVDMF.Storage.StorageAccount' ResourceGroupName = $resourceGroupName ResourceID = $resourceID Name = $ResourceName ReferenceName = $ReferenceName AccountType = $accountType Kind = $Kind SoftDeleteDays = $ShareSoftDeleteRetentionDays DirectoryServiceOptions = $DirectoryServiceOptions DomainName = $DomainName DomainGuid = $DomainGuid DefaultSharePermission = $DefaultSharePermission Tags = $Tags } #register Private Link Register-AVDMFPrivateLink -ResourceGroupName $resourceGroupName -StorageAccountName $ResourceName -StorageAccountID $resourceID # register Auto Grow Logic App if($null -ne $FileShareAutoGrow.Enabled){ Register-AVDMFFileShareAutoGrowLogicApp -ResourceGroupName $resourceGroupName -StorageAccountResourceId $resourceID -TargetFreeSpaceGB $FileShareAutoGrow.TargetFreeSpaceGB -Enabled $FileShareAutoGrow.Enabled -AllowShrink $FileShareAutoGrow.AllowShrink } } } <# This is an example configuration file By default, it is enough to have a single one of them, however if you have enough configuration settings to justify having multiple copies of it, feel totally free to split them into multiple files. #> <# # Example Configuration Set-PSFConfig -Module 'AVDManagementFramework' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" #> Set-PSFConfig -Module 'AVDManagementFramework' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." Set-PSFConfig -Module 'AVDManagementFramework' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'AVDManagementFramework.ScriptBlockName' -Scriptblock { } #> <# # Example: Register-PSFTeppScriptblock -Name "AVDManagementFramework.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name AVDManagementFramework.alcohol #> New-PSFLicense -Product 'AVDManagementFramework' -Manufacturer 'wmoselhy' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2021-04-26") -Text @" Copyright (c) 2021 wmoselhy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ # General Settings $script:WVDConfigurationLoaded = $false $script:NameMappings = @{} # Resource Groups $script:ResourceGroups = @{} # Network $script:VirtualNetworks = @{} $script:Subnets = @{} $Script:AddressSpaces = @() $script:NetworkSecurityGroups = @{} $script:RemotePeerings = @{} $script:RouteTables = @{} # Storage $script:StorageAccounts = @{} $script:FileShares = @{} $script:PrivateLinks = @{} $script:FileShareAutoGrowLogicApps = @{} # DesktopVirtualization $script:HostPools = @{} $script:ApplicationGroups = @{} $script:RemoteAppTemplates = @{} $script:RemoteApps = @{} $script:Workspaces = @{} $script:VMTemplates = @{} $script:TemplateSpecs = @{} # We have one template spec created per host pool. $script:SessionHosts = @{} $script:ReplacementPlanTemplates = @{} $script:ReplacementPlans = @{} $script:ScalingPlanTemplates = @{} $script:ScalingPlanScheduleTemplates = @{} $script:ScalingPlans = @{} # Tags $script:GlobalTags = @{} #endregion Load compiled code |